From f7ff4787cf14826fc01e960546aa4a8b1ddd3854 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 06:12:49 +0000 Subject: [PATCH 01/45] source-text assertions ignore line breaks --- .../test/unit/cleanup-slots-routes.test.ts | 28 +++++++++++++------ .../unit/refresh-boundaries-script.test.ts | 3 ++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/services/api/test/unit/cleanup-slots-routes.test.ts b/services/api/test/unit/cleanup-slots-routes.test.ts index 43436940..105743b5 100644 --- a/services/api/test/unit/cleanup-slots-routes.test.ts +++ b/services/api/test/unit/cleanup-slots-routes.test.ts @@ -140,11 +140,13 @@ describe("PUT /cleanups/:id/slot", () => { // C5 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 }) => [ - s.title, - s.claimed, - s.mine ?? false, - ])).toEqual([ + expect( + dto.slots.map((s: { title: string; claimed: number; mine?: boolean }) => [ + s.title, + s.claimed, + s.mine ?? false, + ]), + ).toEqual([ ["Grill", 1, true], ["Sign-in", 0, false], ]) @@ -263,14 +265,18 @@ describe("route configuration", () => { it("declares the rate limit and CSRF in the source (the two configs a copy-paste route loses)", async () => { const { readFile } = await import("node:fs/promises") - const src = await readFile( + const raw = await readFile( new URL("../../src/routes/cleanups.routes.ts", import.meta.url), "utf8", ) + // 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 // 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") - const claimRoute = src.slice(src.indexOf('route(app, "claimEventSlot"')) + expect(src).toContain( + 'const CLAIM_SLOT_RATE_LIMIT = { max: 30, timeWindow: "1 minute" } as const', + ) + const claimRoute = src.slice(src.indexOf('"claimEventSlot",')) expect(claimRoute.slice(0, 200)).toContain("preHandler: csrfProtect") expect(claimRoute.slice(0, 200)).toContain("rateLimit: CLAIM_SLOT_RATE_LIMIT") }) @@ -281,7 +287,11 @@ describe("the other cleanup reads carry the slot fields", () => { const { app, token } = await makeHarness() const { id } = await createWithSlots(app, token) - const detail = await app.inject({ method: "GET", url: `/v1/cleanups/${id}`, headers: auth(token) }) + const detail = await app.inject({ + method: "GET", + url: `/v1/cleanups/${id}`, + headers: auth(token), + }) expect(detail.json().slots.map((s: { title: string }) => s.title)).toEqual(["Grill", "Sign-in"]) const list = await app.inject({ method: "GET", url: "/v1/cleanups", headers: auth(token) }) diff --git a/services/api/test/unit/refresh-boundaries-script.test.ts b/services/api/test/unit/refresh-boundaries-script.test.ts index 820bd019..83cd9eb3 100644 --- a/services/api/test/unit/refresh-boundaries-script.test.ts +++ b/services/api/test/unit/refresh-boundaries-script.test.ts @@ -21,10 +21,13 @@ import { readFileSync } from "node:fs" import { fileURLToPath } from "node:url" import { describe, expect, it } from "vitest" +// Whitespace-collapsed so the assertions pin the script's structure, not the formatter's line breaks. const SCRIPT = readFileSync( fileURLToPath(new URL("../../scripts/refresh-boundaries.ts", import.meta.url)), "utf8", ) + .replace(/\(\s+/g, "(") + .replace(/\s+/g, " ") describe("refresh-boundaries: connection posture (F141)", () => { it("opens its connection with the long-running-CLI timeouts, not the request-path defaults", () => { From 2b8db386f20cbf2ac63aaa8e5b521d77a12a970b Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 06:12:49 +0000 Subject: [PATCH 02/45] prettier over the code tree --- infra/email-worker/src/index.ts | 6 +- package.json | 2 +- scripts/assert-no-frontend.mjs | 4 +- scripts/check-dynamic-sql.mjs | 114 +- services/api/scripts/prepare-boundaries.ts | 4 +- services/api/scripts/refresh-boundaries.ts | 84 +- services/api/scripts/render-email-gallery.ts | 21 +- services/api/scripts/revoke-certificate.ts | 16 +- services/api/src/abuse/anon-token.ts | 4 +- services/api/src/abuse/counter-store.ts | 1 - services/api/src/abuse/gps-sanity.ts | 1 - services/api/src/abuse/slur-filter.ts | 9 +- services/api/src/adapters/abuse-checks.ts | 1 - services/api/src/adapters/chat-pubsub.ts | 1 - services/api/src/adapters/chat-service.ws.ts | 1 - services/api/src/adapters/email-blocks.ts | 6 +- services/api/src/adapters/email-layout.ts | 5 +- services/api/src/adapters/http-fetch.ts | 5 +- services/api/src/adapters/inbound-mail.cf.ts | 17 +- services/api/src/adapters/jobs.pgboss.ts | 1 - .../adapters/jurisdiction-lookup.census.ts | 1 - services/api/src/adapters/mail-text.ts | 6 +- services/api/src/adapters/mailer.oci.ts | 16 +- services/api/src/adapters/proxy-egress.ts | 1 - services/api/src/adapters/push-apns.ts | 3 +- services/api/src/adapters/push-expo.ts | 1 - services/api/src/adapters/push-sender.ts | 9 +- services/api/src/adapters/push-webpush.ts | 5 +- services/api/src/adapters/redis.ts | 1 - .../src/adapters/reverse-geocode.photon.ts | 4 +- services/api/src/adapters/sms-twilio.ts | 5 +- services/api/src/adapters/storage.local.ts | 4 +- services/api/src/adapters/storage.r2.ts | 13 +- .../api/src/adapters/user-channel.redis.ts | 1 - services/api/src/auth/auth-services.ts | 25 +- services/api/src/auth/cache.ts | 1 - services/api/src/auth/context.ts | 1 - services/api/src/auth/csrf.ts | 8 +- services/api/src/auth/jwks.ts | 1 - services/api/src/auth/oauth.ts | 6 +- services/api/src/auth/otp.ts | 11 +- services/api/src/auth/pg-stores.ts | 14 +- services/api/src/auth/session-service.ts | 5 +- services/api/src/auth/stores.ts | 2 - services/api/src/auth/ws-ticket.ts | 1 - .../api/src/db/backfill-population-core.ts | 16 +- .../api/src/db/backfill-post-geom-core.ts | 4 +- services/api/src/db/backfill-post-geom.ts | 1 - .../src/db/backfill-reference-codes-core.ts | 10 +- services/api/src/db/backfill-served-key.ts | 1 - .../api/src/db/backfill-user-activity-core.ts | 5 +- services/api/src/db/backfill-user-activity.ts | 1 - services/api/src/db/boundaries/manifest.ts | 65 +- services/api/src/db/cli.ts | 1 - services/api/src/db/client.ts | 1 - services/api/src/db/cursor-helpers.ts | 1 - services/api/src/db/data/federal-lands.ts | 1 - services/api/src/db/demo-join-event.ts | 49 +- .../api/src/db/ingest-jurisdictions-core.ts | 14 +- services/api/src/db/ingest-jurisdictions.ts | 12 +- services/api/src/db/schema/chat-groups.ts | 1 - services/api/src/db/schema/chat.ts | 1 - services/api/src/db/schema/chat_reactions.ts | 1 - services/api/src/db/schema/cleanup_guests.ts | 8 +- services/api/src/db/schema/cleanup_members.ts | 1 - services/api/src/db/schema/cleanup_pages.ts | 8 +- .../api/src/db/schema/cleanup_questions.ts | 9 +- .../src/db/schema/cleanup_registrations.ts | 15 +- .../api/src/db/schema/cleanup_ticket_types.ts | 5 +- services/api/src/db/schema/cleanups.ts | 1 - services/api/src/db/schema/consent_records.ts | 4 +- services/api/src/db/schema/dm_messages.ts | 1 - .../api/src/db/schema/event_metrics_daily.ts | 11 +- services/api/src/db/schema/follows.ts | 1 - services/api/src/db/schema/host_exports.ts | 10 +- services/api/src/db/schema/inbound_emails.ts | 12 +- services/api/src/db/schema/index.ts | 1 - .../src/db/schema/jurisdiction_contacts.ts | 1 - services/api/src/db/schema/mail.ts | 5 +- services/api/src/db/schema/media.ts | 1 - services/api/src/db/schema/moderation.ts | 1 - .../api/src/db/schema/moderation_items.ts | 1 - .../api/src/db/schema/notification_prefs.ts | 1 - services/api/src/db/schema/notifications.ts | 1 - .../api/src/db/schema/org_verifications.ts | 5 +- services/api/src/db/schema/organizations.ts | 16 +- services/api/src/db/schema/otp.ts | 1 - services/api/src/db/schema/post_likes.ts | 5 +- services/api/src/db/schema/posts.ts | 1 - services/api/src/db/schema/reports.ts | 5 +- .../db/schema/service-hours-certificates.ts | 1 - services/api/src/db/schema/types.ts | 9 +- services/api/src/db/schema/user_blocks.ts | 1 - services/api/src/db/schema/user_moderation.ts | 1 - services/api/src/db/schema/users.ts | 1 - services/api/src/db/schema/volunteer-hours.ts | 12 +- services/api/src/db/seed-demo-la.ts | 1049 ++++++++++++++--- services/api/src/db/seed.ts | 5 +- services/api/src/db/sql/jurisdiction.ts | 3 +- services/api/src/db/sql/user-activity.ts | 1 - services/api/src/di.ts | 13 +- services/api/src/env.ts | 30 +- services/api/src/env/comms-env.ts | 5 +- services/api/src/env/parsers.ts | 5 +- services/api/src/env/registration-env.ts | 3 +- services/api/src/env/types.ts | 1 - services/api/src/errors/glitchtip.ts | 8 +- services/api/src/errors/http-mapper.ts | 16 +- services/api/src/errors/sms-failure.ts | 4 +- services/api/src/i18n/messages/de.ts | 1 - services/api/src/i18n/messages/en.ts | 7 +- services/api/src/i18n/messages/es.ts | 10 +- services/api/src/i18n/messages/ko.ts | 10 +- services/api/src/plugins/rate-limit.ts | 18 +- services/api/src/routes/_validate.ts | 23 +- .../api/src/routes/admin/analytics.routes.ts | 1 - services/api/src/routes/admin/auth.routes.ts | 100 +- .../api/src/routes/admin/broadcasts.routes.ts | 31 +- services/api/src/routes/admin/gov.routes.ts | 1 - services/api/src/routes/admin/home.routes.ts | 1 - services/api/src/routes/admin/index.ts | 1 - .../src/routes/admin/jurisdictions.routes.ts | 49 +- services/api/src/routes/admin/mail.routes.ts | 58 +- services/api/src/routes/admin/media.routes.ts | 6 +- .../api/src/routes/admin/moderation.routes.ts | 1 - .../src/routes/admin/report-chat.routes.ts | 5 +- .../api/src/routes/admin/reports.routes.ts | 7 +- .../api/src/routes/admin/system.routes.ts | 4 +- services/api/src/routes/admin/users.routes.ts | 7 +- services/api/src/routes/anon.routes.ts | 16 +- services/api/src/routes/auth.routes.ts | 193 +-- .../api/src/routes/chat-gateway-wiring.ts | 14 +- services/api/src/routes/chat-powers-wiring.ts | 9 +- services/api/src/routes/chat-route-helpers.ts | 5 +- services/api/src/routes/chat.routes.ts | 19 +- services/api/src/routes/claim.routes.ts | 7 +- services/api/src/routes/cleanups.routes.ts | 248 ++-- .../api/src/routes/conversations.routes.ts | 9 +- services/api/src/routes/dm.routes.ts | 13 +- services/api/src/routes/forms.routes.ts | 37 +- services/api/src/routes/host/_host-routes.ts | 9 +- .../api/src/routes/host/analytics.routes.ts | 245 ++-- .../src/routes/host/announcements.routes.ts | 5 +- .../api/src/routes/host/checkin.routes.ts | 8 +- services/api/src/routes/host/orgs.routes.ts | 6 +- .../api/src/routes/host/portfolio.routes.ts | 5 +- .../src/routes/host/registrations.routes.ts | 4 +- .../api/src/routes/host/unsubscribe.routes.ts | 1 - services/api/src/routes/index.ts | 1 - services/api/src/routes/legal.routes.ts | 5 +- .../api/src/routes/local-storage.routes.ts | 5 +- services/api/src/routes/map.routes.ts | 41 +- services/api/src/routes/media.routes.ts | 8 +- services/api/src/routes/messages.routes.ts | 1 - .../api/src/routes/notifications.routes.ts | 11 +- services/api/src/routes/posts.routes.ts | 125 +- .../api/src/routes/report-content.routes.ts | 7 +- services/api/src/routes/reports.routes.ts | 62 +- .../service-hours-certificates.routes.ts | 6 +- services/api/src/routes/social.routes.ts | 66 +- services/api/src/routes/users.routes.ts | 169 +-- .../api/src/routes/volunteer-hours.routes.ts | 11 +- .../routes/webhooks/inbound-mail.routes.ts | 7 +- services/api/src/server.ts | 5 +- .../admin/activity-repository.memory.ts | 8 +- .../src/services/admin/activity-service.ts | 7 +- .../admin/admin-event-repository.drizzle.ts | 6 +- .../admin/admin-event-repository.memory.ts | 10 +- .../admin/admin-report-chat-service.ts | 5 +- .../admin/admin-report-repository.drizzle.ts | 6 +- .../admin/admin-report-repository.memory.ts | 6 +- .../services/admin/admin-report-service.ts | 25 +- .../src/services/admin/admin-report-types.ts | 4 +- .../admin/admin-user-repository.drizzle.ts | 12 +- .../src/services/admin/admin-user-service.ts | 7 +- .../admin/analytics-repository.drizzle.ts | 1 - .../src/services/admin/analytics-shaping.ts | 6 +- .../admin/audit-repository.drizzle.ts | 9 +- .../services/admin/audit-repository.memory.ts | 7 +- services/api/src/services/admin/audit.ts | 1 - .../admin/discovery-repository.drizzle.ts | 14 +- .../admin/discovery-repository.memory.ts | 6 +- .../src/services/admin/discovery-service.ts | 15 +- .../admin/forward-template-service.ts | 5 +- .../src/services/admin/gov-claims-service.ts | 9 +- .../services/admin/home-repository.drizzle.ts | 1 - .../services/admin/home-repository.memory.ts | 1 - .../api/src/services/admin/home-service.ts | 1 - .../api/src/services/admin/inbound-bounce.ts | 5 +- .../services/admin/inbound-html-sanitizer.ts | 1 - .../api/src/services/admin/inbound-jobs.ts | 1 - .../src/services/admin/inbound-processor.ts | 21 +- .../admin/inbound-repository.drizzle.ts | 7 +- .../admin/inbound-repository.memory.ts | 3 +- .../api/src/services/admin/inbound-sweep.ts | 1 - .../admin/inbound-thread-correlation.ts | 14 +- ...urisdiction-contacts-repository.drizzle.ts | 19 +- ...jurisdiction-contacts-repository.memory.ts | 5 +- .../admin/jurisdiction-contacts-service.ts | 6 +- .../admin/jurisdiction-contacts-types.ts | 16 +- .../jurisdiction-directory-projection.ts | 8 +- .../api/src/services/admin/mail-format.ts | 6 +- .../api/src/services/admin/mail-mappers.ts | 1 - .../api/src/services/admin/mail-preview.ts | 1 - .../services/admin/mail-repository.drizzle.ts | 12 +- .../services/admin/mail-repository.memory.ts | 8 +- .../api/src/services/admin/mail-repository.ts | 1 - .../api/src/services/admin/mail-service.ts | 11 +- .../admin/moderation-repository.drizzle.ts | 15 +- .../admin/moderation-repository.memory.ts | 4 +- .../src/services/admin/moderation-service.ts | 4 - .../services/admin/outbound-mail-service.ts | 9 +- .../services/admin/outbound-send-policy.ts | 5 +- .../src/services/admin/outbound-send-sql.ts | 5 +- .../api/src/services/admin/outreach-jobs.ts | 7 +- .../admin/outreach-repository.drizzle.ts | 1 - .../admin/outreach-repository.memory.ts | 1 - .../src/services/admin/outreach-service.ts | 12 +- services/api/src/services/admin/pagination.ts | 4 +- .../api/src/services/admin/role-change.ts | 1 - .../api/src/services/admin/sql-fragments.ts | 5 +- .../services/admin/system-health-probes.ts | 1 - .../services/admin/system-health-service.ts | 15 +- .../anon-hold-release-repo.drizzle.ts | 7 +- .../api/src/services/anon-hold-release.ts | 1 - .../src/services/anon-repository.drizzle.ts | 5 +- services/api/src/services/anon-service.ts | 12 +- .../src/services/blocks-repository.drizzle.ts | 1 - .../api/src/services/certificate-model.ts | 1 - services/api/src/services/certificate-pdf.ts | 7 - .../certificate-repository.drizzle.ts | 7 +- .../api/src/services/certificate-service.ts | 10 +- services/api/src/services/chat-bells.ts | 10 +- .../api/src/services/chat-edit-service.ts | 5 +- services/api/src/services/chat-fanout-jobs.ts | 1 - .../services/chat-group-repository.drizzle.ts | 35 +- .../api/src/services/chat-group-service.ts | 28 +- .../api/src/services/chat-mention-resolver.ts | 9 +- .../api/src/services/chat-poll-notifier.ts | 1 - .../services/chat-poll-repository.drizzle.ts | 13 +- .../api/src/services/chat-poll-service.ts | 1 - .../src/services/chat-reactions.drizzle.ts | 4 +- .../api/src/services/chat-reply-hydration.ts | 6 +- .../src/services/chat-repository.drizzle.ts | 80 +- .../src/services/chat-room-fanout-notifier.ts | 1 - .../src/services/chat-room-notifier-wiring.ts | 1 - services/api/src/services/chat-room-roles.ts | 1 - services/api/src/services/chat-tombstone.ts | 1 - services/api/src/services/claim-service.ts | 12 +- services/api/src/services/cleanup-dto.ts | 5 +- .../src/services/cleanup-map-repository.ts | 12 +- .../services/cleanup-repository.drizzle.ts | 31 +- .../src/services/cleanup-repository.types.ts | 11 +- services/api/src/services/cleanup-service.ts | 81 +- services/api/src/services/cleanup-sql.ts | 3 +- .../src/services/content-report-subject.ts | 7 +- .../conversation-mutes-repository.drizzle.ts | 6 +- services/api/src/services/data-export-jobs.ts | 5 +- .../api/src/services/data-export-service.ts | 12 +- .../api/src/services/discussion-mentions.ts | 11 +- .../api/src/services/dm-repository.drizzle.ts | 45 +- .../api/src/services/dm-repository.memory.ts | 43 +- services/api/src/services/dm-service.ts | 1 - services/api/src/services/feed-presence.ts | 6 +- .../api/src/services/group-chat-notifier.ts | 1 - services/api/src/services/guest-jobs.ts | 5 +- .../api/src/services/guest-rsvp-service.ts | 39 +- .../api/src/services/guest-rsvp-wiring.ts | 18 +- .../host/admin-pages-repository.drizzle.ts | 3 +- .../host/analytics-repository.drizzle.ts | 14 +- .../src/services/host/analytics-service.ts | 40 +- .../services/host/broadcast-audience-sql.ts | 10 +- .../host/broadcast-capability-token.ts | 4 +- .../api/src/services/host/broadcast-lanes.ts | 2 +- .../src/services/host/broadcast-pipeline.ts | 16 +- .../api/src/services/host/broadcast-render.ts | 6 +- .../host/broadcast-repository.drizzle.ts | 10 +- .../host/broadcast-repository.memory.ts | 26 +- .../src/services/host/broadcast-repository.ts | 7 +- .../src/services/host/broadcast-service.ts | 27 +- .../api/src/services/host/checkin-service.ts | 12 +- services/api/src/services/host/comms-jobs.ts | 6 +- .../api/src/services/host/comms-wiring.ts | 13 +- .../event-analytics-repository.drizzle.ts | 4 +- .../services/host/event-analytics-service.ts | 13 +- .../host/event-consents-repository.drizzle.ts | 5 +- .../api/src/services/host/event-fields.ts | 5 +- services/api/src/services/host/event-media.ts | 5 +- .../api/src/services/host/export-service.ts | 4 +- .../services/host/host-portfolio-service.ts | 10 +- .../api/src/services/host/host-standing.ts | 6 +- .../host/host-team-repository.drizzle.ts | 22 +- .../host/host-team-repository.memory.ts | 12 +- .../src/services/host/host-team-service.ts | 33 +- .../api/src/services/host/insights-service.ts | 5 +- .../host/metrics-repository.drizzle.ts | 7 +- .../api/src/services/host/metrics-service.ts | 6 +- .../host/organization-repository.drizzle.ts | 26 +- .../host/organization-repository.memory.ts | 46 +- .../host/organization-repository.types.ts | 12 +- .../src/services/host/organization-service.ts | 21 +- .../api/src/services/host/page-service.ts | 25 +- .../api/src/services/host/question-service.ts | 3 +- .../src/services/host/question-validation.ts | 9 +- .../api/src/services/host/registration-dto.ts | 9 +- .../src/services/host/registration-jobs.ts | 6 +- .../host/registration-repository.drizzle.ts | 66 +- .../host/registration-repository.memory.ts | 134 ++- .../host/registration-repository.types.ts | 5 +- .../src/services/host/registration-wiring.ts | 13 +- .../src/services/host/ticket-type-service.ts | 13 +- .../api/src/services/host/waitlist-service.ts | 5 +- .../api/src/services/jurisdiction-service.ts | 1 - services/api/src/services/legal-service.ts | 7 +- .../api/src/services/media-authorization.ts | 11 +- services/api/src/services/media-etag.ts | 7 +- .../api/src/services/media-intake-service.ts | 4 +- services/api/src/services/media-presign.ts | 4 +- .../src/services/media-repository.drizzle.ts | 7 +- services/api/src/services/media-served-key.ts | 1 - .../api/src/services/media-worker-repo.ts | 5 +- .../src/services/mention-resolver.drizzle.ts | 1 - .../src/services/message-mentions.drizzle.ts | 4 +- .../src/services/message-reactions.drizzle.ts | 5 +- .../api/src/services/notification-helpers.ts | 12 +- .../notification-repository.drizzle.ts | 8 +- .../api/src/services/notification-service.ts | 54 +- .../src/services/post-repository.drizzle.ts | 31 +- services/api/src/services/post-service.ts | 25 +- .../api/src/services/push-token-policy.ts | 9 +- .../api/src/services/report-chat-notifier.ts | 1 - .../report-chat-repository.drizzle.ts | 13 +- .../src/services/report-chat-send-wiring.ts | 3 +- services/api/src/services/report-chat-send.ts | 5 +- .../api/src/services/report-city-forward.ts | 8 +- .../api/src/services/report-clustering.ts | 6 +- services/api/src/services/report-service.ts | 57 +- .../api/src/services/report-service.types.ts | 5 +- .../api/src/services/report-timeline-event.ts | 5 +- .../api/src/services/room-read-service.ts | 1 - .../api/src/services/route-geo-helpers.ts | 1 - .../src/services/social-repository.drizzle.ts | 25 +- services/api/src/services/social-service.ts | 30 +- .../src/services/social-suggestions-wiring.ts | 1 - .../services/threads-repository.drizzle.ts | 12 +- services/api/src/services/threads-service.ts | 99 +- .../src/services/volunteer-hours-anomaly.ts | 5 +- .../volunteer-hours-repository.memory.ts | 7 +- .../src/services/volunteer-hours-service.ts | 4 +- services/api/src/versioning/version-gate.ts | 1 - services/api/src/ws/frame-handler.ts | 63 +- services/api/src/ws/report-rate-limit.ts | 6 +- services/api/src/ws/send-resilience.ts | 6 +- services/api/src/ws/socket-lifecycle.ts | 30 +- services/api/test/helpers/anon.ts | 14 +- .../api/test/helpers/auth-harness.test.ts | 4 +- services/api/test/helpers/chat.ts | 14 +- services/api/test/helpers/cleanups.ts | 54 +- services/api/test/helpers/fake-sql.ts | 10 +- services/api/test/helpers/guest-rsvp.ts | 14 +- services/api/test/helpers/media.ts | 1 - services/api/test/helpers/notifications.ts | 2 - services/api/test/helpers/pg-container.ts | 4 +- .../api/test/helpers/pg-selection.test.ts | 12 +- services/api/test/helpers/reports.ts | 12 +- services/api/test/helpers/social.ts | 31 +- services/api/test/helpers/sql-predicate.ts | 1 - .../account-deletion-cascade-pg.test.ts | 577 ++++----- .../test/integration/admin-activity.test.ts | 6 +- .../test/integration/admin-analytics.test.ts | 1 - .../api/test/integration/admin-audit.test.ts | 16 +- .../test/integration/admin-discovery.test.ts | 2 - .../api/test/integration/admin-home.test.ts | 6 +- .../admin-jurisdiction-directory.test.ts | 6 +- .../integration/admin-mail-repository.test.ts | 5 +- .../test/integration/admin-moderation.test.ts | 13 +- .../test/integration/admin-outreach.test.ts | 9 +- .../test/integration/admin-reports.test.ts | 7 +- .../api/test/integration/admin-users.test.ts | 7 +- .../anon-hold-release-sweep-pg.test.ts | 1 - services/api/test/integration/anon-pg.test.ts | 49 +- .../test/integration/avatar-media-pg.test.ts | 50 +- .../block-identity-redaction-pg.test.ts | 4 +- .../test/integration/chat-around-pg.test.ts | 7 +- .../test/integration/chat-channels-pg.test.ts | 74 +- .../test/integration/chat-groups-pg.test.ts | 6 +- .../chat-groups-threads-pg.test.ts | 70 +- .../integration/chat-groups-ws-pg.test.ts | 93 +- .../integration/chat-media-attach-pg.test.ts | 5 +- .../api/test/integration/chat-pins-pg.test.ts | 80 +- .../test/integration/chat-polls-pg.test.ts | 153 ++- .../integration/chat-polls-schema-pg.test.ts | 4 +- .../chat-reply-notifications-pg.test.ts | 36 +- .../integration/chat-tombstone-pg.test.ts | 4 +- .../cleanup-default-slot-pg.test.ts | 9 +- .../test/integration/cleanup-links-pg.test.ts | 5 +- .../test/integration/cleanup-slots-pg.test.ts | 77 +- .../test/integration/cleanups-chat-pg.test.ts | 15 +- .../content-report-visibility-pg.test.ts | 6 +- services/api/test/integration/dm-pg.test.ts | 1 - .../erasure-host-ladder-pg.test.ts | 16 +- .../test/integration/feed-ranked-pg.test.ts | 13 +- .../test/integration/guest-rsvp-pg.test.ts | 12 +- .../integration/host-capacity-race-pg.test.ts | 1 - .../host-checkin-idempotency-pg.test.ts | 1 - .../integration/host-lock-order-pg.test.ts | 11 +- .../integration/host-orgs-team-pg.test.ts | 28 +- .../integration/host-page-media-pg.test.ts | 1 - .../host-waitlist-promotion-pg.test.ts | 1 - .../integration/inbound-mail-webhook.test.ts | 19 +- .../integration/inbound-repository.test.ts | 26 +- .../ingest-jurisdictions-pg.test.ts | 79 +- .../test/integration/messages-edit-pg.test.ts | 57 +- .../moderation-destination-refs-pg.test.ts | 20 +- .../notification-coalescing-pg.test.ts | 17 +- .../integration/posts-repost-feed-pg.test.ts | 7 +- .../integration/posts-unrepost-pg.test.ts | 4 +- services/api/test/integration/posts.test.ts | 255 +++- .../report-chat-members-pg.test.ts | 10 +- .../report-forward-audit-pg.test.ts | 208 ++-- .../report-route-stale-claim.test.ts | 6 +- .../api/test/integration/reports-pg.test.ts | 23 +- .../reports-preview-still-pg.test.ts | 4 +- services/api/test/integration/schema.test.ts | 90 +- .../service-hours-certificates-pg.test.ts | 11 +- .../integration/social-connections-pg.test.ts | 7 +- .../social-notifications-pg.test.ts | 77 +- services/api/test/integration/spatial.test.ts | 6 +- .../integration/suggest-follows-pg.test.ts | 8 +- .../threads-lateral-bound-pg.test.ts | 4 +- .../integration/threads-report-pg.test.ts | 12 +- .../volunteer-hours-integrity-pg.test.ts | 8 +- .../integration/volunteer-hours-pg.test.ts | 65 +- .../test/unit/abuse-checks-adapter.test.ts | 17 +- services/api/test/unit/abuse.test.ts | 4 +- .../api/test/unit/adapters-http-fetch.test.ts | 21 +- .../test/unit/adapters-i18n-messages.test.ts | 4 +- .../api/test/unit/adapters-mailer-oci.test.ts | 6 +- .../unit/adapters-push-classification.test.ts | 8 +- .../api/test/unit/address-resolver.test.ts | 18 +- services/api/test/unit/admin-activity.test.ts | 70 +- .../api/test/unit/admin-analytics.test.ts | 1 - .../api/test/unit/admin-auth-guard.test.ts | 6 +- .../api/test/unit/admin-discovery.test.ts | 34 +- services/api/test/unit/admin-events.test.ts | 4 +- .../api/test/unit/admin-gov-claims.test.ts | 11 +- .../api/test/unit/admin-guard-cache.test.ts | 30 +- services/api/test/unit/admin-home.test.ts | 1 - .../unit/admin-host-platform-routes.test.ts | 83 +- .../unit/admin-inbound-bounce-verdict.test.ts | 1 - services/api/test/unit/admin-inbox.test.ts | 8 +- .../unit/admin-jurisdiction-contacts.test.ts | 44 +- .../test/unit/admin-mail-repository.test.ts | 29 +- services/api/test/unit/admin-mail.test.ts | 210 +++- .../api/test/unit/admin-moderation.test.ts | 55 +- .../test/unit/admin-outreach-claim.test.ts | 11 +- .../api/test/unit/admin-report-chat.test.ts | 17 +- services/api/test/unit/admin-reports.test.ts | 68 +- .../api/test/unit/admin-routes-http.test.ts | 5 +- .../api/test/unit/admin-system-health.test.ts | 34 +- services/api/test/unit/admin-users.test.ts | 2 - services/api/test/unit/affiliation.test.ts | 10 +- .../api/test/unit/anon-hold-release.test.ts | 57 +- services/api/test/unit/anon-routes.test.ts | 49 +- services/api/test/unit/anon-service.test.ts | 72 +- .../api/test/unit/audit-read-actions.test.ts | 1 - services/api/test/unit/auth-jwks.test.ts | 19 +- services/api/test/unit/auth-oauth.test.ts | 12 +- services/api/test/unit/auth-otp.test.ts | 10 +- .../api/test/unit/auth-route-mounting.test.ts | 6 +- .../api/test/unit/backfill-served-key.test.ts | 5 +- .../api/test/unit/boundaries-manifest.test.ts | 4 +- .../api/test/unit/certificate-model.test.ts | 1 - services/api/test/unit/cf-access.test.ts | 17 +- .../unit/chat-attachments-servable.test.ts | 11 +- .../api/test/unit/chat-block-gates.test.ts | 19 +- .../unit/chat-broadcast-viewer-fields.test.ts | 15 +- .../api/test/unit/chat-edit-service.test.ts | 15 +- .../api/test/unit/chat-fanout-job.test.ts | 6 +- .../unit/chat-gateway-fanout-handoff.test.ts | 48 +- .../unit/chat-group-service-gates.test.ts | 26 +- .../test/unit/chat-mentions-gateway.test.ts | 14 +- .../unit/chat-poll-close-membership.test.ts | 4 +- services/api/test/unit/chat-presence.test.ts | 31 +- .../api/test/unit/chat-pubsub-redis.test.ts | 12 +- services/api/test/unit/chat-pubsub.test.ts | 14 +- .../test/unit/chat-reaction-service.test.ts | 7 +- services/api/test/unit/chat-realtime.test.ts | 31 +- .../api/test/unit/chat-reply-gateway.test.ts | 23 +- .../unit/chat-room-fanout-coalescing.test.ts | 24 +- .../api/test/unit/chat-room-roles.test.ts | 12 +- services/api/test/unit/chat-routes.test.ts | 13 +- .../test/unit/chat-send-resilience.test.ts | 33 +- services/api/test/unit/chat-tombstone.test.ts | 7 +- services/api/test/unit/chat-typing.test.ts | 14 +- .../api/test/unit/city-forward-wiring.test.ts | 3 +- services/api/test/unit/claim-service.test.ts | 14 +- .../api/test/unit/cleanup-complete.test.ts | 14 +- .../api/test/unit/cleanup-linking.test.ts | 58 +- .../api/test/unit/cleanup-service.test.ts | 113 +- .../test/unit/cleanup-slots-service.test.ts | 60 +- .../api/test/unit/cleanups-routes.test.ts | 73 +- services/api/test/unit/cursor-helpers.test.ts | 10 +- services/api/test/unit/data-export.test.ts | 8 +- services/api/test/unit/db-client.test.ts | 3 +- .../test/unit/delete-account-route.test.ts | 22 +- services/api/test/unit/discovery-jobs.test.ts | 6 +- .../api/test/unit/discussion-mentions.test.ts | 5 +- .../api/test/unit/dm-blocks-threads.test.ts | 1 - services/api/test/unit/dm-gateway.test.ts | 96 +- .../api/test/unit/dm-notifications.test.ts | 50 +- services/api/test/unit/dm-service.test.ts | 16 +- .../unit/drizzle-client-sql-guard.test.ts | 4 +- services/api/test/unit/email-format.test.ts | 53 +- services/api/test/unit/enums.test.ts | 13 +- services/api/test/unit/env.test.ts | 20 +- .../api/test/unit/errors-http-mapper.test.ts | 6 +- .../unit/event-address-verification.test.ts | 5 +- services/api/test/unit/federal-lands.test.ts | 7 +- .../api/test/unit/feed-counts-service.test.ts | 5 +- .../api/test/unit/feed-ranked-service.test.ts | 25 +- services/api/test/unit/feed-ranking.test.ts | 12 +- .../unit/forward-template-service.test.ts | 5 +- .../api/test/unit/guest-rsvp-routes.test.ts | 4 +- .../api/test/unit/guest-rsvp-service.test.ts | 36 +- services/api/test/unit/handle-policy.test.ts | 10 +- .../api/test/unit/home-turf-routes.test.ts | 93 +- services/api/test/unit/host-analytics.test.ts | 26 +- .../test/unit/host-broadcast-audience.test.ts | 8 +- .../api/test/unit/host-broadcast-caps.test.ts | 5 +- .../test/unit/host-broadcast-pipeline.test.ts | 4 +- .../test/unit/host-broadcast-render.test.ts | 5 +- services/api/test/unit/host-comms-env.test.ts | 11 +- .../api/test/unit/host-event-metrics.test.ts | 4 +- .../api/test/unit/host-export-csv.test.ts | 26 +- services/api/test/unit/host-insights.test.ts | 4 +- .../api/test/unit/host-mail-failure.test.ts | 41 +- .../test/unit/host-unsubscribe-route.test.ts | 5 +- .../test/unit/host/capabilities-authz.test.ts | 4 +- .../test/unit/host/checkin-service.test.ts | 4 +- .../unit/host/cleanup-host-fields.test.ts | 51 +- .../unit/host/event-analytics-service.test.ts | 7 +- .../unit/host/host-portfolio-service.test.ts | 18 +- .../api/test/unit/host/host-routes.test.ts | 9 +- .../host/host-standing-sql-null-guard.test.ts | 4 +- .../test/unit/host/host-team-service.test.ts | 18 +- .../host/insights-invalidation-wiring.test.ts | 5 +- .../test/unit/host/org-invite-inbox.test.ts | 10 +- .../test/unit/host/org-team-routes.test.ts | 66 +- .../unit/host/organization-events.test.ts | 6 +- .../unit/host/organization-service.test.ts | 185 ++- .../api/test/unit/host/page-service.test.ts | 28 +- .../unit/host/registration-retention.test.ts | 5 +- .../unit/host/registration-service.test.ts | 55 +- .../api/test/unit/host/signup-seats.test.ts | 5 +- .../api/test/unit/host/ticket-token.test.ts | 8 +- .../unit/host/ticket-type-service.test.ts | 16 +- .../test/unit/host/waitlist-service.test.ts | 41 +- .../inbound-html-sanitizer-bypass.test.ts | 16 +- .../unit/inbound-mail-authentication.test.ts | 145 ++- .../test/unit/inbound-mail-webhook.test.ts | 73 +- .../unit/inbound-processor-hardening.test.ts | 9 +- .../api/test/unit/inbound-processor.test.ts | 155 ++- .../unit/inbound-retention-repository.test.ts | 4 +- services/api/test/unit/inbound-sweep.test.ts | 33 +- .../api/test/unit/ingest-geojsonseq.test.ts | 14 +- .../test/unit/ingest-jurisdictions.test.ts | 12 +- services/api/test/unit/jobs-pgboss.test.ts | 10 +- .../unit/jurisdiction-lookup-census.test.ts | 9 +- .../test/unit/jurisdiction-service.test.ts | 11 +- services/api/test/unit/legal-seed.test.ts | 5 +- services/api/test/unit/mail-preview.test.ts | 4 +- services/api/test/unit/map-routes.test.ts | 7 +- .../api/test/unit/media-authorization.test.ts | 19 +- .../unit/media-binding-drop-guard.test.ts | 1 - .../test/unit/media-intake-service.test.ts | 25 +- services/api/test/unit/media-routes.test.ts | 13 +- .../unit/media-servable-quarantine.test.ts | 42 +- .../migrations-transaction-control.test.ts | 4 +- .../unit/moderation-operator-guard.test.ts | 8 +- .../api/test/unit/notification-routes.test.ts | 47 +- .../test/unit/notification-service.test.ts | 86 +- .../test/unit/outbound-mail-service.test.ts | 52 +- .../api/test/unit/outreach-pipeline.test.ts | 11 +- .../api/test/unit/packet-media-links.test.ts | 29 +- services/api/test/unit/parse-acs.test.ts | 32 +- services/api/test/unit/post-service.test.ts | 93 +- services/api/test/unit/posts-routes.test.ts | 196 ++- .../unit/primary-affiliation-settings.test.ts | 4 +- services/api/test/unit/push-expo.test.ts | 10 +- services/api/test/unit/push-sender.test.ts | 9 +- .../test/unit/push-token-registration.test.ts | 28 +- .../test/unit/push-webpush-deadline.test.ts | 10 +- .../api/test/unit/rate-limit-plugin.test.ts | 19 +- .../test/unit/report-chat-bell-wiring.test.ts | 12 +- .../unit/report-chat-emitter-seams.test.ts | 3 +- .../test/unit/report-chat-membership.test.ts | 46 +- .../unit/report-chat-notifications.test.ts | 64 +- services/api/test/unit/report-chat.test.ts | 235 +++- .../test/unit/report-content-routes.test.ts | 7 +- .../test/unit/report-forward-audit.test.ts | 9 +- .../unit/report-message-city-forward.test.ts | 19 +- services/api/test/unit/report-routes.test.ts | 151 ++- services/api/test/unit/report-service.test.ts | 536 +++++++-- .../test/unit/report-system-message.test.ts | 5 +- .../test/unit/report-timeline-event.test.ts | 15 +- .../api/test/unit/report-verification.test.ts | 11 +- .../test/unit/reverse-geocode-mapbox.test.ts | 27 +- .../test/unit/reverse-geocode-photon.test.ts | 47 +- .../api/test/unit/room-read-service.test.ts | 1 - services/api/test/unit/route-coverage.test.ts | 1 - .../api/test/unit/route-geo-helpers.test.ts | 39 +- .../api/test/unit/server-timeouts.test.ts | 1 - services/api/test/unit/slur-filter.test.ts | 1 - services/api/test/unit/sms-twilio.test.ts | 15 +- .../api/test/unit/social-mentions.test.ts | 1 - services/api/test/unit/social-routes.test.ts | 25 +- services/api/test/unit/social-service.test.ts | 14 +- .../api/test/unit/social-suggest-sql.test.ts | 1 - services/api/test/unit/storage-local.test.ts | Bin 15785 -> 15809 bytes .../api/test/unit/storage-r2-proxy.test.ts | 1 - .../api/test/unit/thread-read-routes.test.ts | 6 +- services/api/test/unit/threads-report.test.ts | 5 +- services/api/test/unit/threads.test.ts | 54 +- services/api/test/unit/trust-proxy.test.ts | 4 - .../test/unit/user-channel-gateway.test.ts | 21 +- .../api/test/unit/user-channel-redis.test.ts | 6 +- services/api/test/unit/users-routes.test.ts | 74 +- .../test/unit/volunteer-hours-entries.test.ts | 9 +- .../test/unit/volunteer-hours-service.test.ts | 36 +- .../test/unit/ws-security-hardening.test.ts | 40 +- .../api/test/unit/ws-socket-lifecycle.test.ts | 34 +- .../test/unit/ws-upgrade-rate-limit.test.ts | 3 +- services/media-worker/src/config.ts | 4 +- services/media-worker/src/download.ts | 1 - services/media-worker/src/jobs.ts | 23 +- .../src/jobs/hold-release-sweep.ts | 5 +- .../media-worker/src/jobs/media-checks.ts | 13 +- services/media-worker/src/jobs/media-keys.ts | 1 - .../media-worker/src/jobs/media-pipeline.ts | 1 - .../media-worker/src/jobs/orphan-sweep.ts | 13 +- .../src/jobs/partition-maintenance.ts | 1 - .../media-worker/src/jobs/reject-cleanup.ts | 20 +- .../media-worker/src/jobs/retention-sweep.ts | 4 +- services/media-worker/src/jobs/upload-reap.ts | 7 +- services/media-worker/src/sandbox/binaries.ts | 1 - services/media-worker/src/sandbox/exec.ts | 1 - .../media-worker/src/sandbox/ffmpeg-remux.ts | 1 - services/media-worker/src/sandbox/ffprobe.ts | 1 - .../src/sandbox/image-lane-main.ts | 4 +- .../media-worker/src/sandbox/image-lane.ts | 10 +- .../media-worker/src/sandbox/preflight.ts | 9 +- services/media-worker/src/sandbox/tmp.ts | 13 +- services/media-worker/src/seams.ts | 6 +- services/media-worker/src/worker.ts | 41 +- services/media-worker/test/fixtures/make.ts | 1 - .../media-worker/test/helpers/ffprobe-tags.ts | 1 - .../test/helpers/in-memory-repo.ts | 1 - .../test/integration/media-checks-pg.test.ts | 4 +- .../retention-and-phash-pg.test.ts | 16 +- .../served-key-and-orphan-pg.test.ts | 1 - .../test/unit/anon-hold-release.test.ts | 10 +- .../media-worker/test/unit/download.test.ts | 7 +- .../test/unit/egress-proxy.test.ts | 1 - .../test/unit/exec-real-spawn.test.ts | 10 +- .../media-worker/test/unit/image-lane.test.ts | 37 +- .../test/unit/maintenance.test.ts | 162 ++- .../test/unit/media-checks.test.ts | 55 +- .../test/unit/orphan-race.test.ts | 1 - .../test/unit/pg-boss-jobs.test.ts | 40 +- .../test/unit/preflight-proof.test.ts | 31 +- .../test/unit/retention-sweep.test.ts | 5 +- .../test/unit/sandbox-hardening.test.ts | 15 +- .../media-worker/test/unit/sandbox.test.ts | 21 +- .../media-worker/test/unit/scratch-io.test.ts | 23 +- .../media-worker/test/unit/served-key.test.ts | 1 - .../unit/spawn-failure-classification.test.ts | 6 +- .../test/unit/upload-reap.test.ts | 25 +- .../media-worker/test/unit/video-caps.test.ts | 1 - .../test/unit/video-policy.test.ts | 5 +- .../media-worker/test/unit/worker.test.ts | 13 +- 681 files changed, 10374 insertions(+), 4767 deletions(-) diff --git a/infra/email-worker/src/index.ts b/infra/email-worker/src/index.ts index 1c46970d..9e91ccda 100644 --- a/infra/email-worker/src/index.ts +++ b/infra/email-worker/src/index.ts @@ -1,4 +1,3 @@ - export interface Env { R2_BUCKET: R2Bucket BACKEND_WEBHOOK_URL: string @@ -36,7 +35,6 @@ export default { }, } - export async function deriveMessageId(headers: Headers, raw: ArrayBuffer): Promise { const slug = slugify(headers.get("message-id") ?? "") if (slug.length > 0) return slug @@ -54,7 +52,6 @@ export function slugify(messageId: string): string { .slice(0, 200) } - async function nudgeBackend(env: Env, key: string): Promise { const body = JSON.stringify({ key }) const ts = Math.floor(Date.now() / 1000).toString() @@ -69,8 +66,7 @@ async function nudgeBackend(env: Env, key: string): Promise { }, body, }) - } catch { - } + } catch {} } export async function hmacSha256Hex(secret: string, message: string): Promise { diff --git a/package.json b/package.json index 00b37f33..f78b199d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "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 \u2014 seven advisories including RSA and Ed25519 SIGNATURE FORGERY \u2014 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": "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-forge": "^1.4.0", diff --git a/scripts/assert-no-frontend.mjs b/scripts/assert-no-frontend.mjs index 9cc528fc..0a0db273 100644 --- a/scripts/assert-no-frontend.mjs +++ b/scripts/assert-no-frontend.mjs @@ -61,7 +61,9 @@ let lockfileText try { lockfileText = readFileSync(lockfilePath, "utf8") } catch (err) { - console.error(`[assert-no-frontend] FAIL: could not read ${LOCKFILE} at ${lockfilePath}: ${err.message}`) + console.error( + `[assert-no-frontend] FAIL: could not read ${LOCKFILE} at ${lockfilePath}: ${err.message}`, + ) process.exit(1) } diff --git a/scripts/check-dynamic-sql.mjs b/scripts/check-dynamic-sql.mjs index c37ecea4..6e91c9af 100644 --- a/scripts/check-dynamic-sql.mjs +++ b/scripts/check-dynamic-sql.mjs @@ -32,9 +32,7 @@ const NULL_TEST_SKIP_PATH = /(^|[\\/])db[\\/]schema[\\/]/ const IDENTIFIER_HELPER = /^[A-Za-z_$][A-Za-z0-9_$.]*\s*\(/ -const ALLOW_NULL_TEST = new Set([ - "services/api/src/auth/pg-stores.ts:${users.email} is not null", -]) +const ALLOW_NULL_TEST = new Set(["services/api/src/auth/pg-stores.ts:${users.email} is not null"]) export function findParamNullTests(code) { const found = [] @@ -55,12 +53,30 @@ const walk = (dir) => }) const REGEX_PRECEDING_KEYWORDS = new Set([ - "return", "typeof", "instanceof", "in", "of", "new", "delete", "void", - "do", "else", "yield", "await", "case", "throw", + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "do", + "else", + "yield", + "await", + "case", + "throw", ]) function isIdentStart(c) { - return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_" || c === "$" || c.charCodeAt(0) > 127 + return ( + (c >= "a" && c <= "z") || + (c >= "A" && c <= "Z") || + c === "_" || + c === "$" || + c.charCodeAt(0) > 127 + ) } function isIdentPart(c) { return isIdentStart(c) || (c >= "0" && c <= "9") @@ -82,7 +98,10 @@ function stripComments(input) { i++ while (i < n) { const c = input[i] - if (c === "\\") { i += 2; continue } + if (c === "\\") { + i += 2 + continue + } if (c === q) return i + 1 if (c === "\n") return i i++ @@ -96,10 +115,24 @@ function stripComments(input) { while (i < n) { const c = input[i] if (c === "\n") return i - if (c === "\\") { i += 2; continue } - if (c === "[") { inClass = true; i++; continue } - if (c === "]") { inClass = false; i++; continue } - if (c === "/" && !inClass) { i++; break } + if (c === "\\") { + i += 2 + continue + } + if (c === "[") { + inClass = true + i++ + continue + } + if (c === "]") { + inClass = false + i++ + continue + } + if (c === "/" && !inClass) { + i++ + break + } i++ } while (i < n && isIdentPart(input[i])) i++ @@ -110,7 +143,10 @@ function stripComments(input) { i++ while (i < n) { const c = input[i] - if (c === "\\") { i += 2; continue } + if (c === "\\") { + i += 2 + continue + } if (c === "`") return i + 1 if (c === "$" && input[i + 1] === "{") { i = scanCode(i + 2, true) @@ -134,7 +170,10 @@ function stripComments(input) { let depth = 0 while (i < n) { const c = input[i] - if (c === " " || c === "\t" || c === "\n" || c === "\r") { i++; continue } + if (c === " " || c === "\t" || c === "\n" || c === "\r") { + i++ + continue + } if (c === "/" && input[i + 1] === "/") { const start = i i += 2 @@ -150,11 +189,28 @@ function stripComments(input) { spans.push({ start, end: i }) continue } - if (c === '"' || c === "'") { i = scanString(i, c); prevType = "value"; prevWord = ""; continue } - if (c === "`") { i = scanTemplate(i); prevType = "value"; prevWord = ""; continue } + if (c === '"' || c === "'") { + i = scanString(i, c) + prevType = "value" + prevWord = "" + continue + } + if (c === "`") { + i = scanTemplate(i) + prevType = "value" + prevWord = "" + continue + } if (c === "/") { - if (regexAllowed()) { i = scanRegex(i); prevType = "value"; prevWord = "" } - else { i++; prevType = "punct"; prevWord = "" } + if (regexAllowed()) { + i = scanRegex(i) + prevType = "value" + prevWord = "" + } else { + i++ + prevType = "punct" + prevWord = "" + } continue } if (isIdentStart(c)) { @@ -168,19 +224,33 @@ function stripComments(input) { if (c >= "0" && c <= "9") { i++ while (i < n && /[0-9a-fA-FxXbBoOeE._n]/.test(input[i])) i++ - prevType = "value"; prevWord = "" + prevType = "value" + prevWord = "" + continue + } + if (c === "{") { + depth++ + i++ + prevType = "punct" + prevWord = "" continue } - if (c === "{") { depth++; i++; prevType = "punct"; prevWord = ""; continue } if (c === "}") { if (stopAtBrace && depth === 0) return i if (depth > 0) depth-- - i++; prevType = "punct"; prevWord = "" + i++ + prevType = "punct" + prevWord = "" continue } i++ - if (c === ")" || c === "]") { prevType = "value"; prevWord = "" } - else { prevType = "punct"; prevWord = "" } + if (c === ")" || c === "]") { + prevType = "value" + prevWord = "" + } else { + prevType = "punct" + prevWord = "" + } } return i } diff --git a/services/api/scripts/prepare-boundaries.ts b/services/api/scripts/prepare-boundaries.ts index 4f0a6afc..98a7a060 100644 --- a/services/api/scripts/prepare-boundaries.ts +++ b/services/api/scripts/prepare-boundaries.ts @@ -37,7 +37,9 @@ 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.`) + console.error( + `${PREFIX}: ogr2ogr (GDAL) not found on PATH — install GDAL (e.g. \`brew install gdal\`) and re-run.`, + ) process.exit(2) } } diff --git a/services/api/scripts/refresh-boundaries.ts b/services/api/scripts/refresh-boundaries.ts index 9b4233d2..caa636db 100644 --- a/services/api/scripts/refresh-boundaries.ts +++ b/services/api/scripts/refresh-boundaries.ts @@ -30,9 +30,13 @@ function isFederal(job: BoundaryJob): boolean { } function ssh(remoteCmd: string): string { - return execFileSync("ssh", ["-o", "BatchMode=yes", "-o", "ConnectTimeout=20", SSH_HOST, remoteCmd], { - encoding: "utf8", - }).trim() + return execFileSync( + "ssh", + ["-o", "BatchMode=yes", "-o", "ConnectTimeout=20", SSH_HOST, remoteCmd], + { + encoding: "utf8", + }, + ).trim() } async function waitForPort(port: number, tries = 30): Promise { @@ -66,9 +70,13 @@ function resolvePostgresIp(): string { .map((line) => line.trim().split(/\s+/)) .filter((parts): parts is [string, string] => parts.length === 2 && IPV4.test(parts[1]!)) .map(([network, ip]) => ({ network, ip })) - const picked = attached.find((a) => a.network.endsWith("_default")) ?? (attached.length === 1 ? attached[0] : undefined) + const picked = + attached.find((a) => a.network.endsWith("_default")) ?? + (attached.length === 1 ? attached[0] : undefined) if (!picked) { - throw new Error(`could not pick the postgres bridge IP for ${PG_CONTAINER}; networks reported: ${raw || "(none)"}`) + throw new Error( + `could not pick the postgres bridge IP for ${PG_CONTAINER}; networks reported: ${raw || "(none)"}`, + ) } log(`postgres bridge IP ${picked.ip} on network ${picked.network}`) return picked.ip @@ -83,7 +91,16 @@ async function openTunnel(): Promise<{ databaseUrl: string; close: () => void }> log(`opening ssh -L ${LOCAL_PORT}:${pgIp}:5432 ${SSH_HOST}`) const child: ChildProcess = spawn( "ssh", - ["-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-L", `${LOCAL_PORT}:${pgIp}:5432`, SSH_HOST], + [ + "-N", + "-o", + "BatchMode=yes", + "-o", + "ExitOnForwardFailure=yes", + "-L", + `${LOCAL_PORT}:${pgIp}:5432`, + SSH_HOST, + ], { stdio: ["ignore", "inherit", "inherit"] }, ) child.on("exit", (code) => { @@ -111,19 +128,30 @@ function looksLikeZip(path: string): boolean { } function responseSummary(path: string): string { - return readFileSync(path, "utf8").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 160) + return readFileSync(path, "utf8") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 160) } function downloadZip(url: string, zip: string): void { let lastResponse = "" for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt++) { - const attemptUrl = attempt === 1 ? url : `${url}${url.includes("?") ? "&" : "?"}attempt=${attempt}` - execFileSync("curl", ["-fsSL", attemptUrl, "-o", zip], { stdio: ["ignore", "inherit", "inherit"] }) + const attemptUrl = + attempt === 1 ? url : `${url}${url.includes("?") ? "&" : "?"}attempt=${attempt}` + execFileSync("curl", ["-fsSL", attemptUrl, "-o", zip], { + stdio: ["ignore", "inherit", "inherit"], + }) if (looksLikeZip(zip)) return lastResponse = responseSummary(zip) - warn(`${url}: response is not a zip archive (attempt ${attempt}/${DOWNLOAD_ATTEMPTS}): ${lastResponse}`) + warn( + `${url}: response is not a zip archive (attempt ${attempt}/${DOWNLOAD_ATTEMPTS}): ${lastResponse}`, + ) } - throw new Error(`${url}: never returned a zip archive after ${DOWNLOAD_ATTEMPTS} attempts; last response: ${lastResponse}`) + throw new Error( + `${url}: never returned a zip archive after ${DOWNLOAD_ATTEMPTS} attempts; last response: ${lastResponse}`, + ) } function fetchSource(job: BoundaryJob, outDir: string): boolean { @@ -172,7 +200,9 @@ function convert(job: BoundaryJob, outDir: string): string | null { type Sql = Parameters[0] -async function countLoadedLayers(sql: Sql): Promise<{ rowCounts: Record; federalLoaded: boolean }> { +async function countLoadedLayers( + sql: Sql, +): Promise<{ rowCounts: Record; federalLoaded: boolean }> { const rows = await sql<{ layer: string; n: number }[]>` SELECT layer, count(*)::int AS n FROM jurisdictions GROUP BY layer ` @@ -209,7 +239,12 @@ async function prune(sql: Sql): Promise { if (pruned > 0) log(`pruned ${pruned} non-authoritative (dev-seed) federal/tribal row(s)`) } -async function stampVintage(sql: Sql, tag: string, year: number, rowCounts: Record): Promise { +async function stampVintage( + sql: Sql, + tag: string, + year: number, + rowCounts: Record, +): Promise { await sql` INSERT INTO boundary_vintage (id, vintage_tag, tiger_vintage, padus_version, row_counts, loaded_at) VALUES (true, ${tag}, ${year}, ${PADUS_VERSION}, ${sql.json(rowCounts as Parameters[0])}, now()) @@ -239,7 +274,9 @@ async function main(): Promise { try { 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.`) + console.error( + `${PREFIX}: ogr2ogr (GDAL) not found on PATH — install it (e.g. \`brew install gdal\`) and re-run.`, + ) process.exit(2) } } @@ -267,7 +304,10 @@ async function main(): Promise { log("--backfill-only: skipping download/convert/ingest; reading layers already in the DB") ;({ rowCounts, federalLoaded } = await countLoadedLayers(handle.sql)) 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)") + if (total === 0) + warn( + "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}`) mkdirSync(join(outDir, "sources"), { recursive: true }) @@ -288,8 +328,18 @@ async function main(): Promise { for (const { job, path } of converted) { try { const { upserted, skipped, features } = path.endsWith(".geojsonl") - ? await ingestGeoJsonSeqFile(handle.sql, path, job.layer, job.ingestGeoidPrefix ?? undefined) - : await ingestGeoJsonFile(handle.sql, readFileSync(path, "utf8"), job.layer, job.ingestGeoidPrefix ?? undefined) + ? await ingestGeoJsonSeqFile( + handle.sql, + path, + job.layer, + job.ingestGeoidPrefix ?? undefined, + ) + : await ingestGeoJsonFile( + handle.sql, + readFileSync(path, "utf8"), + job.layer, + job.ingestGeoidPrefix ?? undefined, + ) 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`) diff --git a/services/api/scripts/render-email-gallery.ts b/services/api/scripts/render-email-gallery.ts index 66dcbd66..c5ea1003 100644 --- a/services/api/scripts/render-email-gallery.ts +++ b/services/api/scripts/render-email-gallery.ts @@ -64,8 +64,18 @@ function sampleReport(): AdminReportRecord { function collect(): GalleryEntry[] { const entries: GalleryEntry[] = [] - const push = (name: string, label: string, r: { subject: string; html?: string; text: string }): void => { - entries.push({ name, label, subject: r.subject, html: r.html ?? `
${r.text}
`, text: r.text }) + const push = ( + name: string, + label: string, + r: { subject: string; html?: string; text: string }, + ): void => { + entries.push({ + name, + label, + subject: r.subject, + html: r.html ?? `
${r.text}
`, + text: r.text, + }) } push("signin-otp", "Sign-in passcode (Mailer.sendOtp)", renderOtp("482913", "en")) @@ -257,7 +267,12 @@ function collect(): GalleryEntry[] { "admin-discussion-forward", "Admin discussion forward (to city office)", buildDiscussionForwardPacket( - { reportId: "11111111-2222-3333-4444-555555555555", category: "graffiti", place: "Del Rey", org: null }, + { + reportId: "11111111-2222-3333-4444-555555555555", + category: "graffiti", + place: "Del Rey", + org: null, + }, "The tags are back again this week - is there a schedule for abatement on this wall?", ), ) diff --git a/services/api/scripts/revoke-certificate.ts b/services/api/scripts/revoke-certificate.ts index e68d4f7c..16790ae1 100644 --- a/services/api/scripts/revoke-certificate.ts +++ b/services/api/scripts/revoke-certificate.ts @@ -124,7 +124,9 @@ async function main(): Promise { const databaseUrl = process.env.DATABASE_URL if (!databaseUrl) { - console.error(`${PREFIX}: DATABASE_URL is required (open a tunnel to prod Postgres and export it)`) + console.error( + `${PREFIX}: DATABASE_URL is required (open a tunnel to prod Postgres and export it)`, + ) process.exit(2) } @@ -140,7 +142,9 @@ async function main(): Promise { } log(`code ${formatCertificateCode(before.code)}`) - log(`holder ${before.holderName} (${before.holderHandle ?? "no handle"}) ${before.userId}`) + log( + `holder ${before.holderName} (${before.holderHandle ?? "no handle"}) ${before.userId}`, + ) log(`issued ${before.issuedAt.toISOString()}`) log(`hours ${before.totalHours} over ${before.entryCount} activities`) log(`object ${before.r2Key}`) @@ -168,7 +172,9 @@ async function main(): Promise { `(${row.revokedReason ?? "no reason"}) — "${args.reason}" was NOT written`, ) } else { - log(`revoked at ${row.revokedAt?.toISOString() ?? "?"} with reason "${row.revokedReason ?? "?"}"`) + log( + `revoked at ${row.revokedAt?.toISOString() ?? "?"} with reason "${row.revokedReason ?? "?"}"`, + ) log(`verify() now reports status "revoked" with that reason to anyone holding the paper`) } @@ -178,7 +184,9 @@ async function main(): Promise { } await deleteObject(row.r2Key) - log(`done. Notify the holder — after the ledger correction they can re-issue a corrected transcript.`) + log( + `done. Notify the holder — after the ledger correction they can re-issue a corrected transcript.`, + ) } finally { await handle.close() } diff --git a/services/api/src/abuse/anon-token.ts b/services/api/src/abuse/anon-token.ts index 25dc01f5..f7a4cdde 100644 --- a/services/api/src/abuse/anon-token.ts +++ b/services/api/src/abuse/anon-token.ts @@ -148,7 +148,9 @@ export function assertUnderReportCap( cap: number = ANON_TOKEN_REPORT_CAP, ): { remaining: number } { if (row.reportCount >= cap) { - throw AppError.rateLimited("This anonymous session has reached its report limit. Sign in to continue.") + throw AppError.rateLimited( + "This anonymous session has reached its report limit. Sign in to continue.", + ) } return { remaining: cap - row.reportCount } } diff --git a/services/api/src/abuse/counter-store.ts b/services/api/src/abuse/counter-store.ts index c37b4a09..9e8f45dc 100644 --- a/services/api/src/abuse/counter-store.ts +++ b/services/api/src/abuse/counter-store.ts @@ -1,4 +1,3 @@ - import type { RedisClient } from "../adapters/redis.js" import { attachAtomicIncr, attachAtomicIncrBy } from "../adapters/redis-incr.js" diff --git a/services/api/src/abuse/gps-sanity.ts b/services/api/src/abuse/gps-sanity.ts index bd3a3f8e..223522b0 100644 --- a/services/api/src/abuse/gps-sanity.ts +++ b/services/api/src/abuse/gps-sanity.ts @@ -1,4 +1,3 @@ - import type { AbuseChecks } from "@civfix/shared/interfaces" import type { LatLng } from "@civfix/shared" diff --git a/services/api/src/abuse/slur-filter.ts b/services/api/src/abuse/slur-filter.ts index 876b8fc6..036e60a2 100644 --- a/services/api/src/abuse/slur-filter.ts +++ b/services/api/src/abuse/slur-filter.ts @@ -1,4 +1,3 @@ - import { AppError } from "@civfix/shared" const SLUR_BASES: readonly string[] = [ @@ -35,7 +34,13 @@ function buildPatterns(bases: readonly string[]): readonly RegExp[] { const RAW_PATTERNS = buildPatterns(SLUR_BASES) -const DIGIT_LEET: Readonly> = { "0": "o", "1": "i", "3": "e", "4": "a", "5": "s" } +const DIGIT_LEET: Readonly> = { + "0": "o", + "1": "i", + "3": "e", + "4": "a", + "5": "s", +} const SYMBOL_LEET: Readonly> = { "!": "i", "|": "i", "@": "a" } function deLeet(s: string): string { diff --git a/services/api/src/adapters/abuse-checks.ts b/services/api/src/adapters/abuse-checks.ts index 792dba57..41222f17 100644 --- a/services/api/src/adapters/abuse-checks.ts +++ b/services/api/src/adapters/abuse-checks.ts @@ -1,4 +1,3 @@ - import { AppError } from "@civfix/shared" import type { AbuseChecks, NearDuplicateResult } from "@civfix/shared/interfaces" import type { LatLng } from "@civfix/shared" diff --git a/services/api/src/adapters/chat-pubsub.ts b/services/api/src/adapters/chat-pubsub.ts index 9f3e8acf..47f8bbfc 100644 --- a/services/api/src/adapters/chat-pubsub.ts +++ b/services/api/src/adapters/chat-pubsub.ts @@ -1,4 +1,3 @@ - import { attachRedisErrorHandler, type RedisClient } from "./redis.js" import { RefCountedSubscriptions } from "./ref-counted-subscriptions.js" diff --git a/services/api/src/adapters/chat-service.ws.ts b/services/api/src/adapters/chat-service.ws.ts index 76108542..88148992 100644 --- a/services/api/src/adapters/chat-service.ws.ts +++ b/services/api/src/adapters/chat-service.ws.ts @@ -1,4 +1,3 @@ - import type { ChatService, ChatConnection, diff --git a/services/api/src/adapters/email-blocks.ts b/services/api/src/adapters/email-blocks.ts index 308b36ad..f7272b2d 100644 --- a/services/api/src/adapters/email-blocks.ts +++ b/services/api/src/adapters/email-blocks.ts @@ -23,7 +23,6 @@ function htmlText(value: string): string { return escapeHtml(value).replace(/\n/g, "
") } - export function heading(text: string): EmailBlock { return { html: `

${escapeHtml(text)}

`, @@ -135,7 +134,10 @@ export function richList(list: MarkdownList): EmailBlock { ) .join("") const text = list.items - .map((item, i) => `${list.ordered ? `${i + 1}.` : "-"} ${markdownInlineToPlainText(item.children)}`) + .map( + (item, i) => + `${list.ordered ? `${i + 1}.` : "-"} ${markdownInlineToPlainText(item.children)}`, + ) .join("\n") return { html: `<${tag} style="margin:0 0 14px;padding-left:20px;">${items}`, diff --git a/services/api/src/adapters/email-layout.ts b/services/api/src/adapters/email-layout.ts index b866ae1a..f11fb4e6 100644 --- a/services/api/src/adapters/email-layout.ts +++ b/services/api/src/adapters/email-layout.ts @@ -84,7 +84,10 @@ const FOOTER_URL_RE = /https?:\/\/[^\s<>"]+/g function footerHtml(footer: string): string { return escapeHtml(footer) - .replace(FOOTER_URL_RE, (url) => `${url}`) + .replace( + FOOTER_URL_RE, + (url) => `${url}`, + ) .replace(/\n/g, "
") } diff --git a/services/api/src/adapters/http-fetch.ts b/services/api/src/adapters/http-fetch.ts index 3910a996..a5aeea36 100644 --- a/services/api/src/adapters/http-fetch.ts +++ b/services/api/src/adapters/http-fetch.ts @@ -1,4 +1,3 @@ - export type FetchJsonResult = | { ok: true; status: number; json: T } | { ok: false; kind: "http"; status: number } @@ -46,7 +45,9 @@ export async function fetchJsonWithTimeout( } const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES const declared = - typeof res.headers?.get === "function" ? Number(res.headers.get("content-length") ?? "") : Number.NaN + typeof res.headers?.get === "function" + ? Number(res.headers.get("content-length") ?? "") + : Number.NaN if (Number.isFinite(declared) && declared > maxBytes) { await res.body?.cancel().catch(() => {}) return { ok: false, kind: "body", status, error: new JsonBodyTooLargeError(maxBytes) } diff --git a/services/api/src/adapters/inbound-mail.cf.ts b/services/api/src/adapters/inbound-mail.cf.ts index 3ca528b1..97eb3678 100644 --- a/services/api/src/adapters/inbound-mail.cf.ts +++ b/services/api/src/adapters/inbound-mail.cf.ts @@ -1,4 +1,3 @@ - import type { InboundMail, ParsedMail, @@ -114,11 +113,13 @@ const QUOTED_REMOTE_IP_RE = /smtp\.remote-ip\s*=\s*"[0-9a-f:.]+"/gi const FLAT_COMMENT_RE = /\([^()]*\)/g -const ENVELOPE_ADDRESS_RE = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@([^@]+)$/ +const ENVELOPE_ADDRESS_RE = + /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@([^@]+)$/ const REMOTE_IP_RE = /^[0-9a-f:.]+$/ -const HOSTNAME_RE = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ +const HOSTNAME_RE = + /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ const ORGANIZATIONAL_DOMAIN_OPTIONS = { allowPrivateDomains: true, extractHostname: false } as const @@ -165,7 +166,11 @@ function parseStamp(stamp: string): AuthResult[] | null { if (/[()]/.test(uncommented)) return null const results: AuthResult[] = [] for (const resinfo of uncommented.split(";").slice(1)) { - const [methodSpec = "", ...propSpecs] = resinfo.replace(/\s*=\s*/g, "=").trim().toLowerCase().split(/\s+/) + const [methodSpec = "", ...propSpecs] = resinfo + .replace(/\s*=\s*/g, "=") + .trim() + .toLowerCase() + .split(/\s+/) if (propSpecs.length === 0 && (methodSpec === "" || methodSpec === "none")) continue const [, method, result] = METHOD_SPEC_RE.exec(methodSpec) ?? [] if (method === undefined || result === undefined) return null @@ -206,7 +211,9 @@ function isEnvelopeAddress(value: string): boolean { function isBareArcResult(result: AuthResult): boolean { if (result.method !== "arc") return false - return [...result.props].every(([name, value]) => name === "smtp.remote-ip" && REMOTE_IP_RE.test(value)) + return [...result.props].every( + ([name, value]) => name === "smtp.remote-ip" && REMOTE_IP_RE.test(value), + ) } function isAlignedPass(result: AuthResult, identity: string | undefined, from: string): boolean { diff --git a/services/api/src/adapters/jobs.pgboss.ts b/services/api/src/adapters/jobs.pgboss.ts index be9b953e..fa4f7a72 100644 --- a/services/api/src/adapters/jobs.pgboss.ts +++ b/services/api/src/adapters/jobs.pgboss.ts @@ -1,4 +1,3 @@ - import type PgBoss from "pg-boss" import type { Jobs, EnqueueOptions, JobHandler } from "@civfix/shared/interfaces" import { REGISTRATION_QUEUE_NAMES } from "../services/host/registration-queues.js" diff --git a/services/api/src/adapters/jurisdiction-lookup.census.ts b/services/api/src/adapters/jurisdiction-lookup.census.ts index 30c7f7dd..1d240052 100644 --- a/services/api/src/adapters/jurisdiction-lookup.census.ts +++ b/services/api/src/adapters/jurisdiction-lookup.census.ts @@ -1,4 +1,3 @@ - import { fetchJsonWithTimeout } from "./http-fetch.js" export interface JurisdictionLookupResult { diff --git a/services/api/src/adapters/mail-text.ts b/services/api/src/adapters/mail-text.ts index 364b179f..cc2b59d1 100644 --- a/services/api/src/adapters/mail-text.ts +++ b/services/api/src/adapters/mail-text.ts @@ -25,7 +25,11 @@ export function domainOfOrNull(addr: string | null | undefined): string | null { if (!addr) return null const at = addr.lastIndexOf("@") if (at < 0) return null - const domain = addr.slice(at + 1).replace(/>.*$/, "").trim().toLowerCase() + const domain = addr + .slice(at + 1) + .replace(/>.*$/, "") + .trim() + .toLowerCase() return domain.length > 0 ? domain : null } diff --git a/services/api/src/adapters/mailer.oci.ts b/services/api/src/adapters/mailer.oci.ts index e6ab14c4..5d2022b8 100644 --- a/services/api/src/adapters/mailer.oci.ts +++ b/services/api/src/adapters/mailer.oci.ts @@ -1,11 +1,18 @@ - import { randomUUID } from "node:crypto" import { AppError, ErrorCode, MailSendError } from "@civfix/shared" import type { Mailer, OutboundEmail, SentMail } from "@civfix/shared/interfaces" import type { Transporter } from "nodemailer" import { domainOf, escapeHtml, sanitizeHeaderValue } from "./mail-text.js" import { mailFailure } from "./mail-failure.js" -import { button, code, heading, kvTable, paragraph, quote, type EmailBlock } from "./email-blocks.js" +import { + button, + code, + heading, + kvTable, + paragraph, + quote, + type EmailBlock, +} from "./email-blocks.js" import { renderEmailBody } from "./email-layout.js" import { renderMessage } from "../i18n/renderMessage.js" import { resolveLocale, type Locale } from "../i18n/locales.js" @@ -136,7 +143,10 @@ export class OciMailer implements Mailer { }) this.transporter = transporter transporter.verify().catch((err: unknown) => { - console.warn({ err }, "OCI mailer SMTP verify failed (continuing; send will surface the error)") + console.warn( + { err }, + "OCI mailer SMTP verify failed (continuing; send will surface the error)", + ) }) } return this.transporter diff --git a/services/api/src/adapters/proxy-egress.ts b/services/api/src/adapters/proxy-egress.ts index b4b47feb..57ccf11d 100644 --- a/services/api/src/adapters/proxy-egress.ts +++ b/services/api/src/adapters/proxy-egress.ts @@ -1,4 +1,3 @@ - export interface ProxySettings { url: string noProxy: string[] diff --git a/services/api/src/adapters/push-apns.ts b/services/api/src/adapters/push-apns.ts index 99de08d8..6f813a0e 100644 --- a/services/api/src/adapters/push-apns.ts +++ b/services/api/src/adapters/push-apns.ts @@ -52,7 +52,8 @@ export function makeApnsDispatcher( { status, reason, - deviceHash: typeof failure.device === "string" ? hashForLog(failure.device) : undefined, + deviceHash: + typeof failure.device === "string" ? hashForLog(failure.device) : undefined, }, "push(apns): delivery failure", ) diff --git a/services/api/src/adapters/push-expo.ts b/services/api/src/adapters/push-expo.ts index 786e900e..ab11e27e 100644 --- a/services/api/src/adapters/push-expo.ts +++ b/services/api/src/adapters/push-expo.ts @@ -1,7 +1,6 @@ import type { PushLogger, PlatformDispatcher } from "./push-sender.js" import { fetchJsonWithTimeout, type FetchJsonResult } from "./http-fetch.js" - export interface ExpoPushConfig { accessToken?: string endpoint?: string diff --git a/services/api/src/adapters/push-sender.ts b/services/api/src/adapters/push-sender.ts index 75158513..73ef0c56 100644 --- a/services/api/src/adapters/push-sender.ts +++ b/services/api/src/adapters/push-sender.ts @@ -1,4 +1,3 @@ - import { createHash } from "node:crypto" import type { PushSender, PushPayload, PushPlatform } from "@civfix/shared/interfaces" import type { Db } from "../db/client.js" @@ -147,9 +146,7 @@ export class MultiPushSender implements PushSender { async close(): Promise { if (!this.dispatchers) return - await Promise.all( - Object.values(this.dispatchers).map((d) => d?.close?.()), - ) + await Promise.all(Object.values(this.dispatchers).map((d) => d?.close?.())) } private async deliver(recipientIds: string[], payload: PushPayload): Promise { @@ -238,7 +235,9 @@ export class MultiPushSender implements PushSender { this.dispatchers = { ...(this.config.apns ? { ios: makeApnsDispatcher(this.config.apns, this.logger) } : {}), ...(this.config.fcm ? { android: makeFcmDispatcher(this.config.fcm, this.logger) } : {}), - ...(this.config.webPush ? { web: makeWebPushDispatcher(this.config.webPush, this.logger) } : {}), + ...(this.config.webPush + ? { web: makeWebPushDispatcher(this.config.webPush, this.logger) } + : {}), expo: makeExpoDispatcher(this.config.expo ?? {}, this.logger), } return this.dispatchers diff --git a/services/api/src/adapters/push-webpush.ts b/services/api/src/adapters/push-webpush.ts index 04008df5..fe3cb8a5 100644 --- a/services/api/src/adapters/push-webpush.ts +++ b/services/api/src/adapters/push-webpush.ts @@ -261,7 +261,10 @@ export function classifyWebPushError(err: unknown): { prune: boolean statusCode: number | undefined } { - const raw = typeof err === "object" && err !== null ? (err as { statusCode?: unknown }).statusCode : undefined + const raw = + typeof err === "object" && err !== null + ? (err as { statusCode?: unknown }).statusCode + : undefined const statusCode = typeof raw === "number" ? raw : undefined return { prune: statusCode === 404 || statusCode === 410, statusCode } } diff --git a/services/api/src/adapters/redis.ts b/services/api/src/adapters/redis.ts index 9bf393c0..2b266ad2 100644 --- a/services/api/src/adapters/redis.ts +++ b/services/api/src/adapters/redis.ts @@ -1,4 +1,3 @@ - import { Redis } from "ioredis" export type RedisClient = Redis diff --git a/services/api/src/adapters/reverse-geocode.photon.ts b/services/api/src/adapters/reverse-geocode.photon.ts index f292db58..94089b58 100644 --- a/services/api/src/adapters/reverse-geocode.photon.ts +++ b/services/api/src/adapters/reverse-geocode.photon.ts @@ -160,9 +160,7 @@ export function composePhotonReverse( } candidates.sort((a, b) => a.meters - b.meters) - const exact = candidates.find( - (c) => !!c.props.housenumber?.trim() && !!c.props.street?.trim(), - ) + const exact = candidates.find((c) => !!c.props.housenumber?.trim() && !!c.props.street?.trim()) if (exact) { const line = formatPhotonReverse(exact.props) if (line) return { line, precision: "street" } diff --git a/services/api/src/adapters/sms-twilio.ts b/services/api/src/adapters/sms-twilio.ts index aeec6556..3e5c3af6 100644 --- a/services/api/src/adapters/sms-twilio.ts +++ b/services/api/src/adapters/sms-twilio.ts @@ -72,7 +72,10 @@ export class TwilioSmsSender implements SmsSender { } const sid = typeof payload.sid === "string" ? payload.sid : "" if (sid === "") { - throw smsFailure("temporary", "Text message not sent: the SMS provider returned no message id.") + throw smsFailure( + "temporary", + "Text message not sent: the SMS provider returned no message id.", + ) } return { id: sid } } diff --git a/services/api/src/adapters/storage.local.ts b/services/api/src/adapters/storage.local.ts index 8ce5b7ba..f439f16b 100644 --- a/services/api/src/adapters/storage.local.ts +++ b/services/api/src/adapters/storage.local.ts @@ -384,7 +384,5 @@ export function nowSec(): number { } function isNotFound(err: unknown): boolean { - return ( - typeof err === "object" && err !== null && (err as { code?: unknown }).code === "ENOENT" - ) + return typeof err === "object" && err !== null && (err as { code?: unknown }).code === "ENOENT" } diff --git a/services/api/src/adapters/storage.r2.ts b/services/api/src/adapters/storage.r2.ts index 70a509e9..0762a8fa 100644 --- a/services/api/src/adapters/storage.r2.ts +++ b/services/api/src/adapters/storage.r2.ts @@ -1,4 +1,3 @@ - import { AppError, ErrorCode } from "@civfix/shared" import type { Storage, @@ -81,9 +80,7 @@ export class R2Storage implements Storage { const { HeadObjectCommand } = await import("@aws-sdk/client-s3") const client = await this.getClient() try { - const res = await client.send( - new HeadObjectCommand({ Bucket: this.config.bucket, Key: key }), - ) + const res = await client.send(new HeadObjectCommand({ Bucket: this.config.bucket, Key: key })) const etag = normalizeEtag(res.ETag) return { size: typeof res.ContentLength === "number" ? res.ContentLength : 0, @@ -156,9 +153,7 @@ export class R2Storage implements Storage { const { GetObjectCommand } = await import("@aws-sdk/client-s3") const client = await this.getClient() try { - const res = await client.send( - new GetObjectCommand({ Bucket: this.config.bucket, Key: key }), - ) + const res = await client.send(new GetObjectCommand({ Bucket: this.config.bucket, Key: key })) if (!res.Body) return null const bytes = await res.Body.transformToByteArray() return bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes) @@ -188,9 +183,7 @@ export class R2Storage implements Storage { } } -async function proxyRequestHandler( - host: string, -): Promise> { +async function proxyRequestHandler(host: string): Promise> { const settings = readProxySettings() if (settings === null || !shouldProxyHost(host, settings)) return {} const [{ NodeHttpHandler }, { HttpsProxyAgent }] = await Promise.all([ diff --git a/services/api/src/adapters/user-channel.redis.ts b/services/api/src/adapters/user-channel.redis.ts index 306c2e03..ff549288 100644 --- a/services/api/src/adapters/user-channel.redis.ts +++ b/services/api/src/adapters/user-channel.redis.ts @@ -1,4 +1,3 @@ - import { UserSignalSchema, type UserSignal } from "@civfix/shared" import type { ChatConnection, UserChannel } from "@civfix/shared/interfaces" import type { FastifyBaseLogger } from "fastify" diff --git a/services/api/src/auth/auth-services.ts b/services/api/src/auth/auth-services.ts index 7978d310..21e845c2 100644 --- a/services/api/src/auth/auth-services.ts +++ b/services/api/src/auth/auth-services.ts @@ -1,17 +1,16 @@ - import type { Container } from "../di.js" import type { OAuthProvider, UserDTO } from "@civfix/shared" import { RedisCacheClient, type CacheClient } from "./cache.js" import { SessionService, type SessionLogger } from "./session-service.js" -import { - OtpService, - REVIEWER_OTP_EMAIL, - type OtpLogger, - type ReviewerOtpConfig, -} from "./otp.js" +import { OtpService, REVIEWER_OTP_EMAIL, type OtpLogger, type ReviewerOtpConfig } from "./otp.js" import { OAuthService, type OAuthConfig } from "./oauth.js" import type { JwksVerifier } from "./jwks.js" -import { handleChangeableAtFrom, type AuthStores, type UserRecord, type UserStore } from "./stores.js" +import { + handleChangeableAtFrom, + type AuthStores, + type UserRecord, + type UserStore, +} from "./stores.js" import { PgAuthStores } from "./pg-stores.js" import { REVIEWER_OTP_CODE_MIN_LENGTH } from "../env.js" import { resolveLocale } from "../i18n/locales.js" @@ -113,7 +112,11 @@ export function reviewerOtpConfigFromEnv(env: Container["env"]): ReviewerOtpConf export function oauthConfigFromEnv(env: Container["env"]): OAuthConfig { const config: OAuthConfig = {} - if (env.GOOGLE_OAUTH_CLIENT_ID && env.GOOGLE_OAUTH_CLIENT_SECRET && env.GOOGLE_OAUTH_REDIRECT_URI) { + if ( + env.GOOGLE_OAUTH_CLIENT_ID && + env.GOOGLE_OAUTH_CLIENT_SECRET && + env.GOOGLE_OAUTH_REDIRECT_URI + ) { const extraAudiences = [ env.GOOGLE_OAUTH_IOS_CLIENT_ID, env.GOOGLE_OAUTH_ANDROID_CLIENT_ID, @@ -140,9 +143,7 @@ export function oauthConfigFromEnv(env: Container["env"]): OAuthConfig { keyId: env.APPLE_OAUTH_KEY_ID, privateKey: env.APPLE_OAUTH_PRIVATE_KEY, redirectUri: `${env.PUBLIC_API_URL}/auth/apple/callback`, - ...(env.APPLE_OAUTH_WEB_CLIENT_ID - ? { webClientId: env.APPLE_OAUTH_WEB_CLIENT_ID } - : {}), + ...(env.APPLE_OAUTH_WEB_CLIENT_ID ? { webClientId: env.APPLE_OAUTH_WEB_CLIENT_ID } : {}), ...(appleExtraAudiences.length > 0 ? { extraAudiences: appleExtraAudiences } : {}), } } diff --git a/services/api/src/auth/cache.ts b/services/api/src/auth/cache.ts index 49088d46..0bc996c8 100644 --- a/services/api/src/auth/cache.ts +++ b/services/api/src/auth/cache.ts @@ -1,4 +1,3 @@ - import type { RedisClient } from "../adapters/redis.js" import { attachAtomicIncr } from "../adapters/redis-incr.js" diff --git a/services/api/src/auth/context.ts b/services/api/src/auth/context.ts index e55d3ac5..4163ca2d 100644 --- a/services/api/src/auth/context.ts +++ b/services/api/src/auth/context.ts @@ -1,4 +1,3 @@ - import { AppError } from "@civfix/shared" import type { AuthContext } from "@civfix/shared" import type { FastifyInstance, FastifyRequest } from "fastify" diff --git a/services/api/src/auth/csrf.ts b/services/api/src/auth/csrf.ts index ded840d4..79318afe 100644 --- a/services/api/src/auth/csrf.ts +++ b/services/api/src/auth/csrf.ts @@ -31,7 +31,13 @@ import { createHmac } from "node:crypto" import { AppError } from "@civfix/shared" import type { FastifyReply, FastifyRequest } from "fastify" import { constantTimeStringEqual, generateToken, sha256Hex } from "./crypto.js" -import { csrfCookieName, sessionCookieValue, bearerToken, CSRF_COOKIE, CSRF_COOKIE_HOST } from "./transport.js" +import { + csrfCookieName, + sessionCookieValue, + bearerToken, + CSRF_COOKIE, + CSRF_COOKIE_HOST, +} from "./transport.js" import { isProd, type Env } from "../env.js" /** Header carrying the echoed CSRF token on state-changing cookie requests. */ diff --git a/services/api/src/auth/jwks.ts b/services/api/src/auth/jwks.ts index 56bb9d31..fe314e81 100644 --- a/services/api/src/auth/jwks.ts +++ b/services/api/src/auth/jwks.ts @@ -1,4 +1,3 @@ - import { createHash } from "node:crypto" import { AppError, ErrorCode } from "@civfix/shared" import { constantTimeStringEqual } from "./crypto.js" diff --git a/services/api/src/auth/oauth.ts b/services/api/src/auth/oauth.ts index b3fa9a34..fa5b91fe 100644 --- a/services/api/src/auth/oauth.ts +++ b/services/api/src/auth/oauth.ts @@ -1,4 +1,3 @@ - import { Apple, Google, generateCodeVerifier, generateState } from "arctic" import { AppError } from "@civfix/shared" import type { OAuthIdentityStore, UserRecord, UserStore } from "./stores.js" @@ -102,10 +101,7 @@ export class OAuthService { return this.upsertFromClaims(PROVIDER_APPLE, claims, fullName) } - async verifyGoogleIdToken( - idToken: string, - expectedNonce?: string, - ): Promise { + async verifyGoogleIdToken(idToken: string, expectedNonce?: string): Promise { const google = this.requireGoogleConfig() return this.verifier.verify(idToken, { jwksUrl: GOOGLE_JWKS_URL, diff --git a/services/api/src/auth/otp.ts b/services/api/src/auth/otp.ts index bbb2b205..c9348eeb 100644 --- a/services/api/src/auth/otp.ts +++ b/services/api/src/auth/otp.ts @@ -1,4 +1,3 @@ - import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2" import { AppError } from "@civfix/shared" import { constantTimeStringEqual, generateNumericCode } from "./crypto.js" @@ -136,7 +135,10 @@ export class OtpService { } catch (err) { if (emailHits === 1) { await this.cache.del(emailKey).catch((delErr: unknown) => { - this.logger?.warn({ err: delErr }, "otp: failed to release per-email cooldown after issue error") + this.logger?.warn( + { err: delErr }, + "otp: failed to release per-email cooldown after issue error", + ) }) } throw err @@ -194,7 +196,10 @@ export class OtpService { throw AppError.unauthorized("Invalid or expired code.") } await this.cache.del(emailCooldownKey(normalized)).catch((err: unknown) => { - this.logger?.warn({ err }, "otp: failed to release per-email cooldown after successful verify") + this.logger?.warn( + { err }, + "otp: failed to release per-email cooldown after successful verify", + ) }) const existing = await this.users.findByEmail(normalized) if (existing) return existing.id diff --git a/services/api/src/auth/pg-stores.ts b/services/api/src/auth/pg-stores.ts index cb789237..2500cc19 100644 --- a/services/api/src/auth/pg-stores.ts +++ b/services/api/src/auth/pg-stores.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { and, desc, eq, gt, inArray, isNull, ne, sql } from "drizzle-orm" import type { Db } from "../db/client.js" @@ -13,7 +12,13 @@ import { userModeration, users, } from "../db/schema/index.js" -import { AppError, DELETED_USER_LABEL, SOCIAL_PLATFORMS, type Role, type SocialLinks } from "@civfix/shared" +import { + AppError, + DELETED_USER_LABEL, + SOCIAL_PLATFORMS, + type Role, + type SocialLinks, +} from "@civfix/shared" import type { Jobs } from "@civfix/shared/interfaces" import { decideHandleWrite, handleChanged } from "./handle-policy.js" import { resolveAvatarMediaOrThrow } from "../services/avatar-media.js" @@ -631,10 +636,7 @@ export class PgUserStore implements UserStore { for (const key of erasure.objectKeys) { if (this.certificateObjects === undefined) { - this.logger?.warn( - { userId: id, key }, - "erasure object not deleted: no object store wired", - ) + this.logger?.warn({ userId: id, key }, "erasure object not deleted: no object store wired") continue } try { diff --git a/services/api/src/auth/session-service.ts b/services/api/src/auth/session-service.ts index 32f65b8f..26f2301b 100644 --- a/services/api/src/auth/session-service.ts +++ b/services/api/src/auth/session-service.ts @@ -1,4 +1,3 @@ - import { AppError, type Role } from "@civfix/shared" import { generateToken, sha256Hex } from "./crypto.js" import type { CacheClient } from "./cache.js" @@ -122,7 +121,9 @@ export class SessionService { const [epoch, mintStatus] = await Promise.all([ this.currentEpoch(userId), - this.users ? this.users.accountStatus(userId) : Promise.resolve(meta.accountStatus ?? "active"), + this.users + ? this.users.accountStatus(userId) + : Promise.resolve(meta.accountStatus ?? "active"), ]) if (mintStatus === "banned" || mintStatus === "suspended") { throw AppError.forbidden("This account cannot start a new session.") diff --git a/services/api/src/auth/stores.ts b/services/api/src/auth/stores.ts index c2d882a6..524d333c 100644 --- a/services/api/src/auth/stores.ts +++ b/services/api/src/auth/stores.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { AppError, DELETED_USER_LABEL } from "@civfix/shared" import type { Role, SocialLinks } from "@civfix/shared" @@ -175,7 +174,6 @@ export interface UpdateSettingsInput { export const PRIMARY_ORGANIZATION_NOT_A_MEMBER = "Pick an organization you belong to, or clear the selection." - export class InMemoryUserStore implements UserStore { private readonly byId = new Map() private readonly statuses = new Map() diff --git a/services/api/src/auth/ws-ticket.ts b/services/api/src/auth/ws-ticket.ts index b7b068a0..5e2e6715 100644 --- a/services/api/src/auth/ws-ticket.ts +++ b/services/api/src/auth/ws-ticket.ts @@ -1,4 +1,3 @@ - import { makeSingleUseSecretStore } from "./single-use-secret.js" import type { CacheClient } from "./cache.js" diff --git a/services/api/src/db/backfill-population-core.ts b/services/api/src/db/backfill-population-core.ts index 2e45c6ab..79f2b29f 100644 --- a/services/api/src/db/backfill-population-core.ts +++ b/services/api/src/db/backfill-population-core.ts @@ -1,4 +1,3 @@ - import type { Sql } from "./client.js" export const ACS_POP_VAR = "B01003_001E" @@ -22,7 +21,9 @@ const defaultFetchJson: CensusJsonFetch = async (url) => { } if (!res.ok) throw new Error(`Census API ${res.status} ${res.statusText}`) if (!contentType.includes("json")) { - throw new Error(`Census API returned a non-JSON response (${res.status}); check the query/year`) + throw new Error( + `Census API returned a non-JSON response (${res.status}); check the query/year`, + ) } return (await res.json()) as unknown[][] } finally { @@ -30,7 +31,12 @@ const defaultFetchJson: CensusJsonFetch = async (url) => { } } -function acsUrl(year: number, forClause: string, inClause: string | null, key: string | null): string { +function acsUrl( + year: number, + forClause: string, + inClause: string | null, + key: string | null, +): string { const params = new URLSearchParams() params.set("get", ACS_POP_VAR) params.set("for", forClause) @@ -53,7 +59,9 @@ export function parseAcs(rows: unknown[][]): { geoid: string; population: number const header = (rows[0] ?? []).map(String) const varIdx = header.indexOf(ACS_POP_VAR) if (varIdx < 0) return [] - const geoCols = ACS_GEO_COLUMNS.map((c) => header.indexOf(c)).filter((i) => i >= 0 && i !== varIdx) + const geoCols = ACS_GEO_COLUMNS.map((c) => header.indexOf(c)).filter( + (i) => i >= 0 && i !== varIdx, + ) const out: { geoid: string; population: number }[] = [] for (let r = 1; r < rows.length; r++) { const row = rows[r] diff --git a/services/api/src/db/backfill-post-geom-core.ts b/services/api/src/db/backfill-post-geom-core.ts index 1b1c6913..05e9c8ee 100644 --- a/services/api/src/db/backfill-post-geom-core.ts +++ b/services/api/src/db/backfill-post-geom-core.ts @@ -50,7 +50,9 @@ export async function backfillPostGeom( scanned += page.length filled += updated.length cursor = page[page.length - 1]!.id - log(`page of ${page.length} (filled ${updated.length}); running scanned=${scanned}, filled=${filled}`) + log( + `page of ${page.length} (filled ${updated.length}); running scanned=${scanned}, filled=${filled}`, + ) } return { scanned, filled } diff --git a/services/api/src/db/backfill-post-geom.ts b/services/api/src/db/backfill-post-geom.ts index b1216626..96d04e4c 100644 --- a/services/api/src/db/backfill-post-geom.ts +++ b/services/api/src/db/backfill-post-geom.ts @@ -1,4 +1,3 @@ - import { runDbCli, runIfMain } from "./cli.js" import { backfillPostGeom } from "./backfill-post-geom-core.js" diff --git a/services/api/src/db/backfill-reference-codes-core.ts b/services/api/src/db/backfill-reference-codes-core.ts index ec050930..014343f5 100644 --- a/services/api/src/db/backfill-reference-codes-core.ts +++ b/services/api/src/db/backfill-reference-codes-core.ts @@ -15,7 +15,11 @@ */ import type { ReportType } from "@civfix/shared" -import { resolveGeomJurisdictions, stampReferenceCodes, type ReferenceCodeRow } from "./backfill-keyset.js" +import { + resolveGeomJurisdictions, + stampReferenceCodes, + type ReferenceCodeRow, +} from "./backfill-keyset.js" import type { Sql } from "./client.js" import { allocateReportReferenceCode, allocateEventReferenceCode } from "./reference-code.js" @@ -31,7 +35,9 @@ const LABEL = "reference-codes" * (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 }> { +export async function backfillReportReferenceCodes( + sql: Sql, +): Promise<{ stamped: number; failed: number }> { return stampReferenceCodes(sql, { table: "reports", batchSize: BATCH_SIZE, diff --git a/services/api/src/db/backfill-served-key.ts b/services/api/src/db/backfill-served-key.ts index a69f3981..f06dd901 100644 --- a/services/api/src/db/backfill-served-key.ts +++ b/services/api/src/db/backfill-served-key.ts @@ -1,4 +1,3 @@ - import { R2_PUT_TTL_SEC } from "../adapters/storage.r2.js" import type { Sql } from "./client.js" import { runDbCli, runIfMain } from "./cli.js" diff --git a/services/api/src/db/backfill-user-activity-core.ts b/services/api/src/db/backfill-user-activity-core.ts index a961778b..848813a0 100644 --- a/services/api/src/db/backfill-user-activity-core.ts +++ b/services/api/src/db/backfill-user-activity-core.ts @@ -1,4 +1,3 @@ - import type postgres from "postgres" import type { Sql } from "./client.js" @@ -59,7 +58,9 @@ export async function backfillUserActivity( scanned += page.length filled += updated.length cursor = page[page.length - 1]!.id - log(`page of ${page.length} (filled ${updated.length}); running scanned=${scanned}, filled=${filled}`) + log( + `page of ${page.length} (filled ${updated.length}); running scanned=${scanned}, filled=${filled}`, + ) } return { scanned, filled } diff --git a/services/api/src/db/backfill-user-activity.ts b/services/api/src/db/backfill-user-activity.ts index dd60a8a1..ea4f9443 100644 --- a/services/api/src/db/backfill-user-activity.ts +++ b/services/api/src/db/backfill-user-activity.ts @@ -1,4 +1,3 @@ - import { runDbCli, runIfMain } from "./cli.js" import { backfillUserActivity } from "./backfill-user-activity-core.js" diff --git a/services/api/src/db/boundaries/manifest.ts b/services/api/src/db/boundaries/manifest.ts index e3abacb9..5ac5fb51 100644 --- a/services/api/src/db/boundaries/manifest.ts +++ b/services/api/src/db/boundaries/manifest.ts @@ -131,11 +131,56 @@ const PADUS_VERSION_NODOT = PADUS_VERSION.replace(/\./g, "_") * are not part of the first Design-A ship. Order matches the geocoder's STATE_FIPS_TO_USPS key order. */ export const STATE_FIPS: readonly string[] = [ - "01", "02", "04", "05", "06", "08", "09", "10", "12", "13", - "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", - "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", - "35", "36", "37", "38", "39", "40", "41", "42", "44", "45", - "46", "47", "48", "49", "50", "51", "53", "54", "55", "56", + "01", + "02", + "04", + "05", + "06", + "08", + "09", + "10", + "12", + "13", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "32", + "33", + "34", + "35", + "36", + "37", + "38", + "39", + "40", + "41", + "42", + "44", + "45", + "46", + "47", + "48", + "49", + "50", + "51", + "53", + "54", + "55", + "56", ] as const /** Census TIGER directory root for a given vintage, e.g. ".../TIGER2025". */ @@ -191,7 +236,15 @@ export function boundaryManifest( jobs.push({ sourceUrl: `${root}/PLACE/tl_${year}_${ss}_place.zip`, layer: "place", - ogr2ogrArgs: ["-f", "GeoJSON", "-t_srs", "EPSG:4326", "-makevalid", "-where", "MTFCC='G4110'"], + ogr2ogrArgs: [ + "-f", + "GeoJSON", + "-t_srs", + "EPSG:4326", + "-makevalid", + "-where", + "MTFCC='G4110'", + ], outFile: `places_${ss}.geojson`, sourcePath: `tl_${year}_${ss}_place.shp`, ingestGeoidPrefix: null, diff --git a/services/api/src/db/cli.ts b/services/api/src/db/cli.ts index c11b6e04..8abdab59 100644 --- a/services/api/src/db/cli.ts +++ b/services/api/src/db/cli.ts @@ -1,4 +1,3 @@ - import { fileURLToPath } from "node:url" import type { Db, Sql } from "./client.js" import { makeDb } from "./client.js" diff --git a/services/api/src/db/client.ts b/services/api/src/db/client.ts index 320e2c48..e87e64c0 100644 --- a/services/api/src/db/client.ts +++ b/services/api/src/db/client.ts @@ -1,4 +1,3 @@ - import { drizzle } from "drizzle-orm/postgres-js" import postgres from "postgres" import * as schema from "./schema/index.js" diff --git a/services/api/src/db/cursor-helpers.ts b/services/api/src/db/cursor-helpers.ts index b981c847..1d87908f 100644 --- a/services/api/src/db/cursor-helpers.ts +++ b/services/api/src/db/cursor-helpers.ts @@ -1,4 +1,3 @@ - export const CURSOR_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i export function isUuid(v: string): boolean { diff --git a/services/api/src/db/data/federal-lands.ts b/services/api/src/db/data/federal-lands.ts index 6a08d997..bfd8d4af 100644 --- a/services/api/src/db/data/federal-lands.ts +++ b/services/api/src/db/data/federal-lands.ts @@ -1,4 +1,3 @@ - export interface FederalLand { geoid: string name: string diff --git a/services/api/src/db/demo-join-event.ts b/services/api/src/db/demo-join-event.ts index 67fe8637..9223f654 100644 --- a/services/api/src/db/demo-join-event.ts +++ b/services/api/src/db/demo-join-event.ts @@ -107,13 +107,19 @@ export async function main(): Promise { const prngSeed = Number(argValue("--seed") ?? 20260902) rand = mulberry32(prngSeed) - if (!eventRef) throw new Error("--event is required (e.g. --event 1695-000006)") - if (!Number.isInteger(count) || count < 1 || count > 200) throw new Error("--count must be 1..200") + if (!eventRef) + throw new Error("--event is required (e.g. --event 1695-000006)") + if (!Number.isInteger(count) || count < 1 || count > 200) + throw new Error("--count must be 1..200") const databaseUrl = process.env.DATABASE_URL if (!databaseUrl) throw new Error("DATABASE_URL is required") console.log(`target database: ${new URL(databaseUrl).host}`) - console.log(commit ? "mode: COMMIT" : "mode: rehearsal (runs everything, then ROLLBACK; pass --yes to commit)") + console.log( + commit + ? "mode: COMMIT" + : "mode: rehearsal (runs everything, then ROLLBACK; pass --yes to commit)", + ) const handle = makeDb(databaseUrl, { max: 1, statementTimeoutMs: 0, idleInTxTimeoutMs: 0 }) const ROLLBACK = Symbol("rollback") @@ -133,9 +139,13 @@ export async function main(): Promise { Date.now(), ) if (derived === "done" || derived === "cancelled") { - throw new Error(`event "${event.title}" is ${derived}; the live join path refuses closed events`) + throw new Error( + `event "${event.title}" is ${derived}; the live join path refuses closed events`, + ) } - console.log(`event: ${event.title} (${derived}, scheduled ${event.scheduled_at.toISOString()})`) + console.log( + `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 }[]>` @@ -148,7 +158,9 @@ export async function main(): Promise { AND NOT EXISTS (SELECT 1 FROM cleanup_bans b WHERE b.cleanup_id = ${event.id} AND b.user_id = u.id) ` if (candidates.length === 0) { - throw new Error(`no eligible demo users found (@${DEMO_EMAIL_DOMAIN}); run seed-demo-la first`) + throw new Error( + `no eligible demo users found (@${DEMO_EMAIL_DOMAIN}); run seed-demo-la first`, + ) } // Respect the RSVP capacity the way goingCount measures it: members + non-cancelled guests. @@ -177,12 +189,19 @@ export async function main(): Promise { ) const memberRows = joiners - .map((u) => ({ cleanup_id: event.id, user_id: u.id, role: "member", joined_at: joinTimestamp(windowStart, now) })) + .map((u) => ({ + cleanup_id: event.id, + user_id: u.id, + role: "member", + joined_at: joinTimestamp(windowStart, now), + })) .sort((a, b) => a.joined_at.getTime() - b.joined_at.getTime()) await tx`INSERT INTO cleanup_members ${tx(memberRows)}` // 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 }[]>` + const slots = await tx< + { id: string; title: string; capacity: number | null; claims: number }[] + >` SELECT s.id, s.title, s.capacity, (SELECT count(*)::int FROM cleanup_slot_claims c WHERE c.slot_id = s.id) AS claims FROM cleanup_slots s WHERE s.cleanup_id = ${event.id} @@ -193,7 +212,9 @@ export async function main(): Promise { const open = slots.map((s) => ({ ...s, claims: Number(s.claims) })) for (const m of memberRows) { if (!chance(0.45)) continue - const slot = shuffle(open.filter((s) => s.capacity === null || s.claims < s.capacity))[0] + const slot = shuffle( + open.filter((s) => s.capacity === null || s.claims < s.capacity), + )[0] if (!slot) break slot.claims++ claimed++ @@ -206,7 +227,9 @@ export async function main(): Promise { } const handles = joiners.map((u) => `@${u.handle}`).join(", ") - console.log(`joining ${memberRows.length} demo users${claimed > 0 ? ` (${claimed} slot claims)` : ""}:`) + console.log( + `joining ${memberRows.length} demo users${claimed > 0 ? ` (${claimed} slot claims)` : ""}:`, + ) console.log(` ${handles}`) // Verify: capacity + slot invariants still hold after the writes. @@ -215,14 +238,16 @@ export async function main(): Promise { WHERE s.cleanup_id = ${event.id} AND s.capacity IS NOT NULL AND (SELECT count(*) FROM cleanup_slot_claims c WHERE c.slot_id = s.id) > s.capacity ` - if (Number(overCap?.n ?? 0) > 0) throw new Error("verification failed: a slot is over capacity") + if (Number(overCap?.n ?? 0) > 0) + throw new Error("verification failed: a slot is over capacity") if (event.capacity !== null) { const [going] = await tx<{ n: number }[]>` SELECT (SELECT count(*)::int FROM cleanup_members m WHERE m.cleanup_id = ${event.id}) + (SELECT count(*)::int FROM cleanup_guests g WHERE g.cleanup_id = ${event.id} AND g.cancelled_at IS NULL) AS n ` - if (Number(going?.n ?? 0) > event.capacity) throw new Error("verification failed: event over capacity") + if (Number(going?.n ?? 0) > event.capacity) + throw new Error("verification failed: event over capacity") } if (!commit) throw ROLLBACK diff --git a/services/api/src/db/ingest-jurisdictions-core.ts b/services/api/src/db/ingest-jurisdictions-core.ts index c05e98df..fb80de67 100644 --- a/services/api/src/db/ingest-jurisdictions-core.ts +++ b/services/api/src/db/ingest-jurisdictions-core.ts @@ -58,7 +58,14 @@ export function normalizeFeature( const geometry = f.geometry const isPolygon = geometry !== null && (geometry.type === "Polygon" || geometry.type === "MultiPolygon") - const geoid = pickString(f.properties, ["geoid", "GEOID", "UNIT_CODE", "unit_code", "id", "OBJECTID"]) + const geoid = pickString(f.properties, [ + "geoid", + "GEOID", + "UNIT_CODE", + "unit_code", + "id", + "OBJECTID", + ]) const prefixedGeoid = geoid !== null && geoidPrefix ? geoidPrefix + geoid : geoid const name = pickString(f.properties, ["name", "NAME", "UNIT_NAME", "unit_name", "Unit_Name"]) const rawLayer = pickString(f.properties, ["layer", "LAYER", "owner_type", "Own_Type"]) @@ -95,7 +102,10 @@ export function normalizeFeatures( return { rows, skipped } } -export async function upsertJurisdictionBatch(sql: Queryable, rows: readonly IngestRow[]): Promise { +export async function upsertJurisdictionBatch( + sql: Queryable, + rows: readonly IngestRow[], +): Promise { if (rows.length === 0) return 0 const byGeoid = new Map() for (const r of rows) byGeoid.set(r.geoid, r) diff --git a/services/api/src/db/ingest-jurisdictions.ts b/services/api/src/db/ingest-jurisdictions.ts index 0d3716f1..403cbb6b 100644 --- a/services/api/src/db/ingest-jurisdictions.ts +++ b/services/api/src/db/ingest-jurisdictions.ts @@ -30,11 +30,15 @@ async function main(): Promise { const defaultLayer = (process.argv[3] ?? "federal") as IngestRow["layer"] const geoidPrefix = (process.argv[4] ?? "").trim() if (!file) { - console.error("usage: tsx src/db/ingest-jurisdictions.ts [layer] [geoid-prefix]") + console.error( + "usage: tsx src/db/ingest-jurisdictions.ts [layer] [geoid-prefix]", + ) process.exit(2) } if (!(defaultLayer in LAYER_RANK)) { - console.error(`ingest: unknown layer "${defaultLayer}" (expected one of ${Object.keys(LAYER_RANK).join(", ")})`) + console.error( + `ingest: unknown layer "${defaultLayer}" (expected one of ${Object.keys(LAYER_RANK).join(", ")})`, + ) process.exit(2) } @@ -48,7 +52,9 @@ async function main(): Promise { await runDbCli(async (_db, sql) => { const { upserted, skipped } = await ingestGeoJsonFile(sql, text, defaultLayer, geoidPrefix) - console.log(`ingest: ${upserted} jurisdictions upserted from ${file} (${skipped} features skipped)`) + console.log( + `ingest: ${upserted} jurisdictions upserted from ${file} (${skipped} features skipped)`, + ) }) } diff --git a/services/api/src/db/schema/chat-groups.ts b/services/api/src/db/schema/chat-groups.ts index 41060a8d..029141e9 100644 --- a/services/api/src/db/schema/chat-groups.ts +++ b/services/api/src/db/schema/chat-groups.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { index, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core" import { mediaAssets } from "./media.js" diff --git a/services/api/src/db/schema/chat.ts b/services/api/src/db/schema/chat.ts index 95a7aadd..28f0450e 100644 --- a/services/api/src/db/schema/chat.ts +++ b/services/api/src/db/schema/chat.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { index, jsonb, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core" import { chatGroups } from "./chat-groups.js" diff --git a/services/api/src/db/schema/chat_reactions.ts b/services/api/src/db/schema/chat_reactions.ts index 0dab76d6..99696101 100644 --- a/services/api/src/db/schema/chat_reactions.ts +++ b/services/api/src/db/schema/chat_reactions.ts @@ -1,4 +1,3 @@ - import { index, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core" import { users } from "./users.js" diff --git a/services/api/src/db/schema/cleanup_guests.ts b/services/api/src/db/schema/cleanup_guests.ts index a027eae1..5107315c 100644 --- a/services/api/src/db/schema/cleanup_guests.ts +++ b/services/api/src/db/schema/cleanup_guests.ts @@ -23,9 +23,7 @@ export const cleanupGuests = pgTable( verifiedAt: timestamp("verified_at", { withTimezone: true }).notNull().defaultNow(), cancelledAt: timestamp("cancelled_at", { withTimezone: true }), contactScrubbedAt: timestamp("contact_scrubbed_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true, precision: 3 }) - .notNull() - .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true, precision: 3 }).notNull().defaultNow(), }, (t) => [ uniqueIndex("cleanup_guests_manage_token_uidx").on(t.manageTokenHash), @@ -36,7 +34,9 @@ export const cleanupGuests = pgTable( index("cleanup_guests_unscrubbed_idx") .on(t.cleanupId) .where(sql`contact_scrubbed_at IS NULL`), - index("cleanup_guests_active_idx").on(t.cleanupId).where(sql`cancelled_at IS NULL`), + index("cleanup_guests_active_idx") + .on(t.cleanupId) + .where(sql`cancelled_at IS NULL`), ], ) diff --git a/services/api/src/db/schema/cleanup_members.ts b/services/api/src/db/schema/cleanup_members.ts index dc342288..833d1d62 100644 --- a/services/api/src/db/schema/cleanup_members.ts +++ b/services/api/src/db/schema/cleanup_members.ts @@ -1,4 +1,3 @@ - import { pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core" import { index } from "drizzle-orm/pg-core" import { cleanups } from "./cleanups.js" diff --git a/services/api/src/db/schema/cleanup_pages.ts b/services/api/src/db/schema/cleanup_pages.ts index 040276a0..2c0458e4 100644 --- a/services/api/src/db/schema/cleanup_pages.ts +++ b/services/api/src/db/schema/cleanup_pages.ts @@ -25,8 +25,12 @@ export const cleanupPages = pgTable( .references(() => cleanups.id, { onDelete: "cascade" }), status: text("status").$type().notNull().default("draft"), themeAccent: text("theme_accent").$type().notNull().default("bloom"), - blocks: jsonb("blocks").notNull().default(sql`'[]'::jsonb`), - seo: jsonb("seo").notNull().default(sql`'{}'::jsonb`), + blocks: jsonb("blocks") + .notNull() + .default(sql`'[]'::jsonb`), + seo: jsonb("seo") + .notNull() + .default(sql`'{}'::jsonb`), publishedAt: timestamp("published_at", { withTimezone: true }), publishedBy: uuid("published_by").references(() => users.id), flaggedAt: timestamp("flagged_at", { withTimezone: true }), diff --git a/services/api/src/db/schema/cleanup_questions.ts b/services/api/src/db/schema/cleanup_questions.ts index 6cb4864e..829b6485 100644 --- a/services/api/src/db/schema/cleanup_questions.ts +++ b/services/api/src/db/schema/cleanup_questions.ts @@ -31,7 +31,9 @@ export const cleanupQuestions = pgTable( prompt: text("prompt").notNull(), helpText: text("help_text"), required: boolean("required").notNull().default(false), - options: jsonb("options").notNull().default(sql`'[]'::jsonb`), + options: jsonb("options") + .notNull() + .default(sql`'[]'::jsonb`), maxSelections: smallint("max_selections"), consentText: text("consent_text"), showIf: jsonb("show_if"), @@ -92,10 +94,7 @@ export const cleanupAnswers = pgTable( columns: [t.questionId, t.cleanupId], foreignColumns: [cleanupQuestions.id, cleanupQuestions.cleanupId], }).onDelete("cascade"), - check( - "cleanup_answers_value_exclusive", - sql`${t.valueText} IS NULL OR ${t.valueJson} IS NULL`, - ), + check("cleanup_answers_value_exclusive", sql`${t.valueText} IS NULL OR ${t.valueJson} IS NULL`), uniqueIndex("cleanup_answers_registration_question_uidx").on(t.registrationId, t.questionId), index("cleanup_answers_unscrubbed_idx") .on(t.cleanupId) diff --git a/services/api/src/db/schema/cleanup_registrations.ts b/services/api/src/db/schema/cleanup_registrations.ts index 655d50d5..cd9cc6e6 100644 --- a/services/api/src/db/schema/cleanup_registrations.ts +++ b/services/api/src/db/schema/cleanup_registrations.ts @@ -70,18 +70,16 @@ export const cleanupRegistrations = pgTable( .on(t.cleanupId, t.guestId) .where(sql`status = 'registered' AND guest_id IS NOT NULL`), uniqueIndex("cleanup_registrations_id_cleanup_uidx").on(t.id, t.cleanupId), - index("cleanup_registrations_roster_idx").on( - t.cleanupId, - t.registeredAt.desc(), - t.id.desc(), - ), + index("cleanup_registrations_roster_idx").on(t.cleanupId, t.registeredAt.desc(), t.id.desc()), index("cleanup_registrations_type_idx") .on(t.ticketTypeId) .where(sql`status = 'registered'`), index("cleanup_registrations_user_idx") .on(t.userId, t.registeredAt.desc()) .where(sql`user_id IS NOT NULL`), - index("cleanup_registrations_guest_idx").on(t.guestId).where(sql`guest_id IS NOT NULL`), + index("cleanup_registrations_guest_idx") + .on(t.guestId) + .where(sql`guest_id IS NOT NULL`), index("cleanup_registrations_host_note_idx") .on(t.cleanupId) .where(sql`host_note IS NOT NULL`), @@ -116,10 +114,7 @@ export const cleanupRegistrationSeats = pgTable( columns: [t.registrationId, t.cleanupId], foreignColumns: [cleanupRegistrations.id, cleanupRegistrations.cleanupId], }).onDelete("cascade"), - check( - "cleanup_registration_seats_status_check", - sql`${t.status} IN ('active', 'cancelled')`, - ), + check("cleanup_registration_seats_status_check", sql`${t.status} IN ('active', 'cancelled')`), check( "cleanup_registration_seats_method_check", sql`${t.checkinMethod} IS NULL OR ${t.checkinMethod} IN ('scan', 'manual', 'self', 'walkup')`, diff --git a/services/api/src/db/schema/cleanup_ticket_types.ts b/services/api/src/db/schema/cleanup_ticket_types.ts index cd0934eb..b866990b 100644 --- a/services/api/src/db/schema/cleanup_ticket_types.ts +++ b/services/api/src/db/schema/cleanup_ticket_types.ts @@ -50,10 +50,7 @@ export const cleanupTicketTypes = pgTable( "cleanup_ticket_types_reserved_bounds", sql`${t.reservedSeats} >= 0 AND (${t.capacity} IS NULL OR ${t.reservedSeats} <= ${t.capacity})`, ), - check( - "cleanup_ticket_types_party_bounds", - sql`${t.maxPartySize} BETWEEN 1 AND 10`, - ), + check("cleanup_ticket_types_party_bounds", sql`${t.maxPartySize} BETWEEN 1 AND 10`), check( "cleanup_ticket_types_access_code_present", sql`${t.visibility} <> 'access_code' OR ${t.accessCodeHash} IS NOT NULL`, diff --git a/services/api/src/db/schema/cleanups.ts b/services/api/src/db/schema/cleanups.ts index ea3ccfe6..6d704e35 100644 --- a/services/api/src/db/schema/cleanups.ts +++ b/services/api/src/db/schema/cleanups.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { check, diff --git a/services/api/src/db/schema/consent_records.ts b/services/api/src/db/schema/consent_records.ts index 27595e4a..be33eb5a 100644 --- a/services/api/src/db/schema/consent_records.ts +++ b/services/api/src/db/schema/consent_records.ts @@ -28,7 +28,9 @@ export const consentRecords = pgTable( createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => [ - index("consent_records_donation_idx").on(t.donationId).where(sql`donation_id IS NOT NULL`), + index("consent_records_donation_idx") + .on(t.donationId) + .where(sql`donation_id IS NOT NULL`), index("consent_records_org_idx") .on(t.organizationId, t.acceptedAt.desc(), t.id.desc()) .where(sql`organization_id IS NOT NULL`), diff --git a/services/api/src/db/schema/dm_messages.ts b/services/api/src/db/schema/dm_messages.ts index 9bf10698..46d76018 100644 --- a/services/api/src/db/schema/dm_messages.ts +++ b/services/api/src/db/schema/dm_messages.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { index, jsonb, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core" import { dmThreads } from "./dm_threads.js" diff --git a/services/api/src/db/schema/event_metrics_daily.ts b/services/api/src/db/schema/event_metrics_daily.ts index 6c10b0a0..e7efbc5b 100644 --- a/services/api/src/db/schema/event_metrics_daily.ts +++ b/services/api/src/db/schema/event_metrics_daily.ts @@ -1,4 +1,13 @@ -import { bigint, date, index, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core" +import { + bigint, + date, + index, + pgTable, + primaryKey, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core" import { cleanups } from "./cleanups.js" export const eventMetricsDaily = pgTable( diff --git a/services/api/src/db/schema/follows.ts b/services/api/src/db/schema/follows.ts index 8733e1c9..b9c5a027 100644 --- a/services/api/src/db/schema/follows.ts +++ b/services/api/src/db/schema/follows.ts @@ -1,4 +1,3 @@ - import { index, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core" import { users } from "./users.js" diff --git a/services/api/src/db/schema/host_exports.ts b/services/api/src/db/schema/host_exports.ts index 53e8bdf4..ea95241a 100644 --- a/services/api/src/db/schema/host_exports.ts +++ b/services/api/src/db/schema/host_exports.ts @@ -29,7 +29,9 @@ export const hostExports = pgTable( .notNull() .references(() => users.id, { onDelete: "cascade" }), kind: text("kind").$type().notNull(), - filters: jsonb("filters").notNull().default(sql`'{}'::jsonb`), + filters: jsonb("filters") + .notNull() + .default(sql`'{}'::jsonb`), status: text("status").$type().notNull().default("queued"), r2Key: text("r2_key"), rowCount: integer("row_count"), @@ -43,11 +45,7 @@ export const hostExports = pgTable( expiresAt: timestamp("expires_at", { withTimezone: true }), }, (t) => [ - index("host_exports_cleanup_requested_idx").on( - t.cleanupId, - t.requestedAt.desc(), - t.id.desc(), - ), + index("host_exports_cleanup_requested_idx").on(t.cleanupId, t.requestedAt.desc(), t.id.desc()), index("host_exports_requester_idx").on(t.requestedBy, t.requestedAt.desc(), t.id.desc()), index("host_exports_reap_idx") .on(t.expiresAt) diff --git a/services/api/src/db/schema/inbound_emails.ts b/services/api/src/db/schema/inbound_emails.ts index 630ec2ff..5d2dea3d 100644 --- a/services/api/src/db/schema/inbound_emails.ts +++ b/services/api/src/db/schema/inbound_emails.ts @@ -1,6 +1,14 @@ - import { sql } from "drizzle-orm" -import { boolean, index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core" +import { + boolean, + index, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core" import type { INBOUND_EMAIL_STATUS_VALUES } from "./types.js" type InboundEmailStatus = (typeof INBOUND_EMAIL_STATUS_VALUES)[number] diff --git a/services/api/src/db/schema/index.ts b/services/api/src/db/schema/index.ts index 386758c5..db23f70a 100644 --- a/services/api/src/db/schema/index.ts +++ b/services/api/src/db/schema/index.ts @@ -1,4 +1,3 @@ - export * from "./types.js" export * from "./users.js" diff --git a/services/api/src/db/schema/jurisdiction_contacts.ts b/services/api/src/db/schema/jurisdiction_contacts.ts index 06080b56..86b71f19 100644 --- a/services/api/src/db/schema/jurisdiction_contacts.ts +++ b/services/api/src/db/schema/jurisdiction_contacts.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core" import { jurisdictions } from "./jurisdictions.js" diff --git a/services/api/src/db/schema/mail.ts b/services/api/src/db/schema/mail.ts index 78bac4ea..f1bf5829 100644 --- a/services/api/src/db/schema/mail.ts +++ b/services/api/src/db/schema/mail.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { boolean, @@ -81,9 +80,7 @@ export const mailMessages = pgTable( index("mail_messages_thread_created_idx").on(t.threadId, t.createdAt), index("mail_messages_effects_pending_idx") .on(t.createdAt) - .where( - sql`direction = 'in' AND unaffiliated = false AND effects_applied_at IS NULL`, - ), + .where(sql`direction = 'in' AND unaffiliated = false AND effects_applied_at IS NULL`), index("mail_messages_message_id_idx") .on(t.messageId) .where(sql`message_id IS NOT NULL`), diff --git a/services/api/src/db/schema/media.ts b/services/api/src/db/schema/media.ts index 2cca8f63..73d0da61 100644 --- a/services/api/src/db/schema/media.ts +++ b/services/api/src/db/schema/media.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { bigint, diff --git a/services/api/src/db/schema/moderation.ts b/services/api/src/db/schema/moderation.ts index e741f329..44e2ba7f 100644 --- a/services/api/src/db/schema/moderation.ts +++ b/services/api/src/db/schema/moderation.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core" import type { diff --git a/services/api/src/db/schema/moderation_items.ts b/services/api/src/db/schema/moderation_items.ts index b5c769e7..a753eb35 100644 --- a/services/api/src/db/schema/moderation_items.ts +++ b/services/api/src/db/schema/moderation_items.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core" import { users } from "./users.js" diff --git a/services/api/src/db/schema/notification_prefs.ts b/services/api/src/db/schema/notification_prefs.ts index c0ea733d..442ad746 100644 --- a/services/api/src/db/schema/notification_prefs.ts +++ b/services/api/src/db/schema/notification_prefs.ts @@ -1,4 +1,3 @@ - import { boolean, pgTable, text, time, uuid } from "drizzle-orm/pg-core" import { users } from "./users.js" diff --git a/services/api/src/db/schema/notifications.ts b/services/api/src/db/schema/notifications.ts index 5e85afee..0eb6d917 100644 --- a/services/api/src/db/schema/notifications.ts +++ b/services/api/src/db/schema/notifications.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" import { users } from "./users.js" diff --git a/services/api/src/db/schema/org_verifications.ts b/services/api/src/db/schema/org_verifications.ts index 97db6050..25a591ec 100644 --- a/services/api/src/db/schema/org_verifications.ts +++ b/services/api/src/db/schema/org_verifications.ts @@ -11,10 +11,7 @@ import { } from "drizzle-orm/pg-core" import { organizations } from "./organizations.js" import { users } from "./users.js" -import type { - ORG_VERIFICATION_KIND_VALUES, - ORG_VERIFICATION_STATUS_VALUES, -} from "./types-host.js" +import type { ORG_VERIFICATION_KIND_VALUES, ORG_VERIFICATION_STATUS_VALUES } from "./types-host.js" type OrgVerificationStatus = (typeof ORG_VERIFICATION_STATUS_VALUES)[number] type OrgVerificationKind = (typeof ORG_VERIFICATION_KIND_VALUES)[number] diff --git a/services/api/src/db/schema/organizations.ts b/services/api/src/db/schema/organizations.ts index c661ed40..e9b67c4f 100644 --- a/services/api/src/db/schema/organizations.ts +++ b/services/api/src/db/schema/organizations.ts @@ -1,11 +1,17 @@ import { sql } from "drizzle-orm" -import { check, index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core" +import { + check, + index, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core" import { users } from "./users.js" import { citext } from "./types.js" -import type { - ORG_VERIFICATION_KIND_VALUES, - ORG_VERIFICATION_STATUS_VALUES, -} from "./types-host.js" +import type { ORG_VERIFICATION_KIND_VALUES, ORG_VERIFICATION_STATUS_VALUES } from "./types-host.js" type OrgVerificationStatus = (typeof ORG_VERIFICATION_STATUS_VALUES)[number] type OrgVerificationKind = (typeof ORG_VERIFICATION_KIND_VALUES)[number] diff --git a/services/api/src/db/schema/otp.ts b/services/api/src/db/schema/otp.ts index ecd78ddd..af638f19 100644 --- a/services/api/src/db/schema/otp.ts +++ b/services/api/src/db/schema/otp.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { index, integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" import { citext } from "./types.js" diff --git a/services/api/src/db/schema/post_likes.ts b/services/api/src/db/schema/post_likes.ts index 20b9076b..04ef47a1 100644 --- a/services/api/src/db/schema/post_likes.ts +++ b/services/api/src/db/schema/post_likes.ts @@ -22,10 +22,7 @@ export const postLikes = pgTable( .references(() => users.id, { onDelete: "cascade" }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, - (t) => [ - primaryKey({ columns: [t.postId, t.userId] }), - index("post_likes_user_idx").on(t.userId), - ], + (t) => [primaryKey({ columns: [t.postId, t.userId] }), index("post_likes_user_idx").on(t.userId)], ) export type PostLikeRow = typeof postLikes.$inferSelect diff --git a/services/api/src/db/schema/posts.ts b/services/api/src/db/schema/posts.ts index 13217fa5..0c2e3e76 100644 --- a/services/api/src/db/schema/posts.ts +++ b/services/api/src/db/schema/posts.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { type AnyPgColumn, diff --git a/services/api/src/db/schema/reports.ts b/services/api/src/db/schema/reports.ts index e9fb0b24..65d98b1f 100644 --- a/services/api/src/db/schema/reports.ts +++ b/services/api/src/db/schema/reports.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { check, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core" import type { AddressPrecision, ReportAddressSource } from "@civfix/shared" @@ -66,7 +65,9 @@ export const reports = pgTable( .on(t.reporterUserId, t.createdAt) .where(sql`reporter_user_id IS NOT NULL`), index("reports_anon_session_idx").on(t.anonSessionId), - uniqueIndex("reports_claim_code_key").on(t.claimCode).where(sql`claim_code IS NOT NULL`), + uniqueIndex("reports_claim_code_key") + .on(t.claimCode) + .where(sql`claim_code IS NOT NULL`), uniqueIndex("reports_claim_code_hash_key") .on(t.claimCodeHash) .where(sql`claim_code_hash IS NOT NULL`), diff --git a/services/api/src/db/schema/service-hours-certificates.ts b/services/api/src/db/schema/service-hours-certificates.ts index a991079b..6cab6e48 100644 --- a/services/api/src/db/schema/service-hours-certificates.ts +++ b/services/api/src/db/schema/service-hours-certificates.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { boolean, diff --git a/services/api/src/db/schema/types.ts b/services/api/src/db/schema/types.ts index 2808af7d..f425fbca 100644 --- a/services/api/src/db/schema/types.ts +++ b/services/api/src/db/schema/types.ts @@ -1,4 +1,3 @@ - import { customType } from "drizzle-orm/pg-core" export type GeometrySubtype = "Point" | "MultiPolygon" | "Polygon" | "Geometry" @@ -130,17 +129,11 @@ export const ABUSE_SOURCE_VALUES = ["worker", "api", "user_report"] as const export const DISCOVERY_STATUS_VALUES = ["open", "in_progress", "done"] as const - export const GOV_METHOD_VALUES = ["email", "cold_outreach"] as const export const GOV_CLAIM_STATUS_VALUES = ["pending", "approved", "rejected"] as const -export const VERIFICATION_STATUS_VALUES = [ - "unverified", - "pending", - "verified", - "rejected", -] as const +export const VERIFICATION_STATUS_VALUES = ["unverified", "pending", "verified", "rejected"] as const export const USER_ACCOUNT_STATUS_VALUES = ["active", "suspended", "review", "banned"] as const diff --git a/services/api/src/db/schema/user_blocks.ts b/services/api/src/db/schema/user_blocks.ts index bb3a5fb8..6882d435 100644 --- a/services/api/src/db/schema/user_blocks.ts +++ b/services/api/src/db/schema/user_blocks.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { check, index, pgTable, primaryKey, timestamp, uuid } from "drizzle-orm/pg-core" import { users } from "./users.js" diff --git a/services/api/src/db/schema/user_moderation.ts b/services/api/src/db/schema/user_moderation.ts index 10a51eb3..3f695814 100644 --- a/services/api/src/db/schema/user_moderation.ts +++ b/services/api/src/db/schema/user_moderation.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { boolean, index, inet, integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" import { users } from "./users.js" diff --git a/services/api/src/db/schema/users.ts b/services/api/src/db/schema/users.ts index 9235486d..307de8b3 100644 --- a/services/api/src/db/schema/users.ts +++ b/services/api/src/db/schema/users.ts @@ -1,4 +1,3 @@ - import { sql } from "drizzle-orm" import { boolean, diff --git a/services/api/src/db/schema/volunteer-hours.ts b/services/api/src/db/schema/volunteer-hours.ts index 23ebb2a3..82402327 100644 --- a/services/api/src/db/schema/volunteer-hours.ts +++ b/services/api/src/db/schema/volunteer-hours.ts @@ -1,6 +1,14 @@ - import { sql } from "drizzle-orm" -import { index, numeric, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core" +import { + index, + numeric, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core" import type { VolunteerHoursSource } from "@civfix/shared" import { cleanups } from "./cleanups.js" import { jurisdictions } from "./jurisdictions.js" diff --git a/services/api/src/db/seed-demo-la.ts b/services/api/src/db/seed-demo-la.ts index a53f00fc..79854340 100644 --- a/services/api/src/db/seed-demo-la.ts +++ b/services/api/src/db/seed-demo-la.ts @@ -47,10 +47,7 @@ import { resolveJurisdiction } from "./sql/jurisdiction.js" import { reportH3Cell } from "../services/report-clustering.js" import { runIfMain } from "./cli.js" import { DEMO_EMAIL_DOMAIN } from "./seed-demo-domain.js" -import { - DEFAULT_EVENT_DURATION_MS, - DEFAULT_EVENT_SLOT_TITLE, -} from "../services/cleanup-rules.js" +import { DEFAULT_EVENT_DURATION_MS, DEFAULT_EVENT_SLOT_TITLE } from "../services/cleanup-rules.js" // --------------------------------------------------------------------------------------------------- // Deterministic PRNG (mulberry32) + sampling helpers. Seeded so a rehearsal and the committed run (or @@ -136,8 +133,26 @@ const LA_UTC_OFFSET_HOURS = 7 /** Weighted local hour: mornings light, lunchtime medium, evenings heavy, small overnight tail. */ function localHour(): number { return pickWeighted([ - [6, 1], [7, 3], [8, 4], [9, 4], [10, 4], [11, 5], [12, 6], [13, 5], [14, 4], [15, 4], - [16, 5], [17, 7], [18, 9], [19, 10], [20, 10], [21, 8], [22, 5], [23, 2], [0, 1], [1, 1], + [6, 1], + [7, 3], + [8, 4], + [9, 4], + [10, 4], + [11, 5], + [12, 6], + [13, 5], + [14, 4], + [15, 4], + [16, 5], + [17, 7], + [18, 9], + [19, 10], + [20, 10], + [21, 8], + [22, 5], + [23, 2], + [0, 1], + [1, 1], ]) } @@ -179,40 +194,201 @@ function later(a: Date, b: Date): Date { // --------------------------------------------------------------------------------------------------- const HISPANIC_FIRST_M = [ - "Jose", "Juan", "Carlos", "Luis", "Jorge", "Miguel", "Pedro", "Rafael", "Javier", - "Alejandro", "Fernando", "Ricardo", "Eduardo", "Sergio", "Hector", "Oscar", "Raul", "Marco", - "Cesar", "Diego", "Emiliano", "Mateo", "Santiago", "Sebastian", "Andres", - "Cristian", "Ivan", "Erick", "Kevin", "Brandon", "Anthony", "Angel", "Jesus", "Ernesto", - "Gerardo", "Rodrigo", "Ruben", "Salvador", "Armando", "Alfredo", "Enrique", + "Jose", + "Juan", + "Carlos", + "Luis", + "Jorge", + "Miguel", + "Pedro", + "Rafael", + "Javier", + "Alejandro", + "Fernando", + "Ricardo", + "Eduardo", + "Sergio", + "Hector", + "Oscar", + "Raul", + "Marco", + "Cesar", + "Diego", + "Emiliano", + "Mateo", + "Santiago", + "Sebastian", + "Andres", + "Cristian", + "Ivan", + "Erick", + "Kevin", + "Brandon", + "Anthony", + "Angel", + "Jesus", + "Ernesto", + "Gerardo", + "Rodrigo", + "Ruben", + "Salvador", + "Armando", + "Alfredo", + "Enrique", ] as const const HISPANIC_FIRST_F = [ - "Maria", "Guadalupe", "Rosa", "Carmen", "Ana", "Leticia", "Veronica", "Claudia", "Adriana", - "Gabriela", "Alejandra", "Daniela", "Mariana", "Valeria", "Ximena", "Camila", "Lucia", "Elena", - "Isabel", "Sofia", "Paola", "Yesenia", "Marisol", "Araceli", "Esmeralda", "Karina", "Brenda", - "Jessica", "Jasmine", "Vanessa", "Lorena", "Norma", "Silvia", "Patricia", "Sandra", "Monica", - "Angelica", "Maribel", "Rocio", "Beatriz", "Josefina", "Cindy", "Nayeli", "Itzel", "Fatima", - "Alondra", "Giselle", "Ashley", "Destiny", "Selena", + "Maria", + "Guadalupe", + "Rosa", + "Carmen", + "Ana", + "Leticia", + "Veronica", + "Claudia", + "Adriana", + "Gabriela", + "Alejandra", + "Daniela", + "Mariana", + "Valeria", + "Ximena", + "Camila", + "Lucia", + "Elena", + "Isabel", + "Sofia", + "Paola", + "Yesenia", + "Marisol", + "Araceli", + "Esmeralda", + "Karina", + "Brenda", + "Jessica", + "Jasmine", + "Vanessa", + "Lorena", + "Norma", + "Silvia", + "Patricia", + "Sandra", + "Monica", + "Angelica", + "Maribel", + "Rocio", + "Beatriz", + "Josefina", + "Cindy", + "Nayeli", + "Itzel", + "Fatima", + "Alondra", + "Giselle", + "Ashley", + "Destiny", + "Selena", ] as const const HISPANIC_LAST = [ - "Garcia", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Perez", "Sanchez", - "Ramirez", "Torres", "Flores", "Rivera", "Gomez", "Diaz", "Reyes", "Morales", "Cruz", "Ortiz", - "Gutierrez", "Chavez", "Ramos", "Ruiz", "Alvarez", "Mendoza", "Vasquez", "Castillo", "Jimenez", - "Moreno", "Romero", "Herrera", "Medina", "Aguilar", "Vargas", "Guzman", "Castro", "Fernandez", - "Munoz", "Rojas", "Soto", "Contreras", "Silva", "Delgado", "Pena", "Rios", "Salazar", "Estrada", - "Ortega", "Nunez", "Maldonado", "Vega", "Dominguez", "Cabrera", "Velasquez", "Ibarra", "Zavala", - "Cervantes", "Fuentes", "Carrillo", "Trejo", "Solis", "Cardenas", "Villanueva", "Escobar", - "Quintero", "Barrera", "Rosales", "Camacho", "Arellano", "Meza", "Palacios", "Navarro", - "Padilla", "Miranda", "Bautista", "Orozco", "Zuniga", "Ochoa", "Duran", "Macias", "Renteria", + "Garcia", + "Rodriguez", + "Martinez", + "Hernandez", + "Lopez", + "Gonzalez", + "Perez", + "Sanchez", + "Ramirez", + "Torres", + "Flores", + "Rivera", + "Gomez", + "Diaz", + "Reyes", + "Morales", + "Cruz", + "Ortiz", + "Gutierrez", + "Chavez", + "Ramos", + "Ruiz", + "Alvarez", + "Mendoza", + "Vasquez", + "Castillo", + "Jimenez", + "Moreno", + "Romero", + "Herrera", + "Medina", + "Aguilar", + "Vargas", + "Guzman", + "Castro", + "Fernandez", + "Munoz", + "Rojas", + "Soto", + "Contreras", + "Silva", + "Delgado", + "Pena", + "Rios", + "Salazar", + "Estrada", + "Ortega", + "Nunez", + "Maldonado", + "Vega", + "Dominguez", + "Cabrera", + "Velasquez", + "Ibarra", + "Zavala", + "Cervantes", + "Fuentes", + "Carrillo", + "Trejo", + "Solis", + "Cardenas", + "Villanueva", + "Escobar", + "Quintero", + "Barrera", + "Rosales", + "Camacho", + "Arellano", + "Meza", + "Palacios", + "Navarro", + "Padilla", + "Miranda", + "Bautista", + "Orozco", + "Zuniga", + "Ochoa", + "Duran", + "Macias", + "Renteria", ] as const /** Accent variants for display names only (handles and emails stay ASCII). */ const ACCENTED: Record = { - Jose: "José", Maria: "María", Jesus: "Jesús", Andres: "Andrés", - Cesar: "César", Angel: "Ángel", Lucia: "Lucía", Sofia: "Sofía", - Ivan: "Iván", Fatima: "Fátima", Munoz: "Muñoz", Nunez: "Núñez", - Pena: "Peña", Zuniga: "Zúñiga", + Jose: "José", + Maria: "María", + Jesus: "Jesús", + Andres: "Andrés", + Cesar: "César", + Angel: "Ángel", + Lucia: "Lucía", + Sofia: "Sofía", + Ivan: "Iván", + Fatima: "Fátima", + Munoz: "Muñoz", + Nunez: "Núñez", + Pena: "Peña", + Zuniga: "Zúñiga", } const OTHER_POOLS: readonly { @@ -221,38 +397,58 @@ const OTHER_POOLS: readonly { last: readonly string[] weight: number }[] = [ - { // Korean American + { + // Korean American firstM: ["Daniel", "Brian", "Eric", "Andrew", "Joon", "David"], firstF: ["Grace", "Esther", "Hannah", "Julie", "Minji", "Susan"], last: ["Kim", "Park", "Lee", "Choi", "Kang", "Yoon", "Shin", "Cho"], weight: 5, }, - { // Armenian American + { + // Armenian American firstM: ["Armen", "Narek", "Tigran", "Vahe"], firstF: ["Ani", "Lilit", "Mariam", "Sona"], last: ["Hakobyan", "Grigoryan", "Sarkissian", "Petrosyan", "Avetisyan", "Kasparian"], weight: 3, }, - { // Filipino American + { + // Filipino American firstM: ["Angelo", "Mark", "JR", "Paolo"], firstF: ["Kristine", "Joanna", "Camille", "Divine"], last: ["Santos", "Dela Cruz", "Mercado", "Aquino", "Ocampo", "Villareal", "Manalo"], weight: 4, }, - { // Black and White American + { + // Black and White American firstM: ["Marcus", "Darnell", "James", "Mike", "Tyler", "Jordan", "Chris", "Devin"], firstF: ["Keisha", "Tiffany", "Sarah", "Emily", "Aaliyah", "Megan", "Lauren", "Renee"], - last: ["Johnson", "Williams", "Brown", "Smith", "Miller", "Davis", "Jackson", "Harris", - "Thompson", "Robinson", "Walker", "Carter", "Mitchell", "Turner"], + last: [ + "Johnson", + "Williams", + "Brown", + "Smith", + "Miller", + "Davis", + "Jackson", + "Harris", + "Thompson", + "Robinson", + "Walker", + "Carter", + "Mitchell", + "Turner", + ], weight: 10, }, - { // Chinese American + { + // Chinese American firstM: ["Wei", "Kevin", "Jason", "Alan"], firstF: ["Amy", "Cindy", "Michelle", "Tina"], last: ["Chen", "Wang", "Liu", "Huang", "Lin", "Wu", "Zhang"], weight: 4, }, - { // Vietnamese American + { + // Vietnamese American firstM: ["Minh", "Vincent", "Phong", "Tuan"], firstF: ["Linh", "Thao", "Kim-Ly", "Vy"], last: ["Nguyen", "Tran", "Pham", "Le", "Vo", "Dang"], @@ -275,58 +471,237 @@ interface Hood { } const HOODS: readonly Hood[] = [ - { name: "Boyle Heights", lat: 34.0397, lng: -118.2077, r: 0.010, weight: 10, - streets: ["Cesar Chavez Ave", "Soto St", "1st St", "4th St", "Whittier Blvd", "Lorena St", "Evergreen Ave", "St Louis St"] }, - { name: "East LA", lat: 34.0239, lng: -118.1721, r: 0.012, weight: 9, - streets: ["Whittier Blvd", "Atlantic Blvd", "3rd St", "Mednik Ave", "Arizona Ave", "Hammel St"] }, - { name: "Highland Park", lat: 34.1115, lng: -118.1870, r: 0.010, weight: 7, - streets: ["York Blvd", "Figueroa St", "Avenue 56", "Monte Vista St", "Marmion Way"] }, - { name: "El Sereno", lat: 34.0806, lng: -118.1763, r: 0.010, weight: 6, - streets: ["Huntington Dr", "Eastern Ave", "Alhambra Ave", "Valley Blvd"] }, - { name: "Lincoln Heights", lat: 34.0700, lng: -118.2000, r: 0.008, weight: 6, - streets: ["N Broadway", "Daly St", "Main St", "Avenue 26", "Workman St"] }, - { name: "City Terrace", lat: 34.0570, lng: -118.1830, r: 0.007, weight: 4, - streets: ["City Terrace Dr", "Eastern Ave", "Herbert Ave"] }, - { name: "Huntington Park", lat: 33.9817, lng: -118.2251, r: 0.009, weight: 7, - streets: ["Pacific Blvd", "Gage Ave", "Slauson Ave", "Florence Ave", "Santa Fe Ave"] }, - { name: "South Gate", lat: 33.9547, lng: -118.2120, r: 0.010, weight: 5, - streets: ["Tweedy Blvd", "Long Beach Blvd", "Firestone Blvd", "Atlantic Ave"] }, - { name: "Pacoima", lat: 34.2728, lng: -118.4201, r: 0.012, weight: 6, - streets: ["Van Nuys Blvd", "Glenoaks Blvd", "Laurel Canyon Blvd", "Foothill Blvd", "Paxton St"] }, - { name: "Van Nuys", lat: 34.1899, lng: -118.4514, r: 0.012, weight: 5, - streets: ["Van Nuys Blvd", "Victory Blvd", "Sherman Way", "Sepulveda Blvd", "Kester Ave"] }, - { name: "Sylmar", lat: 34.3078, lng: -118.4453, r: 0.012, weight: 3, - streets: ["San Fernando Rd", "Maclay Ave", "Glenoaks Blvd", "Hubbard St"] }, - { name: "Sun Valley", lat: 34.2170, lng: -118.3700, r: 0.010, weight: 3, - streets: ["San Fernando Rd", "Sunland Blvd", "Vineland Ave", "Lankershim Blvd"] }, - { name: "Wilmington", lat: 33.7801, lng: -118.2646, r: 0.010, weight: 5, - streets: ["Avalon Blvd", "Anaheim St", "Pacific Coast Hwy", "Wilmington Blvd", "L St"] }, - { name: "San Pedro", lat: 33.7361, lng: -118.2922, r: 0.010, weight: 4, - streets: ["Gaffey St", "Pacific Ave", "25th St", "Western Ave", "6th St"] }, - { name: "Watts", lat: 33.9425, lng: -118.2417, r: 0.008, weight: 5, - streets: ["103rd St", "Central Ave", "Compton Ave", "Wilmington Ave", "Grandee Ave"] }, - { name: "South LA", lat: 34.0000, lng: -118.2920, r: 0.014, weight: 7, - streets: ["Vermont Ave", "Western Ave", "Normandie Ave", "Slauson Ave", "Manchester Ave", "Figueroa St"] }, - { name: "Koreatown", lat: 34.0577, lng: -118.3009, r: 0.009, weight: 5, - streets: ["Wilshire Blvd", "Olympic Blvd", "Western Ave", "Vermont Ave", "8th St", "Normandie Ave"] }, - { name: "Westlake", lat: 34.0570, lng: -118.2760, r: 0.007, weight: 5, - streets: ["Alvarado St", "7th St", "Wilshire Blvd", "Union Ave", "Bonnie Brae St"] }, - { name: "Pico-Union", lat: 34.0470, lng: -118.2830, r: 0.007, weight: 5, - streets: ["Pico Blvd", "Union Ave", "Hoover St", "Venice Blvd", "Alvarado St"] }, - { name: "Cypress Park", lat: 34.0930, lng: -118.2240, r: 0.006, weight: 3, - streets: ["Cypress Ave", "Figueroa St", "San Fernando Rd", "Division St"] }, - { name: "Glassell Park", lat: 34.1130, lng: -118.2320, r: 0.007, weight: 3, - streets: ["Eagle Rock Blvd", "Verdugo Rd", "San Fernando Rd", "Fletcher Dr"] }, - { name: "Echo Park", lat: 34.0782, lng: -118.2606, r: 0.007, weight: 4, - streets: ["Sunset Blvd", "Echo Park Ave", "Glendale Blvd", "Alvarado St"] }, - { name: "Hollywood", lat: 34.0928, lng: -118.3287, r: 0.010, weight: 3, - streets: ["Hollywood Blvd", "Sunset Blvd", "Santa Monica Blvd", "Western Ave", "Gower St"] }, - { name: "North Hollywood", lat: 34.1720, lng: -118.3770, r: 0.010, weight: 4, - streets: ["Lankershim Blvd", "Magnolia Blvd", "Victory Blvd", "Vineland Ave"] }, - { name: "Panorama City", lat: 34.2270, lng: -118.4490, r: 0.009, weight: 4, - streets: ["Van Nuys Blvd", "Roscoe Blvd", "Nordhoff St", "Woodman Ave"] }, - { name: "Harbor Gateway", lat: 33.8600, lng: -118.2900, r: 0.010, weight: 2, - streets: ["Vermont Ave", "Figueroa St", "Gardena Blvd", "190th St"] }, + { + name: "Boyle Heights", + lat: 34.0397, + lng: -118.2077, + r: 0.01, + weight: 10, + streets: [ + "Cesar Chavez Ave", + "Soto St", + "1st St", + "4th St", + "Whittier Blvd", + "Lorena St", + "Evergreen Ave", + "St Louis St", + ], + }, + { + name: "East LA", + lat: 34.0239, + lng: -118.1721, + r: 0.012, + weight: 9, + streets: ["Whittier Blvd", "Atlantic Blvd", "3rd St", "Mednik Ave", "Arizona Ave", "Hammel St"], + }, + { + name: "Highland Park", + lat: 34.1115, + lng: -118.187, + r: 0.01, + weight: 7, + streets: ["York Blvd", "Figueroa St", "Avenue 56", "Monte Vista St", "Marmion Way"], + }, + { + name: "El Sereno", + lat: 34.0806, + lng: -118.1763, + r: 0.01, + weight: 6, + streets: ["Huntington Dr", "Eastern Ave", "Alhambra Ave", "Valley Blvd"], + }, + { + name: "Lincoln Heights", + lat: 34.07, + lng: -118.2, + r: 0.008, + weight: 6, + streets: ["N Broadway", "Daly St", "Main St", "Avenue 26", "Workman St"], + }, + { + name: "City Terrace", + lat: 34.057, + lng: -118.183, + r: 0.007, + weight: 4, + streets: ["City Terrace Dr", "Eastern Ave", "Herbert Ave"], + }, + { + name: "Huntington Park", + lat: 33.9817, + lng: -118.2251, + r: 0.009, + weight: 7, + streets: ["Pacific Blvd", "Gage Ave", "Slauson Ave", "Florence Ave", "Santa Fe Ave"], + }, + { + name: "South Gate", + lat: 33.9547, + lng: -118.212, + r: 0.01, + weight: 5, + streets: ["Tweedy Blvd", "Long Beach Blvd", "Firestone Blvd", "Atlantic Ave"], + }, + { + name: "Pacoima", + lat: 34.2728, + lng: -118.4201, + r: 0.012, + weight: 6, + streets: ["Van Nuys Blvd", "Glenoaks Blvd", "Laurel Canyon Blvd", "Foothill Blvd", "Paxton St"], + }, + { + name: "Van Nuys", + lat: 34.1899, + lng: -118.4514, + r: 0.012, + weight: 5, + streets: ["Van Nuys Blvd", "Victory Blvd", "Sherman Way", "Sepulveda Blvd", "Kester Ave"], + }, + { + name: "Sylmar", + lat: 34.3078, + lng: -118.4453, + r: 0.012, + weight: 3, + streets: ["San Fernando Rd", "Maclay Ave", "Glenoaks Blvd", "Hubbard St"], + }, + { + name: "Sun Valley", + lat: 34.217, + lng: -118.37, + r: 0.01, + weight: 3, + streets: ["San Fernando Rd", "Sunland Blvd", "Vineland Ave", "Lankershim Blvd"], + }, + { + name: "Wilmington", + lat: 33.7801, + lng: -118.2646, + r: 0.01, + weight: 5, + streets: ["Avalon Blvd", "Anaheim St", "Pacific Coast Hwy", "Wilmington Blvd", "L St"], + }, + { + name: "San Pedro", + lat: 33.7361, + lng: -118.2922, + r: 0.01, + weight: 4, + streets: ["Gaffey St", "Pacific Ave", "25th St", "Western Ave", "6th St"], + }, + { + name: "Watts", + lat: 33.9425, + lng: -118.2417, + r: 0.008, + weight: 5, + streets: ["103rd St", "Central Ave", "Compton Ave", "Wilmington Ave", "Grandee Ave"], + }, + { + name: "South LA", + lat: 34.0, + lng: -118.292, + r: 0.014, + weight: 7, + streets: [ + "Vermont Ave", + "Western Ave", + "Normandie Ave", + "Slauson Ave", + "Manchester Ave", + "Figueroa St", + ], + }, + { + name: "Koreatown", + lat: 34.0577, + lng: -118.3009, + r: 0.009, + weight: 5, + streets: [ + "Wilshire Blvd", + "Olympic Blvd", + "Western Ave", + "Vermont Ave", + "8th St", + "Normandie Ave", + ], + }, + { + name: "Westlake", + lat: 34.057, + lng: -118.276, + r: 0.007, + weight: 5, + streets: ["Alvarado St", "7th St", "Wilshire Blvd", "Union Ave", "Bonnie Brae St"], + }, + { + name: "Pico-Union", + lat: 34.047, + lng: -118.283, + r: 0.007, + weight: 5, + streets: ["Pico Blvd", "Union Ave", "Hoover St", "Venice Blvd", "Alvarado St"], + }, + { + name: "Cypress Park", + lat: 34.093, + lng: -118.224, + r: 0.006, + weight: 3, + streets: ["Cypress Ave", "Figueroa St", "San Fernando Rd", "Division St"], + }, + { + name: "Glassell Park", + lat: 34.113, + lng: -118.232, + r: 0.007, + weight: 3, + streets: ["Eagle Rock Blvd", "Verdugo Rd", "San Fernando Rd", "Fletcher Dr"], + }, + { + name: "Echo Park", + lat: 34.0782, + lng: -118.2606, + r: 0.007, + weight: 4, + streets: ["Sunset Blvd", "Echo Park Ave", "Glendale Blvd", "Alvarado St"], + }, + { + name: "Hollywood", + lat: 34.0928, + lng: -118.3287, + r: 0.01, + weight: 3, + streets: ["Hollywood Blvd", "Sunset Blvd", "Santa Monica Blvd", "Western Ave", "Gower St"], + }, + { + name: "North Hollywood", + lat: 34.172, + lng: -118.377, + r: 0.01, + weight: 4, + streets: ["Lankershim Blvd", "Magnolia Blvd", "Victory Blvd", "Vineland Ave"], + }, + { + name: "Panorama City", + lat: 34.227, + lng: -118.449, + r: 0.009, + weight: 4, + streets: ["Van Nuys Blvd", "Roscoe Blvd", "Nordhoff St", "Woodman Ave"], + }, + { + name: "Harbor Gateway", + lat: 33.86, + lng: -118.29, + r: 0.01, + weight: 2, + streets: ["Vermont Ave", "Figueroa St", "Gardena Blvd", "190th St"], + }, ] const PARKS: readonly { name: string; hood: string; lat: number; lng: number }[] = [ @@ -336,14 +711,14 @@ const PARKS: readonly { name: string; hood: string; lat: number; lng: number }[] { name: "Hazard Park", hood: "Boyle Heights", lat: 34.0645, lng: -118.2005 }, { name: "Lincoln Park", hood: "Lincoln Heights", lat: 34.0705, lng: -118.2028 }, { name: "Sycamore Grove Park", hood: "Highland Park", lat: 34.0996, lng: -118.1998 }, - { name: "Ted Watkins Memorial Park", hood: "Watts", lat: 33.9330, lng: -118.2379 }, - { name: "MacArthur Park", hood: "Westlake", lat: 34.0590, lng: -118.2785 }, + { name: "Ted Watkins Memorial Park", hood: "Watts", lat: 33.933, lng: -118.2379 }, + { name: "MacArthur Park", hood: "Westlake", lat: 34.059, lng: -118.2785 }, { name: "Rio de Los Angeles State Park", hood: "Cypress Park", lat: 34.0994, lng: -118.2273 }, { name: "Ernest E. Debs Regional Park", hood: "El Sereno", lat: 34.0873, lng: -118.1935 }, { name: "Ken Malloy Harbor Regional Park", hood: "Wilmington", lat: 33.7863, lng: -118.2879 }, { name: "Hansen Dam Recreation Area", hood: "Pacoima", lat: 34.2612, lng: -118.3898 }, { name: "Sepulveda Basin", hood: "Van Nuys", lat: 34.1755, lng: -118.4838 }, - { name: "Point Fermin Park", hood: "San Pedro", lat: 33.7060, lng: -118.2936 }, + { name: "Point Fermin Park", hood: "San Pedro", lat: 33.706, lng: -118.2936 }, { name: "Normandie Recreation Center", hood: "South LA", lat: 34.0261, lng: -118.3003 }, { name: "Seoul International Park", hood: "Koreatown", lat: 34.0546, lng: -118.3082 }, { name: "North Hollywood Park", hood: "North Hollywood", lat: 34.1638, lng: -118.3801 }, @@ -560,11 +935,7 @@ const REPLY_EVENT_EN: readonly string[] = [ "my whole family is coming lol", "first time doing one of these, excited", ] -const REPLY_EVENT_ES: readonly string[] = [ - "yo tambien voy", - "ahi estare", - "llevare bolsas extra", -] +const REPLY_EVENT_ES: readonly string[] = ["yo tambien voy", "ahi estare", "llevare bolsas extra"] /** Replies to report posts. */ const REPLY_REPORT_EN: readonly string[] = [ @@ -577,10 +948,7 @@ const REPLY_REPORT_EN: readonly string[] = [ "this intersection is always bad", "sad that it takes an app for the city to do their job", ] -const REPLY_REPORT_ES: readonly string[] = [ - "eso esta a una cuadra de mi casa", - "gracias vecino", -] +const REPLY_REPORT_ES: readonly string[] = ["eso esta a una cuadra de mi casa", "gracias vecino"] /** Quote-post bodies. */ const QUOTE_EN: readonly string[] = [ @@ -591,10 +959,7 @@ const QUOTE_EN: readonly string[] = [ "this is the kind of stuff that keeps me on this app", "proof that reporting works yall", ] -const QUOTE_ES: readonly string[] = [ - "lo que siempre digo", - "mi gente 💪", -] +const QUOTE_ES: readonly string[] = ["lo que siempre digo", "mi gente 💪"] /** Report content per type: [titles, descriptions]. Slots: {street} {street2}. */ const REPORT_CONTENT: Record< @@ -750,12 +1115,26 @@ const EVENT_DESC_ES: readonly string[] = [ ] const BRING_POOL: readonly string[] = [ - "gloves", "water", "sunscreen", "hat", "trash grabbers", "closed toe shoes", "reusable water bottle", + "gloves", + "water", + "sunscreen", + "hat", + "trash grabbers", + "closed toe shoes", + "reusable water bottle", ] -const SLOT_SETS: readonly (readonly { title: string; description: string | null; capacity: number | null }[])[] = [ +const SLOT_SETS: readonly (readonly { + title: string + description: string | null + capacity: number | null +}[])[] = [ [ - { title: "Registration table", description: "check people in and hand out supplies", capacity: 2 }, + { + title: "Registration table", + description: "check people in and hand out supplies", + capacity: 2, + }, { title: "Supplies and water", description: "keep the water station stocked", capacity: 2 }, { title: "Street team", description: "cover the blocks around the park", capacity: null }, ], @@ -764,7 +1143,11 @@ const SLOT_SETS: readonly (readonly { title: string; description: string | null; { title: "10-12 shift", description: null, capacity: 12 }, ], [ - { title: "Heavy lifting crew", description: "for the big stuff, bring work gloves", capacity: 6 }, + { + title: "Heavy lifting crew", + description: "for the big stuff, bring work gloves", + capacity: 6, + }, { title: "General cleanup", description: null, capacity: null }, { title: "Kids zone", description: "light duty for families with little ones", capacity: 8 }, ], @@ -829,7 +1212,13 @@ interface SeedEvent { capacity: number | null bags: number members: { user: SeedUser; role: "organizer" | "cohost" | "member"; joinedAt: Date }[] - slots: { id: string; title: string; description: string | null; capacity: number | null; sortOrder: number }[] + slots: { + id: string + title: string + description: string | null + capacity: number | null + sortOrder: number + }[] claims: { userId: string; slotId: string; claimedAt: Date }[] hood: Hood } @@ -928,7 +1317,12 @@ function makeUsers(count: number, start: Date, end: Date): SeedUser[] { [`${dFirst} ${dLast[0]}.`, 15], [dFirst, 10], [`${dFirst.toLowerCase()} ${dLast.toLowerCase()}`, 10], - [`${dFirst} ${dLast}`.toUpperCase() === `${dFirst} ${dLast}` ? `${dFirst} ${dLast}` : `${dFirst} ${dLast}`, 10], + [ + `${dFirst} ${dLast}`.toUpperCase() === `${dFirst} ${dLast}` + ? `${dFirst} ${dLast}` + : `${dFirst} ${dLast}`, + 10, + ], ]) // Handle: ASCII, matches HANDLE_REGEX (3-20 of [A-Za-z0-9_]). @@ -966,7 +1360,8 @@ function makeUsers(count: number, start: Date, end: Date): SeedUser[] { ["lurker", 12], ]) const popularity = - Math.exp((rand() + rand() + rand() - 1.5) * 1.6) * (tier === "power" ? 3 : tier === "casual" ? 1.2 : 0.6) + Math.exp((rand() + rand() + rand() - 1.5) * 1.6) * + (tier === "power" ? 3 : tier === "casual" ? 1.2 : 0.6) const user: SeedUser = { id: randomUUID(), @@ -979,7 +1374,10 @@ function makeUsers(count: number, start: Date, end: Date): SeedUser[] { tier, popularity, hispanic, - createdAt: randTimestamp(start, new Date(start.getTime() + Math.pow(rand(), 0.65) * (end.getTime() - start.getTime()))), + createdAt: randTimestamp( + start, + new Date(start.getTime() + Math.pow(rand(), 0.65) * (end.getTime() - start.getTime())), + ), showVolunteerHours: chance(0.85) ? null : chance(0.8) ? true : false, allowDirectMessages: chance(0.95), instagram: chance(0.15) ? handle.toLowerCase() : null, @@ -1006,7 +1404,13 @@ function makeFollows(users: SeedUser[], now: Date): SeedFollow[] { for (const u of users) { const target = - u.tier === "power" ? rint(15, 40) : u.tier === "casual" ? rint(6, 18) : u.tier === "light" ? rint(3, 10) : rint(0, 4) + u.tier === "power" + ? rint(15, 40) + : u.tier === "casual" + ? rint(6, 18) + : u.tier === "light" + ? rint(3, 10) + : rint(0, 4) const followees = sampleWeighted(users, weightFor(u), target, new Set([u])) for (const f of followees) { const at = randTimestamp(later(u.createdAt, f.createdAt), now) @@ -1047,16 +1451,26 @@ function makeEvents(users: SeedUser[], now: Date): SeedEvent[] { const scheduledAt = kind === "upcoming" ? nextSaturdayish(now, rint(3, 21)) - : nextSaturdayish(new Date(later(organizer.createdAt, new Date(now.getTime() - 150 * DAY)).getTime()), rint(7, 120), now) + : nextSaturdayish( + new Date(later(organizer.createdAt, new Date(now.getTime() - 150 * DAY)).getTime()), + rint(7, 120), + now, + ) // The create must land in the PAST even for upcoming events (scheduled_at - 2d can exceed now // when the event is less than 2 days out, which would backdate joins into the future). - const createdCeiling = new Date(Math.min(scheduledAt.getTime() - 2 * DAY, now.getTime() - 6 * 3600_000)) + const createdCeiling = new Date( + Math.min(scheduledAt.getTime() - 2 * DAY, now.getTime() - 6 * 3600_000), + ) const createdAt = randTimestamp( later(organizer.createdAt, new Date(scheduledAt.getTime() - 28 * DAY)), createdCeiling, ) const { lat, lng } = jitterPoint(park.lat, park.lng, 0.0015) - const title = fill(bilingual(organizer, EVENT_TITLE_EN, EVENT_TITLE_ES), organizer, { park: park.name, hood: hood.name, street: pick(hood.streets) }) + const title = fill(bilingual(organizer, EVENT_TITLE_EN, EVENT_TITLE_ES), organizer, { + park: park.name, + hood: hood.name, + street: pick(hood.streets), + }) const ev: SeedEvent = { id: randomUUID(), organizer, @@ -1082,7 +1496,9 @@ function makeEvents(users: SeedUser[], now: Date): SeedEvent[] { // 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) * (c.tier === "lurker" ? 0.3 : 1) * Math.sqrt(c.popularity) + (c.hood.name === hood.name ? 4 : 1) * + (c.tier === "lurker" ? 0.3 : 1) * + Math.sqrt(c.popularity) const joiners = sampleWeighted(users, weight, memberTarget, new Set([organizer])) const joinEnd = kind === "upcoming" ? now : scheduledAt for (const [j, u] of joiners.entries()) { @@ -1113,12 +1529,17 @@ function makeEvents(users: SeedUser[], now: Date): SeedEvent[] { for (const m of ev.members) { if (m.role === "organizer" || !chance(0.5)) continue const open = ev.slots.filter( - (s) => s.capacity === null || ev.claims.filter((c) => c.slotId === s.id).length < s.capacity, + (s) => + s.capacity === null || ev.claims.filter((c) => c.slotId === s.id).length < s.capacity, ) if (open.length === 0 || claimed.has(m.user.id)) continue const slot = pick(open) claimed.add(m.user.id) - ev.claims.push({ userId: m.user.id, slotId: slot.id, claimedAt: randTimestamp(m.joinedAt, joinEnd) }) + ev.claims.push({ + userId: m.user.id, + slotId: slot.id, + claimedAt: randTimestamp(m.joinedAt, joinEnd), + }) } } events.push(ev) @@ -1145,12 +1566,25 @@ function makeReports(users: SeedUser[], count: number, now: Date): SeedReport[] const reports: SeedReport[] = [] for (let i = 0; i < count; i++) { const reporter = pickWeighted( - users.map((u) => [u, u.tier === "power" ? 4 : u.tier === "casual" ? 2 : u.tier === "light" ? 1 : 0.2] as const), + users.map( + (u) => + [ + u, + u.tier === "power" ? 4 : u.tier === "casual" ? 2 : u.tier === "light" ? 1 : 0.2, + ] as const, + ), ) - const hood = chance(0.85) ? reporter.hood : pickWeighted(HOODS.map((h) => [h, h.weight] as const)) + const hood = chance(0.85) + ? reporter.hood + : pickWeighted(HOODS.map((h) => [h, h.weight] as const)) const type = pickWeighted([ - ["dump", 38], ["graffiti", 16], ["pavement", 14], ["vegetation", 9], - ["infrastructure", 9], ["encampment", 6], ["other", 8], + ["dump", 38], + ["graffiti", 16], + ["pavement", 14], + ["vegetation", 9], + ["infrastructure", 9], + ["encampment", 6], + ["other", 8], ]) const { lat, lng } = jitterPoint(hood.lat, hood.lng, hood.r) const content = REPORT_CONTENT[type] @@ -1158,7 +1592,11 @@ function makeReports(users: SeedUser[], count: number, now: Date): SeedReport[] const street2 = pick(hood.streets.filter((s) => s !== street)) ?? street const createdAt = randTimestamp(reporter.createdAt, now) const status = pickWeighted([ - ["published", 52], ["acknowledged", 15], ["in_progress", 8], ["resolved", 20], ["submitted", 5], + ["published", 52], + ["acknowledged", 15], + ["in_progress", 8], + ["resolved", 20], + ["submitted", 5], ]) const timeline: SeedReport["timeline"] = [ @@ -1172,7 +1610,12 @@ function makeReports(users: SeedUser[], count: number, now: Date): SeedReport[] let cursor = publishedAt ?? createdAt if (status === "acknowledged" || status === "in_progress" || status === "resolved") { cursor = randTimestamp(cursor, new Date(Math.min(cursor.getTime() + 10 * DAY, now.getTime()))) - timeline.push({ status: "acknowledged", note: pick(TIMELINE_ACK_NOTES), createdAt: cursor, actorId: null }) + timeline.push({ + status: "acknowledged", + note: pick(TIMELINE_ACK_NOTES), + createdAt: cursor, + actorId: null, + }) } if (status === "in_progress" || (status === "resolved" && chance(0.5))) { cursor = randTimestamp(cursor, new Date(Math.min(cursor.getTime() + 12 * DAY, now.getTime()))) @@ -1180,7 +1623,12 @@ function makeReports(users: SeedUser[], count: number, now: Date): SeedReport[] } if (status === "resolved") { cursor = randTimestamp(cursor, new Date(Math.min(cursor.getTime() + 15 * DAY, now.getTime()))) - timeline.push({ status: "resolved", note: chance(0.6) ? pick(TIMELINE_RESOLVE_NOTES) : null, createdAt: cursor, actorId: null }) + timeline.push({ + status: "resolved", + note: chance(0.6) ? pick(TIMELINE_RESOLVE_NOTES) : null, + createdAt: cursor, + actorId: null, + }) } reports.push({ @@ -1221,10 +1669,29 @@ function makePosts( followersOf.set(f.followeeId, arr) } - const addTop = (author: SeedUser, body: string, createdAt: Date, eventId: string | null, reportId: string | null) => { + const addTop = ( + author: SeedUser, + body: string, + createdAt: Date, + eventId: string | null, + reportId: string | null, + ) => { const p: SeedPost = { - id: randomUUID(), author, kind: "post", body, replyTo: null, threadRoot: null, repostOf: null, - eventId, reportId, createdAt, depth: 0, likeCount: 0, replyCount: 0, repostCount: 0, saveCount: 0, + id: randomUUID(), + author, + kind: "post", + body, + replyTo: null, + threadRoot: null, + repostOf: null, + eventId, + reportId, + createdAt, + depth: 0, + likeCount: 0, + replyCount: 0, + repostCount: 0, + saveCount: 0, mentions: [], } posts.push(p) @@ -1233,27 +1700,69 @@ function makePosts( // 1) Plain top-level posts, volume by tier. for (const u of users) { - const n = u.tier === "power" ? rint(4, 10) : u.tier === "casual" ? rint(1, 4) : u.tier === "light" ? rint(0, 2) : 0 + const n = + u.tier === "power" + ? rint(4, 10) + : u.tier === "casual" + ? rint(1, 4) + : u.tier === "light" + ? rint(0, 2) + : 0 for (let i = 0; i < n; i++) { - addTop(u, fill(bilingual(u, POST_TEMPLATES_EN, POST_TEMPLATES_ES), u), randTimestamp(u.createdAt, now), null, null) + addTop( + u, + fill(bilingual(u, POST_TEMPLATES_EN, POST_TEMPLATES_ES), u), + randTimestamp(u.createdAt, now), + null, + null, + ) } } // 2) Event promo + recap posts by organizers (and some cohosts). for (const ev of events) { if (ev.status !== "cancelled") { - const promoAt = randTimestamp(ev.createdAt, new Date(Math.min(ev.scheduledAt.getTime(), now.getTime()))) - addTop(ev.organizer, fill(bilingual(ev.organizer, EVENT_POST_EN, EVENT_POST_ES), ev.organizer, { hood: ev.hood.name }), promoAt, ev.id, null) + const promoAt = randTimestamp( + ev.createdAt, + new Date(Math.min(ev.scheduledAt.getTime(), now.getTime())), + ) + addTop( + ev.organizer, + fill(bilingual(ev.organizer, EVENT_POST_EN, EVENT_POST_ES), ev.organizer, { + hood: ev.hood.name, + }), + promoAt, + ev.id, + null, + ) if (ev.cohost && chance(0.4)) { - addTop(ev.cohost, fill(bilingual(ev.cohost, EVENT_POST_EN, EVENT_POST_ES), ev.cohost, { hood: ev.hood.name }), - randTimestamp(later(ev.cohost.createdAt, ev.createdAt), new Date(Math.min(ev.scheduledAt.getTime(), now.getTime()))), ev.id, null) + addTop( + ev.cohost, + fill(bilingual(ev.cohost, EVENT_POST_EN, EVENT_POST_ES), ev.cohost, { + hood: ev.hood.name, + }), + randTimestamp( + later(ev.cohost.createdAt, ev.createdAt), + new Date(Math.min(ev.scheduledAt.getTime(), now.getTime())), + ), + ev.id, + null, + ) } } if (ev.status === "done" && chance(0.85)) { const recapAt = minutesAfter(ev.scheduledAt, 3 * 60, 30 * 60) if (recapAt.getTime() < now.getTime()) { - addTop(ev.organizer, fill(bilingual(ev.organizer, EVENT_RECAP_EN, EVENT_RECAP_ES), ev.organizer, - { bags: String(ev.bags), n: String(ev.members.length) }), recapAt, ev.id, null) + addTop( + ev.organizer, + fill(bilingual(ev.organizer, EVENT_RECAP_EN, EVENT_RECAP_ES), ev.organizer, { + bags: String(ev.bags), + n: String(ev.members.length), + }), + recapAt, + ev.id, + null, + ) } } } @@ -1263,21 +1772,36 @@ function makePosts( if (r.status === "submitted" || !chance(0.3)) continue const at = minutesAfter(r.publishedAt ?? r.createdAt, 5, 36 * 60) if (at.getTime() >= now.getTime()) continue - addTop(r.reporter, fill(bilingual(r.reporter, REPORT_POST_EN, REPORT_POST_ES), r.reporter, { street: r.hood.streets[0]! }), at, null, r.id) + addTop( + r.reporter, + fill(bilingual(r.reporter, REPORT_POST_EN, REPORT_POST_ES), r.reporter, { + street: r.hood.streets[0]!, + }), + at, + null, + r.id, + ) } // 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 - const n = Math.min(14, Math.floor(Math.pow(rand(), 1.8) * 7 * base * Math.sqrt(p.author.popularity))) + const n = Math.min( + 14, + Math.floor(Math.pow(rand(), 1.8) * 7 * base * Math.sqrt(p.author.popularity)), + ) // One reply per person per thread (the root author may answer their own thread), and no // repeated body text within a thread; both read as bots otherwise. const threadRepliers = new Set() const threadBodies = new Set() let parent: SeedPost = p for (let i = 0; i < n; i++) { - parent = chance(0.75) ? p : posts[posts.length - 1]!.depth > 0 && chance(0.5) ? posts[posts.length - 1]! : p + parent = chance(0.75) + ? p + : posts[posts.length - 1]!.depth > 0 && chance(0.5) + ? posts[posts.length - 1]! + : p if (parent.depth >= 3) parent = p const followerPool = followersOf.get(parent.author.id) ?? [] const replier = @@ -1306,11 +1830,22 @@ function makePosts( mentions.push(parent.author.id) } const reply: SeedPost = { - id: randomUUID(), author: replier, kind: "reply", body, replyTo: parent, + id: randomUUID(), + author: replier, + kind: "reply", + body, + replyTo: parent, threadRoot: parent.depth === 0 ? parent : (parent.threadRoot ?? parent), - repostOf: null, eventId: null, reportId: null, - createdAt: randTimestamp(start, end), depth: parent.depth + 1, - likeCount: 0, replyCount: 0, repostCount: 0, saveCount: 0, mentions, + repostOf: null, + eventId: null, + reportId: null, + createdAt: randTimestamp(start, end), + depth: parent.depth + 1, + likeCount: 0, + replyCount: 0, + repostCount: 0, + saveCount: 0, + mentions, } parent.replyCount++ posts.push(reply) @@ -1333,9 +1868,22 @@ function makePosts( if (start.getTime() >= end.getTime()) continue repostKeys.add(k) posts.push({ - id: randomUUID(), author: reposter, kind: "repost", body: null, replyTo: null, threadRoot: null, - repostOf: target, eventId: null, reportId: null, createdAt: randTimestamp(start, end), depth: 1, - likeCount: 0, replyCount: 0, repostCount: 0, saveCount: 0, mentions: [], + id: randomUUID(), + author: reposter, + kind: "repost", + body: null, + replyTo: null, + threadRoot: null, + repostOf: target, + eventId: null, + reportId: null, + createdAt: randTimestamp(start, end), + depth: 1, + likeCount: 0, + replyCount: 0, + repostCount: 0, + saveCount: 0, + mentions: [], }) target.repostCount++ // pure reposts only, matching the live repost() path } @@ -1346,10 +1894,22 @@ function makePosts( const end = new Date(Math.min(target.createdAt.getTime() + 7 * DAY, now.getTime())) if (start.getTime() >= end.getTime()) continue posts.push({ - id: randomUUID(), author: quoter, kind: "quote", body: fill(bilingual(quoter, QUOTE_EN, QUOTE_ES), quoter), - replyTo: null, threadRoot: null, repostOf: target, eventId: null, reportId: null, - createdAt: randTimestamp(start, end), depth: 1, - likeCount: 0, replyCount: 0, repostCount: 0, saveCount: 0, mentions: [], + id: randomUUID(), + author: quoter, + kind: "quote", + body: fill(bilingual(quoter, QUOTE_EN, QUOTE_ES), quoter), + replyTo: null, + threadRoot: null, + repostOf: target, + eventId: null, + reportId: null, + createdAt: randTimestamp(start, end), + depth: 1, + likeCount: 0, + replyCount: 0, + repostCount: 0, + saveCount: 0, + mentions: [], }) // Quotes do NOT bump repost_count (createPost has no bump for kind='quote'). } @@ -1386,7 +1946,11 @@ function makeLikesAndSaves( if (p.kind === "repost") continue // the app shows the original; likes land on it const followerPool = followersOf.get(p.author.id) ?? [] const reach = followerPool.length - const base = Math.pow(rand(), 1.5) * (3 + reach * 0.7) * (p.eventId || p.reportId ? 1.4 : 1) * (p.depth === 0 ? 1 : 0.35) + const base = + Math.pow(rand(), 1.5) * + (3 + reach * 0.7) * + (p.eventId || p.reportId ? 1.4 : 1) * + (p.depth === 0 ? 1 : 0.35) const n = Math.min(Math.floor(base), 60) for (let i = 0; i < n; i++) { const liker = @@ -1462,7 +2026,8 @@ function validate( handles.add(u.handle.toLowerCase()) if (emails.has(u.email)) errors.push(`dup email: ${u.email}`) emails.add(u.email) - if (!u.email.endsWith(`@${DEMO_EMAIL_DOMAIN}`)) errors.push(`email outside demo domain: ${u.email}`) + if (!u.email.endsWith(`@${DEMO_EMAIL_DOMAIN}`)) + errors.push(`email outside demo domain: ${u.email}`) } // Closed world + counter consistency. const followerCounts = new Map() @@ -1478,8 +2043,10 @@ function validate( followingCounts.set(f.followerId, (followingCounts.get(f.followerId) ?? 0) + 1) } for (const u of users) { - if ((followerCounts.get(u.id) ?? 0) !== u.followerCount) errors.push(`followerCount drift for @${u.handle}`) - if ((followingCounts.get(u.id) ?? 0) !== u.followingCount) errors.push(`followingCount drift for @${u.handle}`) + if ((followerCounts.get(u.id) ?? 0) !== u.followerCount) + errors.push(`followerCount drift for @${u.handle}`) + 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() @@ -1494,7 +2061,8 @@ function validate( if (p.kind === "reply") { if (!p.replyTo || !p.threadRoot) errors.push("reply missing parent/root") else { - if (p.createdAt.getTime() < p.replyTo.createdAt.getTime()) errors.push("reply predates parent") + if (p.createdAt.getTime() < p.replyTo.createdAt.getTime()) + errors.push("reply predates parent") replyAgg.set(p.replyTo.id, (replyAgg.get(p.replyTo.id) ?? 0) + 1) } } @@ -1504,7 +2072,8 @@ function validate( repostKeys.add(k) repostAgg.set(p.repostOf!.id, (repostAgg.get(p.repostOf!.id) ?? 0) + 1) } - if (p.createdAt.getTime() < p.author.createdAt.getTime()) errors.push("post predates its author") + if (p.createdAt.getTime() < p.author.createdAt.getTime()) + errors.push("post predates its author") } for (const p of posts) { if ((likeAgg.get(p.id) ?? 0) !== p.likeCount) errors.push("likeCount drift") @@ -1518,7 +2087,8 @@ function validate( for (const m of ev.members) { if (seen.has(m.user.id)) errors.push(`dup member in ${ev.title}`) seen.add(m.user.id) - if (m.joinedAt.getTime() < ev.createdAt.getTime()) errors.push("member joined before event existed") + if (m.joinedAt.getTime() < ev.createdAt.getTime()) + errors.push("member joined before event existed") } const claimants = new Set() for (const c of ev.claims) { @@ -1539,7 +2109,9 @@ function validate( if (r.description.includes("—") || r.title.includes("—")) errors.push("em dash in report") } if (errors.length > 0) { - throw new Error(`seed validation failed (${errors.length}):\n ${[...new Set(errors)].slice(0, 25).join("\n ")}`) + throw new Error( + `seed validation failed (${errors.length}):\n ${[...new Set(errors)].slice(0, 25).join("\n ")}`, + ) } } @@ -1611,7 +2183,11 @@ async function writeAll( for (const rows of chunk(follows, 500)) { await tx`INSERT INTO follows_people ${tx( - rows.map((f) => ({ follower_id: f.followerId, followee_id: f.followeeId, created_at: f.createdAt })), + rows.map((f) => ({ + follower_id: f.followerId, + followee_id: f.followeeId, + created_at: f.createdAt, + })), )}` } @@ -1638,19 +2214,34 @@ async function writeAll( } for (const ev of events) { await tx`INSERT INTO cleanup_members ${tx( - ev.members.map((m) => ({ cleanup_id: ev.id, user_id: m.user.id, role: m.role, joined_at: m.joinedAt })), + ev.members.map((m) => ({ + cleanup_id: ev.id, + user_id: m.user.id, + role: m.role, + joined_at: m.joinedAt, + })), )}` if (ev.slots.length > 0) { await tx`INSERT INTO cleanup_slots ${tx( ev.slots.map((s) => ({ - id: s.id, cleanup_id: ev.id, title: s.title, description: s.description, - capacity: s.capacity, sort_order: s.sortOrder, created_at: ev.createdAt, + id: s.id, + cleanup_id: ev.id, + title: s.title, + description: s.description, + capacity: s.capacity, + sort_order: s.sortOrder, + created_at: ev.createdAt, })), )}` } if (ev.claims.length > 0) { await tx`INSERT INTO cleanup_slot_claims ${tx( - ev.claims.map((c) => ({ cleanup_id: ev.id, user_id: c.userId, slot_id: c.slotId, claimed_at: c.claimedAt })), + ev.claims.map((c) => ({ + cleanup_id: ev.id, + user_id: c.userId, + slot_id: c.slotId, + claimed_at: c.claimedAt, + })), )}` } } @@ -1675,7 +2266,11 @@ async function writeAll( } const timelineRows = reports.flatMap((r) => r.timeline.map((t) => ({ - report_id: r.id, status: t.status, note: t.note, actor_id: t.actorId, created_at: t.createdAt, + report_id: r.id, + status: t.status, + note: t.note, + actor_id: t.actorId, + created_at: t.createdAt, })), ) for (const rows of chunk(timelineRows, 500)) { @@ -1683,18 +2278,28 @@ async function writeAll( } // Link a few nearby reports to cleanup events (the "reports we'll handle" gallery). - const linkRows: { cleanup_id: string; report_id: string; linked_by_user_id: string; linked_at: Date }[] = [] + const linkRows: { + cleanup_id: string + report_id: string + linked_by_user_id: string + linked_at: Date + }[] = [] const linkedReportIds = new Set() for (const ev of events) { if (ev.status === "cancelled" || !chance(0.5)) continue const nearby = reports.filter( - (r) => r.hood.name === ev.hood.name && !linkedReportIds.has(r.id) && - r.createdAt.getTime() < ev.scheduledAt.getTime() && r.status !== "submitted", + (r) => + r.hood.name === ev.hood.name && + !linkedReportIds.has(r.id) && + r.createdAt.getTime() < ev.scheduledAt.getTime() && + r.status !== "submitted", ) for (const r of shuffle(nearby).slice(0, rint(1, 3))) { linkedReportIds.add(r.id) linkRows.push({ - cleanup_id: ev.id, report_id: r.id, linked_by_user_id: ev.organizer.id, + cleanup_id: ev.id, + report_id: r.id, + linked_by_user_id: ev.organizer.id, linked_at: later(ev.createdAt, r.createdAt), }) } @@ -1712,16 +2317,29 @@ async function writeAll( for (const rows of chunk(waves.get(depth)!, 300)) { await tx`INSERT INTO posts ${tx( rows.map((p) => ({ - id: p.id, author_id: p.author.id, kind: p.kind, body: p.body, visibility: "public", - reply_to_id: p.replyTo?.id ?? null, thread_root_id: p.threadRoot?.id ?? null, - repost_of_id: p.repostOf?.id ?? null, event_id: p.eventId, report_id: p.reportId, - like_count: p.likeCount, repost_count: p.repostCount, reply_count: p.replyCount, - save_count: p.saveCount, created_at: p.createdAt, updated_at: p.createdAt, + id: p.id, + author_id: p.author.id, + kind: p.kind, + body: p.body, + visibility: "public", + reply_to_id: p.replyTo?.id ?? null, + thread_root_id: p.threadRoot?.id ?? null, + repost_of_id: p.repostOf?.id ?? null, + event_id: p.eventId, + report_id: p.reportId, + like_count: p.likeCount, + repost_count: p.repostCount, + reply_count: p.replyCount, + save_count: p.saveCount, + created_at: p.createdAt, + updated_at: p.createdAt, })), )}` } } - const mentionRows = posts.flatMap((p) => p.mentions.map((m) => ({ post_id: p.id, mentioned_user_id: m }))) + const mentionRows = posts.flatMap((p) => + p.mentions.map((m) => ({ post_id: p.id, mentioned_user_id: m })), + ) for (const rows of chunk(mentionRows, 500)) { await tx`INSERT INTO post_mentions ${tx(rows)}` } @@ -1740,14 +2358,23 @@ async function writeAll( for (const rows of chunk(hours, 300)) { await tx`INSERT INTO volunteer_hours ${tx( rows.map((h) => ({ - user_id: h.userId, hours: h.hours, source: "event", cleanup_id: h.cleanupId, - jurisdiction_geoid: h.jurisdictionGeoid, logged_by_user_id: h.loggedBy, created_at: h.createdAt, + user_id: h.userId, + hours: h.hours, + source: "event", + cleanup_id: h.cleanupId, + jurisdiction_geoid: h.jurisdictionGeoid, + logged_by_user_id: h.loggedBy, + created_at: h.createdAt, })), )}` await tx`INSERT INTO volunteer_hours_audit ${tx( rows.map((h) => ({ - cleanup_id: h.cleanupId, user_id: h.userId, actor_user_id: h.loggedBy, - previous_hours: null, new_hours: h.hours, created_at: h.createdAt, + cleanup_id: h.cleanupId, + user_id: h.userId, + actor_user_id: h.loggedBy, + previous_hours: null, + new_hours: h.hours, + created_at: h.createdAt, })), )}` } @@ -1760,7 +2387,9 @@ async function writeAll( rollups.set(k, cur) } const rollupRows = [...rollups.values()].map((r) => ({ - user_id: r.userId, jurisdiction_geoid: r.geoid, total_hours: r.total.toFixed(2), + user_id: r.userId, + jurisdiction_geoid: r.geoid, + total_hours: r.total.toFixed(2), })) for (const rows of chunk(rollupRows, 500)) { await tx` @@ -1848,17 +2477,47 @@ async function purge(tx: TransactionSql): Promise> { counts[label] = (await q).length } const demo = tx`SELECT id FROM users WHERE email LIKE ${"%@" + DEMO_EMAIL_DOMAIN}` - await del("volunteer_hours_audit", tx`DELETE FROM volunteer_hours_audit WHERE user_id IN (${demo}) RETURNING 1 AS one`) - await del("volunteer_hours", tx`DELETE FROM volunteer_hours WHERE user_id IN (${demo}) RETURNING 1 AS one`) - await del("user_jurisdiction_hours", tx`DELETE FROM user_jurisdiction_hours WHERE user_id IN (${demo}) RETURNING 1 AS one`) - await del("cleanup_slot_claims", tx`DELETE FROM cleanup_slot_claims WHERE user_id IN (${demo}) RETURNING 1 AS one`) - await del("cleanup_members", tx`DELETE FROM cleanup_members WHERE user_id IN (${demo}) RETURNING 1 AS one`) - await del("cleanups", tx`DELETE FROM cleanups WHERE organizer_user_id IN (${demo}) RETURNING 1 AS one`) - await del("reports", tx`DELETE FROM reports WHERE reporter_user_id IN (${demo}) RETURNING 1 AS one`) - await del("follows_people", tx`DELETE FROM follows_people WHERE follower_id IN (${demo}) OR followee_id IN (${demo}) RETURNING 1 AS one`) - await del("notification_prefs", tx`DELETE FROM notification_prefs WHERE user_id IN (${demo}) RETURNING 1 AS one`) + await del( + "volunteer_hours_audit", + tx`DELETE FROM volunteer_hours_audit WHERE user_id IN (${demo}) RETURNING 1 AS one`, + ) + await del( + "volunteer_hours", + tx`DELETE FROM volunteer_hours WHERE user_id IN (${demo}) RETURNING 1 AS one`, + ) + await del( + "user_jurisdiction_hours", + tx`DELETE FROM user_jurisdiction_hours WHERE user_id IN (${demo}) RETURNING 1 AS one`, + ) + await del( + "cleanup_slot_claims", + tx`DELETE FROM cleanup_slot_claims WHERE user_id IN (${demo}) RETURNING 1 AS one`, + ) + await del( + "cleanup_members", + tx`DELETE FROM cleanup_members WHERE user_id IN (${demo}) RETURNING 1 AS one`, + ) + await del( + "cleanups", + tx`DELETE FROM cleanups WHERE organizer_user_id IN (${demo}) RETURNING 1 AS one`, + ) + await del( + "reports", + tx`DELETE FROM reports WHERE reporter_user_id IN (${demo}) RETURNING 1 AS one`, + ) + await del( + "follows_people", + tx`DELETE FROM follows_people WHERE follower_id IN (${demo}) OR followee_id IN (${demo}) RETURNING 1 AS one`, + ) + await del( + "notification_prefs", + tx`DELETE FROM notification_prefs WHERE user_id IN (${demo}) RETURNING 1 AS one`, + ) // posts / likes / saves / mentions / timeline cascade from users + reports + cleanups. - await del("users", tx`DELETE FROM users WHERE email LIKE ${"%@" + DEMO_EMAIL_DOMAIN} RETURNING 1 AS one`) + await del( + "users", + tx`DELETE FROM users WHERE email LIKE ${"%@" + DEMO_EMAIL_DOMAIN} RETURNING 1 AS one`, + ) return counts } @@ -1884,7 +2543,11 @@ export async function main(): Promise { } const host = new URL(databaseUrl).host console.log(`target database: ${host}`) - console.log(commit ? "mode: COMMIT" : "mode: rehearsal (full run + verification, then ROLLBACK; pass --yes to commit)") + console.log( + commit + ? "mode: COMMIT" + : "mode: rehearsal (full run + verification, then ROLLBACK; pass --yes to commit)", + ) const handle = makeDb(databaseUrl, { max: 1, statementTimeoutMs: 0, idleInTxTimeoutMs: 0 }) const ROLLBACK = Symbol("rollback") diff --git a/services/api/src/db/seed.ts b/services/api/src/db/seed.ts index 4cf299a4..1b5e0d06 100644 --- a/services/api/src/db/seed.ts +++ b/services/api/src/db/seed.ts @@ -1,4 +1,3 @@ - import type { Sql } from "./client.js" import { runDbCli, runIfMain } from "./cli.js" import { JURISDICTION_SEEDS } from "./seed-fixtures.js" @@ -77,7 +76,9 @@ async function main(): Promise { } const total = JURISDICTION_SEEDS.length + FEDERAL_LANDS.length const inserted = await seedJurisdictions(sql) - console.log(`seed: jurisdictions seeded (${inserted} inserted, ${total - inserted} already present)`) + console.log( + `seed: jurisdictions seeded (${inserted} inserted, ${total - inserted} already present)`, + ) }) } diff --git a/services/api/src/db/sql/jurisdiction.ts b/services/api/src/db/sql/jurisdiction.ts index 8e50ccfa..efe8dee2 100644 --- a/services/api/src/db/sql/jurisdiction.ts +++ b/services/api/src/db/sql/jurisdiction.ts @@ -67,7 +67,8 @@ export const JURISDICTION_LAYER_RANK_CASE = * 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. */ -export const JURISDICTION_RESOLVE_ORDER_BY = `${JURISDICTION_LAYER_RANK_CASE}, priority, geoid` as const +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 diff --git a/services/api/src/db/sql/user-activity.ts b/services/api/src/db/sql/user-activity.ts index 1c47447f..727c38e3 100644 --- a/services/api/src/db/sql/user-activity.ts +++ b/services/api/src/db/sql/user-activity.ts @@ -1,4 +1,3 @@ - import type { Queryable } from "../client.js" export async function touchUserActivity( diff --git a/services/api/src/di.ts b/services/api/src/di.ts index aaf4b1e5..9d016b15 100644 --- a/services/api/src/di.ts +++ b/services/api/src/di.ts @@ -79,10 +79,7 @@ import { makeDrizzleNotificationRepository } from "./services/notification-repos import { MEDIA_GET_URL_TTL_SEC } from "./services/media-intake-service.js" import { makeAffiliationLoader, type AffiliationLoader } from "./services/affiliation.js" import { RedisByteMeter, type ByteMeter } from "./services/media-byte-quota.js" -import { - makeTicketTokenSigner, - type TicketTokenSigner, -} from "./services/host/ticket-token.js" +import { makeTicketTokenSigner, type TicketTokenSigner } from "./services/host/ticket-token.js" import { RedisCounterStore, type CounterStore } from "./abuse/counter-store.js" import { InMemoryBlocksRepository, InMemoryDmRepository } from "./services/dm-repository.memory.js" import { MultiPushSender } from "./adapters/push-sender.js" @@ -281,7 +278,8 @@ export function buildContainer(env: Env): Container { let redisByteMeter: ByteMeter | undefined const lazyByteMeter: ByteMeter = { - add: (subject, bytes) => (redisByteMeter ??= new RedisByteMeter(getRedis())).add(subject, bytes), + add: (subject, bytes) => + (redisByteMeter ??= new RedisByteMeter(getRedis())).add(subject, bytes), } function getByteMeter(): ByteMeter { return lazyByteMeter @@ -421,7 +419,10 @@ export function buildContainer(env: Env): Container { function getSharedPubSub(): RedisChatPubSub { if (!sharedPubSub) { sharedPubSub = new RedisChatPubSub(getRedis(), (err) => - serverLogger?.error({ err, component: "redis", role: "subscriber" }, "redis subscriber error"), + serverLogger?.error( + { err, component: "redis", role: "subscriber" }, + "redis subscriber error", + ), ) } return sharedPubSub diff --git a/services/api/src/env.ts b/services/api/src/env.ts index d8293100..fbb48691 100644 --- a/services/api/src/env.ts +++ b/services/api/src/env.ts @@ -1,4 +1,3 @@ - import { z } from "zod" import { DEFAULT_FEED_RANKING, FeedRankingConfigSchema } from "@civfix/shared" import type { FeedRankingConfig } from "@civfix/shared" @@ -86,7 +85,8 @@ export const FAKE_SEAM_FLAGS: ReadonlyArray<{ flag: keyof FakeFlags; consequence }, { flag: "USE_FAKE_GEOCODER", - consequence: 'every report is labeled "Los Angeles, CA" and that label is persisted as civic record', + consequence: + 'every report is labeled "Los Angeles, CA" and that label is persisted as civic record', }, { flag: "USE_FAKE_SMS", @@ -224,10 +224,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { "(it signs the anon-report claim tokens)", ) } - if ( - SESSION_SIGNING_KEY.length > 0 && - SESSION_SIGNING_KEY === ANON_TOKEN_SIGNING_KEY - ) { + if (SESSION_SIGNING_KEY.length > 0 && SESSION_SIGNING_KEY === ANON_TOKEN_SIGNING_KEY) { errors.push( "SESSION_SIGNING_KEY / ANON_TOKEN_SIGNING_KEY: must be DIFFERENT values in production " + "(one shared secret lets a session-cookie oracle and an anon-token oracle attack the same key)", @@ -266,11 +263,11 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { min: -180, max: 180, }) - const HOME_REGION_RADIUS_KM = reqNumber( - "HOME_REGION_RADIUS_KM", - HOME_REGION_RADIUS_KM_DEFAULT, - { min: 0, max: 20_000, exclusiveMin: true }, - ) + const HOME_REGION_RADIUS_KM = reqNumber("HOME_REGION_RADIUS_KM", HOME_REGION_RADIUS_KM_DEFAULT, { + min: 0, + max: 20_000, + exclusiveMin: true, + }) const FEED_RANKING = reqFeedRanking("FEED_RANKING") @@ -293,13 +290,15 @@ 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 " + - "\"City, ST\" label from the jurisdictions PostGIS table, so there is nothing to query without a database", + '"City, ST" label from the jurisdictions PostGIS table, so there is nothing to query without a database', ) } const R2_ACCOUNT_ID = reqStr("R2_ACCOUNT_ID", { gatedOff: fakeFlags.USE_FAKE_STORAGE }) const R2_ACCESS_KEY_ID = reqStr("R2_ACCESS_KEY_ID", { gatedOff: fakeFlags.USE_FAKE_STORAGE }) - const R2_SECRET_ACCESS_KEY = reqStr("R2_SECRET_ACCESS_KEY", { gatedOff: fakeFlags.USE_FAKE_STORAGE }) + const R2_SECRET_ACCESS_KEY = reqStr("R2_SECRET_ACCESS_KEY", { + gatedOff: fakeFlags.USE_FAKE_STORAGE, + }) const R2_BUCKET = reqStr("R2_BUCKET", { gatedOff: fakeFlags.USE_FAKE_STORAGE }) const R2_INBOUND_BUCKET = (source.R2_INBOUND_BUCKET ?? "").trim() @@ -424,7 +423,10 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { OCI_EMAIL_SMTP_PASS, OCI_EMAIL_SMTP_TIMEOUT_MS, OUTBOUND_SEND_MIN_THROUGHPUT_BPS, - VOLUNTEER_HOURS_WEEKLY_FLAG_HOURS: parsePositiveIntOr(source.VOLUNTEER_HOURS_WEEKLY_FLAG_HOURS, 60), + VOLUNTEER_HOURS_WEEKLY_FLAG_HOURS: parsePositiveIntOr( + source.VOLUNTEER_HOURS_WEEKLY_FLAG_HOURS, + 60, + ), MAIL_FROM_NOREPLY: (source.MAIL_FROM_NOREPLY ?? "").trim() || "no-reply@civfix.org", MAIL_FROM_OUTREACH: (source.MAIL_FROM_OUTREACH ?? "").trim() || "outreach@civfix.org", HOME_TURF_MAIL_FROM: (source.HOME_TURF_MAIL_FROM ?? "").trim() || "donotreply@civfix.org", diff --git a/services/api/src/env/comms-env.ts b/services/api/src/env/comms-env.ts index f564e6f6..874769a1 100644 --- a/services/api/src/env/comms-env.ts +++ b/services/api/src/env/comms-env.ts @@ -68,7 +68,9 @@ export function loadCommsEnv(source: NodeJS.ProcessEnv, errors: string[]): Comms const mailFromEvents = (source.MAIL_FROM_EVENTS ?? "").trim() || DEFAULT_MAIL_FROM_EVENTS if (!mailFromEvents.includes("@")) { - errors.push("MAIL_FROM_EVENTS: must be an email address (it is the From of every host broadcast)") + errors.push( + "MAIL_FROM_EVENTS: must be an email address (it is the From of every host broadcast)", + ) } let unsubscribeKey = (source.UNSUBSCRIBE_SIGNING_KEY ?? "").trim() @@ -153,4 +155,3 @@ function clampDedupeSeconds(raw: string | undefined, errors: string[]): number { } return value } - diff --git a/services/api/src/env/parsers.ts b/services/api/src/env/parsers.ts index 7664a7f0..04d7aa2a 100644 --- a/services/api/src/env/parsers.ts +++ b/services/api/src/env/parsers.ts @@ -61,7 +61,10 @@ export function parseBounds( export function isCronish(raw: string | undefined): boolean { if (raw === undefined) return false - const fields = raw.trim().split(/\s+/).filter((f) => f.length > 0) + const fields = raw + .trim() + .split(/\s+/) + .filter((f) => f.length > 0) if (fields.length !== 5 && fields.length !== 6) return false return fields.every((f) => /^[\dA-Za-z*/,\-?#]+$/.test(f)) } diff --git a/services/api/src/env/registration-env.ts b/services/api/src/env/registration-env.ts index e712456a..c1021194 100644 --- a/services/api/src/env/registration-env.ts +++ b/services/api/src/env/registration-env.ts @@ -60,5 +60,4 @@ export function loadRegistrationEnv( } } -export const DEVELOPMENT_TICKET_TOKEN_SECRET = - "dev-insecure-ticket-token-secret-do-not-use-in-prod" +export const DEVELOPMENT_TICKET_TOKEN_SECRET = "dev-insecure-ticket-token-secret-do-not-use-in-prod" diff --git a/services/api/src/env/types.ts b/services/api/src/env/types.ts index c393a8d2..3442f0dc 100644 --- a/services/api/src/env/types.ts +++ b/services/api/src/env/types.ts @@ -126,6 +126,5 @@ export interface Env extends CommsEnv, RegistrationEnv { USE_FAKE_SMS: boolean - USE_REAL_NSFW: boolean } diff --git a/services/api/src/errors/glitchtip.ts b/services/api/src/errors/glitchtip.ts index 29a26106..dddf7f0a 100644 --- a/services/api/src/errors/glitchtip.ts +++ b/services/api/src/errors/glitchtip.ts @@ -1,4 +1,3 @@ - import type * as SentryNode from "@sentry/node" import { redactPans } from "./pan-redaction.js" @@ -177,7 +176,12 @@ export function scrubEvent(event: T): T { export function scrubBreadcrumb(crumb: T): T | null { const category = typeof crumb.category === "string" ? crumb.category.toLowerCase() : "" - if (category === "http" || category === "fetch" || category === "xhr" || category.includes("query")) { + if ( + category === "http" || + category === "fetch" || + category === "xhr" || + category.includes("query") + ) { return null } const out = { ...crumb } as SentryBreadcrumbLike diff --git a/services/api/src/errors/http-mapper.ts b/services/api/src/errors/http-mapper.ts index 76d30c22..481c6e73 100644 --- a/services/api/src/errors/http-mapper.ts +++ b/services/api/src/errors/http-mapper.ts @@ -79,11 +79,18 @@ export function makeErrorHandler() { // @civfix/shared's zod and the API's zod: a ZodError thrown outside a route parse() (service-level // parse / .transform / nested parse) reaches the handler and must render as 422, never 500. if (error.name === "ZodError" && Array.isArray((error as { issues?: unknown }).issues)) { - const issues = (error as unknown as { issues: { path: (string | number)[]; message: string }[] }).issues + const issues = ( + error as unknown as { issues: { path: (string | number)[]; message: string }[] } + ).issues const fields: Record = {} for (const i of issues) fields[i.path.length ? i.path.join(".") : "_"] = i.message request.log.info({ requestId, fields }, "zod validation error") - const body: ErrorBody = { code: ErrorCode.VALIDATION, message: "Validation failed", requestId, fields } + const body: ErrorBody = { + code: ErrorCode.VALIDATION, + message: "Validation failed", + requestId, + fields, + } reply.status(422).send(body) return } @@ -128,7 +135,10 @@ export function makeErrorHandler() { export function makeNotFoundHandler() { return function notFoundHandler(request: FastifyRequest, reply: FastifyReply): void { - request.log.info({ method: request.method, url: request.url, requestId: request.id }, "route not found") + request.log.info( + { method: request.method, url: request.url, requestId: request.id }, + "route not found", + ) const body: ErrorBody = { code: ErrorCode.NOT_FOUND, // Prod: static (stealth). Dev/test: echo method+url so route-coverage can tell an diff --git a/services/api/src/errors/sms-failure.ts b/services/api/src/errors/sms-failure.ts index 4de9a839..5cf0155f 100644 --- a/services/api/src/errors/sms-failure.ts +++ b/services/api/src/errors/sms-failure.ts @@ -26,9 +26,7 @@ export function smsFailureKind(err: unknown): SmsFailureKind | null { if (typeof fields !== "object" || fields === null) return null const value = (fields as Record)[SMS_FAILURE_FIELD] if (typeof value !== "string") return null - return (SMS_FAILURE_KINDS as readonly string[]).includes(value) - ? (value as SmsFailureKind) - : null + return (SMS_FAILURE_KINDS as readonly string[]).includes(value) ? (value as SmsFailureKind) : null } export function isRetryableSmsFailure(err: unknown): boolean { diff --git a/services/api/src/i18n/messages/de.ts b/services/api/src/i18n/messages/de.ts index bf7d415c..807eb8a4 100644 --- a/services/api/src/i18n/messages/de.ts +++ b/services/api/src/i18n/messages/de.ts @@ -153,5 +153,4 @@ export const de: Partial> = { "{{title}} geändert: jetzt {{when}} an folgendem Ort: {{place}}. Antworte mit STOP, um dich abzumelden.", "sms.guest_cancelled.body": "{{title}} wurde von der Veranstalterin oder dem Veranstalter abgesagt. Antworte mit STOP, um dich abzumelden.", - } diff --git a/services/api/src/i18n/messages/en.ts b/services/api/src/i18n/messages/en.ts index 1b42a0fa..8d536e31 100644 --- a/services/api/src/i18n/messages/en.ts +++ b/services/api/src/i18n/messages/en.ts @@ -166,7 +166,8 @@ export const en = { // 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 5 minutes. If you did not request it, you can ignore this email.", + "email.otp.body_expiry": + "It expires in 5 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). "email.otp.html_intro": "Your civfix sign-in code is:", @@ -205,14 +206,14 @@ export const en = { "email.guest_cancelled.subject": "{{title}} has been cancelled", "email.guest_cancelled.body": "{{title}} has been cancelled by the host. There is nothing you need to do.", - "email.guest_cancelled.body_reason": "{{title}} has been cancelled by the host. Reason: {{reason}}", + "email.guest_cancelled.body_reason": + "{{title}} has been cancelled by the host. Reason: {{reason}}", "sms.guest_otp.body": "{{code}} is your civfix code to RSVP for {{title}}. Msg&data rates may apply. Reply STOP to opt out.", "sms.guest_confirmed.body": "You are on the list for {{title}}. Cancel: {{link}} Reply STOP to opt out.", "sms.guest_updated.body": "{{title}} changed: now {{when}} at {{place}}. Reply STOP to opt out.", "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. */ diff --git a/services/api/src/i18n/messages/es.ts b/services/api/src/i18n/messages/es.ts index ae977290..194334b4 100644 --- a/services/api/src/i18n/messages/es.ts +++ b/services/api/src/i18n/messages/es.ts @@ -75,8 +75,7 @@ export const es: Partial> = { // ---- 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": - "Caduca en 5 minutos. Si no lo solicitaste, puedes ignorar este correo.", + "email.otp.body_expiry": "Caduca en 5 minutos. Si no lo solicitaste, puedes ignorar este correo.", "email.otp.html_intro": "Tu código de acceso a civfix es:", "email.report_update.subject": "Tu reporte en civfix fue {{status}}", @@ -114,7 +113,8 @@ export const es: Partial> = { "certificate.verify.prompt": "Verifica este registro en civfix.org/service-record", "certificate.verify.fingerprint": "Huella del documento", "certificate.footer.page": "Página {{page}} de {{total}}", - "certificate.footer.timezone": "Las fechas se muestran en hora del Pacífico (America/Los_Angeles).", + "certificate.footer.timezone": + "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 ------------------------------------------------------------------------- @@ -147,6 +147,6 @@ export const es: Partial> = { "Estás en la lista de {{title}}. Cancelar: {{link}} Responde STOP para darte de baja.", "sms.guest_updated.body": "{{title}} ha cambiado: ahora {{when}} en {{place}}. Responde STOP para darte de baja.", - "sms.guest_cancelled.body": "El organizador ha cancelado {{title}}. Responde STOP para darte de baja.", - + "sms.guest_cancelled.body": + "El organizador ha cancelado {{title}}. Responde STOP para darte de baja.", } diff --git a/services/api/src/i18n/messages/ko.ts b/services/api/src/i18n/messages/ko.ts index 74726104..6072d276 100644 --- a/services/api/src/i18n/messages/ko.ts +++ b/services/api/src/i18n/messages/ko.ts @@ -75,8 +75,7 @@ export const ko: Partial> = { // ---- Account / OTP emails -------------------------------------------------------------------- "email.otp.subject": "civfix 로그인 코드", "email.otp.body_line1": "civfix 로그인 코드는 {{code}}입니다.", - "email.otp.body_expiry": - "코드는 5분 후 만료됩니다. 요청하지 않으셨다면 이 이메일을 무시하세요.", + "email.otp.body_expiry": "코드는 5분 후 만료됩니다. 요청하지 않으셨다면 이 이메일을 무시하세요.", "email.otp.html_intro": "civfix 로그인 코드:", "email.report_update.subject": "civfix 제보가 {{status}} 처리되었어요", @@ -145,7 +144,8 @@ export const ko: Partial> = { "{{code}}은(는) {{title}} 참가 신청을 위한 civfix 코드입니다. 메시지 및 데이터 요금이 부과될 수 있습니다. 수신을 원하지 않으시면 STOP으로 답장하세요.", "sms.guest_confirmed.body": "{{title}} 참가자 명단에 등록되었습니다. 취소: {{link}} 수신을 원하지 않으시면 STOP으로 답장하세요.", - "sms.guest_updated.body": "{{title}} 변경: 이제 {{when}}, 장소 {{place}}. 수신을 원하지 않으시면 STOP으로 답장하세요.", - "sms.guest_cancelled.body": "주최자가 {{title}}을(를) 취소했습니다. 수신을 원하지 않으시면 STOP으로 답장하세요.", - + "sms.guest_updated.body": + "{{title}} 변경: 이제 {{when}}, 장소 {{place}}. 수신을 원하지 않으시면 STOP으로 답장하세요.", + "sms.guest_cancelled.body": + "주최자가 {{title}}을(를) 취소했습니다. 수신을 원하지 않으시면 STOP으로 답장하세요.", } diff --git a/services/api/src/plugins/rate-limit.ts b/services/api/src/plugins/rate-limit.ts index 034ed965..6830f767 100644 --- a/services/api/src/plugins/rate-limit.ts +++ b/services/api/src/plugins/rate-limit.ts @@ -1,4 +1,3 @@ - import fastifyRateLimit from "@fastify/rate-limit" import { AppError } from "@civfix/shared" import type { @@ -195,12 +194,14 @@ function appendOnRequestHook( } function hostCeilingLimitOf(config: unknown, url: string): RouteRateLimitSpec | null { - const limit = (config as { - rateLimit?: Partial & { - keyGenerator?: unknown - [ROUTE_RATE_LIMIT_POLICY]?: unknown + const limit = ( + config as { + rateLimit?: Partial & { + keyGenerator?: unknown + [ROUTE_RATE_LIMIT_POLICY]?: unknown + } } - })?.rateLimit + )?.rateLimit if (!limit || typeof limit !== "object") return null if (limit.keyGenerator === undefined) return null if (limit[ROUTE_RATE_LIMIT_POLICY] !== true || limit.keyGenerator !== identityRateLimitKey) { @@ -209,7 +210,10 @@ function hostCeilingLimitOf(config: unknown, url: string): RouteRateLimitSpec | ) } const { max, timeWindow, hostMax } = limit - if (typeof max !== "number" || (typeof timeWindow !== "string" && typeof timeWindow !== "number")) { + if ( + typeof max !== "number" || + (typeof timeWindow !== "string" && typeof timeWindow !== "number") + ) { throw new Error( `the rate limit on ${url} replaces the per-host key, so it needs a literal max and timeWindow to derive its host ceiling`, ) diff --git a/services/api/src/routes/_validate.ts b/services/api/src/routes/_validate.ts index fa4c4c78..f2b5b173 100644 --- a/services/api/src/routes/_validate.ts +++ b/services/api/src/routes/_validate.ts @@ -11,8 +11,9 @@ export function parse(schema: S, data: unknown): z.infer = {} for (const issue of issues) { const key = issue.path.length > 0 ? issue.path.join(".") : "_" @@ -47,9 +48,15 @@ export function trimTextFields( }, schema) } -export const validateBody = (schema: S, request: FastifyRequest): z.infer => - parse(schema, request.body) -export const validateQuery = (schema: S, request: FastifyRequest): z.infer => - parse(schema, request.query) -export const validateParams = (schema: S, request: FastifyRequest): z.infer => - parse(schema, request.params) +export const validateBody = ( + schema: S, + request: FastifyRequest, +): z.infer => parse(schema, request.body) +export const validateQuery = ( + schema: S, + request: FastifyRequest, +): z.infer => parse(schema, request.query) +export const validateParams = ( + schema: S, + request: FastifyRequest, +): z.infer => parse(schema, request.params) diff --git a/services/api/src/routes/admin/analytics.routes.ts b/services/api/src/routes/admin/analytics.routes.ts index be7a0ccc..5c6351ab 100644 --- a/services/api/src/routes/admin/analytics.routes.ts +++ b/services/api/src/routes/admin/analytics.routes.ts @@ -1,4 +1,3 @@ - import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" import { diff --git a/services/api/src/routes/admin/auth.routes.ts b/services/api/src/routes/admin/auth.routes.ts index 2af7e16e..e81ad42d 100644 --- a/services/api/src/routes/admin/auth.routes.ts +++ b/services/api/src/routes/admin/auth.routes.ts @@ -1,4 +1,3 @@ - import { AppError, ErrorCode, @@ -15,14 +14,13 @@ import { SUSPENDED_MESSAGE } from "../../auth/account-status.js" import type { Env } from "../../env.js" import { resolveLocale } from "../../i18n/locales.js" import { isAdminEmail } from "../../auth/admin-allowlist.js" -import { createAccessVerifier, type AccessIdentity, type VerifyAccessJwt } from "../../auth/cf-access.js" -import { writeAudit, type WriteAuditInput } from "../../services/admin/audit.js" import { - generateCsrfToken, - setCsrfCookie, - clearCsrfCookie, - type Csrf, -} from "../../auth/csrf.js" + createAccessVerifier, + type AccessIdentity, + type VerifyAccessJwt, +} from "../../auth/cf-access.js" +import { writeAudit, type WriteAuditInput } from "../../services/admin/audit.js" +import { generateCsrfToken, setCsrfCookie, clearCsrfCookie, type Csrf } from "../../auth/csrf.js" import { presentedSessionToken, setSessionCookie, @@ -77,50 +75,60 @@ export async function registerAdminAuthRoutes( return operator } - route(app, "adminAccessExchange", { config: { rateLimit: ADMIN_AUTH_RATE_LIMIT } }, async (request, reply) => { - const verify = app.adminAuthOverrides?.verifyAccessJwt ?? defaultVerify - if (!verify) { - throw new AppError(ErrorCode.INTERNAL, "Cloudflare Access is not configured.", { - httpStatus: 503, - }) - } - const identity = await verifyHeader(verify, request) - const email = identity.email?.toLowerCase() - if (!email || !isAdminEmail(env, email)) { - throw AppError.forbidden("This account is not authorized for the operator dashboard.") - } - const operator = await provisionOperator(email) - const payload = await establishOperatorSession(services, csrf, request, reply, operator) - reply.status(200).send(payload) - }) + route( + app, + "adminAccessExchange", + { config: { rateLimit: ADMIN_AUTH_RATE_LIMIT } }, + async (request, reply) => { + const verify = app.adminAuthOverrides?.verifyAccessJwt ?? defaultVerify + if (!verify) { + throw new AppError(ErrorCode.INTERNAL, "Cloudflare Access is not configured.", { + httpStatus: 503, + }) + } + const identity = await verifyHeader(verify, request) + const email = identity.email?.toLowerCase() + if (!email || !isAdminEmail(env, email)) { + throw AppError.forbidden("This account is not authorized for the operator dashboard.") + } + const operator = await provisionOperator(email) + const payload = await establishOperatorSession(services, csrf, request, reply, operator) + reply.status(200).send(payload) + }, + ) route(app, "adminSession", async (request, reply) => { const payload = await buildAdminSession(services, csrf, env, request, reply) reply.status(200).send(payload) }) - route(app, "adminLogout", { preHandler: csrfProtect, config: { rateLimit: ADMIN_AUTH_RATE_LIMIT } }, async (request, reply) => { - const token = presentedSessionToken(request) - if (token === null) { - throw AppError.unauthorized() - } - const { userId, roles } = request.auth - await services.sessions.revokeSession(token) - clearSessionCookie(reply) - clearCsrfCookie(reply) - if (userId !== null && roles.includes("operator")) { - await auditOperatorAuth(app, container, { - actorId: userId, - action: "operator.logout", - target: `user:${userId}`, - meta: { sessionRevoked: true }, - }).catch((err: unknown) => { - request.log.warn({ err }, "operator.logout audit write failed") - }) - } - const payload: AdminLogoutResponse = { ok: true } - reply.status(200).send(payload) - }) + route( + app, + "adminLogout", + { preHandler: csrfProtect, config: { rateLimit: ADMIN_AUTH_RATE_LIMIT } }, + async (request, reply) => { + const token = presentedSessionToken(request) + if (token === null) { + throw AppError.unauthorized() + } + const { userId, roles } = request.auth + await services.sessions.revokeSession(token) + clearSessionCookie(reply) + clearCsrfCookie(reply) + if (userId !== null && roles.includes("operator")) { + await auditOperatorAuth(app, container, { + actorId: userId, + action: "operator.logout", + target: `user:${userId}`, + meta: { sessionRevoked: true }, + }).catch((err: unknown) => { + request.log.warn({ err }, "operator.logout audit write failed") + }) + } + const payload: AdminLogoutResponse = { ok: true } + reply.status(200).send(payload) + }, + ) } async function verifyHeader( diff --git a/services/api/src/routes/admin/broadcasts.routes.ts b/services/api/src/routes/admin/broadcasts.routes.ts index ff74b171..e1fd36f3 100644 --- a/services/api/src/routes/admin/broadcasts.routes.ts +++ b/services/api/src/routes/admin/broadcasts.routes.ts @@ -151,17 +151,22 @@ export async function registerAdminBroadcastRoutes( reply.status(200).send(payload) }) - route(app, "adminSetHostMessagingSuspended", { preHandler: csrfProtect }, async (request, reply) => { - const operatorId = requireAuth(request) - const body = parse(SetHostMessagingSuspendedRequestSchema, mergeParams(request)) - await broadcastRepo().setHostMessagingSuspended(body.id, body.suspended) - await writeAudit(container.getDb().sql, { - action: body.suspended ? "host.messaging_suspended" : "host.messaging_restored", - actorId: operatorId, - target: `user:${body.id}`, - meta: { reason: body.reason }, - }) - const payload: SetHostMessagingSuspendedResponse = { ok: true, suspended: body.suspended } - reply.status(200).send(payload) - }) + route( + app, + "adminSetHostMessagingSuspended", + { preHandler: csrfProtect }, + async (request, reply) => { + const operatorId = requireAuth(request) + const body = parse(SetHostMessagingSuspendedRequestSchema, mergeParams(request)) + await broadcastRepo().setHostMessagingSuspended(body.id, body.suspended) + await writeAudit(container.getDb().sql, { + action: body.suspended ? "host.messaging_suspended" : "host.messaging_restored", + actorId: operatorId, + target: `user:${body.id}`, + meta: { reason: body.reason }, + }) + const payload: SetHostMessagingSuspendedResponse = { ok: true, suspended: body.suspended } + reply.status(200).send(payload) + }, + ) } diff --git a/services/api/src/routes/admin/gov.routes.ts b/services/api/src/routes/admin/gov.routes.ts index 5fafe1d6..15deb4b2 100644 --- a/services/api/src/routes/admin/gov.routes.ts +++ b/services/api/src/routes/admin/gov.routes.ts @@ -1,4 +1,3 @@ - import { ApproveGovClaimRequestSchema, GovClaimListQuerySchema, diff --git a/services/api/src/routes/admin/home.routes.ts b/services/api/src/routes/admin/home.routes.ts index e77970ce..277061c9 100644 --- a/services/api/src/routes/admin/home.routes.ts +++ b/services/api/src/routes/admin/home.routes.ts @@ -1,4 +1,3 @@ - import type { HomeMapResponse, HomeSummaryResponse } from "@civfix/shared" import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" diff --git a/services/api/src/routes/admin/index.ts b/services/api/src/routes/admin/index.ts index d43f7a27..2aa0e273 100644 --- a/services/api/src/routes/admin/index.ts +++ b/services/api/src/routes/admin/index.ts @@ -1,4 +1,3 @@ - import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" import { requireOperatorPreHandler } from "../../auth/admin-guard.js" diff --git a/services/api/src/routes/admin/jurisdictions.routes.ts b/services/api/src/routes/admin/jurisdictions.routes.ts index 8a9e88ca..748fd056 100644 --- a/services/api/src/routes/admin/jurisdictions.routes.ts +++ b/services/api/src/routes/admin/jurisdictions.routes.ts @@ -81,33 +81,28 @@ export async function registerAdminJurisdictionsRoutes( }, ) - route( - app, - "saveJurisdictionContacts", - { preHandler: csrfProtect }, - async (request, reply) => { - const actorId = requireOperator(request) - const geoid = geoidParam(request) - const body = parse(SaveContactsRequestSchema, { ...(request.body as object), geoid }) - await service().saveAndRoute( - geoid, - { - contacts: (body.contacts ?? {}) as Partial>, - defaultEmails: body.defaultEmails ?? [], - // L7: reject javascript:/data: URIs the shared `.url()` schema lets through (see httpUrlField). - formUrl: httpUrlField(body.formUrl, "formUrl"), - ...(body.forwardSubjectTemplate !== undefined - ? { forwardSubjectTemplate: body.forwardSubjectTemplate } - : {}), - ...(body.forwardBodyTemplate !== undefined - ? { forwardBodyTemplate: body.forwardBodyTemplate } - : {}), - }, - actorId, - ) - sendOk(reply) - }, - ) + route(app, "saveJurisdictionContacts", { preHandler: csrfProtect }, async (request, reply) => { + const actorId = requireOperator(request) + const geoid = geoidParam(request) + const body = parse(SaveContactsRequestSchema, { ...(request.body as object), geoid }) + await service().saveAndRoute( + geoid, + { + contacts: (body.contacts ?? {}) as Partial>, + defaultEmails: body.defaultEmails ?? [], + // L7: reject javascript:/data: URIs the shared `.url()` schema lets through (see httpUrlField). + formUrl: httpUrlField(body.formUrl, "formUrl"), + ...(body.forwardSubjectTemplate !== undefined + ? { forwardSubjectTemplate: body.forwardSubjectTemplate } + : {}), + ...(body.forwardBodyTemplate !== undefined + ? { forwardBodyTemplate: body.forwardBodyTemplate } + : {}), + }, + actorId, + ) + sendOk(reply) + }) route(app, "listJurisdictions", async (request, reply) => { const query = parse(JurisdictionListQuerySchema, request.query) diff --git a/services/api/src/routes/admin/mail.routes.ts b/services/api/src/routes/admin/mail.routes.ts index 771dc873..09d7aa24 100644 --- a/services/api/src/routes/admin/mail.routes.ts +++ b/services/api/src/routes/admin/mail.routes.ts @@ -1,4 +1,3 @@ - import { ComposeRequestSchema, MailListQuerySchema, @@ -96,7 +95,8 @@ export async function registerAdminMailRoutes( makeMailService({ repo: overrides.repo, outboundMail: overrides.outboundMail, - loadAttachmentBytes: (key) => (overrides.outboundStorage ?? container.storage).getObject(key), + loadAttachmentBytes: (key) => + (overrides.outboundStorage ?? container.storage).getObject(key), }), () => { const sql = container.getDb().sql @@ -112,8 +112,7 @@ export async function registerAdminMailRoutes( function templateService(): ForwardTemplateService { const overrides = app.adminMailOverrides?.forwardTemplates - const repo = - overrides ?? makeDrizzleForwardTemplateRepository(container.getDb().sql) + const repo = overrides ?? makeDrizzleForwardTemplateRepository(container.getDb().sql) return makeForwardTemplateService({ repo }) } @@ -150,19 +149,29 @@ export async function registerAdminMailRoutes( reply.status(200).send(payload) }) - route(app, "composeMail", { preHandler: csrfProtect, config: { rateLimit: ADMIN_OUTBOUND_MAIL_RATE_LIMIT } }, async (request, reply) => { - const actorId = requireOperator(request) - const body = parse(ComposeRequestSchema, request.body) - await service().compose({ to: body.to, subject: body.subject, body: body.body }, actorId) - sendOk(reply) - }) + route( + app, + "composeMail", + { preHandler: csrfProtect, config: { rateLimit: ADMIN_OUTBOUND_MAIL_RATE_LIMIT } }, + async (request, reply) => { + const actorId = requireOperator(request) + const body = parse(ComposeRequestSchema, request.body) + await service().compose({ to: body.to, subject: body.subject, body: body.body }, actorId) + sendOk(reply) + }, + ) - route(app, "replyMail", { preHandler: csrfProtect, config: { rateLimit: ADMIN_OUTBOUND_MAIL_RATE_LIMIT } }, async (request, reply) => { - const actorId = requireOperator(request) - const { id, body } = parseBodyWithId(ReplyRequestSchema, request) - await service().reply(id, { body: body.body }, actorId) - sendOk(reply) - }) + route( + app, + "replyMail", + { preHandler: csrfProtect, config: { rateLimit: ADMIN_OUTBOUND_MAIL_RATE_LIMIT } }, + async (request, reply) => { + const actorId = requireOperator(request) + const { id, body } = parseBodyWithId(ReplyRequestSchema, request) + await service().reply(id, { body: body.body }, actorId) + sendOk(reply) + }, + ) route(app, "markMailRead", { preHandler: csrfProtect }, async (request, reply) => { const { id } = parseBodyWithId(MarkMailReadRequestSchema, request) @@ -177,12 +186,17 @@ export async function registerAdminMailRoutes( sendOk(reply) }) - route(app, "resendMail", { preHandler: csrfProtect, config: { rateLimit: ADMIN_OUTBOUND_MAIL_RATE_LIMIT } }, async (request, reply) => { - const actorId = requireOperator(request) - const { id } = parseBodyWithId(ResendRequestSchema, request) - await service().resend(id, actorId) - sendOk(reply) - }) + route( + app, + "resendMail", + { preHandler: csrfProtect, config: { rateLimit: ADMIN_OUTBOUND_MAIL_RATE_LIMIT } }, + async (request, reply) => { + const actorId = requireOperator(request) + const { id } = parseBodyWithId(ResendRequestSchema, request) + await service().resend(id, actorId) + sendOk(reply) + }, + ) route(app, "getForwardTemplateDefault", async (_request, reply) => { const payload: ForwardTemplateSettingsDTO = await templateService().get() diff --git a/services/api/src/routes/admin/media.routes.ts b/services/api/src/routes/admin/media.routes.ts index 134ef608..273ca848 100644 --- a/services/api/src/routes/admin/media.routes.ts +++ b/services/api/src/routes/admin/media.routes.ts @@ -1,8 +1,4 @@ -import { - AdminGetMediaRequestSchema, - AppError, - type AdminGetMediaResponse, -} from "@civfix/shared" +import { AdminGetMediaRequestSchema, AppError, type AdminGetMediaResponse } from "@civfix/shared" import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" import { requireOperator } from "../../auth/admin-guard.js" diff --git a/services/api/src/routes/admin/moderation.routes.ts b/services/api/src/routes/admin/moderation.routes.ts index 8ba0fcf0..651be227 100644 --- a/services/api/src/routes/admin/moderation.routes.ts +++ b/services/api/src/routes/admin/moderation.routes.ts @@ -1,4 +1,3 @@ - import { ApproveModerationRequestSchema, AppealModerationRequestSchema, diff --git a/services/api/src/routes/admin/report-chat.routes.ts b/services/api/src/routes/admin/report-chat.routes.ts index 24aba7a4..42267c70 100644 --- a/services/api/src/routes/admin/report-chat.routes.ts +++ b/services/api/src/routes/admin/report-chat.routes.ts @@ -19,7 +19,10 @@ import { makeDrizzleAdminReportChatRepository, type AdminReportChatRepository, } from "../../services/admin/admin-report-chat-repository.drizzle.js" -import { makeDrizzleChatRepository, type ChatRepository } from "../../services/chat-repository.drizzle.js" +import { + makeDrizzleChatRepository, + type ChatRepository, +} from "../../services/chat-repository.drizzle.js" import type { ChatHistorySource } from "../chat-route-helpers.js" import { makePrivateMediaPresigner } from "../../services/media-presign.js" import { makeContainerReportChatSendDeps } from "../../services/report-chat-send-wiring.js" diff --git a/services/api/src/routes/admin/reports.routes.ts b/services/api/src/routes/admin/reports.routes.ts index edd894c8..10bb380a 100644 --- a/services/api/src/routes/admin/reports.routes.ts +++ b/services/api/src/routes/admin/reports.routes.ts @@ -1,4 +1,3 @@ - import { AdminReportListQuerySchema, FlagReportRequestSchema, @@ -38,7 +37,10 @@ import { type OutboundMailService, } from "../../services/admin/outbound-mail-service.js" import { makeDrizzleCleanupRepository } from "../../services/cleanup-repository.drizzle.js" -import { makePacketMediaPresigner, makePrivateMediaPresigner } from "../../services/media-presign.js" +import { + makePacketMediaPresigner, + makePrivateMediaPresigner, +} from "../../services/media-presign.js" import { ADMIN_OUTBOUND_MAIL_RATE_LIMIT } from "./mail.routes.js" import { makeContainerReportChatEmitter } from "../../services/report-chat-emitter.js" import { makeRouteNotificationService } from "../../services/route-notifier.js" @@ -216,4 +218,3 @@ export const ADMIN_REPORT_MUTATION_RATE_LIMIT = perIdentity({ timeWindow: "1 minute", skipOnError: false, }) - diff --git a/services/api/src/routes/admin/system.routes.ts b/services/api/src/routes/admin/system.routes.ts index 1ad8eebd..bd48bca1 100644 --- a/services/api/src/routes/admin/system.routes.ts +++ b/services/api/src/routes/admin/system.routes.ts @@ -1,4 +1,3 @@ - import type { SystemHealthResponse } from "@civfix/shared" import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" @@ -43,8 +42,7 @@ export async function registerAdminSystemRoutes( probes, log: (err, meta) => app.log.warn({ err, ...meta }, "admin system-health probe failed"), env: { - glitchTipConfigured: - typeof env.GLITCHTIP_DSN === "string" && env.GLITCHTIP_DSN.length > 0, + glitchTipConfigured: typeof env.GLITCHTIP_DSN === "string" && env.GLITCHTIP_DSN.length > 0, tileCdnConfigured: true, mailerIsFake: env.USE_FAKE_MAILER, jobsIsFake: env.USE_FAKE_JOBS, diff --git a/services/api/src/routes/admin/users.routes.ts b/services/api/src/routes/admin/users.routes.ts index e6cf53c6..15484c62 100644 --- a/services/api/src/routes/admin/users.routes.ts +++ b/services/api/src/routes/admin/users.routes.ts @@ -1,4 +1,3 @@ - import { AdminUserListQuerySchema, FlagUserRequestSchema, @@ -145,7 +144,11 @@ export async function registerAdminUsersRoutes( route(app, "removeUserMessage", { preHandler: csrfProtect }, async (request, reply) => { const actorId = requireOperator(request) const { id, messageId } = twoIdParams(request, "messageId") - const body = parse(RemoveUserMessageRequestSchema, { ...(request.body as object), id, messageId }) + const body = parse(RemoveUserMessageRequestSchema, { + ...(request.body as object), + id, + messageId, + }) await service().removeMessage(id, messageId, { reason: body.reason ?? null, actorId }) sendOk(reply) }) diff --git a/services/api/src/routes/anon.routes.ts b/services/api/src/routes/anon.routes.ts index 4da423db..92b7890a 100644 --- a/services/api/src/routes/anon.routes.ts +++ b/services/api/src/routes/anon.routes.ts @@ -1,4 +1,3 @@ - import { AnonReportRequestSchema, AnonReportStatusRequestSchema, @@ -15,10 +14,7 @@ import { ANON_TOKEN_TTL_SECONDS } from "../abuse/anon-token.js" import { cfGeoFromTrustedEdge } from "../abuse/gps-sanity.js" import { makeAnonService, type AnonService } from "../services/anon-service.js" import { makeDrizzleAnonReportRepository } from "../services/anon-repository.drizzle.js" -import { - makeCachedAddressResolver, - makeGeoidResolver, -} from "../services/route-geo-helpers.js" +import { makeCachedAddressResolver, makeGeoidResolver } from "../services/route-geo-helpers.js" import { resolveJurisdictionCode } from "../db/reference-code.js" import { route } from "../versioning/route.js" import { parse, trimTextFields } from "./_validate.js" @@ -62,7 +58,15 @@ const AnonReportStatusResponseJsonSchema = { properties: { status: { type: "string", - enum: ["submitted", "held", "published", "acknowledged", "in_progress", "resolved", "rejected"], + enum: [ + "submitted", + "held", + "published", + "acknowledged", + "in_progress", + "resolved", + "rejected", + ], }, publishedAt: { type: "string", nullable: true }, }, diff --git a/services/api/src/routes/auth.routes.ts b/services/api/src/routes/auth.routes.ts index aab5b420..675f3049 100644 --- a/services/api/src/routes/auth.routes.ts +++ b/services/api/src/routes/auth.routes.ts @@ -1,4 +1,3 @@ - import { AppleSignInRequestSchema, AppleCallbackBodySchema, @@ -19,12 +18,7 @@ import { type HandleAvailableResponse, type UserDTO, } from "@civfix/shared" -import type { - FastifyBaseLogger, - FastifyInstance, - FastifyReply, - FastifyRequest, -} from "fastify" +import type { FastifyBaseLogger, FastifyInstance, FastifyReply, FastifyRequest } from "fastify" import { perHost } from "../plugins/rate-limit.js" import type { Container } from "../di.js" import type { AuthServices } from "../auth/auth-services.js" @@ -40,10 +34,7 @@ import { isProd } from "../env.js" import { route } from "../versioning/route.js" import { parse } from "./_validate.js" import { setCsrfCookie, clearCsrfCookie, type Csrf } from "../auth/csrf.js" -import { - makeSingleUseSecretStore, - type SingleUseSecretStore, -} from "../auth/single-use-secret.js" +import { makeSingleUseSecretStore, type SingleUseSecretStore } from "../auth/single-use-secret.js" import type { CacheClient } from "../auth/cache.js" import { MEDIA_GET_URL_TTL_SEC } from "../services/media-intake-service.js" import { @@ -78,7 +69,9 @@ function oauthNonces(cache: CacheClient): SingleUseSecretStore { }) } -async function mintOAuthNonce(cache: CacheClient): Promise<{ nonce: string; expiresInSeconds: number }> { +async function mintOAuthNonce( + cache: CacheClient, +): Promise<{ nonce: string; expiresInSeconds: number }> { const { secret, expiresInSeconds } = await oauthNonces(cache).mint() return { nonce: secret, expiresInSeconds } } @@ -126,18 +119,28 @@ export async function registerAuthRoutes( return handleCollidesWithJurisdiction(container.getDb().sql, handle) } - route(app, "otpRequest", { config: { rateLimit: OTP_REQUEST_RATE_LIMIT, allowSuspended: true } }, async (request, reply) => { - const body = parse(EmailOtpRequestRequestSchema, request.body) - const result = await services.otp.issueOtp(body.email, request.ip || null) - const payload: EmailOtpRequestResponse = { sent: true, resendAfterSec: result.resendAfterSec } - reply.status(200).send(payload) - }) + route( + app, + "otpRequest", + { config: { rateLimit: OTP_REQUEST_RATE_LIMIT, allowSuspended: true } }, + async (request, reply) => { + const body = parse(EmailOtpRequestRequestSchema, request.body) + const result = await services.otp.issueOtp(body.email, request.ip || null) + const payload: EmailOtpRequestResponse = { sent: true, resendAfterSec: result.resendAfterSec } + reply.status(200).send(payload) + }, + ) - route(app, "otpVerify", { config: { rateLimit: OTP_VERIFY_RATE_LIMIT } }, async (request, reply) => { - const body = parse(EmailOtpVerifyRequestSchema, request.body) - const userId = await services.otp.verifyOtp(body.email, body.code, request.ip || null) - await issueSession(services, csrf, request, reply, userId, { guestSmsEnabled }) - }) + route( + app, + "otpVerify", + { config: { rateLimit: OTP_VERIFY_RATE_LIMIT } }, + async (request, reply) => { + const body = parse(EmailOtpVerifyRequestSchema, request.body) + const userId = await services.otp.verifyOtp(body.email, body.code, request.ip || null) + await issueSession(services, csrf, request, reply, userId, { guestSmsEnabled }) + }, + ) app.post( "/v1/auth/oauth/nonce", @@ -162,19 +165,27 @@ export async function registerAuthRoutes( await issueSessionForUser(services, csrf, request, reply, user, { guestSmsEnabled }) }) - route(app, "googleSignIn", { config: { rateLimit: OAUTH_RATE_LIMIT } }, async (request, reply) => { - const body = parse(GoogleSignInRequestSchema, request.body) - const expectedNonce = await requireIssuedNonce(services.cache, body.nonce, { - required: container.env.OAUTH_REQUIRE_NONCE, - log: request.log, - }) - const user = await services.oauth.signInWithGoogleIdToken(body.idToken, expectedNonce) - await issueSessionForUser(services, csrf, request, reply, user, { guestSmsEnabled }) - }) + route( + app, + "googleSignIn", + { config: { rateLimit: OAUTH_RATE_LIMIT } }, + async (request, reply) => { + const body = parse(GoogleSignInRequestSchema, request.body) + const expectedNonce = await requireIssuedNonce(services.cache, body.nonce, { + required: container.env.OAUTH_REQUIRE_NONCE, + log: request.log, + }) + const user = await services.oauth.signInWithGoogleIdToken(body.idToken, expectedNonce) + await issueSessionForUser(services, csrf, request, reply, user, { guestSmsEnabled }) + }, + ) route(app, "googleStart", { config: { rateLimit: OAUTH_RATE_LIMIT } }, async (request, reply) => { const startQuery = parse(OAuthStartQuerySchema, request.query) - if (startQuery.redirect !== undefined && !isAllowedPostLoginRedirect(startQuery.redirect, webOrigins)) { + if ( + startQuery.redirect !== undefined && + !isAllowedPostLoginRedirect(startQuery.redirect, webOrigins) + ) { throw AppError.validation({ redirect: "must be an allowed origin or a relative path" }) } const auth = services.oauth.createGoogleAuthUrl() @@ -194,25 +205,33 @@ export async function registerAuthRoutes( reply.redirect(auth.url) }) - route(app, "googleCallback", { config: { rateLimit: OAUTH_RATE_LIMIT } }, async (request, reply) => { - const query = parse(OAuthCallbackQuerySchema, request.query) - const stash = readOAuthStash(request) - if (!stash || stash.state !== query.state || stash.codeVerifier === undefined) { - throw AppError.unauthorized("Invalid OAuth state.") - } - reply.clearCookie(OAUTH_STATE_COOKIE, { path: "/" }) - const user = await services.oauth.completeGoogleCallback(query.code, stash.codeVerifier) - const target = resolvePostLoginRedirect(stash.redirect, webOrigins) - await issueSessionForUser(services, csrf, request, reply, user, { - forceKind: "web", - webRedirectTo: target, - guestSmsEnabled, - }) - }) + route( + app, + "googleCallback", + { config: { rateLimit: OAUTH_RATE_LIMIT } }, + async (request, reply) => { + const query = parse(OAuthCallbackQuerySchema, request.query) + const stash = readOAuthStash(request) + if (!stash || stash.state !== query.state || stash.codeVerifier === undefined) { + throw AppError.unauthorized("Invalid OAuth state.") + } + reply.clearCookie(OAUTH_STATE_COOKIE, { path: "/" }) + const user = await services.oauth.completeGoogleCallback(query.code, stash.codeVerifier) + const target = resolvePostLoginRedirect(stash.redirect, webOrigins) + await issueSessionForUser(services, csrf, request, reply, user, { + forceKind: "web", + webRedirectTo: target, + guestSmsEnabled, + }) + }, + ) route(app, "appleStart", { config: { rateLimit: OAUTH_RATE_LIMIT } }, async (request, reply) => { const startQuery = parse(OAuthStartQuerySchema, request.query) - if (startQuery.redirect !== undefined && !isAllowedPostLoginRedirect(startQuery.redirect, webOrigins)) { + if ( + startQuery.redirect !== undefined && + !isAllowedPostLoginRedirect(startQuery.redirect, webOrigins) + ) { throw AppError.validation({ redirect: "must be an allowed origin or a relative path" }) } const auth = services.oauth.createAppleAuthUrl() @@ -244,22 +263,27 @@ export async function registerAuthRoutes( } }, ) - route(appleScope, "appleCallback", { config: { rateLimit: OAUTH_RATE_LIMIT } }, async (request, reply) => { - const body = parse(AppleCallbackBodySchema, request.body) - const stash = readOAuthStash(request) - if (!stash || stash.state !== body.state) { - throw AppError.unauthorized("Invalid OAuth state.") - } - reply.clearCookie(OAUTH_STATE_COOKIE, { path: "/" }) - const fullName = appleFullNameFromUserField(body.user) - const user = await services.oauth.completeAppleCallback(body.code, fullName) - const target = resolvePostLoginRedirect(stash.redirect, webOrigins) - await issueSessionForUser(services, csrf, request, reply, user, { - forceKind: "web", - webRedirectTo: target, - guestSmsEnabled, - }) - }) + route( + appleScope, + "appleCallback", + { config: { rateLimit: OAUTH_RATE_LIMIT } }, + async (request, reply) => { + const body = parse(AppleCallbackBodySchema, request.body) + const stash = readOAuthStash(request) + if (!stash || stash.state !== body.state) { + throw AppError.unauthorized("Invalid OAuth state.") + } + reply.clearCookie(OAUTH_STATE_COOKIE, { path: "/" }) + const fullName = appleFullNameFromUserField(body.user) + const user = await services.oauth.completeAppleCallback(body.code, fullName) + const target = resolvePostLoginRedirect(stash.redirect, webOrigins) + await issueSessionForUser(services, csrf, request, reply, user, { + forceKind: "web", + webRedirectTo: target, + guestSmsEnabled, + }) + }, + ) }) route(app, "session", async (request, reply) => { @@ -267,17 +291,22 @@ export async function registerAuthRoutes( reply.status(200).send({ ...payload, guestSmsEnabled: guestSmsEnabledFor(container.env) }) }) - route(app, "logout", { preHandler: csrfProtect, config: { allowSuspended: true } }, async (request, reply) => { - const token = presentedSessionToken(request) - if (token === null) { - throw AppError.unauthorized() - } - await services.sessions.revokeSession(token) - clearSessionCookie(reply) - clearCsrfCookie(reply) - const payload: LogoutResponse = { ok: true } - reply.status(200).send(payload) - }) + route( + app, + "logout", + { preHandler: csrfProtect, config: { allowSuspended: true } }, + async (request, reply) => { + const token = presentedSessionToken(request) + if (token === null) { + throw AppError.unauthorized() + } + await services.sessions.revokeSession(token) + clearSessionCookie(reply) + clearCsrfCookie(reply) + const payload: LogoutResponse = { ok: true } + reply.status(200).send(payload) + }, + ) route(app, "wsTicket", { preHandler: csrfProtect }, async (request, reply) => { const userId = requireAuth(request) @@ -307,9 +336,7 @@ export async function registerAuthRoutes( return } const payload: HandleAvailableResponse = - existing === null - ? { available: true, reason: null } - : { available: false, reason: "taken" } + existing === null ? { available: true, reason: null } : { available: false, reason: "taken" } reply.status(200).send(payload) }) @@ -338,7 +365,10 @@ export async function registerAuthRoutes( ? { avatarUploadId: body.avatarUploadId, ...(avatarUrlDurable - ? { presignAvatar: (k: string) => container.storage.presignGet(k, MEDIA_GET_URL_TTL_SEC) } + ? { + presignAvatar: (k: string) => + container.storage.presignGet(k, MEDIA_GET_URL_TTL_SEC), + } : {}), } : {}), @@ -499,7 +529,8 @@ function appleFullNameFromUserField(user: string | undefined): string | undefine if (!user) return undefined try { const parsed = JSON.parse(user) as { name?: { firstName?: string; lastName?: string } } - const full = `${parsed.name?.firstName?.trim() ?? ""} ${parsed.name?.lastName?.trim() ?? ""}`.trim() + const full = + `${parsed.name?.firstName?.trim() ?? ""} ${parsed.name?.lastName?.trim() ?? ""}`.trim() return full.length > 0 ? full : undefined } catch { return undefined diff --git a/services/api/src/routes/chat-gateway-wiring.ts b/services/api/src/routes/chat-gateway-wiring.ts index e89fc0ff..b05935c2 100644 --- a/services/api/src/routes/chat-gateway-wiring.ts +++ b/services/api/src/routes/chat-gateway-wiring.ts @@ -149,7 +149,10 @@ export interface ConversationReadSeam { const readSeams = new WeakMap() -export function conversationReadSeam(app: FastifyInstance, container: Container): ConversationReadSeam { +export function conversationReadSeam( + app: FastifyInstance, + container: Container, +): ConversationReadSeam { const cached = readSeams.get(app) if (cached) return cached @@ -468,11 +471,12 @@ export function wireChatGateway(app: FastifyInstance, container: Container): Cha const roomFanoutHandoff = ( kind: "report" | "group", ): Pick => ({ - ...(fanoutMode.queued - ? { dispatchToJob: makeRoomFanoutDispatcher(container.jobs, kind) } - : {}), + ...(fanoutMode.queued ? { dispatchToJob: makeRoomFanoutDispatcher(container.jobs, kind) } : {}), ...(fanoutMode.claimed - ? { claimWindow: (roomId: string, windowMs: number) => roomFanoutClaim(kind, roomId, windowMs) } + ? { + claimWindow: (roomId: string, windowMs: number) => + roomFanoutClaim(kind, roomId, windowMs), + } : {}), }) diff --git a/services/api/src/routes/chat-powers-wiring.ts b/services/api/src/routes/chat-powers-wiring.ts index d3ae37d0..c71f8667 100644 --- a/services/api/src/routes/chat-powers-wiring.ts +++ b/services/api/src/routes/chat-powers-wiring.ts @@ -25,10 +25,7 @@ import type { FastifyInstance } from "fastify" import type { Container } from "../di.js" -import { - makeChatPowersResolver, - type ResolveChatPowers, -} from "../services/chat-room-roles.js" +import { makeChatPowersResolver, type ResolveChatPowers } from "../services/chat-room-roles.js" import type { ROLE_VALUES } from "../db/schema/types.js" import { makeDrizzleCleanupRepository } from "../services/cleanup-repository.drizzle.js" import { @@ -104,7 +101,9 @@ function buildChatPowers(app: FastifyInstance, container: Container): ResolveCha : {}), cleanupRoleOf: () => Promise.resolve(null), reportChatRoleOf: (reportId, userId) => - overrides.reportChat ? overrides.reportChat.roleOf(reportId, userId) : Promise.resolve(null), + overrides.reportChat + ? overrides.reportChat.roleOf(reportId, userId) + : Promise.resolve(null), globalRoleOf: () => Promise.resolve(null), groupRoleOf: (groupId, userId) => overrides.groups ? overrides.groups.roleOf(groupId, userId) : Promise.resolve(null), diff --git a/services/api/src/routes/chat-route-helpers.ts b/services/api/src/routes/chat-route-helpers.ts index c40df34b..2c019d90 100644 --- a/services/api/src/routes/chat-route-helpers.ts +++ b/services/api/src/routes/chat-route-helpers.ts @@ -1,4 +1,3 @@ - import { AppError, type ChatHistoryResponse, type ChatMessageDTO } from "@civfix/shared" import type { ChatHistoryPage, ChatService } from "@civfix/shared/interfaces" import { broadcastMessageUpdate, roomKeyFor } from "../ws/gateway.js" @@ -94,7 +93,9 @@ export async function deleteMessageWithPowers( const roomView = neutralizeChatViewerFields(tombstone) if (input.legacyBroadcast) { - void Promise.resolve(input.chat.broadcast(roomKeyFor(roomKind, roomId), roomView)).catch(() => {}) + void Promise.resolve(input.chat.broadcast(roomKeyFor(roomKind, roomId), roomView)).catch( + () => {}, + ) } broadcastMessageUpdate(input.chat, roomKind, roomId, roomView) return tombstone diff --git a/services/api/src/routes/chat.routes.ts b/services/api/src/routes/chat.routes.ts index f933fdb1..94d46b02 100644 --- a/services/api/src/routes/chat.routes.ts +++ b/services/api/src/routes/chat.routes.ts @@ -1,4 +1,3 @@ - import fastifyWebsocket from "@fastify/websocket" import { PaginationQuerySchema, @@ -26,11 +25,7 @@ import { makeChatReactionService } from "../services/chat-reaction-service.js" import type { ChatRepository } from "../services/chat-repository.drizzle.js" import type { ReportChatRepository } from "../services/report-chat-repository.drizzle.js" import type { ChatPollRepository } from "../services/chat-poll-repository.drizzle.js" -import { - type GatewayChatMentions, - type IsMemberFn, - type ReportVisibleFn, -} from "../ws/gateway.js" +import { type GatewayChatMentions, type IsMemberFn, type ReportVisibleFn } from "../ws/gateway.js" import { makeDrizzleGroupThreadsSource, makeDrizzleReportThreadsSource, @@ -90,7 +85,10 @@ export const CHAT_REACTION_RATE_LIMIT = perIdentity({ max: 60, timeWindow: "1 mi export const CHAT_DELETE_RATE_LIMIT = perIdentity({ max: 30, timeWindow: "1 minute" }) -export async function registerChatRoutes(app: FastifyInstance, container: Container): Promise { +export async function registerChatRoutes( + app: FastifyInstance, + container: Container, +): Promise { const csrfProtect = container.csrf.protect await app.register(fastifyWebsocket, { options: { maxPayload: 64 * 1024 } }) @@ -156,7 +154,12 @@ export async function registerChatRoutes(app: FastifyInstance, container: Contai dmPeerOf: wiring.dmPeerOf, isBlockedEitherWay: wiring.isBlockedEitherWay, }) - const updated: ChatMessageDTO = await reactions.toggleCleanupReaction(cleanupId, messageId, userId, body.emoji) + const updated: ChatMessageDTO = await reactions.toggleCleanupReaction( + cleanupId, + messageId, + userId, + body.emoji, + ) void Promise.resolve( container.chatService.broadcastEvent?.(roomKeyFor("cleanup", cleanupId), { type: "reaction", diff --git a/services/api/src/routes/claim.routes.ts b/services/api/src/routes/claim.routes.ts index f7e20cef..d6f25e88 100644 --- a/services/api/src/routes/claim.routes.ts +++ b/services/api/src/routes/claim.routes.ts @@ -1,4 +1,3 @@ - import { ClaimNudgeRequestSchema, ClaimReportRequestSchema, @@ -61,7 +60,11 @@ export async function registerClaimRoutes( anonTokenSigningKey: container.env.ANON_TOKEN_SIGNING_KEY, getReportForOwner: (reportId, owner) => reportService().getReport(reportId, owner), enqueueHoldRelease: async (reportId) => { - await container.jobs.enqueue(ANON_HOLD_RELEASE_JOB, { reportId }, { singletonKey: reportId }) + await container.jobs.enqueue( + ANON_HOLD_RELEASE_JOB, + { reportId }, + { singletonKey: reportId }, + ) }, }) } diff --git a/services/api/src/routes/cleanups.routes.ts b/services/api/src/routes/cleanups.routes.ts index 487caa31..4d40f997 100644 --- a/services/api/src/routes/cleanups.routes.ts +++ b/services/api/src/routes/cleanups.routes.ts @@ -1,4 +1,3 @@ - import { CreateCleanupRequestSchema, UpdateCleanupRequestSchema, @@ -44,20 +43,14 @@ import { enrichCleanupDTOs } from "../services/cleanup-enrichment.js" import { makeCommsRuntime } from "../services/host/comms-wiring.js" import { makeInsightsGeneration } from "../services/host/host-analytics-cache.js" import { makeEventMediaPresigner } from "../services/host/event-media.js" -import { - SCHEDULE_MAX_AHEAD_MS, - SCHEDULE_MAX_BACKDATE_MS, -} from "../services/cleanup-rules.js" +import { SCHEDULE_MAX_AHEAD_MS, SCHEDULE_MAX_BACKDATE_MS } from "../services/cleanup-rules.js" import { makeDrizzleChatRepository, type ChatRepository, } from "../services/chat-repository.drizzle.js" import { makePrivateMediaPresigner } from "../services/media-presign.js" import { buildIcs } from "@civfix/shared/ics" -import { - makeCachedAddressResolver, - makeGeoidResolver, -} from "../services/route-geo-helpers.js" +import { makeCachedAddressResolver, makeGeoidResolver } from "../services/route-geo-helpers.js" import { resolveJurisdictionCode } from "../db/reference-code.js" import { makeOutboundMailService } from "../services/admin/outbound-mail-service.js" import { makeDrizzleMailRepository } from "../services/admin/mail-repository.drizzle.js" @@ -263,20 +256,30 @@ export async function registerCleanupRoutes( return makeContainerCleanupService(app, container) } - route(app, "createCleanup", { preHandler: csrfProtect, config: { rateLimit: CREATE_CLEANUP_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const body = parse(CreateCleanupBodySchema, request.body) - const dto: CleanupDTO = await service().createCleanup(body, userId) - reply.status(201).send(dto) - }) - - route(app, "duplicateCleanup", { preHandler: csrfProtect, config: { rateLimit: CREATE_CLEANUP_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(CleanupIdParamsSchema, request.params) - const body = parse(DuplicateCleanupBodySchema, { ...(request.body as object), id }) - const dto: GetCleanupResponse = await service().duplicateCleanup(userId, body) - reply.status(201).send(dto) - }) + route( + app, + "createCleanup", + { preHandler: csrfProtect, config: { rateLimit: CREATE_CLEANUP_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const body = parse(CreateCleanupBodySchema, request.body) + const dto: CleanupDTO = await service().createCleanup(body, userId) + reply.status(201).send(dto) + }, + ) + + route( + app, + "duplicateCleanup", + { preHandler: csrfProtect, config: { rateLimit: CREATE_CLEANUP_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(CleanupIdParamsSchema, request.params) + const body = parse(DuplicateCleanupBodySchema, { ...(request.body as object), id }) + const dto: GetCleanupResponse = await service().duplicateCleanup(userId, body) + reply.status(201).send(dto) + }, + ) route(app, "updateCleanup", { preHandler: csrfProtect }, async (request, reply) => { const userId = requireAuth(request) @@ -294,26 +297,36 @@ export async function registerCleanupRoutes( reply.status(200).send(dto) }) - route(app, "completeCleanup", { preHandler: csrfProtect, config: { rateLimit: COMPLETE_CLEANUP_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(CleanupIdParamsSchema, request.params) - const body = parse(CompleteCleanupRequestSchema, { ...(request.body as object), id }) - const dto: GetCleanupResponse = await service().completeCleanup( - id, - body.note ?? null, - userId, - request.headers["user-agent"] ?? null, - ) - reply.status(200).send(dto) - }) - - route(app, "claimEventSlot", { preHandler: csrfProtect, config: { rateLimit: CLAIM_SLOT_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(CleanupIdParamsSchema, request.params) - const body = parse(ClaimEventSlotRequestSchema, { ...(request.body as object), id }) - const dto: GetCleanupResponse = await service().claimEventSlot(id, userId, body.slotId) - reply.status(200).send(dto) - }) + route( + app, + "completeCleanup", + { preHandler: csrfProtect, config: { rateLimit: COMPLETE_CLEANUP_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(CleanupIdParamsSchema, request.params) + const body = parse(CompleteCleanupRequestSchema, { ...(request.body as object), id }) + const dto: GetCleanupResponse = await service().completeCleanup( + id, + body.note ?? null, + userId, + request.headers["user-agent"] ?? null, + ) + reply.status(200).send(dto) + }, + ) + + route( + app, + "claimEventSlot", + { preHandler: csrfProtect, config: { rateLimit: CLAIM_SLOT_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(CleanupIdParamsSchema, request.params) + const body = parse(ClaimEventSlotRequestSchema, { ...(request.body as object), id }) + const dto: GetCleanupResponse = await service().claimEventSlot(id, userId, body.slotId) + reply.status(200).send(dto) + }, + ) route(app, "requestEventResources", { preHandler: csrfProtect }, async (request, reply) => { const userId = requireAuth(request) @@ -327,26 +340,36 @@ export async function registerCleanupRoutes( reply.status(200).send(payload) }) - route(app, "setCleanupMemberRole", { preHandler: csrfProtect, config: { rateLimit: MEMBER_MANAGEMENT_RATE_LIMIT } }, async (request, reply) => { - const actorId = requireAuth(request) - const { id, userId } = parse(MemberParamsSchema, request.params) - const body = parse(SetMemberRoleRequestSchema, { ...(request.body as object), id, userId }) - const payload: SetMemberRoleResponse = await service().setMemberRole( - id, - actorId, - body.userId, - body.role, - ) - reply.status(200).send(payload) - }) - - route(app, "removeCleanupMember", { preHandler: csrfProtect, config: { rateLimit: MEMBER_MANAGEMENT_RATE_LIMIT } }, async (request, reply) => { - const actorId = requireAuth(request) - const { id, userId } = parse(MemberParamsSchema, request.params) - const body = parse(RemoveMemberRequestSchema, { ...(request.body as object), id, userId }) - const payload: RemoveMemberResponse = await service().removeMember(id, actorId, body.userId) - reply.status(200).send(payload) - }) + route( + app, + "setCleanupMemberRole", + { preHandler: csrfProtect, config: { rateLimit: MEMBER_MANAGEMENT_RATE_LIMIT } }, + async (request, reply) => { + const actorId = requireAuth(request) + const { id, userId } = parse(MemberParamsSchema, request.params) + const body = parse(SetMemberRoleRequestSchema, { ...(request.body as object), id, userId }) + const payload: SetMemberRoleResponse = await service().setMemberRole( + id, + actorId, + body.userId, + body.role, + ) + reply.status(200).send(payload) + }, + ) + + route( + app, + "removeCleanupMember", + { preHandler: csrfProtect, config: { rateLimit: MEMBER_MANAGEMENT_RATE_LIMIT } }, + async (request, reply) => { + const actorId = requireAuth(request) + const { id, userId } = parse(MemberParamsSchema, request.params) + const body = parse(RemoveMemberRequestSchema, { ...(request.body as object), id, userId }) + const payload: RemoveMemberResponse = await service().removeMember(id, actorId, body.userId) + reply.status(200).send(payload) + }, + ) route(app, "listCleanups", async (request, reply) => { const q = parse(ListCleanupsQuerySchema, request.query) @@ -367,48 +390,63 @@ export async function registerCleanupRoutes( reply.status(200).send(dto) }) - route(app, "getEventIcs", { config: { rateLimit: EVENT_ICS_RATE_LIMIT } }, async (request, reply) => { - const { id } = parse(GetEventIcsRequestSchema, request.params) - const event = await service().getCleanup(id, viewerOf(request)) - const mine = event.slots.find( - (slot): slot is typeof slot & { startsAt: string; endsAt: string } => - slot.mine === true && slot.startsAt != null && slot.endsAt != null, - ) - const endsAt = mine !== undefined ? mine.endsAt : event.endsAt - const payload: GetEventIcsResponse = { - ics: buildIcs({ - uid: `cleanup-${event.id}@civfix.org`, - 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 } - : {}), - ...(endsAt !== null && endsAt !== undefined ? { endsAt } : {}), - ...(event.timezone !== null && event.timezone !== undefined - ? { timezone: event.timezone } - : {}), - ...(event.address !== null ? { location: event.address } : {}), - url: `${webOrigin}/events/${event.id}`, - status: event.status === "cancelled" ? "CANCELLED" : "CONFIRMED", - }), - filename: `civfix-event-${event.id}.ics`, - } - reply.status(200).send(payload) - }) - - route(app, "joinCleanup", { preHandler: csrfProtect, config: { rateLimit: CLEANUP_MEMBERSHIP_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(CleanupIdParamsSchema, request.params) - const payload: JoinCleanupResponse = await service().joinCleanup(id, userId) - reply.status(200).send(payload) - }) - - route(app, "leaveCleanup", { preHandler: csrfProtect, config: { rateLimit: CLEANUP_MEMBERSHIP_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(CleanupIdParamsSchema, request.params) - const payload: LeaveCleanupResponse = await service().leaveCleanup(id, userId) - reply.status(200).send(payload) - }) + route( + app, + "getEventIcs", + { config: { rateLimit: EVENT_ICS_RATE_LIMIT } }, + async (request, reply) => { + const { id } = parse(GetEventIcsRequestSchema, request.params) + const event = await service().getCleanup(id, viewerOf(request)) + const mine = event.slots.find( + (slot): slot is typeof slot & { startsAt: string; endsAt: string } => + slot.mine === true && slot.startsAt != null && slot.endsAt != null, + ) + const endsAt = mine !== undefined ? mine.endsAt : event.endsAt + const payload: GetEventIcsResponse = { + ics: buildIcs({ + uid: `cleanup-${event.id}@civfix.org`, + 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 } + : {}), + ...(endsAt !== null && endsAt !== undefined ? { endsAt } : {}), + ...(event.timezone !== null && event.timezone !== undefined + ? { timezone: event.timezone } + : {}), + ...(event.address !== null ? { location: event.address } : {}), + url: `${webOrigin}/events/${event.id}`, + status: event.status === "cancelled" ? "CANCELLED" : "CONFIRMED", + }), + filename: `civfix-event-${event.id}.ics`, + } + reply.status(200).send(payload) + }, + ) + + route( + app, + "joinCleanup", + { preHandler: csrfProtect, config: { rateLimit: CLEANUP_MEMBERSHIP_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(CleanupIdParamsSchema, request.params) + const payload: JoinCleanupResponse = await service().joinCleanup(id, userId) + reply.status(200).send(payload) + }, + ) + + route( + app, + "leaveCleanup", + { preHandler: csrfProtect, config: { rateLimit: CLEANUP_MEMBERSHIP_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(CleanupIdParamsSchema, request.params) + const payload: LeaveCleanupResponse = await service().leaveCleanup(id, userId) + reply.status(200).send(payload) + }, + ) route(app, "getCleanupAttendees", async (request, reply) => { const { id } = parse(CleanupIdParamsSchema, request.params) diff --git a/services/api/src/routes/conversations.routes.ts b/services/api/src/routes/conversations.routes.ts index 43310bb7..246fd969 100644 --- a/services/api/src/routes/conversations.routes.ts +++ b/services/api/src/routes/conversations.routes.ts @@ -1,4 +1,3 @@ - import { MarkThreadReadRequestSchema, ToggleMuteRequestSchema, @@ -67,7 +66,10 @@ function isMutableRoomKind(roomKind: string): roomKind is ConversationMuteRoomKi return MUTABLE_ROOM_KINDS.has(roomKind as ConversationMuteRoomKind) } -export async function registerConversationRoutes(app: FastifyInstance, container: Container): Promise { +export async function registerConversationRoutes( + app: FastifyInstance, + container: Container, +): Promise { const csrfProtect = container.csrf.protect const overrides = app.conversationRoutesOverrides @@ -99,7 +101,8 @@ export async function registerConversationRoutes(app: FastifyInstance, container } if (roomKind === "report") { if (await (reportChat ??= makeReportChatRepository(sql)).isMember(roomId, userId)) return true - const report = await (reports ??= makeDrizzleDiscussionRepository(sql)).findReportForDiscussion(roomId) + const report = await (reports ??= + makeDrizzleDiscussionRepository(sql)).findReportForDiscussion(roomId) return isReportVisibleTo(report, userId) } const access = await (groups ??= makeChatGroupRepository(sql)).accessOf(roomId, userId) diff --git a/services/api/src/routes/dm.routes.ts b/services/api/src/routes/dm.routes.ts index 9916d86f..848a5a02 100644 --- a/services/api/src/routes/dm.routes.ts +++ b/services/api/src/routes/dm.routes.ts @@ -1,4 +1,3 @@ - import { OpenDmRequestSchema, DmHistoryQuerySchema, @@ -136,7 +135,11 @@ export async function registerDmRoutes(app: FastifyInstance, container: Containe async (request, reply) => { const userId = requireAuth(request) const { threadId, messageId } = parse(ThreadMessageParamsSchema, request.params) - const body = parse(EditDmMessageBodySchema, { ...(request.body as object), threadId, messageId }) + const body = parse(EditDmMessageBodySchema, { + ...(request.body as object), + threadId, + messageId, + }) const edits = makeChatEditService({ dm: dmRepo(), @@ -203,7 +206,11 @@ export async function registerDmRoutes(app: FastifyInstance, container: Containe const { threadId, messageId } = parse(ThreadMessageParamsSchema, request.params) await authorizePeer(threadId, userId, "You can't delete this message.") - const tombstone: ChatMessageDTO | null = await dmRepo().softDelete(threadId, messageId, userId) + const tombstone: ChatMessageDTO | null = await dmRepo().softDelete( + threadId, + messageId, + userId, + ) if (tombstone === null) { const meta = await dmRepo().findMessageMeta(messageId) if ( diff --git a/services/api/src/routes/forms.routes.ts b/services/api/src/routes/forms.routes.ts index b21a32ac..5fb280a1 100644 --- a/services/api/src/routes/forms.routes.ts +++ b/services/api/src/routes/forms.routes.ts @@ -1,4 +1,3 @@ - import { createHash } from "node:crypto" import { AppError } from "@civfix/shared" import { z } from "zod" @@ -122,7 +121,9 @@ export async function registerHomeTurfRoutes( function requireNotifyTo(): string { const notifyTo = container.env.HOME_TURF_NOTIFY_TO if (notifyTo === "") { - throw AppError.conflict("This form isn't accepting submissions right now. Please try again later.") + throw AppError.conflict( + "This form isn't accepting submissions right now. Please try again later.", + ) } return notifyTo } @@ -133,15 +134,22 @@ export async function registerHomeTurfRoutes( async (request, reply) => { const form = parse(HomeTurfFormSchema, request.body) - const human = await container.abuseChecks.verifyTurnstile(form.turnstileToken, request.ip ?? "", { - action: "home-turf", - }) + const human = await container.abuseChecks.verifyTurnstile( + form.turnstileToken, + request.ip ?? "", + { + action: "home-turf", + }, + ) if (!human) { throw AppError.turnstileFailed() } if (honeypotTripped(form.honeypot)) { - request.log.info({ ip: request.ip }, "home-turf form: honeypot tripped; fake success, no mail") + request.log.info( + { ip: request.ip }, + "home-turf form: honeypot tripped; fake success, no mail", + ) return reply.status(200).send({ ok: true }) } @@ -158,7 +166,10 @@ export async function registerHomeTurfRoutes( try { await container.mailer.sendOutbound(buildConfirmationEmail(form, from, notifyTo)) } catch (err) { - request.log.warn({ err }, "home-turf form: confirmation email failed (non-fatal; returning 200)") + request.log.warn( + { err }, + "home-turf form: confirmation email failed (non-fatal; returning 200)", + ) } return reply.status(200).send({ ok: true }) @@ -191,7 +202,11 @@ function formRows(form: HomeTurfForm): Array<[string, string]> { return rows } -export function buildNotificationEmail(form: HomeTurfForm, from: string, to: string): FormOutboundEmail { +export function buildNotificationEmail( + form: HomeTurfForm, + from: string, + to: string, +): FormOutboundEmail { const subject = sanitizeHeaderValue(`Home Turf sign-up: ${form.school}`) const { text, html } = renderEmailBody({ preheader: subject, @@ -200,7 +215,11 @@ export function buildNotificationEmail(form: HomeTurfForm, from: string, to: str return { from, to, replyTo: form.email, subject, text, html } } -export function buildConfirmationEmail(form: HomeTurfForm, from: string, notifyTo: string): FormOutboundEmail { +export function buildConfirmationEmail( + form: HomeTurfForm, + from: string, + notifyTo: string, +): FormOutboundEmail { const subject = "We got your Home Turf sign-up" const { text, html } = renderEmailBody({ preheader: subject, diff --git a/services/api/src/routes/host/_host-routes.ts b/services/api/src/routes/host/_host-routes.ts index 892d0500..bcb979d3 100644 --- a/services/api/src/routes/host/_host-routes.ts +++ b/services/api/src/routes/host/_host-routes.ts @@ -16,9 +16,7 @@ import type { PageService } from "../../services/host/page-service.js" export const CleanupIdParamsSchema = z.object({ id: IdSchema }).strict() -export const TicketTypeParamsSchema = z - .object({ id: IdSchema, ticketTypeId: IdSchema }) - .strict() +export const TicketTypeParamsSchema = z.object({ id: IdSchema, ticketTypeId: IdSchema }).strict() export const RegistrationParamsSchema = z .object({ id: IdSchema, registrationId: IdSchema }) @@ -61,10 +59,7 @@ export interface HostRouteContext { pageGuards(): HostGuards } -export function makeHostRouteContext( - app: FastifyInstance, - container: Container, -): HostRouteContext { +export function makeHostRouteContext(app: FastifyInstance, container: Container): HostRouteContext { let services: HostRegistrationServices | undefined let guards: HostGuards | undefined let pages: PageService | undefined diff --git a/services/api/src/routes/host/analytics.routes.ts b/services/api/src/routes/host/analytics.routes.ts index 95da3d3c..055e96a0 100644 --- a/services/api/src/routes/host/analytics.routes.ts +++ b/services/api/src/routes/host/analytics.routes.ts @@ -85,108 +85,161 @@ export async function registerHostAnalyticsRoutes( return { cleanupId: query.id, range: query.range ?? "30d", viewerScope } } - route(app, "getEventAnalytics", { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const query = parse(GetEventAnalyticsRequestSchema, mergeQuery(request)) - const resolution = await requireCapability( - container.getDb().sql, - query.id, - userId, - "view_analytics", - ) - const viewerScope = `${resolution.standing.eventRole ?? "none"}:${resolution.standing.orgRole ?? "none"}` - reply.status(200).send( - await eventAnalytics().analytics(query.id, query.scope ?? "full", { - userId, - organizationId: resolution.organizationId, - viewerScope, - }), - ) - }) - - route(app, "eventAnalyticsOverview", { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, async (request, reply) => { - const scope = await eventScope(request) - reply.status(200).send(await analytics().overview(scope.cleanupId, scope.range, scope.viewerScope)) - }) - - route(app, "eventAnalyticsRegistrations", { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, async (request, reply) => { - const scope = await eventScope(request) - reply - .status(200) - .send(await analytics().registrations(scope.cleanupId, scope.range, scope.viewerScope)) - }) - - route(app, "eventAnalyticsCheckins", { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, async (request, reply) => { - const scope = await eventScope(request) - reply.status(200).send(await analytics().checkins(scope.cleanupId, scope.range, scope.viewerScope)) - }) - - route(app, "eventAnalyticsBroadcasts", { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, async (request, reply) => { - const scope = await eventScope(request) - reply.status(200).send(await analytics().broadcasts(scope.cleanupId, scope.range, scope.viewerScope)) - }) - - route(app, "eventAnalyticsSources", { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, async (request, reply) => { - const scope = await eventScope(request) - reply.status(200).send(await analytics().sources(scope.cleanupId, scope.range, scope.viewerScope)) - }) - - route(app, "getEventInsights", { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const params = parse(GetEventInsightsRequestSchema, request.params) - const resolution = await requireCapability( - container.getDb().sql, - params.id, - userId, - "view_analytics", - ) - if (!can(resolution.standing, "view_roster")) { - throw AppError.forbidden(hostForbiddenCopy("view_roster")) - } - const viewerScope = `${resolution.standing.eventRole ?? "none"}:${resolution.standing.orgRole ?? "none"}` - reply.status(200).send( - await insights().insights(params.id, { - userId, - viewerScope, - }), - ) - }) - - route(app, "hostedEventsAnalytics", { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const query = parse(HostedEventsAnalyticsRequestSchema, request.query) - let viewerScope = "self" - if (query.orgId !== undefined) { - const standing = await requireOrgCapability( + route( + app, + "getEventAnalytics", + { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const query = parse(GetEventAnalyticsRequestSchema, mergeQuery(request)) + const resolution = await requireCapability( container.getDb().sql, - query.orgId, + query.id, userId, "view_analytics", ) - viewerScope = `org:${standing.orgRole ?? "none"}` - } - const range: PortfolioAnalyticsRange = query.range ?? "90d" - reply - .status(200) - .send(await analytics().portfolio(userId, query.orgId ?? null, range, viewerScope)) - }) - - route(app, "hostedEventsAnalyticsSummary", { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const query = parse(HostAnalyticsSummaryRequestSchema, request.query) - let viewerScope = "self" - if (query.orgId !== undefined) { - const standing = await requireOrgCapability( + const viewerScope = `${resolution.standing.eventRole ?? "none"}:${resolution.standing.orgRole ?? "none"}` + reply.status(200).send( + await eventAnalytics().analytics(query.id, query.scope ?? "full", { + userId, + organizationId: resolution.organizationId, + viewerScope, + }), + ) + }, + ) + + route( + app, + "eventAnalyticsOverview", + { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, + async (request, reply) => { + const scope = await eventScope(request) + reply + .status(200) + .send(await analytics().overview(scope.cleanupId, scope.range, scope.viewerScope)) + }, + ) + + route( + app, + "eventAnalyticsRegistrations", + { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, + async (request, reply) => { + const scope = await eventScope(request) + reply + .status(200) + .send(await analytics().registrations(scope.cleanupId, scope.range, scope.viewerScope)) + }, + ) + + route( + app, + "eventAnalyticsCheckins", + { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, + async (request, reply) => { + const scope = await eventScope(request) + reply + .status(200) + .send(await analytics().checkins(scope.cleanupId, scope.range, scope.viewerScope)) + }, + ) + + route( + app, + "eventAnalyticsBroadcasts", + { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, + async (request, reply) => { + const scope = await eventScope(request) + reply + .status(200) + .send(await analytics().broadcasts(scope.cleanupId, scope.range, scope.viewerScope)) + }, + ) + + route( + app, + "eventAnalyticsSources", + { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, + async (request, reply) => { + const scope = await eventScope(request) + reply + .status(200) + .send(await analytics().sources(scope.cleanupId, scope.range, scope.viewerScope)) + }, + ) + + route( + app, + "getEventInsights", + { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const params = parse(GetEventInsightsRequestSchema, request.params) + const resolution = await requireCapability( container.getDb().sql, - query.orgId, + params.id, userId, "view_analytics", ) - viewerScope = `org:${standing.orgRole ?? "none"}` - } - const range: AnalyticsRange = query.range ?? "30d" - reply - .status(200) - .send(await analytics().summary(userId, query.orgId ?? null, range, viewerScope)) - }) + if (!can(resolution.standing, "view_roster")) { + throw AppError.forbidden(hostForbiddenCopy("view_roster")) + } + const viewerScope = `${resolution.standing.eventRole ?? "none"}:${resolution.standing.orgRole ?? "none"}` + reply.status(200).send( + await insights().insights(params.id, { + userId, + viewerScope, + }), + ) + }, + ) + + route( + app, + "hostedEventsAnalytics", + { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const query = parse(HostedEventsAnalyticsRequestSchema, request.query) + let viewerScope = "self" + if (query.orgId !== undefined) { + const standing = await requireOrgCapability( + container.getDb().sql, + query.orgId, + userId, + "view_analytics", + ) + viewerScope = `org:${standing.orgRole ?? "none"}` + } + const range: PortfolioAnalyticsRange = query.range ?? "90d" + reply + .status(200) + .send(await analytics().portfolio(userId, query.orgId ?? null, range, viewerScope)) + }, + ) + + route( + app, + "hostedEventsAnalyticsSummary", + { config: { rateLimit: ANALYTICS_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const query = parse(HostAnalyticsSummaryRequestSchema, request.query) + let viewerScope = "self" + if (query.orgId !== undefined) { + const standing = await requireOrgCapability( + container.getDb().sql, + query.orgId, + userId, + "view_analytics", + ) + viewerScope = `org:${standing.orgRole ?? "none"}` + } + const range: AnalyticsRange = query.range ?? "30d" + reply + .status(200) + .send(await analytics().summary(userId, query.orgId ?? null, range, viewerScope)) + }, + ) } diff --git a/services/api/src/routes/host/announcements.routes.ts b/services/api/src/routes/host/announcements.routes.ts index f3e85c9f..3d328c0f 100644 --- a/services/api/src/routes/host/announcements.routes.ts +++ b/services/api/src/routes/host/announcements.routes.ts @@ -14,10 +14,7 @@ import { route } from "../../versioning/route.js" import { parse, trimTextFields } from "../_validate.js" import { requireCapability, resolveVisibleStanding } from "../../services/host/authz.js" import { writeAudit } from "../../services/admin/audit.js" -import { - BroadcastCapError, - capError, -} from "../../services/host/broadcast-service.js" +import { BroadcastCapError, capError } from "../../services/host/broadcast-service.js" import { makeCommsRuntime } from "../../services/host/comms-wiring.js" import type { CommsRuntime } from "../../services/host/comms-wiring.js" import type { diff --git a/services/api/src/routes/host/checkin.routes.ts b/services/api/src/routes/host/checkin.routes.ts index 5138bcab..34511f7b 100644 --- a/services/api/src/routes/host/checkin.routes.ts +++ b/services/api/src/routes/host/checkin.routes.ts @@ -45,9 +45,7 @@ export function registerHostCheckinRoutes( const userId = requireAuth(request) const { id } = parse(CleanupIdParamsSchema, request.params) const query = parse(GetMyEventTicketRequestSchema, { id }) - const payload: GetMyEventTicketResponse = await ctx - .services() - .checkin.myTicket(query, userId) + const payload: GetMyEventTicketResponse = await ctx.services().checkin.myTicket(query, userId) reply.status(200).send(payload) }, ) @@ -130,9 +128,7 @@ export function registerHostCheckinRoutes( const { id } = parse(CleanupIdParamsSchema, request.params) await ctx.guards().requireCapability(id, userId, "check_in") const query = parse(GetEventCheckinCountersRequestSchema, { id }) - const payload: GetEventCheckinCountersResponse = await ctx - .services() - .checkin.counters(query) + const payload: GetEventCheckinCountersResponse = await ctx.services().checkin.counters(query) reply.status(200).send(payload) }, ) diff --git a/services/api/src/routes/host/orgs.routes.ts b/services/api/src/routes/host/orgs.routes.ts index cecf3658..9fd3fa28 100644 --- a/services/api/src/routes/host/orgs.routes.ts +++ b/services/api/src/routes/host/orgs.routes.ts @@ -265,7 +265,11 @@ export async function registerHostOrgRoutes( ...(request.body as object), id, }) - const payload: InviteOrganizationMemberResponse = await service().inviteMember(id, userId, body) + const payload: InviteOrganizationMemberResponse = await service().inviteMember( + id, + userId, + body, + ) reply.status(200).send(payload) }, ) diff --git a/services/api/src/routes/host/portfolio.routes.ts b/services/api/src/routes/host/portfolio.routes.ts index 7cbd0af8..8d6ae64e 100644 --- a/services/api/src/routes/host/portfolio.routes.ts +++ b/services/api/src/routes/host/portfolio.routes.ts @@ -1,7 +1,4 @@ -import { - ListMyHostedEventsRequestSchema, - type ListMyHostedEventsResponse, -} from "@civfix/shared" +import { ListMyHostedEventsRequestSchema, type ListMyHostedEventsResponse } from "@civfix/shared" import { z } from "zod" import type { FastifyInstance } from "fastify" import type { Container } from "../../di.js" diff --git a/services/api/src/routes/host/registrations.routes.ts b/services/api/src/routes/host/registrations.routes.ts index 377be607..580f8cc6 100644 --- a/services/api/src/routes/host/registrations.routes.ts +++ b/services/api/src/routes/host/registrations.routes.ts @@ -145,9 +145,7 @@ export function registerHostRegistrationRoutes( const { id, registrationId } = parse(RegistrationParamsSchema, request.params) const byHost = await ctx.guards().canManage(id, userId, "manage_event") if (!byHost) { - const mine = await ctx - .services() - .repo.findRegistration(id, registrationId) + const mine = await ctx.services().repo.findRegistration(id, registrationId) if (mine === null || mine.userId !== userId) { await ctx.guards().requireCapability(id, userId, "manage_event") } diff --git a/services/api/src/routes/host/unsubscribe.routes.ts b/services/api/src/routes/host/unsubscribe.routes.ts index 4d33a966..c7c28428 100644 --- a/services/api/src/routes/host/unsubscribe.routes.ts +++ b/services/api/src/routes/host/unsubscribe.routes.ts @@ -89,5 +89,4 @@ export async function registerUnsubscribeRoutes( }, ) }) - } diff --git a/services/api/src/routes/index.ts b/services/api/src/routes/index.ts index 30dce677..8f15d2ea 100644 --- a/services/api/src/routes/index.ts +++ b/services/api/src/routes/index.ts @@ -1,4 +1,3 @@ - import type { FastifyInstance } from "fastify" import type { Container } from "../di.js" import { registerHealthRoutes } from "./health.routes.js" diff --git a/services/api/src/routes/legal.routes.ts b/services/api/src/routes/legal.routes.ts index 33b59776..91d93708 100644 --- a/services/api/src/routes/legal.routes.ts +++ b/services/api/src/routes/legal.routes.ts @@ -3,10 +3,7 @@ import type { FastifyInstance } from "fastify" import { perHost } from "../plugins/rate-limit.js" import type { Container } from "../di.js" import { route } from "../versioning/route.js" -import { - LEGAL_VERSIONS_CACHE_SECONDS, - legalDocumentVersions, -} from "../services/legal-service.js" +import { LEGAL_VERSIONS_CACHE_SECONDS, legalDocumentVersions } from "../services/legal-service.js" export const LEGAL_VERSIONS_RATE_LIMIT = perHost({ max: 120, timeWindow: "1 minute" }) diff --git a/services/api/src/routes/local-storage.routes.ts b/services/api/src/routes/local-storage.routes.ts index 9cd9fd54..be9e4c3d 100644 --- a/services/api/src/routes/local-storage.routes.ts +++ b/services/api/src/routes/local-storage.routes.ts @@ -215,10 +215,7 @@ function resolveGetGrant( return { storage, key } } -function assertDeclaredUploadMatchesGrant( - request: FastifyRequest, - grant: ResolvedPutGrant, -): void { +function assertDeclaredUploadMatchesGrant(request: FastifyRequest, grant: ResolvedPutGrant): void { if (contentTypeOf(request) !== grant.contentType.trim().toLowerCase()) { throw AppError.mediaRejected("Content-Type does not match the presigned upload") } diff --git a/services/api/src/routes/map.routes.ts b/services/api/src/routes/map.routes.ts index 05ec4fec..9075208e 100644 --- a/services/api/src/routes/map.routes.ts +++ b/services/api/src/routes/map.routes.ts @@ -1,4 +1,3 @@ - import { ResolveAddressRequestSchema, ResolveJurisdictionRequestSchema, @@ -23,10 +22,7 @@ import { makeCachedAddressResolver, makeRouteJurisdictionService, } from "../services/route-geo-helpers.js" -import { - makeCleanupMapRepository, - MAP_CLEANUPS_LIMIT, -} from "../services/cleanup-map-repository.js" +import { makeCleanupMapRepository, MAP_CLEANUPS_LIMIT } from "../services/cleanup-map-repository.js" import { writeAudit } from "../services/admin/audit.js" import { CappedBBoxQueryParam } from "./query-encoding.js" import { parse, trimTextFields } from "./_validate.js" @@ -142,26 +138,21 @@ export async function registerMapRoutes(app: FastifyInstance, container: Contain }, ) - route( - app, - "suggest", - { config: { rateLimit: GEOCODER_RATE_LIMIT } }, - async (request, reply) => { - const { q, proximity, proximityZoom, limit, language } = parse( - SuggestPlacesBodySchema, - request.body, - ) - const suggestions = await suggestAddresses(q, { - ...(proximity ? { proximity } : {}), - ...(proximityZoom != null ? { proximityZoom } : {}), - ...(limit != null ? { limit } : {}), - ...(language ? { language } : {}), - ...(container.env.MAPBOX_TOKEN ? { mapboxToken: container.env.MAPBOX_TOKEN } : {}), - }) - const payload: SuggestPlacesResponse = { suggestions } - reply.status(200).send(payload) - }, - ) + route(app, "suggest", { config: { rateLimit: GEOCODER_RATE_LIMIT } }, async (request, reply) => { + const { q, proximity, proximityZoom, limit, language } = parse( + SuggestPlacesBodySchema, + request.body, + ) + const suggestions = await suggestAddresses(q, { + ...(proximity ? { proximity } : {}), + ...(proximityZoom != null ? { proximityZoom } : {}), + ...(limit != null ? { limit } : {}), + ...(language ? { language } : {}), + ...(container.env.MAPBOX_TOKEN ? { mapboxToken: container.env.MAPBOX_TOKEN } : {}), + }) + const payload: SuggestPlacesResponse = { suggestions } + reply.status(200).send(payload) + }) route( app, diff --git a/services/api/src/routes/media.routes.ts b/services/api/src/routes/media.routes.ts index 27fa489a..715ec19e 100644 --- a/services/api/src/routes/media.routes.ts +++ b/services/api/src/routes/media.routes.ts @@ -125,8 +125,7 @@ export async function registerMediaRoutes( } function service(): MediaIntakeService { - const repo: MediaRepository = - app.mediaRepo ?? makeDrizzleMediaRepository(container.getDb().db) + 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. const authorizer = @@ -151,7 +150,10 @@ export async function registerMediaRoutes( { config: { rateLimit: MEDIA_WRITE_RATE_LIMIT } }, async (request, reply) => { const body = parse(CreateMediaUploadRequestSchema, request.body) - const payload: CreateMediaUploadResponse = await service().createUpload(body, ownerOf(request)) + const payload: CreateMediaUploadResponse = await service().createUpload( + body, + ownerOf(request), + ) reply.status(200).send(payload) }, ) diff --git a/services/api/src/routes/messages.routes.ts b/services/api/src/routes/messages.routes.ts index 652cfa04..56f5062b 100644 --- a/services/api/src/routes/messages.routes.ts +++ b/services/api/src/routes/messages.routes.ts @@ -1,4 +1,3 @@ - import { AppError, ClosePollRequestSchema, diff --git a/services/api/src/routes/notifications.routes.ts b/services/api/src/routes/notifications.routes.ts index d3527c52..25ea2672 100644 --- a/services/api/src/routes/notifications.routes.ts +++ b/services/api/src/routes/notifications.routes.ts @@ -1,4 +1,3 @@ - import { PaginationQuerySchema, MarkReadRequestSchema, @@ -95,7 +94,10 @@ export async function registerNotificationRoutes( async (request, reply) => { const userId = requireAuth(request) const pagination = parse(PaginationQuerySchema, request.query) - const payload: ListNotificationsResponse = await service().listNotifications(userId, pagination) + const payload: ListNotificationsResponse = await service().listNotifications( + userId, + pagination, + ) reply.status(200).send(payload) }, ) @@ -129,7 +131,10 @@ export async function registerNotificationRoutes( const body = parse(RegisterPushTokenRequestSchema, request.body) const deviceId = normalizeDeviceId(body.deviceId) if (body.deviceId !== undefined && deviceId === undefined) { - request.log.warn({ userId }, "registerPush: malformed deviceId dropped (device-claim skipped)") + request.log.warn( + { userId }, + "registerPush: malformed deviceId dropped (device-claim skipped)", + ) } const { deviceId: _raw, ...rest } = body const payload: RegisterPushTokenResponse = await service().registerPushToken(userId, { diff --git a/services/api/src/routes/posts.routes.ts b/services/api/src/routes/posts.routes.ts index 51bec376..06a81af7 100644 --- a/services/api/src/routes/posts.routes.ts +++ b/services/api/src/routes/posts.routes.ts @@ -1,4 +1,3 @@ - import { FeedCountsRequestSchema, HomeFeedQuerySchema, @@ -28,7 +27,10 @@ export const CREATE_POST_RATE_LIMIT = perIdentity({ max: 12, timeWindow: "1 minu export const POST_INTERACTION_RATE_LIMIT = perIdentity({ max: 60, timeWindow: "1 minute" }) -export async function registerPostRoutes(app: FastifyInstance, container: Container): Promise { +export async function registerPostRoutes( + app: FastifyInstance, + container: Container, +): Promise { const csrfProtect = container.csrf.protect const service = () => container.getPostService() @@ -63,41 +65,71 @@ export async function registerPostRoutes(app: FastifyInstance, container: Contai reply.status(200).send(await service().listReplies(id, userId, pagination)) }) - route(app, "repostPost", { preHandler: csrfProtect, config: { rateLimit: POST_INTERACTION_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(PostIdParamsSchema, request.params) - reply.status(200).send(await service().repostPost(id, userId)) - }) + route( + app, + "repostPost", + { preHandler: csrfProtect, config: { rateLimit: POST_INTERACTION_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(PostIdParamsSchema, request.params) + reply.status(200).send(await service().repostPost(id, userId)) + }, + ) - route(app, "unrepostPost", { preHandler: csrfProtect, config: { rateLimit: POST_INTERACTION_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(PostIdParamsSchema, request.params) - reply.status(200).send(await service().unrepostPost(id, userId)) - }) + route( + app, + "unrepostPost", + { preHandler: csrfProtect, config: { rateLimit: POST_INTERACTION_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(PostIdParamsSchema, request.params) + reply.status(200).send(await service().unrepostPost(id, userId)) + }, + ) - route(app, "likePost", { preHandler: csrfProtect, config: { rateLimit: POST_INTERACTION_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(PostIdParamsSchema, request.params) - reply.status(200).send(await service().likePost(id, userId)) - }) + route( + app, + "likePost", + { preHandler: csrfProtect, config: { rateLimit: POST_INTERACTION_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(PostIdParamsSchema, request.params) + reply.status(200).send(await service().likePost(id, userId)) + }, + ) - route(app, "unlikePost", { preHandler: csrfProtect, config: { rateLimit: POST_INTERACTION_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(PostIdParamsSchema, request.params) - reply.status(200).send(await service().unlikePost(id, userId)) - }) + route( + app, + "unlikePost", + { preHandler: csrfProtect, config: { rateLimit: POST_INTERACTION_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(PostIdParamsSchema, request.params) + reply.status(200).send(await service().unlikePost(id, userId)) + }, + ) - route(app, "savePost", { preHandler: csrfProtect, config: { rateLimit: POST_INTERACTION_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(PostIdParamsSchema, request.params) - reply.status(200).send(await service().savePost(id, userId)) - }) + route( + app, + "savePost", + { preHandler: csrfProtect, config: { rateLimit: POST_INTERACTION_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(PostIdParamsSchema, request.params) + reply.status(200).send(await service().savePost(id, userId)) + }, + ) - route(app, "unsavePost", { preHandler: csrfProtect, config: { rateLimit: POST_INTERACTION_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(PostIdParamsSchema, request.params) - reply.status(200).send(await service().unsavePost(id, userId)) - }) + route( + app, + "unsavePost", + { preHandler: csrfProtect, config: { rateLimit: POST_INTERACTION_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(PostIdParamsSchema, request.params) + reply.status(200).send(await service().unsavePost(id, userId)) + }, + ) const viewerLocationOf = (request: FastifyRequest): FeedViewerLocation => { const env = container.env @@ -108,18 +140,23 @@ export async function registerPostRoutes(app: FastifyInstance, container: Contai return { lat: approximate.lat, lng: approximate.lng } } - route(app, "homeFeed", { config: { rateLimit: HOME_FEED_RATE_LIMIT } }, async (request, reply) => { - const userId = request.auth.userId - const query = parse(HomeFeedQuerySchema, request.query) - const location = viewerLocationOf(request) - reply - .status(200) - .send( - userId - ? await service().homeFeed(userId, query, location) - : await service().publicFeed(query, location), - ) - }) + route( + app, + "homeFeed", + { config: { rateLimit: HOME_FEED_RATE_LIMIT } }, + async (request, reply) => { + const userId = request.auth.userId + const query = parse(HomeFeedQuerySchema, request.query) + const location = viewerLocationOf(request) + reply + .status(200) + .send( + userId + ? await service().homeFeed(userId, query, location) + : await service().publicFeed(query, location), + ) + }, + ) route( app, diff --git a/services/api/src/routes/report-content.routes.ts b/services/api/src/routes/report-content.routes.ts index 3b4db5f5..90d79ff7 100644 --- a/services/api/src/routes/report-content.routes.ts +++ b/services/api/src/routes/report-content.routes.ts @@ -1,4 +1,3 @@ - import { ReportContentRequestSchema, type ReportContentResponse } from "@civfix/shared" import type { FastifyInstance } from "fastify" import type { Container } from "../di.js" @@ -19,7 +18,11 @@ import { type ContentSubjectGate, } from "../services/content-report-subject.js" -export const REPORT_CONTENT_RATE_LIMIT = perIdentity({ max: 20, timeWindow: "1 minute", hostMax: 60 }) +export const REPORT_CONTENT_RATE_LIMIT = perIdentity({ + max: 20, + timeWindow: "1 minute", + hostMax: 60, +}) declare module "fastify" { interface FastifyInstance { diff --git a/services/api/src/routes/reports.routes.ts b/services/api/src/routes/reports.routes.ts index 354a3ba7..8b01bdd1 100644 --- a/services/api/src/routes/reports.routes.ts +++ b/services/api/src/routes/reports.routes.ts @@ -1,4 +1,3 @@ - import { CreateReportRequestSchema, ListReportsInBBoxRequestSchema, @@ -22,10 +21,7 @@ import type { FastifyInstance, FastifyRequest } from "fastify" import type { Container } from "../di.js" import type { Sql } from "../db/client.js" import { requireAuth } from "../auth/context.js" -import { - makeCachedAddressResolver, - makeGeoidResolver, -} from "../services/route-geo-helpers.js" +import { makeCachedAddressResolver, makeGeoidResolver } from "../services/route-geo-helpers.js" import { makeMediaPresigner, makePrivateMediaPresigner } from "../services/media-presign.js" import { perIdentity } from "../plugins/rate-limit.js" import { @@ -46,7 +42,11 @@ import { route } from "../versioning/route.js" import { parse, trimTextFields } from "./_validate.js" import { CappedBBoxQueryParam, CategoriesQueryParam, TypesQueryParam } from "./query-encoding.js" -export const CREATE_REPORT_RATE_LIMIT = perIdentity({ max: 20, timeWindow: "1 minute", hostMax: 60 }) +export const CREATE_REPORT_RATE_LIMIT = perIdentity({ + max: 20, + timeWindow: "1 minute", + hostMax: 60, +}) const MAP_REPORTS_RATE_LIMIT = { max: 60, timeWindow: "1 minute" } as const const SEARCH_REPORTS_RATE_LIMIT = { max: 30, timeWindow: "1 minute" } as const @@ -317,23 +317,36 @@ export async function registerReportRoutes( reply.status(200).send(payload) }) - route(app, "mapReports", { schema: { response: { 200: MapReportsResponseJsonSchema } }, config: { rateLimit: MAP_REPORTS_RATE_LIMIT } }, async (request, reply) => { - const validated = parse(MapReportsQuerySchema, request.query) - const payload: ReportClusterResponse = await service().listReportsInBBox( - validated.bbox, - validated.categories ?? null, - validated.types ?? null, - validated.zoom, - ) - reply.header("Cache-Control", "public, max-age=60") - reply.status(200).send(payload) - }) + route( + app, + "mapReports", + { + schema: { response: { 200: MapReportsResponseJsonSchema } }, + config: { rateLimit: MAP_REPORTS_RATE_LIMIT }, + }, + async (request, reply) => { + const validated = parse(MapReportsQuerySchema, request.query) + const payload: ReportClusterResponse = await service().listReportsInBBox( + validated.bbox, + validated.categories ?? null, + validated.types ?? null, + validated.zoom, + ) + reply.header("Cache-Control", "public, max-age=60") + reply.status(200).send(payload) + }, + ) - route(app, "searchReports", { config: { rateLimit: SEARCH_REPORTS_RATE_LIMIT } }, async (request, reply) => { - const validated = parse(SearchReportsQuerySchema, request.query) - const payload: ListReportsSearchResponse = await service().searchReports(validated) - reply.status(200).send(payload) - }) + route( + app, + "searchReports", + { config: { rateLimit: SEARCH_REPORTS_RATE_LIMIT } }, + async (request, reply) => { + const validated = parse(SearchReportsQuerySchema, request.query) + const payload: ListReportsSearchResponse = await service().searchReports(validated) + reply.status(200).send(payload) + }, + ) route(app, "resolveReport", { preHandler: csrfProtect }, async (request, reply) => { const userId = requireAuth(request) @@ -359,7 +372,10 @@ async function isReportVerified(sql: Sql, userId: string): Promise { return rows[0]?.report_verified ?? false } -function ownerOf(request: FastifyRequest): { userId?: string | undefined; anonSessionId?: string | undefined } { +function ownerOf(request: FastifyRequest): { + userId?: string | undefined + anonSessionId?: string | undefined +} { const auth = request.auth return { userId: auth?.userId ?? undefined, diff --git a/services/api/src/routes/service-hours-certificates.routes.ts b/services/api/src/routes/service-hours-certificates.routes.ts index 9be7a964..01043b7a 100644 --- a/services/api/src/routes/service-hours-certificates.routes.ts +++ b/services/api/src/routes/service-hours-certificates.routes.ts @@ -64,7 +64,11 @@ const CodeParamsSchema = z.object({ code: z.string().min(1).max(32) }).strict() * 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({ max: 6, timeWindow: "1 hour", hostMax: 6 }) +export const CERTIFICATE_ISSUE_RATE_LIMIT = perIdentity({ + max: 6, + timeWindow: "1 hour", + hostMax: 6, +}) export const CERTIFICATE_REVOKE_RATE_LIMIT = perIdentity({ max: 20, timeWindow: "1 hour" }) export const CERTIFICATE_VERIFY_RATE_LIMIT = perHost({ max: 60, timeWindow: "1 minute" }) diff --git a/services/api/src/routes/social.routes.ts b/services/api/src/routes/social.routes.ts index 1655169a..752902e2 100644 --- a/services/api/src/routes/social.routes.ts +++ b/services/api/src/routes/social.routes.ts @@ -1,4 +1,3 @@ - import { ListPeopleRequestSchema, FollowSuggestionsRequestSchema, @@ -93,9 +92,7 @@ export async function registerSocialRoutes( ? undefined : (viewerId: string, targetId: string) => container.getBlocksRepo().blockState(viewerId, targetId) - const affiliations = app.socialOverrides - ? undefined - : container.getAffiliationLoader() + const affiliations = app.socialOverrides ? undefined : container.getAffiliationLoader() return makeSocialService({ repo: repo(), logger: app.log, @@ -136,29 +133,44 @@ export async function registerSocialRoutes( reply.status(200).send(payload) }) - route(app, "followSuggestions", { config: { rateLimit: FOLLOW_SUGGESTIONS_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const q = parse(FollowSuggestionsRequestSchema, request.query) - const payload: FollowSuggestionsResponse = await service().followSuggestions( - userId, - q.limit ?? FOLLOW_SUGGESTIONS_DEFAULT_LIMIT, - ) - reply.status(200).send(payload) - }) - - route(app, "followPerson", { preHandler: csrfProtect, config: { rateLimit: FOLLOW_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(PersonIdParamsSchema, request.params) - const payload: FollowPersonResponse = await service(true).followPerson(userId, id) - reply.status(200).send(payload) - }) - - route(app, "unfollowPerson", { preHandler: csrfProtect, config: { rateLimit: FOLLOW_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(PersonIdParamsSchema, request.params) - const payload: FollowPersonResponse = await service().unfollowPerson(userId, id) - reply.status(200).send(payload) - }) + route( + app, + "followSuggestions", + { config: { rateLimit: FOLLOW_SUGGESTIONS_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const q = parse(FollowSuggestionsRequestSchema, request.query) + const payload: FollowSuggestionsResponse = await service().followSuggestions( + userId, + q.limit ?? FOLLOW_SUGGESTIONS_DEFAULT_LIMIT, + ) + reply.status(200).send(payload) + }, + ) + + route( + app, + "followPerson", + { preHandler: csrfProtect, config: { rateLimit: FOLLOW_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(PersonIdParamsSchema, request.params) + const payload: FollowPersonResponse = await service(true).followPerson(userId, id) + reply.status(200).send(payload) + }, + ) + + route( + app, + "unfollowPerson", + { preHandler: csrfProtect, config: { rateLimit: FOLLOW_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(PersonIdParamsSchema, request.params) + const payload: FollowPersonResponse = await service().unfollowPerson(userId, id) + reply.status(200).send(payload) + }, + ) route(app, "getProfile", async (request, reply) => { const { id } = parse(PersonRefParamsSchema, request.params) diff --git a/services/api/src/routes/users.routes.ts b/services/api/src/routes/users.routes.ts index 2b517cbc..e2ed0e09 100644 --- a/services/api/src/routes/users.routes.ts +++ b/services/api/src/routes/users.routes.ts @@ -1,4 +1,3 @@ - import { SearchUsersRequestSchema, MentionSearchRequestSchema, @@ -53,7 +52,10 @@ export const BLOCK_RATE_LIMIT = { max: 60, timeWindow: "1 minute" } as const export const UNBLOCKABLE_MESSAGE = "User not found" -export async function registerUsersRoutes(app: FastifyInstance, container: Container): Promise { +export async function registerUsersRoutes( + app: FastifyInstance, + container: Container, +): Promise { const csrfProtect = container.csrf.protect function blocksRepo(): BlocksRepository { @@ -95,26 +97,36 @@ export async function registerUsersRoutes(app: FastifyInstance, container: Conta }, ) - route(app, "blockUser", { preHandler: csrfProtect, config: { rateLimit: BLOCK_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(UserIdParamsSchema, request.params) - if (id === userId) throw AppError.validation({ id: "You cannot block yourself." }) - const blocks = blocksRepo() - await assertUserBlockable(app, blocks, userId, id) - await blocks.block(userId, id) - await dropContainerSuggestions(container, [userId, id], request.log) - const payload: BlockUserResponse = { blocked: true } - reply.status(200).send(payload) - }) + route( + app, + "blockUser", + { preHandler: csrfProtect, config: { rateLimit: BLOCK_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(UserIdParamsSchema, request.params) + if (id === userId) throw AppError.validation({ id: "You cannot block yourself." }) + const blocks = blocksRepo() + await assertUserBlockable(app, blocks, userId, id) + await blocks.block(userId, id) + await dropContainerSuggestions(container, [userId, id], request.log) + const payload: BlockUserResponse = { blocked: true } + reply.status(200).send(payload) + }, + ) - route(app, "unblockUser", { preHandler: csrfProtect, config: { rateLimit: BLOCK_RATE_LIMIT } }, async (request, reply) => { - const userId = requireAuth(request) - const { id } = parse(UserIdParamsSchema, request.params) - await blocksRepo().unblock(userId, id) - await dropContainerSuggestions(container, [userId, id], request.log) - const payload: BlockUserResponse = { blocked: false } - reply.status(200).send(payload) - }) + route( + app, + "unblockUser", + { preHandler: csrfProtect, config: { rateLimit: BLOCK_RATE_LIMIT } }, + async (request, reply) => { + const userId = requireAuth(request) + const { id } = parse(UserIdParamsSchema, request.params) + await blocksRepo().unblock(userId, id) + await dropContainerSuggestions(container, [userId, id], request.log) + const payload: BlockUserResponse = { blocked: false } + reply.status(200).send(payload) + }, + ) route( app, @@ -162,64 +174,71 @@ export async function registerUsersRoutes(app: FastifyInstance, container: Conta reply.status(200).send(payload) }) - route(app, "deleteAccount", { preHandler: csrfProtect, config: { allowSuspended: true } }, async (request, reply) => { - const userId = requireAuth(request) - const store = app.authServices?.users - const sessions = app.authServices?.sessions - const otp = app.authServices?.otp - const oauth = app.authServices?.oauth - if (!store || !sessions || !otp || !oauth) { - throw AppError.unauthorized("Authentication required.") - } + route( + app, + "deleteAccount", + { preHandler: csrfProtect, config: { allowSuspended: true } }, + async (request, reply) => { + const userId = requireAuth(request) + const store = app.authServices?.users + const sessions = app.authServices?.sessions + const otp = app.authServices?.otp + const oauth = app.authServices?.oauth + if (!store || !sessions || !otp || !oauth) { + throw AppError.unauthorized("Authentication required.") + } - const { emailOtp } = parse(DeleteAccountRequestSchema, request.body) - const me = await store.findById(userId) - const email = me?.email ?? null - if (email) { - const verifiedUserId = await otp.verifyOtp(email, emailOtp, request.ip || null) - if (verifiedUserId !== userId) { - throw AppError.unauthorized("That code could not be verified for this account.") + const { emailOtp } = parse(DeleteAccountRequestSchema, request.body) + const me = await store.findById(userId) + const email = me?.email ?? null + if (email) { + const verifiedUserId = await otp.verifyOtp(email, emailOtp, request.ip || null) + if (verifiedUserId !== userId) { + throw AppError.unauthorized("That code could not be verified for this account.") + } } - } - await store.softDeleteAndAnonymize(userId) - await sessions.banUser(userId) + await store.softDeleteAndAnonymize(userId) + await sessions.banUser(userId) - clearSessionCookie(reply) - clearCsrfCookie(reply) - const cleanups: ReadonlyArray Promise]> = [ - ["oauth.unlink", () => oauth.unlinkAllForUser(userId)], - [ - "push-tokens.delete", - () => - makeDrizzleNotificationRepository(container.getDb().sql).deletePushTokensForUser(userId), - ], - [ - "notifications.delete", - () => container.getDb().sql`DELETE FROM notifications WHERE user_id = ${userId}`, - ], - [ - "audit.account-deleted", - () => - writeAudit(container.getDb().sql, { - actorId: userId, - action: "account.deleted", - target: `user:${userId}`, - }), - ], - ] - const outcomes = await Promise.allSettled(cleanups.map(async ([, run]) => run())) - outcomes.forEach((outcome, i) => { - if (outcome.status === "rejected") { - request.log.error( - { err: outcome.reason, userId, step: cleanups[i]![0] }, - "account deletion: post-revocation cleanup step failed (the account IS deleted and every session revoked)", - ) - } - }) - const payload: DeleteAccountResponse = { ok: true } - reply.status(200).send(payload) - }) + clearSessionCookie(reply) + clearCsrfCookie(reply) + const cleanups: ReadonlyArray Promise]> = [ + ["oauth.unlink", () => oauth.unlinkAllForUser(userId)], + [ + "push-tokens.delete", + () => + makeDrizzleNotificationRepository(container.getDb().sql).deletePushTokensForUser( + userId, + ), + ], + [ + "notifications.delete", + () => container.getDb().sql`DELETE FROM notifications WHERE user_id = ${userId}`, + ], + [ + "audit.account-deleted", + () => + writeAudit(container.getDb().sql, { + actorId: userId, + action: "account.deleted", + target: `user:${userId}`, + }), + ], + ] + const outcomes = await Promise.allSettled(cleanups.map(async ([, run]) => run())) + outcomes.forEach((outcome, i) => { + if (outcome.status === "rejected") { + request.log.error( + { err: outcome.reason, userId, step: cleanups[i]![0] }, + "account deletion: post-revocation cleanup step failed (the account IS deleted and every session revoked)", + ) + } + }) + const payload: DeleteAccountResponse = { ok: true } + reply.status(200).send(payload) + }, + ) route( app, diff --git a/services/api/src/routes/volunteer-hours.routes.ts b/services/api/src/routes/volunteer-hours.routes.ts index 74cdfe66..08140ca4 100644 --- a/services/api/src/routes/volunteer-hours.routes.ts +++ b/services/api/src/routes/volunteer-hours.routes.ts @@ -61,7 +61,11 @@ const LOG_EVENT_HOURS_RATE_LIMIT = { max: 10, timeWindow: "1 minute" } as const function appendVary(reply: FastifyReply, ...fields: readonly string[]): void { const existing = reply.getHeader("Vary") - const raw = Array.isArray(existing) ? existing.join(",") : typeof existing === "string" ? existing : "" + const raw = Array.isArray(existing) + ? existing.join(",") + : typeof existing === "string" + ? existing + : "" const current = raw .split(",") .map((s) => s.trim()) @@ -173,7 +177,10 @@ export async function registerVolunteerHoursRoutes( route(app, "getMyHoursEntries", async (request, reply) => { const userId = requireAuth(request) const query = parse(MyVolunteerHoursEntriesQuerySchema, request.query ?? {}) - const payload: MyVolunteerHoursEntriesResponse = await service().getMyHoursEntries(userId, query) + const payload: MyVolunteerHoursEntriesResponse = await service().getMyHoursEntries( + userId, + query, + ) reply.status(200).send(payload) }) diff --git a/services/api/src/routes/webhooks/inbound-mail.routes.ts b/services/api/src/routes/webhooks/inbound-mail.routes.ts index 372005e5..4076d169 100644 --- a/services/api/src/routes/webhooks/inbound-mail.routes.ts +++ b/services/api/src/routes/webhooks/inbound-mail.routes.ts @@ -1,4 +1,3 @@ - import { createHmac } from "node:crypto" import { AppError } from "@civfix/shared" import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify" @@ -44,7 +43,11 @@ export async function registerInboundMailWebhook( scope.addContentTypeParser( "application/json", { parseAs: "buffer" }, - (_req: FastifyRequest, payload: Buffer, done: (err: Error | null, body?: unknown) => void) => { + ( + _req: FastifyRequest, + payload: Buffer, + done: (err: Error | null, body?: unknown) => void, + ) => { done(null, payload) }, ) diff --git a/services/api/src/server.ts b/services/api/src/server.ts index 0e0be51a..e256b7b6 100644 --- a/services/api/src/server.ts +++ b/services/api/src/server.ts @@ -30,7 +30,10 @@ import type { ModerationRouteOverrides } from "./routes/admin/moderation.routes. import type { OrganizationOverrides } from "./routes/host/orgs.routes.js" import type { HostTeamOverrides } from "./routes/host/team.routes.js" import type { HostPortfolioOverrides } from "./routes/host/portfolio.routes.js" -import type { HostRegistrationOverrides, HostPageOverrides } from "./services/host/registration-wiring.js" +import type { + HostRegistrationOverrides, + HostPageOverrides, +} from "./services/host/registration-wiring.js" import type { BroadcastOverrides } from "./routes/host/broadcasts.routes.js" import type { HostAnalyticsOverrides } from "./routes/host/analytics.routes.js" import type { HostExportOverrides } from "./routes/host/exports.routes.js" diff --git a/services/api/src/services/admin/activity-repository.memory.ts b/services/api/src/services/admin/activity-repository.memory.ts index 00cbe79e..c6caa161 100644 --- a/services/api/src/services/admin/activity-repository.memory.ts +++ b/services/api/src/services/admin/activity-repository.memory.ts @@ -25,7 +25,13 @@ 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, record.where, record.action ?? "", record.eventType ?? "", record.subject ?? ""] + return [ + record.who, + record.where, + record.action ?? "", + record.eventType ?? "", + record.subject ?? "", + ] .join(" ") .toLowerCase() } diff --git a/services/api/src/services/admin/activity-service.ts b/services/api/src/services/admin/activity-service.ts index 7e06ef85..a332c5b4 100644 --- a/services/api/src/services/admin/activity-service.ts +++ b/services/api/src/services/admin/activity-service.ts @@ -10,7 +10,12 @@ */ import { relativeAgo, ActivityKindSchema } from "@civfix/shared" -import type { ActivityItemDTO, ActivityKind, ActivityListQuery, ActivityListResponse } from "@civfix/shared" +import type { + ActivityItemDTO, + ActivityKind, + ActivityListQuery, + ActivityListResponse, +} from "@civfix/shared" import { clampLimit } from "./pagination.js" export type ActivitySource = "audit" | "report" | "cleanup" | "mail_event" diff --git a/services/api/src/services/admin/admin-event-repository.drizzle.ts b/services/api/src/services/admin/admin-event-repository.drizzle.ts index 6c31f235..afe5ccc7 100644 --- a/services/api/src/services/admin/admin-event-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-event-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Sql } from "../../db/client.js" import { decodeCursor, clampLimit, paginate } from "./pagination.js" import { writeAudit } from "./audit.js" @@ -137,10 +136,7 @@ export function makeDrizzleAdminEventRepository(sql: Sql): AdminEventRepository })) }, - async setBags( - id: string, - input: { bags: number; actorId: string | null }, - ): Promise { + async setBags(id: string, input: { bags: number; actorId: string | null }): Promise { return sql.begin(async (tx) => { const updated = await tx<{ id: string }[]>` UPDATE cleanups SET bags = ${input.bags} WHERE id = ${id} RETURNING id 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 c7a6a3a5..239e1712 100644 --- a/services/api/src/services/admin/admin-event-repository.memory.ts +++ b/services/api/src/services/admin/admin-event-repository.memory.ts @@ -43,7 +43,10 @@ function seededWindow( endsAt: Date | undefined, ): { scheduledAt: Date; endsAt: Date } { const start = scheduledAt ?? new Date(Date.now() + defaultStartOffsetMs(status)) - return { scheduledAt: start, endsAt: endsAt ?? new Date(start.getTime() + DEFAULT_EVENT_DURATION_MS) } + return { + scheduledAt: start, + endsAt: endsAt ?? new Date(start.getTime() + DEFAULT_EVENT_DURATION_MS), + } } function defaultStartOffsetMs(status: string): number { @@ -213,7 +216,10 @@ export class InMemoryAdminEventRepository implements AdminEventRepository { */ private reportVisible(r: SeededAdminReport | undefined): r is SeededAdminReport { return ( - r !== undefined && !r.deleted && isPubliclyVisibleStatus(r.status) && r.visibility === "public" + r !== undefined && + !r.deleted && + isPubliclyVisibleStatus(r.status) && + r.visibility === "public" ) } 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 a64fb01b..5791a7bf 100644 --- a/services/api/src/services/admin/admin-report-chat-service.ts +++ b/services/api/src/services/admin/admin-report-chat-service.ts @@ -5,10 +5,7 @@ import { type ReportChatHistoryRequest, } from "@civfix/shared" import { chatHistoryPayload, type ChatHistorySource } from "../../routes/chat-route-helpers.js" -import { - sendReportChatMessage, - type ReportChatSendDeps, -} from "../report-chat-send.js" +import { sendReportChatMessage, type ReportChatSendDeps } from "../report-chat-send.js" import type { AdminReportChatRepository } from "./admin-report-chat-repository.drizzle.js" export const ADMIN_REPORT_CHAT_HISTORY_DEFAULT = 30 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 a72e9414..8e55d844 100644 --- a/services/api/src/services/admin/admin-report-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-report-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Queryable, Sql } from "../../db/client.js" import { decodeCursor, clampLimit, paginate } from "./pagination.js" import { isUuid } from "../../db/cursor-helpers.js" @@ -658,10 +657,7 @@ export function makeDrizzleAdminReportRepository(sql: Sql): AdminReportRepositor } } -export function mapOutreachStatus( - threadStatus: string, - hasInbound: boolean, -): ReportOutreachStatus { +export function mapOutreachStatus(threadStatus: string, hasInbound: boolean): ReportOutreachStatus { if (threadStatus === "bounced") return "bounced" if (hasInbound || threadStatus === "replied") return "replied" if (threadStatus === "delivered" || threadStatus === "opened") return "delivered" diff --git a/services/api/src/services/admin/admin-report-repository.memory.ts b/services/api/src/services/admin/admin-report-repository.memory.ts index 6858d4d6..99050442 100644 --- a/services/api/src/services/admin/admin-report-repository.memory.ts +++ b/services/api/src/services/admin/admin-report-repository.memory.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { isUuid } from "../../db/cursor-helpers.js" import { pageInMemoryById } from "./pagination.js" @@ -412,7 +411,10 @@ export class InMemoryAdminReportRepository implements AdminReportRepository { s.record.verificationVerdict === "approved" && s.deletedAt === null, ).length - if (approvedCount >= REPORT_VERIFIED_THRESHOLD && this.reporterReportVerified.get(reporterId) !== true) { + if ( + approvedCount >= REPORT_VERIFIED_THRESHOLD && + this.reporterReportVerified.get(reporterId) !== true + ) { this.reporterReportVerified.set(reporterId, true) } return true diff --git a/services/api/src/services/admin/admin-report-service.ts b/services/api/src/services/admin/admin-report-service.ts index 9bb79dd3..dd0a40bf 100644 --- a/services/api/src/services/admin/admin-report-service.ts +++ b/services/api/src/services/admin/admin-report-service.ts @@ -1,4 +1,3 @@ - import { ADMIN_REPORT_STATUS_LABELS, AppError, @@ -68,7 +67,11 @@ 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." export function isAlreadyRoutedConflict(err: unknown): boolean { - return err instanceof AppError && err.code === ErrorCode.CONFLICT && err.message === ALREADY_ROUTED_CONFLICT + return ( + err instanceof AppError && + err.code === ErrorCode.CONFLICT && + err.message === ALREADY_ROUTED_CONFLICT + ) } function firstTemplate(...candidates: (string | null | undefined)[]): string | null { @@ -261,7 +264,12 @@ export function makeAdminReportService(deps: AdminReportServiceDeps): AdminRepor ) } await notifyReporterOfStatus(deps, id, record.reporter?.id ?? null, input.status) - await emitTimeline({ reportId: id, status: input.status, kind: timelineKindForStatus(input.status), note }) + await emitTimeline({ + reportId: id, + status: input.status, + kind: timelineKindForStatus(input.status), + note, + }) }, async flag( @@ -296,7 +304,12 @@ export function makeAdminReportService(deps: AdminReportServiceDeps): AdminRepor : statusChangeNote("rejected") const ok = await deps.repo.remove(id, { note, actorId: input.actorId }) if (!ok) throw AppError.notFound("Report not found") - await emitTimeline({ reportId: id, status: "rejected", kind: timelineKindForStatus("rejected"), note }) + await emitTimeline({ + reportId: id, + status: "rejected", + kind: timelineKindForStatus("rejected"), + note, + }) }, async sendFollowup( @@ -453,7 +466,9 @@ export function makeAdminReportService(deps: AdminReportServiceDeps): AdminRepor await deps.repo.appendSystemTimeline(id, { note: routeNote, kind: "route" }) } }) - const current = advanced ? "acknowledged" : ((await deps.repo.getReport(id))?.status ?? record.status) + const current = advanced + ? "acknowledged" + : ((await deps.repo.getReport(id))?.status ?? record.status) await emitTimeline({ reportId: id, status: current, kind: "route", note: routeNote }) } diff --git a/services/api/src/services/admin/admin-report-types.ts b/services/api/src/services/admin/admin-report-types.ts index 57b7538f..c13fec7f 100644 --- a/services/api/src/services/admin/admin-report-types.ts +++ b/services/api/src/services/admin/admin-report-types.ts @@ -168,9 +168,7 @@ export interface AdminReportServiceDeps { thumbKey: string | null, ) => Promise<{ url: string; thumbUrl?: string }> presignPacketMedia?: PresignPacketMedia - loadLinkedEventsForReports?: ( - reportIds: string[], - ) => Promise> + loadLinkedEventsForReports?: (reportIds: string[]) => Promise> loadMediaBytes?: (r2Key: string) => Promise now?: () => Date reportChatEmitter?: ReportChatSystemEmitter diff --git a/services/api/src/services/admin/admin-user-repository.drizzle.ts b/services/api/src/services/admin/admin-user-repository.drizzle.ts index 6ff30f73..9f00f575 100644 --- a/services/api/src/services/admin/admin-user-repository.drizzle.ts +++ b/services/api/src/services/admin/admin-user-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Queryable, Sql } from "../../db/client.js" import { writeAudit } from "./audit.js" import { clampLimit, decodeCursor } from "./pagination.js" @@ -172,7 +171,12 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { const extraWhere = andAll(sql, conds) const orderLimit = sql`ORDER BY u.created_at DESC, u.id DESC LIMIT ${limit + 1}` - const rows = (await userSelect(sql, extraWhere, orderLimit, false)) as unknown as UserRowSelect[] + const rows = (await userSelect( + sql, + extraWhere, + orderLimit, + false, + )) as unknown as UserRowSelect[] const { items, nextCursor } = paginate(rows, limit, (r) => ({ createdAt: r.created_at ?? EPOCH, id: r.id, @@ -529,7 +533,9 @@ export function makeDrizzleAdminUserRepository(sql: Sql): AdminUserRepository { }, async listUserOrganizations(id: string): Promise { - const rows = await sql<{ id: string; slug: string; name: string; role: OrganizationMemberRole }[]>` + const rows = await sql< + { id: string; slug: string; name: string; role: OrganizationMemberRole }[] + >` SELECT o.id, o.slug, o.name, m.role FROM organization_members m JOIN organizations o ON o.id = m.organization_id diff --git a/services/api/src/services/admin/admin-user-service.ts b/services/api/src/services/admin/admin-user-service.ts index 3522d7bd..20a75374 100644 --- a/services/api/src/services/admin/admin-user-service.ts +++ b/services/api/src/services/admin/admin-user-service.ts @@ -1,4 +1,3 @@ - import { AppError, avatarGradient } from "@civfix/shared" import type { AdminUserCounts, @@ -123,10 +122,7 @@ export interface AdminUserRepository { ): Promise applyRole(id: string, input: { role: Role; actorId: string | null }): Promise listUserOrganizations(id: string): Promise - setReportVerified( - id: string, - input: { value: boolean; actorId: string | null }, - ): Promise + setReportVerified(id: string, input: { value: boolean; actorId: string | null }): Promise removeUserMessage( userId: string, messageId: string, @@ -336,7 +332,6 @@ export function makeAdminUserService(deps: AdminUserServiceDeps): AdminUserServi }, async setRole(id: string, input: { role: Role; actorId: string | null }): Promise { - if (!GRANTABLE_ROLES.has(input.role)) { throw AppError.forbidden( "Operator access is granted only through ADMIN_EMAILS and Cloudflare Access, not this endpoint.", diff --git a/services/api/src/services/admin/analytics-repository.drizzle.ts b/services/api/src/services/admin/analytics-repository.drizzle.ts index b9925877..fd0d6e0d 100644 --- a/services/api/src/services/admin/analytics-repository.drizzle.ts +++ b/services/api/src/services/admin/analytics-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Sql } from "../../db/client.js" import type { AnalyticsRepository, diff --git a/services/api/src/services/admin/analytics-shaping.ts b/services/api/src/services/admin/analytics-shaping.ts index 9dd661e0..00c8e81f 100644 --- a/services/api/src/services/admin/analytics-shaping.ts +++ b/services/api/src/services/admin/analytics-shaping.ts @@ -147,7 +147,11 @@ export function buildFunnel(counts: FunnelCounts): AnalyticsFunnelResponse { export function buildCoverage(counts: CoverageCounts): AnalyticsCoverageResponse { const total = counts.mapped + counts.needsMapping - return { pct: pct(counts.mapped, total), mapped: counts.mapped, needsMapping: counts.needsMapping } + return { + pct: pct(counts.mapped, total), + mapped: counts.mapped, + needsMapping: counts.needsMapping, + } } /** diff --git a/services/api/src/services/admin/audit-repository.drizzle.ts b/services/api/src/services/admin/audit-repository.drizzle.ts index 818fac80..7b26dfd3 100644 --- a/services/api/src/services/admin/audit-repository.drizzle.ts +++ b/services/api/src/services/admin/audit-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Sql } from "../../db/client.js" import { clampLimit, decodeCursor, paginate } from "./pagination.js" import { isUuid } from "../../db/cursor-helpers.js" @@ -47,9 +46,13 @@ export function makeDrizzleAuditRepository(sql: Sql): AuditRepository { })() : sql`` const actionFilter = - args.action !== null ? sql`AND a.action ILIKE ${likeContains(args.action)} ESCAPE '\\'` : sql`` + args.action !== null + ? sql`AND a.action ILIKE ${likeContains(args.action)} ESCAPE '\\'` + : sql`` const targetFilter = - args.target !== null ? sql`AND a.target ILIKE ${likeContains(args.target)} ESCAPE '\\'` : sql`` + args.target !== null + ? sql`AND a.target ILIKE ${likeContains(args.target)} ESCAPE '\\'` + : sql`` const rows = await sql` SELECT a.id, a.actor_id, u.display_name AS actor_name, a.action, a.target, a.meta, a.created_at diff --git a/services/api/src/services/admin/audit-repository.memory.ts b/services/api/src/services/admin/audit-repository.memory.ts index 421e0ce0..7d065aa6 100644 --- a/services/api/src/services/admin/audit-repository.memory.ts +++ b/services/api/src/services/admin/audit-repository.memory.ts @@ -43,9 +43,7 @@ export class InMemoryAuditRepository implements AuditRepository { return record } - async list( - args: ListAuditArgs, - ): Promise<{ records: AuditRecord[]; nextCursor: string | null }> { + async list(args: ListAuditArgs): Promise<{ records: AuditRecord[]; nextCursor: string | null }> { const anchor = decodeCursor(args.cursor) const actor = args.actor?.toLowerCase() ?? null const action = args.action?.toLowerCase() ?? null @@ -64,8 +62,7 @@ export class InMemoryAuditRepository implements AuditRepository { // 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 + const windowed = anchor !== null ? sorted.filter((r) => beforeAnchor(r, anchor)) : sorted const { items, nextCursor } = paginate(windowed, args.limit, (r) => ({ createdAt: r.createdAt, diff --git a/services/api/src/services/admin/audit.ts b/services/api/src/services/admin/audit.ts index 9b9dc066..eca439a7 100644 --- a/services/api/src/services/admin/audit.ts +++ b/services/api/src/services/admin/audit.ts @@ -1,4 +1,3 @@ - import type { Queryable } from "../../db/client.js" export type AdminAuditAction = diff --git a/services/api/src/services/admin/discovery-repository.drizzle.ts b/services/api/src/services/admin/discovery-repository.drizzle.ts index e742c5c3..bd1929c0 100644 --- a/services/api/src/services/admin/discovery-repository.drizzle.ts +++ b/services/api/src/services/admin/discovery-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { JurisdictionLayer, ReportCategory } from "@civfix/shared" import type { Queryable, Sql } from "../../db/client.js" import { decodeCursor, clampLimit, paginate } from "./pagination.js" @@ -330,7 +329,13 @@ export function makeDrizzleDiscoveryRepository(sql: Sql): DiscoveryRepository { if (!task || task.geoid === null) return false const geoid = task.geoid - await upsertJurisdictionContacts(tx, geoid, input.contacts, input.defaultEmails, input.formUrl) + await upsertJurisdictionContacts( + tx, + geoid, + input.contacts, + input.defaultEmails, + input.formUrl, + ) await writeAudit(tx, { actorId: input.actorId, action: "discovery.draft_saved", @@ -374,8 +379,9 @@ async function loadContacts(sql: Queryable, geoid: string): Promise - r.category !== null && (ADMIN_CATEGORIES as readonly string[]).includes(r.category), + .filter( + (r): r is { category: ReportCategory; email: string | null } => + r.category !== null && (ADMIN_CATEGORIES as readonly string[]).includes(r.category), ) .map((r) => ({ category: r.category, email: r.email })) } diff --git a/services/api/src/services/admin/discovery-repository.memory.ts b/services/api/src/services/admin/discovery-repository.memory.ts index 2b1f192a..9c6e2f7e 100644 --- a/services/api/src/services/admin/discovery-repository.memory.ts +++ b/services/api/src/services/admin/discovery-repository.memory.ts @@ -200,7 +200,11 @@ export class InMemoryDiscoveryRepository implements DiscoveryRepository { id: string, input: { text: string; actorId: string | null; who: string }, ): Promise { - const note: DiscoveryNoteRecord = { text: input.text, who: input.who, createdAt: this.nextDate() } + const note: DiscoveryNoteRecord = { + text: input.text, + who: input.who, + createdAt: this.nextDate(), + } const list = this.notes.get(id) ?? [] list.push(note) this.notes.set(id, list) diff --git a/services/api/src/services/admin/discovery-service.ts b/services/api/src/services/admin/discovery-service.ts index 77ec1390..b171c458 100644 --- a/services/api/src/services/admin/discovery-service.ts +++ b/services/api/src/services/admin/discovery-service.ts @@ -148,7 +148,9 @@ export interface DiscoveryRepository { * the next cursor (null when exhausted). The repo is responsible for the filter + sort semantics so * the service stays a pure projector. */ - listTasks(args: ListDiscoveryArgs): Promise<{ records: DiscoveryTaskRecord[]; nextCursor: string | null }> + 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. */ @@ -164,7 +166,10 @@ export interface DiscoveryRepository { * 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 + 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 @@ -319,7 +324,11 @@ export function makeDiscoveryService(deps: DiscoveryServiceDeps): DiscoveryServi } /** Project a task record (+ its notes) into the list/detail base DTO. */ - function toTaskDTO(record: DiscoveryTaskRecord, notes: DiscoveryNoteRecord[], ref: Date): DiscoveryTaskDTO { + function toTaskDTO( + record: DiscoveryTaskRecord, + notes: DiscoveryNoteRecord[], + ref: Date, + ): DiscoveryTaskDTO { const category = dominantCategory(record.perCategory) return { id: record.id, diff --git a/services/api/src/services/admin/forward-template-service.ts b/services/api/src/services/admin/forward-template-service.ts index df54bede..8df4c3c0 100644 --- a/services/api/src/services/admin/forward-template-service.ts +++ b/services/api/src/services/admin/forward-template-service.ts @@ -7,10 +7,7 @@ import { type SetForwardTemplateDefaultRequest, } from "@civfix/shared" import { buildReportPacket } from "./mail-format.js" -import type { - AdminReportRecord, - AdminReportRoutingRecord, -} from "./admin-report-types.js" +import type { AdminReportRecord, AdminReportRoutingRecord } from "./admin-report-types.js" import type { ForwardTemplateRepository, ForwardTemplateSettingsRecord, diff --git a/services/api/src/services/admin/gov-claims-service.ts b/services/api/src/services/admin/gov-claims-service.ts index 2734545f..8a13955e 100644 --- a/services/api/src/services/admin/gov-claims-service.ts +++ b/services/api/src/services/admin/gov-claims-service.ts @@ -119,9 +119,7 @@ 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 }> + 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 /** @@ -288,10 +286,7 @@ export function makeGovClaimsService(deps: GovClaimsServiceDeps): GovClaimsServi } }, - async approve( - id: string, - input: { actorId: string; note: string | null }, - ): Promise { + async approve(id: string, input: { actorId: string; note: string | null }): Promise { const claim = await deps.repo.getClaim(id) if (!claim) throw AppError.notFound("Gov claim not found") if (claim.status !== "pending") { diff --git a/services/api/src/services/admin/home-repository.drizzle.ts b/services/api/src/services/admin/home-repository.drizzle.ts index bf7a1e75..cafacd28 100644 --- a/services/api/src/services/admin/home-repository.drizzle.ts +++ b/services/api/src/services/admin/home-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Sql } from "../../db/client.js" import { makeDrizzleMailRepository } from "./mail-repository.drizzle.js" import { flaggedReportExpr } from "./admin-report-repository.drizzle.js" diff --git a/services/api/src/services/admin/home-repository.memory.ts b/services/api/src/services/admin/home-repository.memory.ts index 1b9a3732..59c13fdb 100644 --- a/services/api/src/services/admin/home-repository.memory.ts +++ b/services/api/src/services/admin/home-repository.memory.ts @@ -1,4 +1,3 @@ - import type { DiscoverySectionCounts, EventsSectionCounts, diff --git a/services/api/src/services/admin/home-service.ts b/services/api/src/services/admin/home-service.ts index 42780a75..1ac94ebb 100644 --- a/services/api/src/services/admin/home-service.ts +++ b/services/api/src/services/admin/home-service.ts @@ -1,4 +1,3 @@ - import type { AnalyticsCoverageResponse, EventStatus, diff --git a/services/api/src/services/admin/inbound-bounce.ts b/services/api/src/services/admin/inbound-bounce.ts index 0841258c..cf2dd429 100644 --- a/services/api/src/services/admin/inbound-bounce.ts +++ b/services/api/src/services/admin/inbound-bounce.ts @@ -1,4 +1,3 @@ - import type { Container } from "../../di.js" import type { Sql } from "../../db/client.js" import type { ParsedMail } from "@civfix/shared/interfaces" @@ -116,7 +115,9 @@ export async function handleBounce( if (thread === null) return const sql = container.getDb().sql - const ownsRecipient = await threadSentTo(sql, thread.id, bounce.failedRecipient).catch(() => false) + const ownsRecipient = await threadSentTo(sql, thread.id, bounce.failedRecipient).catch( + () => false, + ) if (!ownsRecipient) return await mailRepo diff --git a/services/api/src/services/admin/inbound-html-sanitizer.ts b/services/api/src/services/admin/inbound-html-sanitizer.ts index f6605b46..06894f74 100644 --- a/services/api/src/services/admin/inbound-html-sanitizer.ts +++ b/services/api/src/services/admin/inbound-html-sanitizer.ts @@ -1,4 +1,3 @@ - const DROP_WITH_CONTENT = new Set([ "script", "style", diff --git a/services/api/src/services/admin/inbound-jobs.ts b/services/api/src/services/admin/inbound-jobs.ts index 0070c485..f7d80332 100644 --- a/services/api/src/services/admin/inbound-jobs.ts +++ b/services/api/src/services/admin/inbound-jobs.ts @@ -1,4 +1,3 @@ - import type { Container } from "../../di.js" import { runInboundSweep } from "./inbound-sweep.js" diff --git a/services/api/src/services/admin/inbound-processor.ts b/services/api/src/services/admin/inbound-processor.ts index fe06ae99..481d19db 100644 --- a/services/api/src/services/admin/inbound-processor.ts +++ b/services/api/src/services/admin/inbound-processor.ts @@ -1,4 +1,3 @@ - import type { Container } from "../../di.js" import type { InboundMail, ParsedMail, Storage } from "@civfix/shared/interfaces" import type { MailAttachment } from "@civfix/shared" @@ -196,7 +195,11 @@ async function routeThreaded( ): Promise { const unaffiliated = authVerdict !== "pass" || !(await isJurisdictionSender(mailRepo, thread.id, mail)) - const { attachments, oversize } = await streamAttachments(storage, `inbound-mail/${thread.id}`, mail) + const { attachments, oversize } = await streamAttachments( + storage, + `inbound-mail/${thread.id}`, + mail, + ) const inserted = await mailRepo.insertMessage({ threadId: thread.id, direction: "in", @@ -227,12 +230,14 @@ async function routeThreaded( const message = inserted ?? (await mailRepo.findMessageByMessageId(messageId).catch(() => null)) if (message !== null && message.threadId === thread.id) { - await applyInboundEffects(container, injected, mailRepo, thread, message).catch((err: unknown) => { - logger.warn( - { err: errorText(err), threadId: thread.id, messageId: message.id }, - "inbound: side effects failed (claim released; the sweep re-drives it)", - ) - }) + await applyInboundEffects(container, injected, mailRepo, thread, message).catch( + (err: unknown) => { + logger.warn( + { err: errorText(err), threadId: thread.id, messageId: message.id }, + "inbound: side effects failed (claim released; the sweep re-drives it)", + ) + }, + ) } if (inserted === null) return { outcome: "replay" } return { outcome: "threaded", id: inserted.id } diff --git a/services/api/src/services/admin/inbound-repository.drizzle.ts b/services/api/src/services/admin/inbound-repository.drizzle.ts index eaa9ef0f..244af965 100644 --- a/services/api/src/services/admin/inbound-repository.drizzle.ts +++ b/services/api/src/services/admin/inbound-repository.drizzle.ts @@ -1,13 +1,8 @@ - import type { Sql } from "../../db/client.js" import { clampLimit, decodeCursor, encodeCursor } from "./pagination.js" import { likeContains } from "./like.js" import { writeAudit } from "./audit.js" -import { - HTML_PREVIEW_SOURCE_CHARS, - PREVIEW_SOURCE_CHARS, - toPreview, -} from "./mail-preview.js" +import { HTML_PREVIEW_SOURCE_CHARS, PREVIEW_SOURCE_CHARS, toPreview } from "./mail-preview.js" import type { InboundEmailDTO, InboundEmailListItemDTO, diff --git a/services/api/src/services/admin/inbound-repository.memory.ts b/services/api/src/services/admin/inbound-repository.memory.ts index 66f3101d..2fc3509a 100644 --- a/services/api/src/services/admin/inbound-repository.memory.ts +++ b/services/api/src/services/admin/inbound-repository.memory.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { clampLimit, decodeCursor, encodeCursor } from "./pagination.js" import { @@ -69,7 +68,7 @@ export class InMemoryInboundRepository implements InboundRepository { }) filtered = filtered.sort((a, b) => { const d = b.receivedAt.getTime() - a.receivedAt.getTime() - return d !== 0 ? d : (a.id < b.id ? 1 : a.id > b.id ? -1 : 0) + return d !== 0 ? d : a.id < b.id ? 1 : a.id > b.id ? -1 : 0 }) if (anchor) { filtered = filtered.filter((r) => { diff --git a/services/api/src/services/admin/inbound-sweep.ts b/services/api/src/services/admin/inbound-sweep.ts index 82bf7ac0..d74dbf07 100644 --- a/services/api/src/services/admin/inbound-sweep.ts +++ b/services/api/src/services/admin/inbound-sweep.ts @@ -1,4 +1,3 @@ - import type { Container } from "../../di.js" import { processInboundObject, diff --git a/services/api/src/services/admin/inbound-thread-correlation.ts b/services/api/src/services/admin/inbound-thread-correlation.ts index f4469bf7..172fc925 100644 --- a/services/api/src/services/admin/inbound-thread-correlation.ts +++ b/services/api/src/services/admin/inbound-thread-correlation.ts @@ -34,12 +34,14 @@ export const EFFECTS_STAGE_NOTIFIED = 3 export const JURISDICTION_REPLY_NOTIFICATION_BODY = "The city responded. See their reply in the report chat." -export function inboundEffectDeps(deps: { - adminReportRepo?: AdminReportRepository - cleanupRepo?: CleanupRepository - notifications?: ReporterNotifier - chatEmitter?: ReportChatSystemEmitter -} = {}): InboundEffectDeps { +export function inboundEffectDeps( + deps: { + adminReportRepo?: AdminReportRepository + cleanupRepo?: CleanupRepository + notifications?: ReporterNotifier + chatEmitter?: ReportChatSystemEmitter + } = {}, +): InboundEffectDeps { return { ...(deps.adminReportRepo !== undefined ? { reportRepo: deps.adminReportRepo } : {}), ...(deps.cleanupRepo !== undefined ? { cleanupRepo: deps.cleanupRepo } : {}), 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 8f0b9dea..497022a3 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Sql } from "../../db/client.js" import { decodeOffsetCursor, encodeOffsetCursor, clampLimit } from "./pagination.js" import { writeAudit } from "./audit.js" @@ -144,7 +143,13 @@ export function makeDrizzleJurisdictionContactsRepository( audit: { actorId: string | null }, ): Promise<{ taskResolved: boolean }> { const committed = await sql.begin(async (tx) => { - await upsertJurisdictionContacts(tx, geoid, input.contacts, input.defaultEmails, input.formUrl) + await upsertJurisdictionContacts( + tx, + geoid, + input.contacts, + input.defaultEmails, + input.formUrl, + ) await tx` UPDATE jurisdictions SET contact_updated_at = now(), @@ -265,7 +270,9 @@ export function makeDrizzleJurisdictionContactsRepository( target: `jurisdiction:${geoid}`, meta: { geoid, - fields: Object.keys(input).filter((k) => (input as Record)[k] !== undefined), + fields: Object.keys(input).filter( + (k) => (input as Record)[k] !== undefined, + ), }, }) return true @@ -312,8 +319,7 @@ export function makeDrizzleJurisdictionContactsRepository( ? sql`AND NOT (${hasEmailExpr} OR ${hasFormExpr})` : args.filter === "routed" ? sql`AND (${hasEmailExpr} OR ${hasFormExpr})` - : - args.filter === "needs_mapping" + : args.filter === "needs_mapping" ? sql`AND NOT (${hasEmailExpr} OR ${hasFormExpr}) AND COALESCE(w.total, 0) > 0` : sql`` @@ -324,8 +330,7 @@ export function makeDrizzleJurisdictionContactsRepository( ? sql`ORDER BY COALESCE(w.total, 0) DESC, j.geoid ASC` : args.sort === "name" ? sql`ORDER BY j.name ASC, j.geoid ASC` - : - args.sort === "oldest" + : args.sort === "oldest" ? sql`ORDER BY w.oldest_waiting_at ASC NULLS LAST, j.geoid ASC` : sql`ORDER BY COALESCE(j.population, 0) DESC, j.geoid ASC` diff --git a/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts b/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts index b759a398..c0214afc 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-repository.memory.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { clampLimit, decodeOffsetCursor, encodeOffsetCursor } from "./pagination.js" import type { OutreachStateRecord } from "./mail-repository.drizzle.js" @@ -276,7 +275,6 @@ export class InMemoryJurisdictionContactsRepository implements JurisdictionConta return this.outreach.get(geoid) ?? null } - async listDirectory(args: ListDirectoryArgs): Promise { const all = [...this.jurisdictions.values()].map((j) => toRecord(j, this.reports)) @@ -285,7 +283,8 @@ export class InMemoryJurisdictionContactsRepository implements JurisdictionConta ? (() => { const needle = args.q.toLowerCase() return all.filter( - (r) => r.name.toLowerCase().includes(needle) || r.geoid.toLowerCase().includes(needle), + (r) => + r.name.toLowerCase().includes(needle) || r.geoid.toLowerCase().includes(needle), ) })() : all diff --git a/services/api/src/services/admin/jurisdiction-contacts-service.ts b/services/api/src/services/admin/jurisdiction-contacts-service.ts index 7d28e3ed..e547c819 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-service.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-service.ts @@ -58,7 +58,11 @@ export function makeJurisdictionContactsService( // 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). - if (typeof input.handle === "string" && input.handle !== "" && isReservedHandle(input.handle)) { + if ( + typeof input.handle === "string" && + input.handle !== "" && + isReservedHandle(input.handle) + ) { throw AppError.validation({ handle: "That @handle is reserved." }) } const ok = await deps.repo.patch(geoid, input, { actorId }) diff --git a/services/api/src/services/admin/jurisdiction-contacts-types.ts b/services/api/src/services/admin/jurisdiction-contacts-types.ts index b6244490..f2f512a1 100644 --- a/services/api/src/services/admin/jurisdiction-contacts-types.ts +++ b/services/api/src/services/admin/jurisdiction-contacts-types.ts @@ -159,9 +159,15 @@ export interface JurisdictionContactsRepository { * 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. */ - patch(geoid: string, input: PatchContactsInput, audit: { actorId: string | null }): Promise + 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> + 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. */ @@ -195,7 +201,11 @@ export interface JurisdictionContactsServiceDeps { */ 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 + 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). */ diff --git a/services/api/src/services/admin/jurisdiction-directory-projection.ts b/services/api/src/services/admin/jurisdiction-directory-projection.ts index ad2f2331..724d13f6 100644 --- a/services/api/src/services/admin/jurisdiction-directory-projection.ts +++ b/services/api/src/services/admin/jurisdiction-directory-projection.ts @@ -16,7 +16,9 @@ import { */ export function coverageLabel(record: JurisdictionDirectoryRecord): string { const covered = new Set( - record.categoryContacts.filter((c) => c.email !== null && c.email.trim() !== "").map((c) => c.category), + record.categoryContacts + .filter((c) => c.email !== null && c.email.trim() !== "") + .map((c) => c.category), ) const hasDefault = record.hasDefaultContact || record.defaultEmails.some((e) => e.trim() !== "") if (hasDefault || covered.size === ADMIN_CATEGORIES.length) return "All categories" @@ -37,7 +39,9 @@ export function directoryMethod(record: JurisdictionDirectoryRecord): "email" | return "none" } -export function directoryStatus(record: JurisdictionDirectoryRecord): "verified" | "pending" | "bounced" { +export function directoryStatus( + record: JurisdictionDirectoryRecord, +): "verified" | "pending" | "bounced" { if (record.bounced) return "bounced" if (record.contactUpdatedAt !== null && directoryMethod(record) !== "none") return "verified" return "pending" diff --git a/services/api/src/services/admin/mail-format.ts b/services/api/src/services/admin/mail-format.ts index b2e4c617..29971287 100644 --- a/services/api/src/services/admin/mail-format.ts +++ b/services/api/src/services/admin/mail-format.ts @@ -41,8 +41,7 @@ function hasExtension(name: string): boolean { } export function attachmentFilename(r2Key: string, index: number, contentType?: string): string { - const ext = - ATTACHMENT_EXTENSIONS[(contentType ?? "").trim().toLowerCase()] ?? EXTENSION_FALLBACK + const ext = ATTACHMENT_EXTENSIONS[(contentType ?? "").trim().toLowerCase()] ?? EXTENSION_FALLBACK const tail = r2Key.split("/").pop() ?? "" const cleaned = tail.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^\.+/, "") if (cleaned.length === 0) return `photo-${index + 1}${ext}` @@ -237,7 +236,8 @@ export interface EventPacketInput { export function buildEventPacket(event: EventPacketInput, message: string): ReportPacket { const safeTitle = sanitizeHeaderValue(event.title) const ref = event.referenceCode ?? null - const subject = ref !== null ? `civfix event: ${safeTitle} [${ref}]` : `civfix event: ${safeTitle}` + const subject = + ref !== null ? `civfix event: ${safeTitle} [${ref}]` : `civfix event: ${safeTitle}` const place = event.place ?? "the area" const address = event.address && event.address.trim() !== "" ? event.address : place const msgText = message.trim() !== "" ? message.trim() : "(no message provided)" diff --git a/services/api/src/services/admin/mail-mappers.ts b/services/api/src/services/admin/mail-mappers.ts index 14b10522..9539868b 100644 --- a/services/api/src/services/admin/mail-mappers.ts +++ b/services/api/src/services/admin/mail-mappers.ts @@ -1,4 +1,3 @@ - import type { CursorAnchor } from "./pagination.js" import { toPreview } from "./mail-preview.js" import type { diff --git a/services/api/src/services/admin/mail-preview.ts b/services/api/src/services/admin/mail-preview.ts index 88bee054..25aeba46 100644 --- a/services/api/src/services/admin/mail-preview.ts +++ b/services/api/src/services/admin/mail-preview.ts @@ -1,4 +1,3 @@ - export const PREVIEW_LEN = 140 export const PREVIEW_SOURCE_CHARS = 400 diff --git a/services/api/src/services/admin/mail-repository.drizzle.ts b/services/api/src/services/admin/mail-repository.drizzle.ts index f5faa0aa..bf58947a 100644 --- a/services/api/src/services/admin/mail-repository.drizzle.ts +++ b/services/api/src/services/admin/mail-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Queryable, Sql } from "../../db/client.js" import { clampLimit, decodeCursor, encodeCursor } from "./pagination.js" import { PREVIEW_SOURCE_CHARS } from "./mail-preview.js" @@ -81,7 +80,6 @@ interface PendingEffectsRowSelect extends MessageRowSelect { t_created_at: Date } - function threadColumns(sql: Queryable, alias?: string): SqlFragment { const p = alias === undefined ? sql`` : sql`${sql(alias)}.` return sql` @@ -268,9 +266,7 @@ export function makeDrizzleMailRepository(sql: Sql): MailRepository { return rows[0] ? toThreadRecord(rows[0]) : null }, - async findThreadByOutboundMessageIds( - messageIds: string[], - ): Promise { + async findThreadByOutboundMessageIds(messageIds: string[]): Promise { const ids = messageIds.filter((m) => typeof m === "string" && m.length > 0) if (ids.length === 0) return null const rows = await sql` @@ -594,7 +590,11 @@ export function makeDrizzleMailRepository(sql: Sql): MailRepository { return rows.length > 0 }, - async setThreadStatus(id: string, status: MailStatus, audit?: MailAuditInput): Promise { + async setThreadStatus( + id: string, + status: MailStatus, + audit?: MailAuditInput, + ): Promise { return sql.begin(async (tx) => { const rows = await tx<{ id: string }[]>` UPDATE mail_threads SET status = ${status} WHERE id = ${id} RETURNING id diff --git a/services/api/src/services/admin/mail-repository.memory.ts b/services/api/src/services/admin/mail-repository.memory.ts index 9d5b4e31..c7d68701 100644 --- a/services/api/src/services/admin/mail-repository.memory.ts +++ b/services/api/src/services/admin/mail-repository.memory.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { MAIL_STATS_WINDOW_DAYS, @@ -21,12 +20,7 @@ import { type RecordSendFailureInput, type ThreadInit, } from "./mail-repository.js" -import { - deriveWho, - mintThreadToken, - toMessageDTO, - toThreadListItem, -} from "./mail-mappers.js" +import { deriveWho, mintThreadToken, toMessageDTO, toThreadListItem } from "./mail-mappers.js" import { buildMailStats } from "./mail-stats.js" import { ROUTE_DEADLINE_INFLIGHT_SECONDS } from "./outbound-send-policy.js" import { clampLimit, decodeCursor, encodeCursor } from "./pagination.js" diff --git a/services/api/src/services/admin/mail-repository.ts b/services/api/src/services/admin/mail-repository.ts index 61f299bd..39a2265f 100644 --- a/services/api/src/services/admin/mail-repository.ts +++ b/services/api/src/services/admin/mail-repository.ts @@ -1,4 +1,3 @@ - import type { AdminAuditAction } from "./audit.js" import type { MailAttachment, diff --git a/services/api/src/services/admin/mail-service.ts b/services/api/src/services/admin/mail-service.ts index c9d2f1f1..ef1ab53f 100644 --- a/services/api/src/services/admin/mail-service.ts +++ b/services/api/src/services/admin/mail-service.ts @@ -1,4 +1,3 @@ - import { AppError } from "@civfix/shared" import type { ComposeRequest, @@ -198,7 +197,9 @@ export function resolveCorrespondent( replyDomain?: string, ): string | null { const ourDomains = new Set( - [domainOf(fromOutreach), replyDomain ?? ""].map((d) => d.trim().toLowerCase()).filter((d) => d.length > 0), + [domainOf(fromOutreach), replyDomain ?? ""] + .map((d) => d.trim().toLowerCase()) + .filter((d) => d.length > 0), ) for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i] @@ -209,7 +210,11 @@ export function resolveCorrespondent( return null } -function isOurAddress(from: string, fromOutreach: string, ourDomains: ReadonlySet): boolean { +function isOurAddress( + from: string, + fromOutreach: string, + ourDomains: ReadonlySet, +): boolean { if (addressesEqual(from, fromOutreach)) return true const addr = emailOf(from) if (addr === null) return false diff --git a/services/api/src/services/admin/moderation-repository.drizzle.ts b/services/api/src/services/admin/moderation-repository.drizzle.ts index 996e109d..667602b1 100644 --- a/services/api/src/services/admin/moderation-repository.drizzle.ts +++ b/services/api/src/services/admin/moderation-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { ReportCategory } from "@civfix/shared" import type { Queryable, Sql } from "../../db/client.js" import { writeAudit } from "./audit.js" @@ -115,12 +114,8 @@ function refFor(reportId: string | null, cleanupId: string | null): string | nul } async function attachDestinationRefs(sql: Queryable, page: ModerationItemRow[]): Promise { - const chatIds = page - .filter((r) => isMessageSubject(r.subject_type)) - .map((r) => r.subject_id) - const photoIds = page - .filter((r) => r.subject_type === "photo") - .map((r) => r.subject_id) + const chatIds = page.filter((r) => isMessageSubject(r.subject_type)).map((r) => r.subject_id) + const photoIds = page.filter((r) => r.subject_type === "photo").map((r) => r.subject_id) if (chatIds.length === 0 && photoIds.length === 0) return const byId = new Map() @@ -390,7 +385,11 @@ export function makeDrizzleModerationRepository(sql: Sql): ModerationRepository ` } if (removed) { - const authorId = await resolveSubjectAuthor(tx, resolved.subject_type, resolved.subject_id) + const authorId = await resolveSubjectAuthor( + tx, + resolved.subject_type, + resolved.subject_id, + ) if (authorId != null) await incrementUserModeration(tx, authorId) } await writeAudit(tx, { diff --git a/services/api/src/services/admin/moderation-repository.memory.ts b/services/api/src/services/admin/moderation-repository.memory.ts index 56ebc2a2..64340e58 100644 --- a/services/api/src/services/admin/moderation-repository.memory.ts +++ b/services/api/src/services/admin/moderation-repository.memory.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { clampLimit, decodeCursor, encodeCursor } from "./pagination.js" import { assertTargetIsNotOperatorRole } from "../../auth/operator-target.js" @@ -64,7 +63,8 @@ export class InMemoryModerationRepository implements ModerationRepository { subjectId, destinationKind: input.destinationKind === undefined ? destination.destinationKind : input.destinationKind, - destinationId: input.destinationId === undefined ? destination.destinationId : input.destinationId, + destinationId: + input.destinationId === undefined ? destination.destinationId : input.destinationId, flag: input.flag ?? null, reason: input.reason ?? null, category: input.category ?? null, diff --git a/services/api/src/services/admin/moderation-service.ts b/services/api/src/services/admin/moderation-service.ts index 8671634f..a0bdf58b 100644 --- a/services/api/src/services/admin/moderation-service.ts +++ b/services/api/src/services/admin/moderation-service.ts @@ -1,4 +1,3 @@ - import { AppError, relativeAgo } from "@civfix/shared" import type { ModerationItemDTO, @@ -20,7 +19,6 @@ import { timelineKindForStatus } from "./admin-report-status.js" import { mapWithLimit, PRESIGN_CONCURRENCY, type PresignMedia } from "../media-presign.js" import type { ReportChatSystemEmitter } from "../report-timeline-event.js" - export type ModerationFilter = "all" | ModerationKind | "high" export interface ListModerationArgs { @@ -119,7 +117,6 @@ export interface ModerationRepository { backfillFromHeldReports(): Promise } - export const NEUTRAL_USER_SNAPSHOT: ModerationUserSnapshot = { id: null, handle: "", @@ -145,7 +142,6 @@ function toUserDTO(snapshot: ModerationUserSnapshot | null): ModerationUser { } } - export interface ModerationSessionControl { applyStatus(userId: string, status: UserStatus): Promise } diff --git a/services/api/src/services/admin/outbound-mail-service.ts b/services/api/src/services/admin/outbound-mail-service.ts index 09ae422c..db8be33d 100644 --- a/services/api/src/services/admin/outbound-mail-service.ts +++ b/services/api/src/services/admin/outbound-mail-service.ts @@ -418,7 +418,9 @@ export function makeOutboundMailService(deps: OutboundMailServiceDeps): Outbound }) return { thread, - async deliver(opts?: DeliverOptions): Promise<{ thread: MailThreadRecord; messageId: string }> { + async deliver( + opts?: DeliverOptions, + ): Promise<{ thread: MailThreadRecord; messageId: string }> { const messageId = await deliverAndRecord({ ...(opts?.onLateSuccess !== undefined ? { onLateSuccess: opts.onLateSuccess } : {}), threadId: thread.id, @@ -598,7 +600,10 @@ type OutboundMailContainer = { mailer: Mailer env: Pick< Env, - "MAIL_FROM_OUTREACH" | "MAIL_REPLY_DOMAIN" | "OCI_EMAIL_SMTP_TIMEOUT_MS" | "OUTBOUND_SEND_MIN_THROUGHPUT_BPS" + | "MAIL_FROM_OUTREACH" + | "MAIL_REPLY_DOMAIN" + | "OCI_EMAIL_SMTP_TIMEOUT_MS" + | "OUTBOUND_SEND_MIN_THROUGHPUT_BPS" > } diff --git a/services/api/src/services/admin/outbound-send-policy.ts b/services/api/src/services/admin/outbound-send-policy.ts index 96bc25b0..d9a762d1 100644 --- a/services/api/src/services/admin/outbound-send-policy.ts +++ b/services/api/src/services/admin/outbound-send-policy.ts @@ -86,10 +86,7 @@ export function assertOutboundSendPolicy(input: { `(a larger phase budget lets one send outlive the ${ROUTE_DEADLINE_INFLIGHT_SECONDS}s in-flight guard)`, ) } - if ( - errors.length === 0 && - inflightWindowSeconds(input) > ROUTE_DEADLINE_INFLIGHT_SECONDS - ) { + if (errors.length === 0 && inflightWindowSeconds(input) > ROUTE_DEADLINE_INFLIGHT_SECONDS) { errors.push( `OCI_EMAIL_SMTP_TIMEOUT_MS / OUTBOUND_SEND_MIN_THROUGHPUT_BPS: the largest computable send ` + `deadline exceeds the ${ROUTE_DEADLINE_INFLIGHT_SECONDS}s in-flight guard`, diff --git a/services/api/src/services/admin/outbound-send-sql.ts b/services/api/src/services/admin/outbound-send-sql.ts index 9a9cb25b..5479368b 100644 --- a/services/api/src/services/admin/outbound-send-sql.ts +++ b/services/api/src/services/admin/outbound-send-sql.ts @@ -1,6 +1,9 @@ import type { Queryable } from "../../db/client.js" import type { SqlFragment } from "./sql-fragments.js" -import { ROUTE_CLAIM_STALE_SECONDS, ROUTE_DEADLINE_INFLIGHT_SECONDS } from "./outbound-send-policy.js" +import { + ROUTE_CLAIM_STALE_SECONDS, + ROUTE_DEADLINE_INFLIGHT_SECONDS, +} from "./outbound-send-policy.js" export function latestOutboundAttempt(sql: Queryable, threadRef: SqlFragment): SqlFragment { return sql` diff --git a/services/api/src/services/admin/outreach-jobs.ts b/services/api/src/services/admin/outreach-jobs.ts index 5b15a7af..3b0c93c8 100644 --- a/services/api/src/services/admin/outreach-jobs.ts +++ b/services/api/src/services/admin/outreach-jobs.ts @@ -1,11 +1,14 @@ - import type { Container } from "../../di.js" import { writeAudit } from "./audit.js" import { OUTREACH_DIGEST_JOB } from "./jurisdiction-contacts-types.js" import { makeDrizzleMailRepository } from "./mail-repository.drizzle.js" import { makeContainerOutboundMailService } from "./outbound-mail-service.js" import { makeDrizzleOutreachRepository } from "./outreach-repository.drizzle.js" -import { makeOutreachService, type OutreachRunResult, type OutreachService } from "./outreach-service.js" +import { + makeOutreachService, + type OutreachRunResult, + type OutreachService, +} from "./outreach-service.js" export { OUTREACH_DIGEST_JOB } diff --git a/services/api/src/services/admin/outreach-repository.drizzle.ts b/services/api/src/services/admin/outreach-repository.drizzle.ts index d27f637f..2a581ed6 100644 --- a/services/api/src/services/admin/outreach-repository.drizzle.ts +++ b/services/api/src/services/admin/outreach-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Sql } from "../../db/client.js" import { OUTREACH_CATEGORIES, diff --git a/services/api/src/services/admin/outreach-repository.memory.ts b/services/api/src/services/admin/outreach-repository.memory.ts index 251059d7..a79421c0 100644 --- a/services/api/src/services/admin/outreach-repository.memory.ts +++ b/services/api/src/services/admin/outreach-repository.memory.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { OUTREACH_CATEGORIES, diff --git a/services/api/src/services/admin/outreach-service.ts b/services/api/src/services/admin/outreach-service.ts index bdad7e6c..72235cd3 100644 --- a/services/api/src/services/admin/outreach-service.ts +++ b/services/api/src/services/admin/outreach-service.ts @@ -1,4 +1,3 @@ - import { REPORT_CATEGORY_LABELS } from "@civfix/shared" import type { ReportCategory } from "@civfix/shared" import { ADMIN_CATEGORIES } from "./category-counts.js" @@ -17,21 +16,14 @@ export interface OutreachDigest { export interface OutreachRepository { loadDigest(geoid: string): Promise listCandidateGeoids(limit?: number): Promise - claimOutreachWindow?( - geoid: string, - window: { at: Date; windowStart: Date }, - ): Promise + claimOutreachWindow?(geoid: string, window: { at: Date; windowStart: Date }): Promise } export const OUTREACH_SWEEP_BATCH_SIZE = 200 export const OUTREACH_CATEGORIES: readonly ReportCategory[] = ADMIN_CATEGORIES -export function isThrottled( - lastOutreachAt: Date | null, - now: Date, - throttleDays: number, -): boolean { +export function isThrottled(lastOutreachAt: Date | null, now: Date, throttleDays: number): boolean { if (lastOutreachAt === null) return false const windowMs = throttleDays * 24 * 60 * 60 * 1000 return now.getTime() - lastOutreachAt.getTime() < windowMs diff --git a/services/api/src/services/admin/pagination.ts b/services/api/src/services/admin/pagination.ts index ba89d4dc..ce7f992c 100644 --- a/services/api/src/services/admin/pagination.ts +++ b/services/api/src/services/admin/pagination.ts @@ -52,7 +52,9 @@ export function decodeCursor( * (and the typed client's infinite-scroll loop) is unchanged. */ export function encodeOffsetCursor(offset: number): string { - return Buffer.from(JSON.stringify({ o: Math.max(0, Math.floor(offset)) }), "utf8").toString("base64url") + return Buffer.from(JSON.stringify({ o: Math.max(0, Math.floor(offset)) }), "utf8").toString( + "base64url", + ) } /** diff --git a/services/api/src/services/admin/role-change.ts b/services/api/src/services/admin/role-change.ts index 41611085..87e0dd1f 100644 --- a/services/api/src/services/admin/role-change.ts +++ b/services/api/src/services/admin/role-change.ts @@ -1,4 +1,3 @@ - import type { Role } from "@civfix/shared" export type RevokeAllSessions = (userId: string) => Promise diff --git a/services/api/src/services/admin/sql-fragments.ts b/services/api/src/services/admin/sql-fragments.ts index 41fb75a2..fa8ddd09 100644 --- a/services/api/src/services/admin/sql-fragments.ts +++ b/services/api/src/services/admin/sql-fragments.ts @@ -1,4 +1,3 @@ - import type postgres from "postgres" import type { Queryable } from "../../db/client.js" import { likeContains } from "./like.js" @@ -18,7 +17,9 @@ export function ilikeAnyOf( ] const first = branches[0] if (first === undefined) return sql`(false)` - const ored = branches.slice(1).reduce((acc, branch) => sql`${acc} OR ${branch}`, first) + const ored = branches + .slice(1) + .reduce((acc, branch) => sql`${acc} OR ${branch}`, first) return sql`(${ored})` } diff --git a/services/api/src/services/admin/system-health-probes.ts b/services/api/src/services/admin/system-health-probes.ts index ff2c7dac..cb810ee7 100644 --- a/services/api/src/services/admin/system-health-probes.ts +++ b/services/api/src/services/admin/system-health-probes.ts @@ -1,4 +1,3 @@ - import type { Sql } from "../../db/client.js" import type { ProbeResult, SystemHealthProbes } from "./system-health-service.js" import { MEDIA_WORKER_BACKLOG_WARN } from "./system-health-service.js" diff --git a/services/api/src/services/admin/system-health-service.ts b/services/api/src/services/admin/system-health-service.ts index ff56066f..cb36f32c 100644 --- a/services/api/src/services/admin/system-health-service.ts +++ b/services/api/src/services/admin/system-health-service.ts @@ -1,4 +1,3 @@ - import type { SystemHealthResponse, SystemService } from "@civfix/shared" export type HealthStatus = "ok" | "warn" | "down" | "not_deployed" @@ -51,7 +50,8 @@ async function probeRow( } function classifyProbeError(err: unknown): string { - const code = typeof err === "object" && err !== null ? (err as { code?: unknown }).code : undefined + const code = + typeof err === "object" && err !== null ? (err as { code?: unknown }).code : undefined const message = err instanceof Error ? err.message : String(err) if (message.includes("timed out") || code === "ETIMEDOUT") return "Timed out" if (code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ECONNRESET") return "Unreachable" @@ -98,16 +98,7 @@ export function makeSystemHealthService(deps: SystemHealthServiceDeps): SystemHe } return { - services: [ - api, - postgres, - redis, - mediaWorker, - ociEmailRow, - glitchTip, - tileCdn, - routing, - ], + services: [api, postgres, redis, mediaWorker, ociEmailRow, glitchTip, tileCdn, routing], } }, } diff --git a/services/api/src/services/anon-hold-release-repo.drizzle.ts b/services/api/src/services/anon-hold-release-repo.drizzle.ts index 5f6d9c3d..2cf5c056 100644 --- a/services/api/src/services/anon-hold-release-repo.drizzle.ts +++ b/services/api/src/services/anon-hold-release-repo.drizzle.ts @@ -1,10 +1,5 @@ - import type { Sql } from "../db/client.js" -import type { - AnonHoldReleaseRepo, - HeldReportView, - ReleaseMediaView, -} from "./anon-hold-release.js" +import type { AnonHoldReleaseRepo, HeldReportView, ReleaseMediaView } from "./anon-hold-release.js" export function makeDrizzleAnonHoldReleaseRepo(sql: Sql): AnonHoldReleaseRepo { return { diff --git a/services/api/src/services/anon-hold-release.ts b/services/api/src/services/anon-hold-release.ts index 46a5d875..7e0a2d25 100644 --- a/services/api/src/services/anon-hold-release.ts +++ b/services/api/src/services/anon-hold-release.ts @@ -1,4 +1,3 @@ - import type { AbuseChecks } from "@civfix/shared/interfaces" import type { LatLng } from "@civfix/shared" diff --git a/services/api/src/services/anon-repository.drizzle.ts b/services/api/src/services/anon-repository.drizzle.ts index 688c74ac..d893e933 100644 --- a/services/api/src/services/anon-repository.drizzle.ts +++ b/services/api/src/services/anon-repository.drizzle.ts @@ -357,10 +357,7 @@ export function makeDrizzleClaimRepository(sql: Sql): ClaimRepository { return { reportId: row.id } }, - async claimByCode( - claimCodeHash: string, - userId: string, - ): Promise<{ reportId: string } | null> { + 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 diff --git a/services/api/src/services/anon-service.ts b/services/api/src/services/anon-service.ts index c834c992..f639ca1e 100644 --- a/services/api/src/services/anon-service.ts +++ b/services/api/src/services/anon-service.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { AppError, REPORT_TYPE_TO_CATEGORY } from "@civfix/shared" import type { @@ -104,7 +103,11 @@ export interface AnonServiceDeps { resolveJurisdictionCode?: (geoid: string | null) => Promise /** Structured twin of the signed-in path's dep - same resolver, same cache, same provenance rules. */ resolveAddress?: AddressResolver - raiseAbuseFlag?: (subjectType: "report" | "anon_token", subjectId: string, reason: AnonAbuseReason) => Promise + raiseAbuseFlag?: ( + subjectType: "report" | "anon_token", + subjectId: string, + reason: AnonAbuseReason, + ) => Promise enqueueMediaChecks?: (reportId: string, mediaUploadIds: string[]) => Promise newId?: () => string newClaimCode?: () => string @@ -260,10 +263,7 @@ export function makeAnonService(deps: AnonServiceDeps): AnonService { } }, - async anonReportStatus( - reportId: string, - claimCode: string, - ): Promise { + async anonReportStatus(reportId: string, claimCode: string): Promise { const row = await deps.repo.findAnonReportStatus(reportId) if (!row || row.claimCodeHash === null) { throw AppError.notFound("Report not found") diff --git a/services/api/src/services/blocks-repository.drizzle.ts b/services/api/src/services/blocks-repository.drizzle.ts index 671dc9cc..939cb035 100644 --- a/services/api/src/services/blocks-repository.drizzle.ts +++ b/services/api/src/services/blocks-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Sql } from "../db/client.js" import { avatarGradient } from "@civfix/shared" import type { PersonDTO } from "@civfix/shared" diff --git a/services/api/src/services/certificate-model.ts b/services/api/src/services/certificate-model.ts index ec8e42e3..100b535f 100644 --- a/services/api/src/services/certificate-model.ts +++ b/services/api/src/services/certificate-model.ts @@ -1,4 +1,3 @@ - import { createHash } from "node:crypto" import { MAX_CERTIFICATE_ENTRIES } from "@civfix/shared" import type { VolunteerHoursSource } from "@civfix/shared" diff --git a/services/api/src/services/certificate-pdf.ts b/services/api/src/services/certificate-pdf.ts index 756330d1..0c6c7cd1 100644 --- a/services/api/src/services/certificate-pdf.ts +++ b/services/api/src/services/certificate-pdf.ts @@ -1,4 +1,3 @@ - import { formatCertificateCode } from "@civfix/shared" import { FONT, fontBuffer, fontFor } from "./certificate-fonts.js" import { @@ -26,7 +25,6 @@ export interface ServiceHoursPdfInput { t?: CertificateTranslator } - const PAGE = { left: 54, right: 558, @@ -146,7 +144,6 @@ export async function buildServiceHoursPdf(input: ServiceHoursPdfInput): Promise else drawContinuationChrome() }) - function drawFirstPageChrome(): void { doc.rect(PAGE.left, 36, PAGE.contentWidth, 5).fill(COLOR.accent) @@ -300,7 +297,6 @@ export async function buildServiceHoursPdf(input: ServiceHoursPdfInput): Promise .stroke() } - const rowHeights = model.rows.map((row) => { font(fontFor(row.activity, "regular"), 9.5) const twoLines = doc.currentLineHeight() * 2 @@ -366,7 +362,6 @@ export async function buildServiceHoursPdf(input: ServiceHoursPdfInput): Promise ) } - if (!totalsFitsOnPage(cursorY)) { tableContinues = false doc.addPage() @@ -410,7 +405,6 @@ export async function buildServiceHoursPdf(input: ServiceHoursPdfInput): Promise cursorY += 22 } - if (issuerNeedsNewPage(cursorY)) { tableContinues = false doc.addPage() @@ -518,7 +512,6 @@ export async function buildServiceHoursPdf(input: ServiceHoursPdfInput): Promise } } - const range = doc.bufferedPageRange() for (let i = range.start; i < range.start + range.count; i++) { doc.switchToPage(i) diff --git a/services/api/src/services/certificate-repository.drizzle.ts b/services/api/src/services/certificate-repository.drizzle.ts index c3924bf3..fb162f86 100644 --- a/services/api/src/services/certificate-repository.drizzle.ts +++ b/services/api/src/services/certificate-repository.drizzle.ts @@ -45,7 +45,8 @@ function conflictKind(err: unknown): "code" | "fingerprint" | null { if (e.code !== PG_UNIQUE_VIOLATION) return null const constraint = typeof e.constraint_name === "string" ? e.constraint_name : "" const detail = typeof e.detail === "string" ? e.detail : "" - if (constraint === FINGERPRINT_INDEX || detail.includes("ledger_fingerprint")) return "fingerprint" + if (constraint === FINGERPRINT_INDEX || detail.includes("ledger_fingerprint")) + return "fingerprint" if (constraint === CODE_INDEX || detail.includes("(code)")) return "code" return null } @@ -234,9 +235,7 @@ export function makeDrizzleCertificateRepository(sql: Sql): CertificateRepositor /** Holder identity frozen onto the document. A tombstoned account cannot issue. */ async findHolder(userId: string): Promise { - const rows = await sql< - { display_name: string; handle: string | null; locale: string }[] - >` + const rows = await sql<{ display_name: string; handle: string | null; locale: string }[]>` SELECT u.display_name, u.handle, diff --git a/services/api/src/services/certificate-service.ts b/services/api/src/services/certificate-service.ts index d9ad4e07..dcd69406 100644 --- a/services/api/src/services/certificate-service.ts +++ b/services/api/src/services/certificate-service.ts @@ -41,10 +41,7 @@ import type { } from "@civfix/shared" import type { StorageHead, StoragePutMeta } from "@civfix/shared/interfaces" import { resolveLocale } from "../i18n/locales.js" -import { - CERTIFICATE_CODE_MINT_ATTEMPTS, - generateCertificateCode, -} from "./certificate-code.js" +import { CERTIFICATE_CODE_MINT_ATTEMPTS, generateCertificateCode } from "./certificate-code.js" import { buildTranscriptModel, certificateTranslator, @@ -353,7 +350,10 @@ export function makeCertificateService(deps: CertificateServiceDeps): Certificat 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). - return { certificate: toCertificateDTO(existing, await presign(existing.r2Key), at), reused: true } + 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 — diff --git a/services/api/src/services/chat-bells.ts b/services/api/src/services/chat-bells.ts index 2dcbed2c..877e662e 100644 --- a/services/api/src/services/chat-bells.ts +++ b/services/api/src/services/chat-bells.ts @@ -54,7 +54,11 @@ function groupBellRoute( export interface ChatBellDeps { notificationService: Pick /** Best-effort "has this user muted this room?" (absent store / lookup error => false). */ - isMutedFor(userId: string, kind: "dm" | "cleanup" | "report" | "group", roomId: string): Promise + isMutedFor( + userId: string, + kind: "dm" | "cleanup" | "report" | "group", + roomId: string, + ): 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). */ @@ -81,7 +85,9 @@ async function isGroupMember( * 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). */ -export function makeChatMentionNotifier(deps: ChatBellDeps): GatewayChatMentions["notifyChatMention"] { +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). diff --git a/services/api/src/services/chat-edit-service.ts b/services/api/src/services/chat-edit-service.ts index 8470b48b..80054eab 100644 --- a/services/api/src/services/chat-edit-service.ts +++ b/services/api/src/services/chat-edit-service.ts @@ -129,7 +129,10 @@ export function makeChatEditService(deps: ChatEditServiceDeps): ChatEditService roomId, }) // Record even an EMPTY set: an edit that drops an @mention must clear the stale record. - await mentions.recordChatMentions(messageId, resolved.map((m) => m.id)) + await mentions.recordChatMentions( + messageId, + resolved.map((m) => m.id), + ) } catch { // Best-effort, like the WS send path: a mention failure never fails the edit. } diff --git a/services/api/src/services/chat-fanout-jobs.ts b/services/api/src/services/chat-fanout-jobs.ts index 575416c9..6d4d1015 100644 --- a/services/api/src/services/chat-fanout-jobs.ts +++ b/services/api/src/services/chat-fanout-jobs.ts @@ -1,4 +1,3 @@ - import type { FastifyBaseLogger } from "fastify" import type { ChatMessageDTO } from "@civfix/shared" import type { Jobs } from "@civfix/shared/interfaces" diff --git a/services/api/src/services/chat-group-repository.drizzle.ts b/services/api/src/services/chat-group-repository.drizzle.ts index 18605f47..c34b7fe8 100644 --- a/services/api/src/services/chat-group-repository.drizzle.ts +++ b/services/api/src/services/chat-group-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Sql } from "../db/client.js" import type { MediaDTO, MediaKind, MediaStatus, PersonDTO } from "@civfix/shared" import type { ChatGroupKind, ChatGroupVisibility } from "../db/schema/chat-groups.js" @@ -21,7 +20,10 @@ export interface GroupRoomAccess { role: GroupMemberRole | null } -export function canPostToGroup(access: { kind: ChatGroupKind; role: GroupMemberRole | null }): boolean { +export function canPostToGroup(access: { + kind: ChatGroupKind + role: GroupMemberRole | null +}): boolean { if (access.role === null) return false return access.kind === "group" || access.role === "owner" || access.role === "admin" } @@ -71,7 +73,11 @@ export interface ChatGroupRepository { banMember(groupId: string, userId: string, bannedBy: string): Promise isBanned(groupId: string, userId: string): Promise setRole(groupId: string, userId: string, role: "admin" | "member"): Promise - findMember(groupId: string, userId: string, viewerId: string | null): Promise + findMember( + groupId: string, + userId: string, + viewerId: string | null, + ): Promise listMembers( groupId: string, viewerId: string | null, @@ -145,7 +151,12 @@ export function toMemberView(r: MemberRowSelect): GroupMemberView { export function makeChatGroupRepository(sql: Sql, presign?: PresignMedia): ChatGroupRepository { async function toGroupView(r: GroupRowSelect): Promise { let avatar: MediaDTO | null = null - if (presign && r.avatar_id !== null && r.avatar_status === "ready" && r.avatar_r2_key !== null) { + if ( + presign && + r.avatar_id !== null && + r.avatar_status === "ready" && + r.avatar_r2_key !== null + ) { const { url, thumbUrl } = await presign(r.avatar_r2_key, r.avatar_thumb_key) avatar = { id: r.avatar_id, @@ -206,7 +217,11 @@ export function makeChatGroupRepository(sql: Sql, presign?: PresignMedia): ChatG VALUES (${groupId}, ${input.ownerId}, 'owner') ` if (memberIds.length > 0) { - const rows = memberIds.map((userId) => ({ group_id: groupId, user_id: userId, role: "member" })) + const rows = memberIds.map((userId) => ({ + group_id: groupId, + user_id: userId, + role: "member", + })) await tx` INSERT INTO chat_group_members ${tx(rows, "group_id", "user_id", "role")} ON CONFLICT (group_id, user_id) DO NOTHING @@ -261,7 +276,9 @@ export function makeChatGroupRepository(sql: Sql, presign?: PresignMedia): ChatG async accessOf(groupId: string, userId: string): Promise { if (!isUuid(groupId)) return null - const rows = await sql<{ kind: ChatGroupKind; visibility: ChatGroupVisibility; role: GroupMemberRole | null }[]>` + const rows = await sql< + { kind: ChatGroupKind; visibility: ChatGroupVisibility; role: GroupMemberRole | null }[] + >` SELECT g.kind, g.visibility, m.role FROM chat_groups g LEFT JOIN chat_group_members m ON m.group_id = g.id AND m.user_id = ${userId} @@ -430,7 +447,11 @@ export function makeChatGroupRepository(sql: Sql, presign?: PresignMedia): ChatG return rows.map((r) => r.user_id) }, - async advanceReadWatermark(groupId: string, userId: string, upToMessageId: string): Promise { + async advanceReadWatermark( + groupId: string, + userId: string, + upToMessageId: string, + ): Promise { await monotonicReadWatermarkUpdate( sql, "chat_group_members", diff --git a/services/api/src/services/chat-group-service.ts b/services/api/src/services/chat-group-service.ts index e4e6bfdd..09aa7e32 100644 --- a/services/api/src/services/chat-group-service.ts +++ b/services/api/src/services/chat-group-service.ts @@ -1,4 +1,3 @@ - import { AppError, ErrorCode } from "@civfix/shared" import type { AddGroupMembersRequest, @@ -127,9 +126,7 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi .filter((id) => !candidates.includes(id)) .slice(0, INVITE_BLOCK_SCAN_MEMBERS) const blocked = await groups.blockedPairsAmong([...candidates, ...existing]) - const rosterOk = candidates.filter( - (c) => !existing.some((m) => blocked.has(blockedKey(c, m))), - ) + const rosterOk = candidates.filter((c) => !existing.some((m) => blocked.has(blockedKey(c, m)))) return greedyPairwise(rosterOk, blocked) } @@ -143,18 +140,27 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi viewerId: string, groupId: string, ): Promise<{ view: ChatGroupView; role: GroupMemberRole | null }> { - const [view, role] = await Promise.all([groups.findById(groupId), groups.roleOf(groupId, viewerId)]) + const [view, role] = await Promise.all([ + groups.findById(groupId), + groups.roleOf(groupId, viewerId), + ]) if (view === null || (role === null && view.visibility === "private")) { throw forbidden("You aren't a member of this group.", "not_a_member") } return { view, role } } - async function requireReadable(viewerId: string, groupId: string): Promise { + async function requireReadable( + viewerId: string, + groupId: string, + ): Promise { return (await readableGroup(viewerId, groupId)).role } - async function firstMembersPage(groupId: string, viewerId: string): Promise { + async function firstMembersPage( + groupId: string, + viewerId: string, + ): Promise { const page = await groups.listMembers(groupId, viewerId, null, GROUP_MEMBERS_DEFAULT_LIMIT) return { members: page.members.map(toMemberDTO), nextCursor: page.nextCursor } } @@ -172,7 +178,8 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi { kind: req.kind, name: req.name, - description: req.description !== undefined && req.description !== "" ? req.description : null, + description: + req.description !== undefined && req.description !== "" ? req.description : null, avatarMediaId, ownerId, visibility: req.visibility, @@ -200,7 +207,10 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi assertNoSlur(req.description ?? null, "description") const view = await requireGroup(req.id) if (req.visibility !== undefined && req.visibility !== view.visibility && role !== "owner") { - throw forbidden("Only the owner can change this group's visibility.", "visibility_owner_only") + throw forbidden( + "Only the owner can change this group's visibility.", + "visibility_owner_only", + ) } await groups.update(req.id, { ...(req.name !== undefined ? { name: req.name } : {}), diff --git a/services/api/src/services/chat-mention-resolver.ts b/services/api/src/services/chat-mention-resolver.ts index cb3b67af..1d5a92f8 100644 --- a/services/api/src/services/chat-mention-resolver.ts +++ b/services/api/src/services/chat-mention-resolver.ts @@ -47,7 +47,10 @@ export async function resolveAndRecordChatMentions( roomId: input.roomId, }) if (mentions.length > 0) { - await seam.recordChatMentions(input.messageId, mentions.map((m) => m.id)) + await seam.recordChatMentions( + input.messageId, + mentions.map((m) => m.id), + ) } return mentions } catch { @@ -95,7 +98,9 @@ export function makeChatMentionResolver( const memberIds = new Set(await deps.listGroupMemberIds(input.roomId)) return resolved.filter((m) => memberIds.has(m.id)) } - const memberIds = new Set(await deps.listCleanupMemberIds(input.roomId, THREAD_SIGNAL_MEMBER_CAP)) + const memberIds = new Set( + await deps.listCleanupMemberIds(input.roomId, THREAD_SIGNAL_MEMBER_CAP), + ) return resolved.filter((m) => memberIds.has(m.id)) } } diff --git a/services/api/src/services/chat-poll-notifier.ts b/services/api/src/services/chat-poll-notifier.ts index fd96677b..a1ef3e8b 100644 --- a/services/api/src/services/chat-poll-notifier.ts +++ b/services/api/src/services/chat-poll-notifier.ts @@ -1,4 +1,3 @@ - import type { FastifyBaseLogger } from "fastify" import type { ChatMessageDTO } from "@civfix/shared" import type { Container } from "../di.js" diff --git a/services/api/src/services/chat-poll-repository.drizzle.ts b/services/api/src/services/chat-poll-repository.drizzle.ts index 04891039..e995eb57 100644 --- a/services/api/src/services/chat-poll-repository.drizzle.ts +++ b/services/api/src/services/chat-poll-repository.drizzle.ts @@ -95,7 +95,12 @@ export function makeChatPollRepository(sql: Sql): ChatPollRepository { async findPollMeta(messageId: string): Promise { const rows = await sql< - { message_id: string; created_by: string; closed_at: Date | null; allow_multiple: boolean }[] + { + message_id: string + created_by: string + closed_at: Date | null + allow_multiple: boolean + }[] >` SELECT message_id, created_by, closed_at, allow_multiple FROM chat_polls @@ -133,7 +138,11 @@ export function makeChatPollRepository(sql: Sql): ChatPollRepository { DELETE FROM chat_poll_votes WHERE poll_id = ${pollId} AND user_id = ${userId} ` if (optionIdxs.length > 0) { - const rows = optionIdxs.map((idx) => ({ poll_id: pollId, option_idx: idx, user_id: userId })) + const rows = optionIdxs.map((idx) => ({ + poll_id: pollId, + option_idx: idx, + user_id: userId, + })) await tx`INSERT INTO chat_poll_votes ${tx(rows, "poll_id", "option_idx", "user_id")}` } }) diff --git a/services/api/src/services/chat-poll-service.ts b/services/api/src/services/chat-poll-service.ts index dfac6cc0..e1e44cb1 100644 --- a/services/api/src/services/chat-poll-service.ts +++ b/services/api/src/services/chat-poll-service.ts @@ -1,4 +1,3 @@ - import { AppError, ErrorCode } from "@civfix/shared" import type { ChatMessageDTO } from "@civfix/shared" import type { ChatRepository } from "./chat-repository.drizzle.js" diff --git a/services/api/src/services/chat-reactions.drizzle.ts b/services/api/src/services/chat-reactions.drizzle.ts index f5f745ef..72264a6e 100644 --- a/services/api/src/services/chat-reactions.drizzle.ts +++ b/services/api/src/services/chat-reactions.drizzle.ts @@ -17,7 +17,9 @@ export async function loadChatReactions( messageId: string, viewerUserId: string | null, ): Promise { - return (await loadReactionsFor(tag, CHAT_REACTIONS, [messageId], viewerUserId)).get(messageId) ?? [] + return ( + (await loadReactionsFor(tag, CHAT_REACTIONS, [messageId], viewerUserId)).get(messageId) ?? [] + ) } /** Batched: one grouped query for a whole page of message ids (the N+1 fix for list reads). */ diff --git a/services/api/src/services/chat-reply-hydration.ts b/services/api/src/services/chat-reply-hydration.ts index 07b928ec..50d2e209 100644 --- a/services/api/src/services/chat-reply-hydration.ts +++ b/services/api/src/services/chat-reply-hydration.ts @@ -162,5 +162,9 @@ export function replyMapForRows( table: ReplyTable, rows: ReadonlyArray<{ reply_to_id: string | null }>, ): Promise> { - return loadReplyTargets(sql, table, rows.map((r) => r.reply_to_id)) + return loadReplyTargets( + sql, + table, + rows.map((r) => r.reply_to_id), + ) } diff --git a/services/api/src/services/chat-repository.drizzle.ts b/services/api/src/services/chat-repository.drizzle.ts index 90a2ca57..34116aee 100644 --- a/services/api/src/services/chat-repository.drizzle.ts +++ b/services/api/src/services/chat-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Queryable, Sql } from "../db/client.js" import { publicAuthorIdentity } from "./public-author.js" import type { @@ -12,7 +11,11 @@ import type { UserMentionDTO, } from "@civfix/shared" import type { ChatHistoryPage, PersistChatInput } from "@civfix/shared/interfaces" -import { loadChatReactions, loadChatReactionsFor, toggleChatReaction } from "./chat-reactions.drizzle.js" +import { + loadChatReactions, + loadChatReactionsFor, + toggleChatReaction, +} from "./chat-reactions.drizzle.js" import { loadChatMentions, loadChatMentionsFor } from "./chat-mentions.drizzle.js" import { attachChatMedia, loadChatAttachments } from "./chat-attachments.drizzle.js" import type { PresignMedia } from "./media-presign.js" @@ -379,14 +382,21 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha ): Promise { const ids = liveMessageIds(page) const pollIds = page.filter((r) => r.kind === "poll" && r.deleted_at === null).map((r) => r.id) - const [attachmentsByMessage, reactionsByMessage, mentionsByMessage, replyByTarget, pollsByMessage] = - await Promise.all([ - presign ? loadChatAttachments(sql, ids, presign) : Promise.resolve(new Map()), - loadChatReactionsFor(sql, ids, viewerUserId), - loadChatMentionsFor(sql, ids), - replyMapForRows(sql, "chat_messages", page), - loadPollsFor(sql, pollIds, viewerUserId), - ]) + const [ + attachmentsByMessage, + reactionsByMessage, + mentionsByMessage, + replyByTarget, + pollsByMessage, + ] = await Promise.all([ + presign + ? loadChatAttachments(sql, ids, presign) + : Promise.resolve(new Map()), + loadChatReactionsFor(sql, ids, viewerUserId), + loadChatMentionsFor(sql, ids), + replyMapForRows(sql, "chat_messages", page), + loadPollsFor(sql, pollIds, viewerUserId), + ]) return page.map((r) => toMessageDTO( r, @@ -396,7 +406,7 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha undefined, attachmentsByMessage.get(r.id) ?? [], reportCity, - r.reply_to_id !== null ? replyByTarget.get(r.reply_to_id) ?? null : null, + r.reply_to_id !== null ? (replyByTarget.get(r.reply_to_id) ?? null) : null, pollsByMessage.get(r.id) ?? null, ), ) @@ -413,7 +423,9 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha await Promise.all([ liveIds.length > 0 ? loadChatReactions(sql, row.id, viewerUserId) : Promise.resolve([]), liveIds.length > 0 ? loadChatMentions(sql, row.id) : Promise.resolve([]), - presign ? loadChatAttachments(sql, liveIds, presign) : Promise.resolve(new Map()), + presign + ? loadChatAttachments(sql, liveIds, presign) + : Promise.resolve(new Map()), resolveReportCity(scope), replyMapForRows(sql, "chat_messages", [row]), loadPollsFor(sql, pollIds, viewerUserId), @@ -426,7 +438,7 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha undefined, attachmentsByMessage.get(row.id) ?? [], reportCity, - row.reply_to_id !== null ? replyByTarget.get(row.reply_to_id) ?? null : null, + row.reply_to_id !== null ? (replyByTarget.get(row.reply_to_id) ?? null) : null, pollsByMessage.get(row.id) ?? null, ) } @@ -437,7 +449,9 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha table: "chat_messages", alias: "cm", scope: (prefix) => - prefix === null ? anchorScope(scope) : sql`${sql(prefix)}.${sql(scope.column)} = ${scope.id}`, + prefix === null + ? anchorScope(scope) + : sql`${sql(prefix)}.${sql(scope.column)} = ${scope.id}`, columns: chatColumns(sql, isReport), from: sql`FROM chat_messages cm LEFT JOIN users u ON u.id = cm.sender_id`, context: () => resolveReportCity(scope), @@ -492,7 +506,10 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha return roomSetPinned(sql, roomSql(scope), messageId, userId, pinned) } - function listPinsScoped(scope: RoomScope, viewerUserId: string | null): Promise { + function listPinsScoped( + scope: RoomScope, + viewerUserId: string | null, + ): Promise { return roomListPins(sql, roomSql(scope), viewerUserId) } @@ -532,7 +549,7 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha undefined, [], reportCity, - row.reply_to_id !== null ? replyByTarget.get(row.reply_to_id) ?? null : null, + row.reply_to_id !== null ? (replyByTarget.get(row.reply_to_id) ?? null) : null, ) } @@ -588,8 +605,19 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha ? resolveReportCity({ column: "report_id", id: input.cleanupId }) : Promise.resolve(null), ]) - const attachments = wantsMedia ? (await loadChatAttachments(sql, [id], presign!)).get(id) ?? [] : [] - return toMessageDTO(rows[0]!, [], [], input.userId, input.clientId, attachments, reportCity, replyTo) + const attachments = wantsMedia + ? ((await loadChatAttachments(sql, [id], presign!)).get(id) ?? []) + : [] + return toMessageDTO( + rows[0]!, + [], + [], + input.userId, + input.clientId, + attachments, + reportCity, + replyTo, + ) }, async findMessageMeta(messageId: string): Promise { @@ -631,7 +659,13 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha viewerUserId: string | null = null, around?: string, ): Promise { - return historyScoped({ column: "cleanup_id", id: cleanupId }, before, limit, viewerUserId, around) + return historyScoped( + { column: "cleanup_id", id: cleanupId }, + before, + limit, + viewerUserId, + around, + ) }, editMessage( @@ -706,7 +740,13 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha viewerUserId: string | null = null, around?: string, ): Promise { - return historyScoped({ column: "report_id", id: reportId }, before, limit, viewerUserId, around) + return historyScoped( + { column: "report_id", id: reportId }, + before, + limit, + viewerUserId, + around, + ) }, findReportMessage( diff --git a/services/api/src/services/chat-room-fanout-notifier.ts b/services/api/src/services/chat-room-fanout-notifier.ts index 8590c926..908d2302 100644 --- a/services/api/src/services/chat-room-fanout-notifier.ts +++ b/services/api/src/services/chat-room-fanout-notifier.ts @@ -1,4 +1,3 @@ - import type { ChatMessageDTO } from "@civfix/shared" import type { FastifyBaseLogger } from "fastify" import type { NotificationService } from "./notification-service.js" diff --git a/services/api/src/services/chat-room-notifier-wiring.ts b/services/api/src/services/chat-room-notifier-wiring.ts index 263cefa6..2f8ab00e 100644 --- a/services/api/src/services/chat-room-notifier-wiring.ts +++ b/services/api/src/services/chat-room-notifier-wiring.ts @@ -1,4 +1,3 @@ - import type { FastifyBaseLogger } from "fastify" import type { Container } from "../di.js" import { makeReportChatRepository } from "./report-chat-repository.drizzle.js" diff --git a/services/api/src/services/chat-room-roles.ts b/services/api/src/services/chat-room-roles.ts index 1b0047b4..0e7be3fe 100644 --- a/services/api/src/services/chat-room-roles.ts +++ b/services/api/src/services/chat-room-roles.ts @@ -1,4 +1,3 @@ - import type { CleanupMemberRole, RoomKind } from "@civfix/shared" import { can } from "@civfix/shared/host" import type { diff --git a/services/api/src/services/chat-tombstone.ts b/services/api/src/services/chat-tombstone.ts index 2f7bb99a..687fb2b1 100644 --- a/services/api/src/services/chat-tombstone.ts +++ b/services/api/src/services/chat-tombstone.ts @@ -1,4 +1,3 @@ - import type { ChatMessageDTO } from "@civfix/shared" export function toTombstoneDTO(dto: ChatMessageDTO, deletedAt: Date): ChatMessageDTO { diff --git a/services/api/src/services/claim-service.ts b/services/api/src/services/claim-service.ts index 32f3ca34..3978d6d4 100644 --- a/services/api/src/services/claim-service.ts +++ b/services/api/src/services/claim-service.ts @@ -1,11 +1,6 @@ - import { AppError } from "@civfix/shared" import type { ClaimNudgeResponse, ClaimReportResponse, ReportDTO } from "@civfix/shared" -import { - resolveAnonToken, - type AnonTokenDeps, - type AnonTokenStore, -} from "../abuse/anon-token.js" +import { resolveAnonToken, type AnonTokenDeps, type AnonTokenStore } from "../abuse/anon-token.js" import type { ReportOwner } from "./report-service.js" import { generateToken, sha256Hex } from "../auth/crypto.js" @@ -14,10 +9,7 @@ export interface PendingAnonReport { } export interface ClaimRepository extends AnonTokenStore { - rotatePendingClaimCode( - tokenId: string, - claimCodeHash: string, - ): Promise + rotatePendingClaimCode(tokenId: string, claimCodeHash: string): Promise claimByCode(claimCodeHash: string, userId: string): Promise<{ reportId: string } | null> } diff --git a/services/api/src/services/cleanup-dto.ts b/services/api/src/services/cleanup-dto.ts index 023f7ae4..acc8c510 100644 --- a/services/api/src/services/cleanup-dto.ts +++ b/services/api/src/services/cleanup-dto.ts @@ -150,7 +150,10 @@ export function toCleanupDTO( } } -export function toLinkedReportRef(view: LinkedReportView, thumbUrl: string | null): LinkedReportRef { +export function toLinkedReportRef( + view: LinkedReportView, + thumbUrl: string | null, +): LinkedReportRef { return { id: view.id, category: view.category, diff --git a/services/api/src/services/cleanup-map-repository.ts b/services/api/src/services/cleanup-map-repository.ts index 7d2a9de4..53bbed68 100644 --- a/services/api/src/services/cleanup-map-repository.ts +++ b/services/api/src/services/cleanup-map-repository.ts @@ -1,12 +1,20 @@ import type { CleanupPinDTO } from "@civfix/shared" import type { Sql } from "../db/client.js" import type { CleanupBBox } from "./cleanup-repository.types.js" -import { buildBboxFilter, buildVisibilityFilter, buildWhenFilter, goingScalar } from "./cleanup-sql.js" +import { + buildBboxFilter, + buildVisibilityFilter, + buildWhenFilter, + goingScalar, +} from "./cleanup-sql.js" export const MAP_CLEANUPS_LIMIT = 500 export interface CleanupMapRepository { - listCleanupPins(bbox: CleanupBBox, when: "upcoming" | "past" | undefined): Promise + listCleanupPins( + bbox: CleanupBBox, + when: "upcoming" | "past" | undefined, + ): Promise } export function makeCleanupMapRepository(sql: Sql): CleanupMapRepository { diff --git a/services/api/src/services/cleanup-repository.drizzle.ts b/services/api/src/services/cleanup-repository.drizzle.ts index a235277a..49e9a783 100644 --- a/services/api/src/services/cleanup-repository.drizzle.ts +++ b/services/api/src/services/cleanup-repository.drizzle.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { AppError, @@ -302,9 +301,7 @@ function hostSetFragments(sql: Sql, patch: EventHostWrite): postgres.Fragment[] sets.push(sql`organization_id = ${patch.organizationId}`) } if (patch.reminderOffsetsMin !== undefined) { - sets.push( - sql`reminder_offsets_min = ${patch.reminderOffsetsMin as unknown as number[] | null}`, - ) + sets.push(sql`reminder_offsets_min = ${patch.reminderOffsetsMin as unknown as number[] | null}`) } if (patch.hostReplyTo !== undefined) { sets.push(sql`host_reply_to = ${patch.hostReplyTo}, host_reply_to_verified_at = NULL`) @@ -606,9 +603,7 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { return grouped }, - async loadLinkedEventsForReports( - reportIds: string[], - ): Promise> { + async loadLinkedEventsForReports(reportIds: string[]): Promise> { const grouped = new Map() if (reportIds.length === 0) return grouped const rows = await sql< @@ -1291,7 +1286,10 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { SELECT id, title, starts_at, ends_at FROM cleanup_slots WHERE cleanup_id = ${cleanupId} ` const have = new Map( - existing.map((r) => [r.id, { title: r.title, startsAt: r.starts_at, endsAt: r.ends_at }]), + existing.map((r) => [ + r.id, + { title: r.title, startsAt: r.starts_at, endsAt: r.ends_at }, + ]), ) for (const slot of desired) { @@ -1300,8 +1298,9 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { } } - - const keep = new Set(desired.map((s) => s.id).filter((id): id is string => id !== undefined)) + const keep = new Set( + desired.map((s) => s.id).filter((id): id is string => id !== undefined), + ) const toRemove = [...have.keys()].filter((id) => !keep.has(id)) const removed: SlotReconcileResult["removed"] = [] if (toRemove.length > 0) { @@ -1329,7 +1328,10 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { } const kept = desired.filter((s): s is DesiredSlot & { id: string } => s.id !== undefined) - const changed = (slot: DesiredSlot & { id: string }, keyOf: (s: SlotIdentity) => string): boolean => { + const changed = ( + slot: DesiredSlot & { id: string }, + keyOf: (s: SlotIdentity) => string, + ): boolean => { const before = have.get(slot.id) return before === undefined || keyOf(before) !== keyOf(slot) } @@ -1434,7 +1436,8 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { return { kind: "not_found" } } if (cleanup.status === "cancelled") return { kind: "closed" } - if (hasEventEnded(eventWindowOfRow(cleanup), cleanup.now.getTime())) return { kind: "ended" } + if (hasEventEnded(eventWindowOfRow(cleanup), cleanup.now.getTime())) + return { kind: "ended" } const banned = await tx<{ one: number }[]>` SELECT 1 AS one FROM cleanup_bans @@ -1518,7 +1521,9 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { geoid: string | null, ): Promise<{ contact: string; name: string } | null> { if (geoid === null) return null - const rows = await sql<{ name: string | null; default_email: string | null; legacy_email: string | null }[]>` + const rows = await sql< + { name: string | null; default_email: string | null; legacy_email: string | null }[] + >` SELECT j.name, (SELECT jc.email FROM jurisdiction_contacts jc diff --git a/services/api/src/services/cleanup-repository.types.ts b/services/api/src/services/cleanup-repository.types.ts index d9d56ae2..cb5073e6 100644 --- a/services/api/src/services/cleanup-repository.types.ts +++ b/services/api/src/services/cleanup-repository.types.ts @@ -241,11 +241,7 @@ export type RemoveMemberOutcome = | { kind: "closed" } | { kind: "not_found" } -export type CancelCleanupOutcome = - | "cancelled" - | "already_cancelled" - | "already_ended" - | "not_found" +export type CancelCleanupOutcome = "cancelled" | "already_cancelled" | "already_ended" | "not_found" export interface NearPoint { lat: number @@ -341,7 +337,6 @@ export interface CleanupRepository { ): Promise listAttendees(args: ListAttendeesArgs): Promise - listSlots(cleanupId: string, viewerId: string | null): Promise loadSlotsForCleanups( cleanupIds: string[], @@ -361,7 +356,9 @@ export interface CleanupRepository { ): Promise releaseSlot(cleanupId: string, userId: string): Promise slotOf(cleanupId: string, userId: string): Promise - resolveJurisdictionContact(geoid: string | null): Promise<{ contact: string; name: string } | null> + resolveJurisdictionContact( + geoid: string | null, + ): Promise<{ contact: string; name: string } | null> appendCleanupTimeline( cleanupId: string, input: { kind: string; note: string | null; actorId: string | null }, diff --git a/services/api/src/services/cleanup-service.ts b/services/api/src/services/cleanup-service.ts index 7e727504..3d48c5d1 100644 --- a/services/api/src/services/cleanup-service.ts +++ b/services/api/src/services/cleanup-service.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { AppError, @@ -90,10 +89,7 @@ import { eventWindowOf, } from "./cleanup-rules.js" import type { EventWindow } from "./cleanup-rules.js" -import { - CLEANUP_GUEST_UPDATE_FANOUT_JOB, - type GuestUpdateFanoutJob, -} from "./guest-rsvp-service.js" +import { CLEANUP_GUEST_UPDATE_FANOUT_JOB, type GuestUpdateFanoutJob } from "./guest-rsvp-service.js" export * from "./cleanup-repository.types.js" export * from "./cleanup-rules.js" @@ -312,8 +308,7 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { return { seatId, tokenHash: deps.tickets.hashFor(seatId) } } - const enrichDTOs = - deps.enrichDTOs ?? ((dtos: CleanupDTO[]) => Promise.resolve(dtos)) + const enrichDTOs = deps.enrichDTOs ?? ((dtos: CleanupDTO[]) => Promise.resolve(dtos)) async function enrichOne(dto: CleanupDTO, viewerUserId: string | null): Promise { const [enriched] = await enrichDTOs([dto], viewerUserId) @@ -333,7 +328,10 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { return deps.repo.standingsOf(cleanupIds, userId) } - function assertVisible(record: { visibility: CleanupRecord["visibility"] }, standing: HostStanding): void { + function assertVisible( + record: { visibility: CleanupRecord["visibility"] }, + standing: HostStanding, + ): void { if (!hasHostStanding(standing) && !isEventPubliclyVisible(record.visibility)) { notFoundCleanup() } @@ -369,7 +367,11 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { async function eventMediaUrls( record: CleanupRecord, opts: { gallery: boolean }, - ): Promise<{ coverUrl: string | null; galleryUrls: string[]; organizationLogoUrl: string | null }> { + ): Promise<{ + coverUrl: string | null + galleryUrls: string[] + organizationLogoUrl: string | null + }> { const presign = deps.presignEventMedia if (presign === undefined) { return { coverUrl: null, galleryUrls: [], organizationLogoUrl: null } @@ -380,7 +382,9 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { opts.gallery && record.galleryMediaIds.length > 0 ? deps.repo .galleryKeysFor(record.id) - .then((keys) => mapWithLimit(keys, PRESIGN_CONCURRENCY, (key) => presign(key, { forceSigned }))) + .then((keys) => + mapWithLimit(keys, PRESIGN_CONCURRENCY, (key) => presign(key, { forceSigned })), + ) : Promise.resolve([]), record.organization === null || record.organization.logoKey === null ? Promise.resolve(null) @@ -423,7 +427,10 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { organizationId: string | null, actorId: string, opts: { linking: boolean }, - ): Promise<{ organization: CleanupOrganizationView | null; orgRole: OrganizationMemberRole | null }> { + ): Promise<{ + organization: CleanupOrganizationView | null + orgRole: OrganizationMemberRole | null + }> { if (organizationId === null) return { organization: null, orgRole: null } const [organization, orgRole] = await Promise.all([ deps.repo.loadOrganizationRef(organizationId), @@ -439,9 +446,7 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { // linking is refused, while an unchanged organizationId on an edit passes so the host can still // manage what already exists. if (opts.linking && organization.suspended) { - throw AppError.conflict( - "That organization is suspended and can't host events right now.", - ) + throw AppError.conflict("That organization is suspended and can't host events right now.") } return { organization, orgRole } } @@ -882,7 +887,9 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { if (presign === undefined) return out const withCover = records.filter((r) => r.coverKey !== null) const urls = await mapWithLimit(withCover, PRESIGN_CONCURRENCY, (record) => - presign(record.coverKey as string, { forceSigned: !isEventPubliclyVisible(record.visibility) }), + presign(record.coverKey as string, { + forceSigned: !isEventPubliclyVisible(record.visibility), + }), ) withCover.forEach((record, i) => { const url = urls[i] @@ -971,7 +978,9 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { throw AppError.validation({ linkedReportIds: "only cleanup events can link reports" }) } await assertReportsLinkable(linkedReportIds) - const addressWrite = await resolveEventAddress(input, { fromStoredEvent: copyFrom !== undefined }) + const addressWrite = await resolveEventAddress(input, { + fromStoredEvent: copyFrom !== undefined, + }) if (input.endsAt === null) throw AppError.validation({ endsAt: "required" }) if (input.slots !== undefined && input.slots.length === 0) { throw AppError.validation({ slots: EVENT_NEEDS_A_SLOT_MESSAGE }) @@ -1190,7 +1199,8 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { } const effectiveWindow: EventWindow = { status: current.status, - scheduledAt: patch.scheduledAt !== undefined ? new Date(patch.scheduledAt) : current.scheduledAt, + scheduledAt: + patch.scheduledAt !== undefined ? new Date(patch.scheduledAt) : current.scheduledAt, endsAt: patch.endsAt !== undefined ? new Date(patch.endsAt) : current.endsAt, } const windowMoved = @@ -1597,11 +1607,10 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { target: `cleanup:${id}`, meta: { targetUserId, from: targetRole, to: role }, }) - await notifyRoleChange( - targetUserId, - role === "member" ? "demoted" : "promoted", - { id: record.id, title: record.title }, - ) + await notifyRoleChange(targetUserId, role === "member" ? "demoted" : "promoted", { + id: record.id, + title: record.title, + }) return { ok: true } }, @@ -1644,11 +1653,7 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { return { ok: true, going: outcome.going } }, - async claimEventSlot( - id: string, - userId: string, - slotId: string | null, - ): Promise { + async claimEventSlot(id: string, userId: string, slotId: string | null): Promise { const flips = await counters.incr(`cleanup:slot:${id}:${userId}`, SLOT_FLIP_WINDOW_SEC) if (flips > SLOT_FLIPS_PER_EVENT_PER_WINDOW) { throw AppError.rateLimited( @@ -1690,11 +1695,17 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { eventMediaUrls(updated, { gallery: true }), ]) return enrichOne( - toCleanupDTO(updated, hasHostStanding(nextStanding), linkedReports, nextStanding.eventRole, { - slots: slotBoard, - myCapabilities: capabilityList(nextStanding), - ...media, - }), + toCleanupDTO( + updated, + hasHostStanding(nextStanding), + linkedReports, + nextStanding.eventRole, + { + slots: slotBoard, + myCapabilities: capabilityList(nextStanding), + ...media, + }, + ), userId, ) }, @@ -1780,11 +1791,7 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { async runCancelFanout(job: CleanupCancelFanoutJob): Promise { const record = await deps.repo.findCleanupById(job.cleanupId, null) if (record === null) return - await notifyCancellation( - { id: record.id, title: record.title }, - job.reason, - job.actorId, - ) + await notifyCancellation({ id: record.id, title: record.title }, job.reason, job.actorId) }, } } diff --git a/services/api/src/services/cleanup-sql.ts b/services/api/src/services/cleanup-sql.ts index 1fe948ed..e1dba58d 100644 --- a/services/api/src/services/cleanup-sql.ts +++ b/services/api/src/services/cleanup-sql.ts @@ -247,8 +247,7 @@ export function goingJoin(sql: Queryable) { export function buildWhenFilter(sql: Sql, when: "upcoming" | "past" | "attending" | undefined) { if (when === "upcoming" || when === "attending") return sql`AND c.status <> 'cancelled' AND c.ends_at > now()` - if (when === "past") - return sql`AND c.status <> 'cancelled' AND c.ends_at <= now()` + if (when === "past") return sql`AND c.status <> 'cancelled' AND c.ends_at <= now()` return sql`AND c.status <> 'cancelled'` } diff --git a/services/api/src/services/content-report-subject.ts b/services/api/src/services/content-report-subject.ts index 7aeb0c84..7cf5be3e 100644 --- a/services/api/src/services/content-report-subject.ts +++ b/services/api/src/services/content-report-subject.ts @@ -128,7 +128,12 @@ async function isChatMessageReportable( if (msg.report_id !== null) { const rows = await sql< - { reporter_user_id: string | null; status: string; visibility: string; deleted_at: Date | null }[] + { + reporter_user_id: string | null + status: string + visibility: string + deleted_at: Date | null + }[] >` SELECT reporter_user_id, status, visibility, deleted_at FROM reports WHERE id = ${msg.report_id} LIMIT 1 diff --git a/services/api/src/services/conversation-mutes-repository.drizzle.ts b/services/api/src/services/conversation-mutes-repository.drizzle.ts index a2e627b1..d7b0cc21 100644 --- a/services/api/src/services/conversation-mutes-repository.drizzle.ts +++ b/services/api/src/services/conversation-mutes-repository.drizzle.ts @@ -53,7 +53,11 @@ export interface ConversationMutesRepository { export function makeConversationMutesRepository(sql: Sql): ConversationMutesRepository { return { - async isMuted(userId: string, roomKind: ConversationMuteRoomKind, roomId: string): Promise { + async isMuted( + userId: string, + roomKind: ConversationMuteRoomKind, + roomId: string, + ): Promise { const rows = await sql<{ exists: boolean }[]>` SELECT EXISTS ( SELECT 1 FROM conversation_mutes diff --git a/services/api/src/services/data-export-jobs.ts b/services/api/src/services/data-export-jobs.ts index 85f3f488..2cd2eeed 100644 --- a/services/api/src/services/data-export-jobs.ts +++ b/services/api/src/services/data-export-jobs.ts @@ -1,10 +1,7 @@ import { AppError, ErrorCode } from "@civfix/shared" import type { Env } from "../env.js" import type { Container } from "../di.js" -import { - makeDataExportService, - type DataExportService, -} from "./data-export-service.js" +import { makeDataExportService, type DataExportService } from "./data-export-service.js" export const DATA_EXPORT_JOB = "data.export" diff --git a/services/api/src/services/data-export-service.ts b/services/api/src/services/data-export-service.ts index 2e66d593..a6354d72 100644 --- a/services/api/src/services/data-export-service.ts +++ b/services/api/src/services/data-export-service.ts @@ -175,17 +175,13 @@ export function makeDataExportService(deps: DataExportServiceDeps): DataExportSe LIMIT ${DATA_EXPORT_MAX_ROWS + 1} ` - const cleanupsOrganized = sql< - { id: string; title: string | null; created_at: Date }[] - >` + const cleanupsOrganized = sql<{ id: string; title: string | null; created_at: Date }[]>` SELECT id, title, created_at FROM cleanups WHERE organizer_user_id = ${userId} ORDER BY created_at DESC LIMIT ${DATA_EXPORT_MAX_ROWS + 1} ` - const cleanupsJoined = sql< - { cleanup_id: string; role: string; joined_at: Date }[] - >` + const cleanupsJoined = sql<{ cleanup_id: string; role: string; joined_at: Date }[]>` SELECT cleanup_id, role, joined_at FROM cleanup_members WHERE user_id = ${userId} ORDER BY joined_at DESC @@ -314,9 +310,7 @@ export function makeDataExportService(deps: DataExportServiceDeps): DataExportSe LIMIT ${DATA_EXPORT_MAX_ROWS + 1} ` - const eventAnswers = sql< - { cleanup_id: string; prompt: string; value: string | null }[] - >` + const eventAnswers = sql<{ cleanup_id: string; prompt: string; value: string | null }[]>` SELECT a.cleanup_id, q.prompt, COALESCE(a.value_text, a.value_json::text) AS value FROM cleanup_answers a diff --git a/services/api/src/services/discussion-mentions.ts b/services/api/src/services/discussion-mentions.ts index b024673e..525fc0e6 100644 --- a/services/api/src/services/discussion-mentions.ts +++ b/services/api/src/services/discussion-mentions.ts @@ -1,4 +1,3 @@ - export function jurisdictionHandle(name: string | null | undefined): string | null { if (name === null || name === undefined) return null let s = name.toLowerCase().trim() @@ -10,7 +9,10 @@ export function jurisdictionHandle(name: string | null | undefined): string | nu return s === "" ? null : s } -export function effectiveJurisdictionHandle(j: { handle: string | null; name: string }): string | null { +export function effectiveJurisdictionHandle(j: { + handle: string | null + name: string +}): string | null { return j.handle ?? jurisdictionHandle(j.name) } @@ -40,7 +42,10 @@ export function parseUserMentions(body: string): string[] { return out } -export function parseCityMention(body: string, cityHandle: string | null | undefined): string | null { +export function parseCityMention( + body: string, + cityHandle: string | null | undefined, +): string | null { if (cityHandle === null || cityHandle === undefined) return null const handle = cityHandle.trim() if (handle === "") return null diff --git a/services/api/src/services/dm-repository.drizzle.ts b/services/api/src/services/dm-repository.drizzle.ts index cbbdb9a9..3cf19657 100644 --- a/services/api/src/services/dm-repository.drizzle.ts +++ b/services/api/src/services/dm-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Queryable, Sql } from "../db/client.js" import { publicAuthorIdentity } from "./public-author.js" import type { @@ -11,7 +10,11 @@ import type { UserMentionDTO, } from "@civfix/shared" import type { ChatHistoryPage } from "@civfix/shared/interfaces" -import { loadChatReactions, loadChatReactionsFor, toggleChatReaction } from "./chat-reactions.drizzle.js" +import { + loadChatReactions, + loadChatReactionsFor, + toggleChatReaction, +} from "./chat-reactions.drizzle.js" import { loadChatMentions, loadChatMentionsFor } from "./chat-mentions.drizzle.js" import { attachChatMedia, loadChatAttachments } from "./chat-attachments.drizzle.js" import { monotonicReadWatermark } from "./chat-read-state.drizzle.js" @@ -86,11 +89,7 @@ export interface DmRepository { body: string, ): Promise findMessageMeta(messageId: string): Promise - softDelete( - threadId: string, - messageId: string, - senderId: string, - ): Promise + softDelete(threadId: string, messageId: string, senderId: string): Promise setPinned( threadId: string, messageId: string, @@ -252,7 +251,9 @@ export function makeDrizzleDmRepository(sql: Sql, presign?: PresignMedia): DmRep const ids = liveMessageIds(page) const [attachmentsByMessage, reactionsByMessage, mentionsByMessage, replyByTarget] = await Promise.all([ - presign ? loadChatAttachments(sql, ids, presign) : Promise.resolve(new Map()), + presign + ? loadChatAttachments(sql, ids, presign) + : Promise.resolve(new Map()), loadChatReactionsFor(sql, ids, viewerUserId), loadChatMentionsFor(sql, ids), replyMapForRows(sql, "dm_messages", page), @@ -265,17 +266,22 @@ export function makeDrizzleDmRepository(sql: Sql, presign?: PresignMedia): DmRep viewerUserId, undefined, attachmentsByMessage.get(r.id) ?? [], - r.reply_to_id !== null ? replyByTarget.get(r.reply_to_id) ?? null : null, + r.reply_to_id !== null ? (replyByTarget.get(r.reply_to_id) ?? null) : null, ), ) } - async function hydrateDmRow(row: DmRowSelect, viewerUserId: string | null): Promise { + async function hydrateDmRow( + row: DmRowSelect, + viewerUserId: string | null, + ): Promise { const liveIds = liveMessageIds([row]) const [reactions, mentions, attachmentsByMessage, replyByTarget] = await Promise.all([ liveIds.length > 0 ? loadChatReactions(sql, row.id, viewerUserId) : Promise.resolve([]), liveIds.length > 0 ? loadChatMentions(sql, row.id) : Promise.resolve([]), - presign ? loadChatAttachments(sql, liveIds, presign) : Promise.resolve(new Map()), + presign + ? loadChatAttachments(sql, liveIds, presign) + : Promise.resolve(new Map()), replyMapForRows(sql, "dm_messages", [row]), ]) return toMessageDTO( @@ -285,7 +291,7 @@ export function makeDrizzleDmRepository(sql: Sql, presign?: PresignMedia): DmRep viewerUserId, undefined, attachmentsByMessage.get(row.id) ?? [], - row.reply_to_id !== null ? replyByTarget.get(row.reply_to_id) ?? null : null, + row.reply_to_id !== null ? (replyByTarget.get(row.reply_to_id) ?? null) : null, ) } @@ -294,7 +300,9 @@ export function makeDrizzleDmRepository(sql: Sql, presign?: PresignMedia): DmRep table: "dm_messages", alias: "dm", scope: (prefix) => - prefix === null ? sql`thread_id = ${threadId}` : sql`${sql(prefix)}.thread_id = ${threadId}`, + prefix === null + ? sql`thread_id = ${threadId}` + : sql`${sql(prefix)}.thread_id = ${threadId}`, columns: dmColumns, from: sql`FROM dm_messages dm JOIN users u ON u.id = dm.sender_id`, context: () => Promise.resolve(null), @@ -388,7 +396,7 @@ export function makeDrizzleDmRepository(sql: Sql, presign?: PresignMedia): DmRep : await run(sql) const messageId = rows[0]!.id const attachments = wantsMedia - ? (await loadChatAttachments(sql, [messageId], presign!)).get(messageId) ?? [] + ? ((await loadChatAttachments(sql, [messageId], presign!)).get(messageId) ?? []) : [] return toMessageDTO(rows[0]!, [], [], input.senderId, input.clientId, attachments, replyTo) }, @@ -471,7 +479,7 @@ export function makeDrizzleDmRepository(sql: Sql, presign?: PresignMedia): DmRep senderId, undefined, [], - row.reply_to_id !== null ? replyByTarget.get(row.reply_to_id) ?? null : null, + row.reply_to_id !== null ? (replyByTarget.get(row.reply_to_id) ?? null) : null, ) }, @@ -498,7 +506,12 @@ export function makeDrizzleDmRepository(sql: Sql, presign?: PresignMedia): DmRep }, async markRead(threadId: string, userId: string, at: Date): Promise { - await monotonicReadWatermark(sql, "dm_read_state", { thread_id: threadId, user_id: userId }, at) + await monotonicReadWatermark( + sql, + "dm_read_state", + { thread_id: threadId, user_id: userId }, + at, + ) }, async lastReadAt(threadId: string, userId: string): Promise { diff --git a/services/api/src/services/dm-repository.memory.ts b/services/api/src/services/dm-repository.memory.ts index 447b6734..3dbd00aa 100644 --- a/services/api/src/services/dm-repository.memory.ts +++ b/services/api/src/services/dm-repository.memory.ts @@ -1,13 +1,8 @@ - import { randomUUID } from "node:crypto" import { avatarGradient, AppError } from "@civfix/shared" import type { ChatMessageDTO, ReactionEmoji, ReactionSummaryDTO, ReplyToDTO } from "@civfix/shared" import type { ChatHistoryPage } from "@civfix/shared/interfaces" -import { - REPLY_EXCERPT_MAX, - replyDeletedTarget, - replyWrongRoom, -} from "./chat-reply-hydration.js" +import { REPLY_EXCERPT_MAX, replyDeletedTarget, replyWrongRoom } from "./chat-reply-hydration.js" import { PIN_LIST_CAP } from "./chat-repository.drizzle.js" import { publicAuthorIdentity } from "./public-author.js" import { aroundLimits } from "./chat-history-window.js" @@ -75,7 +70,9 @@ export class InMemoryDmRepository implements DmRepository { } private userOf(id: string): DmUser { - return this.users.get(id) ?? { id, displayName: `User ${id.slice(0, 4)}`, handle: null, bio: null } + return ( + this.users.get(id) ?? { id, displayName: `User ${id.slice(0, 4)}`, handle: null, bio: null } + ) } private nextDate(): Date { @@ -105,7 +102,12 @@ export class InMemoryDmRepository implements DmRepository { const key = `${lo}:${hi}` const existingId = this.byPair.get(key) if (existingId) return Promise.resolve(this.threads.get(existingId)!) - const thread: DmThread = { id: randomUUID(), userLo: lo, userHi: hi, createdAt: this.nextDate() } + const thread: DmThread = { + id: randomUUID(), + userLo: lo, + userHi: hi, + createdAt: this.nextDate(), + } this.threads.set(thread.id, thread) this.byPair.set(key, thread.id) return Promise.resolve(thread) @@ -251,7 +253,10 @@ export class InMemoryDmRepository implements DmRepository { }) .slice(0, PIN_LIST_CAP) .map((m) => - this.withReply(threadId, { ...m.dto, reactions: this.reactionsFor(m.dto.id, viewerUserId) }), + this.withReply(threadId, { + ...m.dto, + reactions: this.reactionsFor(m.dto.id, viewerUserId), + }), ) return Promise.resolve(pins) } @@ -273,7 +278,10 @@ export class InMemoryDmRepository implements DmRepository { const ordered = afterAnchor .filter((m) => !m.deleted) .map((m) => - this.withReply(threadId, { ...m.dto, reactions: this.reactionsFor(m.dto.id, viewerUserId) }), + this.withReply(threadId, { + ...m.dto, + reactions: this.reactionsFor(m.dto.id, viewerUserId), + }), ) const page = ordered.slice(0, limit) const nextCursor = ordered.length > limit ? (page[page.length - 1]?.id ?? null) : null @@ -336,7 +344,10 @@ export class InMemoryDmRepository implements DmRepository { const stored = (this.log.get(threadId) ?? []).find((m) => m.dto.id === messageId && !m.deleted) if (!stored) return Promise.resolve(null) return Promise.resolve( - this.withReply(threadId, { ...stored.dto, reactions: this.reactionsFor(messageId, viewerUserId) }), + this.withReply(threadId, { + ...stored.dto, + reactions: this.reactionsFor(messageId, viewerUserId), + }), ) } @@ -380,9 +391,7 @@ export class InMemoryDmRepository implements DmRepository { ) const unread = (this.log.get(threadId) ?? []).filter( (m) => - !m.deleted && - m.dto.from?.id !== userId && - new Date(m.dto.createdAt).getTime() > baseline, + !m.deleted && m.dto.from?.id !== userId && new Date(m.dto.createdAt).getTime() > baseline, ).length return Promise.resolve(unread) } @@ -432,7 +441,11 @@ export class InMemoryDmRepository implements DmRepository { }, last: last !== null - ? { body: last.body ?? null, createdAt: new Date(last.createdAt), senderId: lastSenderId(last) } + ? { + body: last.body ?? null, + createdAt: new Date(last.createdAt), + senderId: lastSenderId(last), + } : null, unread, }) diff --git a/services/api/src/services/dm-service.ts b/services/api/src/services/dm-service.ts index 462ffeff..92bfd90d 100644 --- a/services/api/src/services/dm-service.ts +++ b/services/api/src/services/dm-service.ts @@ -1,4 +1,3 @@ - import { AppError, relativeAgo, avatarGradient } from "@civfix/shared" import type { ChatMessageDTO, MessageThreadDTO, PersonDTO } from "@civfix/shared" import type { BlocksRepository } from "./blocks-repository.drizzle.js" diff --git a/services/api/src/services/feed-presence.ts b/services/api/src/services/feed-presence.ts index 59e73646..fde27ccf 100644 --- a/services/api/src/services/feed-presence.ts +++ b/services/api/src/services/feed-presence.ts @@ -26,7 +26,11 @@ export interface FeedSnapshotEntry { export interface FeedPresence { readonly snapshotsAvailable: boolean readSnapshot(userId: string, filter: string): Promise - writeSnapshot(userId: string, filter: string, ranked: readonly RankedCandidate[]): Promise + writeSnapshot( + userId: string, + filter: string, + ranked: readonly RankedCandidate[], + ): Promise touchSnapshot(userId: string, filter: string): Promise seenBy(userId: string, postIds: readonly string[]): Promise> recordServed(userId: string, postIds: readonly string[]): Promise diff --git a/services/api/src/services/group-chat-notifier.ts b/services/api/src/services/group-chat-notifier.ts index f2a86820..c845dc6f 100644 --- a/services/api/src/services/group-chat-notifier.ts +++ b/services/api/src/services/group-chat-notifier.ts @@ -1,4 +1,3 @@ - import type { ChatMessageDTO } from "@civfix/shared" import type { NotificationService } from "./notification-service.js" import { makeRoomFanoutNotifier } from "./chat-room-fanout-notifier.js" diff --git a/services/api/src/services/guest-jobs.ts b/services/api/src/services/guest-jobs.ts index c820824b..e57d037c 100644 --- a/services/api/src/services/guest-jobs.ts +++ b/services/api/src/services/guest-jobs.ts @@ -48,10 +48,7 @@ export async function registerGuestJobs( await container.jobs.work(CLEANUP_GUEST_UPDATE_FANOUT_JOB, async (job) => { const data = parseUpdateFanoutJob(job.data) if (data === null) { - logger?.warn( - { jobId: job.id }, - "cleanup.guest.update.fanout: malformed job data (skipped)", - ) + logger?.warn({ jobId: job.id }, "cleanup.guest.update.fanout: malformed job data (skipped)") return } await runGuestUpdateFanout( diff --git a/services/api/src/services/guest-rsvp-service.ts b/services/api/src/services/guest-rsvp-service.ts index 22d243ef..a34ee7c1 100644 --- a/services/api/src/services/guest-rsvp-service.ts +++ b/services/api/src/services/guest-rsvp-service.ts @@ -186,11 +186,7 @@ export interface GuestRsvpRepository { recordPhoneOptOut(phone: string): Promise invalidateActiveOtps(cleanupId: string, contact: string, now: Date): Promise insertOtp(args: InsertGuestOtpArgs): Promise - findLatestActiveOtp( - cleanupId: string, - contact: string, - now: Date, - ): Promise + findLatestActiveOtp(cleanupId: string, contact: string, now: Date): Promise incrementOtpAttempts(otpId: string): Promise markOtpConsumed(otpId: string, now: Date): Promise upsertVerifiedGuest(args: UpsertGuestArgs): Promise<{ id: string }> @@ -584,9 +580,7 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi ...(registration.ticketTypeId !== undefined ? { ticketTypeId: registration.ticketTypeId } : {}), - ...(registration.accessCode !== undefined - ? { accessCode: registration.accessCode } - : {}), + ...(registration.accessCode !== undefined ? { accessCode: registration.accessCode } : {}), ...(registration.answers !== undefined ? { answers: registration.answers } : {}), ...(registration.consent !== undefined ? { consent: registration.consent } : {}), }, @@ -627,11 +621,7 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi manageTokenHash, now: new Date(now()), }) - const seat = await registerVerifiedGuest( - args.event.id, - guest.id, - args.registration ?? {}, - ) + const seat = await registerVerifiedGuest(args.event.id, guest.id, args.registration ?? {}) const going = await deps.repo.goingCount(args.event.id) if (args.confirm) { await sendConfirmation({ ...args, rawToken }).catch((err: unknown) => { @@ -660,7 +650,10 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi if (args.channel === "email") { await deps.mailer.sendTransactional(args.contact, "guest_confirmed", { title: args.event.title, - when: formatEventWhen(args.event.scheduledAt, args.event.timezone ?? DEFAULT_EVENT_TIME_ZONE), + when: formatEventWhen( + args.event.scheduledAt, + args.event.timezone ?? DEFAULT_EVENT_TIME_ZONE, + ), ...(args.event.address !== null && args.event.address.length > 0 ? { place: args.event.address } : {}), @@ -928,14 +921,16 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi limit, }) const count = await deps.repo.countActiveGuests(event.id) - await deps.audit?.({ - actorId: viewerUserId, - action: "event.guests_viewed", - target: `cleanup:${event.id}`, - meta: { rows: rows.length }, - }).catch((err: unknown) => { - deps.logger?.warn({ err }, "guest contact: audit write failed (suppressed)") - }) + await deps + .audit?.({ + actorId: viewerUserId, + action: "event.guests_viewed", + target: `cleanup:${event.id}`, + meta: { rows: rows.length }, + }) + .catch((err: unknown) => { + deps.logger?.warn({ err }, "guest contact: audit write failed (suppressed)") + }) return { guests: rows.map(toCleanupGuestDTO), count, nextCursor } }, diff --git a/services/api/src/services/guest-rsvp-wiring.ts b/services/api/src/services/guest-rsvp-wiring.ts index efc7e295..75b85799 100644 --- a/services/api/src/services/guest-rsvp-wiring.ts +++ b/services/api/src/services/guest-rsvp-wiring.ts @@ -52,14 +52,16 @@ export function makeContainerGuestRsvpService( subject, ), assertInputValid: (cleanupId, fields) => - makeContainerRegistrationServices(container, undefined, logger).registrations.assertInputValid( - { - id: cleanupId, - ...(fields.ticketTypeId !== undefined ? { ticketTypeId: fields.ticketTypeId } : {}), - ...(fields.answers !== undefined ? { answers: fields.answers } : {}), - ...(fields.consent !== undefined ? { consent: fields.consent } : {}), - }, - ), + makeContainerRegistrationServices( + container, + undefined, + logger, + ).registrations.assertInputValid({ + id: cleanupId, + ...(fields.ticketTypeId !== undefined ? { ticketTypeId: fields.ticketTypeId } : {}), + ...(fields.answers !== undefined ? { answers: fields.answers } : {}), + ...(fields.consent !== undefined ? { consent: fields.consent } : {}), + }), } const audit: GuestRsvpServiceDeps["audit"] = overrides?.audit ?? diff --git a/services/api/src/services/host/admin-pages-repository.drizzle.ts b/services/api/src/services/host/admin-pages-repository.drizzle.ts index 865afdcd..62f2196e 100644 --- a/services/api/src/services/host/admin-pages-repository.drizzle.ts +++ b/services/api/src/services/host/admin-pages-repository.drizzle.ts @@ -117,7 +117,8 @@ export function makeDrizzleAdminEventPageRepository(sql: Sql): AdminEventPageRep return { async list(params: AdminEventPageListParams): Promise { - const statusFilter = params.status !== undefined ? sql`AND p.status = ${params.status}` : sql`` + const statusFilter = + params.status !== undefined ? sql`AND p.status = ${params.status}` : sql`` const flaggedFilter = params.flagged === undefined ? sql`` diff --git a/services/api/src/services/host/analytics-repository.drizzle.ts b/services/api/src/services/host/analytics-repository.drizzle.ts index 665c56b2..e8666686 100644 --- a/services/api/src/services/host/analytics-repository.drizzle.ts +++ b/services/api/src/services/host/analytics-repository.drizzle.ts @@ -162,8 +162,18 @@ export interface HostSummarySignups { export interface AnalyticsRepository { eventKpis(cleanupId: string): Promise - registrationsByDay(cleanupId: string, timezone: string, from: string, to: string): Promise - cancellationsByDay(cleanupId: string, timezone: string, from: string, to: string): Promise + registrationsByDay( + cleanupId: string, + timezone: string, + from: string, + to: string, + ): Promise + cancellationsByDay( + cleanupId: string, + timezone: string, + from: string, + to: string, + ): Promise registrationsByTicketType(cleanupId: string): Promise registrationsByAudience(cleanupId: string): Promise checkinsByTicketType(cleanupId: string): Promise diff --git a/services/api/src/services/host/analytics-service.ts b/services/api/src/services/host/analytics-service.ts index c826b437..3a1c265c 100644 --- a/services/api/src/services/host/analytics-service.ts +++ b/services/api/src/services/host/analytics-service.ts @@ -42,14 +42,21 @@ import { DEFAULT_EVENT_TIME_ZONE } from "./event-fields.js" function shiftDayKey(day: string, deltaDays: number): string { const [year, month, date] = day.split("-").map(Number) - const shifted = new Date(Date.UTC(year ?? 1970, (month ?? 1) - 1, date ?? 1) + deltaDays * 86_400_000) + const shifted = new Date( + Date.UTC(year ?? 1970, (month ?? 1) - 1, date ?? 1) + deltaDays * 86_400_000, + ) return shifted.toISOString().slice(0, 10) } export const PORTFOLIO_EVENT_LIMIT = 200 export const ARRIVAL_SAMPLE_LIMIT = 20_000 -const RANGE_DAYS: Record = { "7d": 7, "30d": 30, "90d": 90, all: null } +const RANGE_DAYS: Record = { + "7d": 7, + "30d": 30, + "90d": 90, + all: null, +} const PORTFOLIO_RANGE_DAYS: Record = { "30d": 30, "90d": 90, @@ -69,7 +76,11 @@ export interface AnalyticsServiceDeps { } export interface AnalyticsService { - overview(cleanupId: string, range: AnalyticsRange, viewerScope: string): Promise + overview( + cleanupId: string, + range: AnalyticsRange, + viewerScope: string, + ): Promise registrations( cleanupId: string, range: AnalyticsRange, @@ -148,12 +159,7 @@ export function makeAnalyticsService(deps: AnalyticsServiceDeps): AnalyticsServi const window = eventRangeWindow(RANGE_DAYS[range], timezone) const [kpis, metricRows, registrationDays] = await Promise.all([ deps.analytics.eventKpis(cleanupId), - deps.metrics.read( - cleanupId, - ["page_views", "donation_clicks"], - window.from, - window.to, - ), + deps.metrics.read(cleanupId, ["page_views", "donation_clicks"], window.from, window.to), deps.analytics.registrationsByDay(cleanupId, timezone, window.from, window.to), ]) const registeredPublishable = closureAllowsTotal(closureOf(registrationDays, window)) @@ -213,7 +219,9 @@ export function makeAnalyticsService(deps: AnalyticsServiceDeps): AnalyticsServi cumulative: toSeries(closure.cumulative), byTicketType: toPanel(breakdown(byType, { totalPublishable: registeredPublishable })), byAudience: toPanel(breakdown(byAudience, { totalPublishable: registeredPublishable })), - cancellations: toSeries(dailySeries(cancellations, window, { suppressPoints: true }).points), + cancellations: toSeries( + dailySeries(cancellations, window, { suppressPoints: true }).points, + ), waitlistConversion: toRate(waitlist.promoted, waitlist.joined), } }) @@ -290,9 +298,7 @@ export function makeAnalyticsService(deps: AnalyticsServiceDeps): AnalyticsServi failed: failedByChannel.get(channel) ?? null, suppressed: suppressedByChannel.get(channel) ?? null, })), - series: toSeries( - dailySeries(seriesOf(rows, "broadcast_sent"), window).points, - ), + series: toSeries(dailySeries(seriesOf(rows, "broadcast_sent"), window).points), } }) }, @@ -315,9 +321,7 @@ export function makeAnalyticsService(deps: AnalyticsServiceDeps): AnalyticsServi return { ...envelope(range), pageViews: toSeries(dailySeries(seriesOf(rows, "page_views"), window).points), - bySource: toPanel( - breakdown([...byBucket].map(([key, count]) => ({ key, count }))), - ), + bySource: toPanel(breakdown([...byBucket].map(([key, count]) => ({ key, count })))), donationClicks: suppressCount(sumMetric(rows, "donation_clicks")).value, } }) @@ -564,7 +568,9 @@ function toPanel(panel: DerivedPanel): Panel { } } -function toFunnelSteps(panel: DerivedPanel<{ key: string; value: number | null; suppressed: boolean }>): FunnelStep[] { +function toFunnelSteps( + panel: DerivedPanel<{ key: string; value: number | null; suppressed: boolean }>, +): FunnelStep[] { return panel.rows.map((row) => ({ step: row.key, label: row.key, diff --git a/services/api/src/services/host/broadcast-audience-sql.ts b/services/api/src/services/host/broadcast-audience-sql.ts index cb1135cf..786f4fde 100644 --- a/services/api/src/services/host/broadcast-audience-sql.ts +++ b/services/api/src/services/host/broadcast-audience-sql.ts @@ -1,9 +1,6 @@ import type { BroadcastKind, BroadcastSegment } from "@civfix/shared" import type { Queryable } from "../../db/client.js" -import { - CRITICAL_BROADCAST_KINDS, - HOST_COMPOSED_BROADCAST_KINDS, -} from "./broadcast-types.js" +import { CRITICAL_BROADCAST_KINDS, HOST_COMPOSED_BROADCAST_KINDS } from "./broadcast-types.js" export const AUDIENCE_PAGE_SIZE = 1000 @@ -27,7 +24,10 @@ export async function listMemberAudiencePage( return rows.map((row) => row.id) } -export async function listGuestAudiencePage(sql: Queryable, query: AudienceQuery): Promise { +export async function listGuestAudiencePage( + sql: Queryable, + query: AudienceQuery, +): Promise { const rows = await guestQuery(sql, query) return rows.map((row) => row.id) } diff --git a/services/api/src/services/host/broadcast-capability-token.ts b/services/api/src/services/host/broadcast-capability-token.ts index 13641b63..db4c5be6 100644 --- a/services/api/src/services/host/broadcast-capability-token.ts +++ b/services/api/src/services/host/broadcast-capability-token.ts @@ -84,5 +84,7 @@ function encode(value: string): string { } function sign(body: string, signingKey: string): string { - return createHmac("sha256", signingKey).update(`${UNSUBSCRIBE_TOKEN_VERSION}.${body}`).digest("base64url") + return createHmac("sha256", signingKey) + .update(`${UNSUBSCRIBE_TOKEN_VERSION}.${body}`) + .digest("base64url") } diff --git a/services/api/src/services/host/broadcast-lanes.ts b/services/api/src/services/host/broadcast-lanes.ts index 4ac587c5..08e4e738 100644 --- a/services/api/src/services/host/broadcast-lanes.ts +++ b/services/api/src/services/host/broadcast-lanes.ts @@ -65,7 +65,7 @@ export function makeBroadcastLanes(deps: BroadcastLaneDeps) { const used = await deps.counters.incr( `bcast:evupd:${cleanupId}:${hourKey}`, EVENT_UPDATE_WINDOW_SEC, - ) + ) if (used <= deps.perEventPerHour) return true deps.logger?.warn( { evt: "broadcast.event_updated.throttled", cleanupId, used }, diff --git a/services/api/src/services/host/broadcast-pipeline.ts b/services/api/src/services/host/broadcast-pipeline.ts index ec87d639..e647d4bc 100644 --- a/services/api/src/services/host/broadcast-pipeline.ts +++ b/services/api/src/services/host/broadcast-pipeline.ts @@ -26,10 +26,7 @@ import { renderBroadcast, type RenderedBroadcast, } from "./broadcast-render.js" -import { - mintUnsubscribeToken, - unsubscribeExpiryFrom, -} from "./broadcast-capability-token.js" +import { mintUnsubscribeToken, unsubscribeExpiryFrom } from "./broadcast-capability-token.js" import { AUDIENCE_MAX_PAGES, AUDIENCE_PAGE_SIZE, @@ -287,7 +284,10 @@ export function makeBroadcastPipeline(deps: BroadcastPipelineDeps) { const reservedNow = await repo.markPlanned(record.id, { recipientCount, plannedAt: now() }) if (reservedNow && record.createdBy !== null && BUDGETED_KINDS.has(record.kind)) { - const withinBudget = await deps.service.reserveRecipientBudget(record.createdBy, recipientCount) + const withinBudget = await deps.service.reserveRecipientBudget( + record.createdBy, + recipientCount, + ) if (!withinBudget) { await repo.suppressRemaining(record.id, "cap") await repo.transition(record.id, ["sending"], "failed", { finishedAt: now() }) @@ -576,7 +576,11 @@ export function makeBroadcastPipeline(deps: BroadcastPipelineDeps) { } const hash = addresses.get(claim.id) ?? emailHashOf(email) if (suppressedHashes.has(hash)) { - outcomes.push({ id: claim.id, status: "suppressed", suppressionReason: "bounce_suppressed" }) + outcomes.push({ + id: claim.id, + status: "suppressed", + suppressionReason: "bounce_suppressed", + }) return } const fresh = await claimAddress(record.id, hash, claim.id) diff --git a/services/api/src/services/host/broadcast-render.ts b/services/api/src/services/host/broadcast-render.ts index 68262554..664e5398 100644 --- a/services/api/src/services/host/broadcast-render.ts +++ b/services/api/src/services/host/broadcast-render.ts @@ -143,9 +143,9 @@ export function broadcastLinkWarnings( return inspectBroadcastLinks(text, linkOptions(allowedHosts)).map((issue) => issueCopy(issue)) } -function linkOptions( - allowedHosts: readonly string[] | undefined, -): { allowedHosts?: readonly string[] } { +function linkOptions(allowedHosts: readonly string[] | undefined): { + allowedHosts?: readonly string[] +} { return allowedHosts !== undefined && allowedHosts.length > 0 ? { allowedHosts } : {} } diff --git a/services/api/src/services/host/broadcast-repository.drizzle.ts b/services/api/src/services/host/broadcast-repository.drizzle.ts index 79030a1c..d48545ae 100644 --- a/services/api/src/services/host/broadcast-repository.drizzle.ts +++ b/services/api/src/services/host/broadcast-repository.drizzle.ts @@ -202,8 +202,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { }, async list(query: BroadcastListQuery): Promise { - const statusFilter = - query.status !== undefined ? sql`AND status = ${query.status}` : sql`` + const statusFilter = query.status !== undefined ? sql`AND status = ${query.status}` : sql`` const cursorFilter = query.cursor !== null ? sql`AND (created_at, id) < (${query.cursor.createdAt}, ${query.cursor.id})` @@ -628,9 +627,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { }, async deliveryCounts(broadcastId: string): Promise { - const rows = await sql< - { status: DeliveryStatus; n: string }[] - >` + const rows = await sql<{ status: DeliveryStatus; n: string }[]>` SELECT status, count(*)::text AS n FROM broadcast_deliveries WHERE broadcast_id = ${broadcastId} @@ -670,7 +667,8 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { async listDeliveries(query: DeliveryListQuery): Promise { const statusFilter = query.status !== undefined ? sql`AND status = ${query.status}` : sql`` - const channelFilter = query.channel !== undefined ? sql`AND channel = ${query.channel}` : sql`` + const channelFilter = + query.channel !== undefined ? sql`AND channel = ${query.channel}` : sql`` const cursorFilter = query.cursor !== null ? sql`AND (created_at, id) < (${query.cursor.createdAt}, ${query.cursor.id})` diff --git a/services/api/src/services/host/broadcast-repository.memory.ts b/services/api/src/services/host/broadcast-repository.memory.ts index 1e321d78..82c85eb1 100644 --- a/services/api/src/services/host/broadcast-repository.memory.ts +++ b/services/api/src/services/host/broadcast-repository.memory.ts @@ -117,11 +117,17 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { } seedMembers(cleanupId: string, rows: readonly MemoryMember[]): void { - this.members.set(cleanupId, rows.map((row) => ({ registered: true, ...row }))) + this.members.set( + cleanupId, + rows.map((row) => ({ registered: true, ...row })), + ) } seedGuests(cleanupId: string, rows: readonly MemoryGuest[]): void { - this.guests.set(cleanupId, rows.map((row) => ({ registered: true, ...row }))) + this.guests.set( + cleanupId, + rows.map((row) => ({ registered: true, ...row })), + ) } forceCreatedAt(broadcastId: string, at: Date): void { @@ -370,7 +376,10 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { return Promise.resolve(next) } - markPlanned(broadcastId: string, args: { recipientCount: number; plannedAt: Date }): Promise { + markPlanned( + broadcastId: string, + args: { recipientCount: number; plannedAt: Date }, + ): Promise { const found = this.broadcasts.get(broadcastId) if (!found || found.plannedAt !== null) return Promise.resolve(false) let highestChunk = -1 @@ -413,7 +422,9 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { listStaleSending(staleBefore: Date, limit: number): Promise { return Promise.resolve( [...this.broadcasts.values()] - .filter((b) => b.status === "sending" && (b.updatedAt?.getTime() ?? 0) < staleBefore.getTime()) + .filter( + (b) => b.status === "sending" && (b.updatedAt?.getTime() ?? 0) < staleBefore.getTime(), + ) .slice(0, limit) .map((b) => b.id), ) @@ -473,7 +484,8 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { if (claimed.length >= args.limit) break if (row.broadcastId !== args.broadcastId || row.chunkNo !== args.chunkNo) continue if (row.attempts >= args.maxAttempts) continue - const stale = row.status === "in_flight" && row.updatedAt.getTime() < args.staleBefore.getTime() + const stale = + row.status === "in_flight" && row.updatedAt.getTime() < args.staleBefore.getTime() if (row.status !== "pending" && !stale) continue row.status = "in_flight" row.attempts += 1 @@ -689,7 +701,9 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { subjectKind: "user" | "guest" subjectId: string }): Promise { - this.unsubscribes.add(`${args.scope}|${args.cleanupId ?? ""}|${args.subjectKind}|${args.subjectId}`) + this.unsubscribes.add( + `${args.scope}|${args.cleanupId ?? ""}|${args.subjectKind}|${args.subjectId}`, + ) return Promise.resolve() } diff --git a/services/api/src/services/host/broadcast-repository.ts b/services/api/src/services/host/broadcast-repository.ts index ff6a5d25..4987dbd6 100644 --- a/services/api/src/services/host/broadcast-repository.ts +++ b/services/api/src/services/host/broadcast-repository.ts @@ -1,4 +1,9 @@ -import type { BroadcastKind, BroadcastSegment, BroadcastStatus, DeliveryStatus } from "@civfix/shared" +import type { + BroadcastKind, + BroadcastSegment, + BroadcastStatus, + DeliveryStatus, +} from "@civfix/shared" import type { NotificationPrefsRecord } from "../notification-service.js" import type { AdminBroadcastRow, diff --git a/services/api/src/services/host/broadcast-service.ts b/services/api/src/services/host/broadcast-service.ts index 466a101c..3d54a89b 100644 --- a/services/api/src/services/host/broadcast-service.ts +++ b/services/api/src/services/host/broadcast-service.ts @@ -142,7 +142,10 @@ export function toBroadcastDTO(record: BroadcastRecord): BroadcastDTO { } export interface BroadcastService { - list(cleanupId: string, query: ListEventBroadcastsRequest): Promise<{ + list( + cleanupId: string, + query: ListEventBroadcastsRequest, + ): Promise<{ items: BroadcastDTO[] nextCursor: string | null }> @@ -215,7 +218,12 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi } } - async function reserve(key: string, ttlSeconds: number, limit: number, kind: CapKind): Promise { + async function reserve( + key: string, + ttlSeconds: number, + limit: number, + kind: CapKind, + ): Promise { let used: number try { used = await deps.counters.incr(key, ttlSeconds) @@ -230,11 +238,7 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi return ctaUrl != null && ctaUrl.length > 0 ? `${bodyMd}\n${ctaUrl}` : bodyMd } - function assertContent( - subject: string, - bodyMd: string, - ctaUrl: string | null | undefined, - ): void { + function assertContent(subject: string, bodyMd: string, ctaUrl: string | null | undefined): void { assertNoSlur(subject, "subject") assertNoSlur(bodyMd, "bodyMd") assertBroadcastLinkPolicy(linkPolicyText(bodyMd, ctaUrl), config.linkAllowedHosts) @@ -383,9 +387,7 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi const event = await repo.eventContext(cleanupId) if (event === null) throw notFound() const existing = - body.broadcastId !== undefined - ? await repo.findForEvent(cleanupId, body.broadcastId) - : null + body.broadcastId !== undefined ? await repo.findForEvent(cleanupId, body.broadcastId) : null const subject = body.subject ?? existing?.subject ?? "" const bodyMd = body.bodyMd ?? existing?.bodyMd ?? "" if (subject.length === 0 || bodyMd.length === 0) { @@ -598,10 +600,7 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi } } -function previewVars( - event: EventBroadcastContext, - webBaseUrl: string, -): Record { +function previewVars(event: EventBroadcastContext, webBaseUrl: string): Record { return { first_name: "Alex", event_title: event.title, diff --git a/services/api/src/services/host/checkin-service.ts b/services/api/src/services/host/checkin-service.ts index 53ed7757..9f06eb32 100644 --- a/services/api/src/services/host/checkin-service.ts +++ b/services/api/src/services/host/checkin-service.ts @@ -26,7 +26,9 @@ import { normalizeTicketToken, type TicketTokenSigner } from "./ticket-token.js" export const CHECKIN_NOSHOW_SWEEP_BATCH = 1000 export interface GuestTicketLookup { - (manageTokenHash: string): Promise<{ id: string; cleanupId: string; cancelledAt: Date | null } | null> + ( + manageTokenHash: string, + ): Promise<{ id: string; cleanupId: string; cancelledAt: Date | null } | null> } export interface CheckinServiceDeps { @@ -44,10 +46,7 @@ export interface CheckinService { scan(input: ScanEventTicketRequest, actorId: string): Promise checkIn(input: CheckInEventSeatRequest, actorId: string): Promise undo(input: UndoEventCheckInRequest, actorId: string): Promise - markNoShows( - input: MarkEventNoShowsRequest, - actorId: string, - ): Promise + markNoShows(input: MarkEventNoShowsRequest, actorId: string): Promise counters(query: GetEventCheckinCountersRequest): Promise myTicket(query: GetMyEventTicketRequest, userId: string): Promise guestTicket(input: GetGuestEventTicketRequest): Promise @@ -59,8 +58,7 @@ function toCheckinResultDTO(record: CheckinResultRecord): CheckinResultDTO { outcome: record.outcome, firstTime: record.firstTime, seat: record.seat === null ? null : toSeatDTO(record.seat), - registration: - record.registration === null ? null : toEventRegistrationDTO(record.registration), + registration: record.registration === null ? null : toEventRegistrationDTO(record.registration), attendeeName: record.attendeeName, ticketTypeName: record.ticketTypeName, partySize: record.partySize, diff --git a/services/api/src/services/host/comms-jobs.ts b/services/api/src/services/host/comms-jobs.ts index f9a68e83..573af2d5 100644 --- a/services/api/src/services/host/comms-jobs.ts +++ b/services/api/src/services/host/comms-jobs.ts @@ -14,11 +14,7 @@ import { parseHostExportJob, } from "./broadcast-queues.js" import { makeCommsRuntime } from "./comms-wiring.js" -import { - drainTable, - registerRetentionLane, - runRetentionLanes, -} from "./retention-lanes.js" +import { drainTable, registerRetentionLane, runRetentionLanes } from "./retention-lanes.js" import { makeDrizzleHostExportRepository } from "./export-repository.drizzle.js" import { makeDrizzleBroadcastRepository } from "./broadcast-repository.drizzle.js" import { makeDrizzleHostTeamRepository } from "./host-team-repository.drizzle.js" diff --git a/services/api/src/services/host/comms-wiring.ts b/services/api/src/services/host/comms-wiring.ts index 0f806762..0acaf90d 100644 --- a/services/api/src/services/host/comms-wiring.ts +++ b/services/api/src/services/host/comms-wiring.ts @@ -7,10 +7,7 @@ import { makeRouteNotificationService } from "../route-notifier.js" import { makeDrizzleAnalyticsRepository } from "./analytics-repository.drizzle.js" import { makeAnalyticsService, type AnalyticsService } from "./analytics-service.js" import { makeDrizzleEventAnalyticsRepository } from "./event-analytics-repository.drizzle.js" -import { - makeEventAnalyticsService, - type EventAnalyticsService, -} from "./event-analytics-service.js" +import { makeEventAnalyticsService, type EventAnalyticsService } from "./event-analytics-service.js" import { makeDrizzleAnnouncementIdentityRepository } from "./announcement-repository.drizzle.js" import { makeAnnouncementService, type AnnouncementService } from "./announcement-service.js" import { MEDIA_GET_URL_TTL_SEC } from "../media-intake-service.js" @@ -26,11 +23,11 @@ import { } from "./broadcast-service.js" import { makeBroadcastPipeline, type BroadcastPipeline } from "./broadcast-pipeline.js" import { makeBroadcastLanes, type BroadcastLanes } from "./broadcast-lanes.js" +import { BROADCAST_CHUNK_JOB, BROADCAST_PLAN_JOB } from "./broadcast-queues.js" import { - BROADCAST_CHUNK_JOB, - BROADCAST_PLAN_JOB, -} from "./broadcast-queues.js" -import { makeDrizzleMetricsRepository, type MetricsRepository } from "./metrics-repository.drizzle.js" + makeDrizzleMetricsRepository, + type MetricsRepository, +} from "./metrics-repository.drizzle.js" import { makeMetricsService, type MetricsService } from "./metrics-service.js" import { makeDrizzleHostExportRepository } from "./export-repository.drizzle.js" import { diff --git a/services/api/src/services/host/event-analytics-repository.drizzle.ts b/services/api/src/services/host/event-analytics-repository.drizzle.ts index d02e384f..baff633d 100644 --- a/services/api/src/services/host/event-analytics-repository.drizzle.ts +++ b/services/api/src/services/host/event-analytics-repository.drizzle.ts @@ -46,9 +46,7 @@ export function makeDrizzleEventAnalyticsRepository(sql: Sql): EventAnalyticsRep return { async previousCompletedEventIds(args) { const orgFilter = (): Fragment => - args.organizationId !== null - ? sql`AND c.organization_id = ${args.organizationId}` - : sql`` + args.organizationId !== null ? sql`AND c.organization_id = ${args.organizationId}` : sql`` const rows = await sql<{ id: string }[]>` WITH hosted AS ( SELECT c.id, c.completed_at diff --git a/services/api/src/services/host/event-analytics-service.ts b/services/api/src/services/host/event-analytics-service.ts index 1ee5f663..45044911 100644 --- a/services/api/src/services/host/event-analytics-service.ts +++ b/services/api/src/services/host/event-analytics-service.ts @@ -31,10 +31,7 @@ import { type KeyCount, type SeriesClosure, } from "@civfix/shared/host" -import type { - AnalyticsRepository, - EventClockRecord, -} from "./analytics-repository.drizzle.js" +import type { AnalyticsRepository, EventClockRecord } from "./analytics-repository.drizzle.js" import type { EventAnalyticsRepository } from "./event-analytics-repository.drizzle.js" import type { MetricRow, MetricsRepository } from "./metrics-repository.drizzle.js" import { hostAnalyticsCacheKey, type HostAnalyticsCache } from "./host-analytics-cache.js" @@ -72,9 +69,7 @@ export interface EventAnalyticsService { function shiftDayKey(day: string, deltaDays: number): string { const [year, month, date] = day.split("-").map(Number) - const shifted = new Date( - Date.UTC(year ?? 1970, (month ?? 1) - 1, date ?? 1) + deltaDays * DAY_MS, - ) + const shifted = new Date(Date.UTC(year ?? 1970, (month ?? 1) - 1, date ?? 1) + deltaDays * DAY_MS) return shifted.toISOString().slice(0, 10) } @@ -190,9 +185,7 @@ export function arrivalBuckets(offsets: readonly number[]): SeriesPoint[] { })) } -export function makeEventAnalyticsService( - deps: EventAnalyticsServiceDeps, -): EventAnalyticsService { +export function makeEventAnalyticsService(deps: EventAnalyticsServiceDeps): EventAnalyticsService { const now = deps.now ?? (() => new Date()) function lifecycleWindow(clock: EventClockRecord, timezone: string): DayRange { 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 ced0902b..23cdd3a3 100644 --- a/services/api/src/services/host/event-consents-repository.drizzle.ts +++ b/services/api/src/services/host/event-consents-repository.drizzle.ts @@ -37,10 +37,7 @@ export function assertCurrentConsentVersions(consent: EventConsentInput): void { if (Object.keys(fields).length > 0) throw AppError.validation(fields) } -export async function insertConsent( - tx: Queryable, - input: InsertConsentInput, -): Promise { +export async function insertConsent(tx: Queryable, input: InsertConsentInput): Promise { assertCurrentConsentVersions(input.consent) if (input.subjectType === "user" && input.userId === null) { throw AppError.internal("event consent for a member needs a user id") diff --git a/services/api/src/services/host/event-fields.ts b/services/api/src/services/host/event-fields.ts index 465fd384..9487b518 100644 --- a/services/api/src/services/host/event-fields.ts +++ b/services/api/src/services/host/event-fields.ts @@ -27,9 +27,8 @@ let timezoneSet: ReadonlySet | null = null function supportedTimezones(): ReadonlySet { if (timezoneSet !== null) return timezoneSet - const supported = ( - Intl as unknown as { supportedValuesOf?: (key: string) => string[] } - ).supportedValuesOf + const supported = (Intl as unknown as { supportedValuesOf?: (key: string) => string[] }) + .supportedValuesOf const values = typeof supported === "function" ? supported.call(Intl, "timeZone") : [...FALLBACK_TIMEZONES] timezoneSet = new Set(values.length > 0 ? values : FALLBACK_TIMEZONES) diff --git a/services/api/src/services/host/event-media.ts b/services/api/src/services/host/event-media.ts index 649dbe43..2fd9547c 100644 --- a/services/api/src/services/host/event-media.ts +++ b/services/api/src/services/host/event-media.ts @@ -6,10 +6,7 @@ export interface EventMediaStorage { presignGet(key: string, ttlSec: number, opts?: { forceSigned?: boolean }): Promise } -export type EventMediaPresigner = ( - key: string, - opts: { forceSigned: boolean }, -) => Promise +export type EventMediaPresigner = (key: string, opts: { forceSigned: boolean }) => Promise export function makeEventMediaPresigner(storage: EventMediaStorage): EventMediaPresigner { return (key, opts) => diff --git a/services/api/src/services/host/export-service.ts b/services/api/src/services/host/export-service.ts index e0bc1b3e..f680ba29 100644 --- a/services/api/src/services/host/export-service.ts +++ b/services/api/src/services/host/export-service.ts @@ -63,7 +63,9 @@ export interface HostExportService { listForOrganization(organizationId: string): Promise get(exportId: string): Promise run(exportId: string): Promise<{ status: "ready" | "failed" | "skipped" }> - downloadUrl(record: HostExportRecord): Promise<{ url: string; expiresAt: string; filename: string }> + downloadUrl( + record: HostExportRecord, + ): Promise<{ url: string; expiresAt: string; filename: string }> reap(limit: number): Promise<{ reaped: number }> } diff --git a/services/api/src/services/host/host-portfolio-service.ts b/services/api/src/services/host/host-portfolio-service.ts index 80b120d6..595e7163 100644 --- a/services/api/src/services/host/host-portfolio-service.ts +++ b/services/api/src/services/host/host-portfolio-service.ts @@ -1,8 +1,4 @@ -import type { - HostedEventDTO, - HostPortfolioKpis, - ListMyHostedEventsResponse, -} from "@civfix/shared" +import type { HostedEventDTO, HostPortfolioKpis, ListMyHostedEventsResponse } from "@civfix/shared" import { hostCapabilities, NO_HOST_STANDING, type HostStanding } from "@civfix/shared/host" import type { EventMediaPresigner } from "./event-media.js" import type { @@ -43,9 +39,7 @@ function standingOf(record: HostedEventRecord): HostStanding { return { eventRole: record.eventRole, orgRole: record.orgRole } } -export function makeHostPortfolioService( - deps: HostPortfolioServiceDeps, -): HostPortfolioService { +export function makeHostPortfolioService(deps: HostPortfolioServiceDeps): HostPortfolioService { const now = deps.now ?? (() => new Date()) return { diff --git a/services/api/src/services/host/host-standing.ts b/services/api/src/services/host/host-standing.ts index 76e62e39..9f85cce9 100644 --- a/services/api/src/services/host/host-standing.ts +++ b/services/api/src/services/host/host-standing.ts @@ -1,8 +1,4 @@ -import type { - CleanupMemberRole, - EventVisibility, - OrganizationMemberRole, -} from "@civfix/shared" +import type { CleanupMemberRole, EventVisibility, OrganizationMemberRole } from "@civfix/shared" import { NO_HOST_STANDING, type HostStanding } from "@civfix/shared/host" import type { Queryable } from "../../db/client.js" diff --git a/services/api/src/services/host/host-team-repository.drizzle.ts b/services/api/src/services/host/host-team-repository.drizzle.ts index 948fb1fb..189948d1 100644 --- a/services/api/src/services/host/host-team-repository.drizzle.ts +++ b/services/api/src/services/host/host-team-repository.drizzle.ts @@ -197,7 +197,10 @@ async function reofferOpenInvite( return { kind: "updated", invite: { ...open, role: args.role } } } -async function eventOpenInTx(tx: Queryable, cleanupId: string): Promise<"open" | "closed" | "gone"> { +async function eventOpenInTx( + tx: Queryable, + cleanupId: string, +): Promise<"open" | "closed" | "gone"> { const rows = await tx<{ closed: boolean }[]>` SELECT (status = 'cancelled' OR ends_at <= now()) AS closed FROM cleanups WHERE id = ${cleanupId} LIMIT 1 FOR SHARE @@ -263,7 +266,14 @@ async function seatTeamMemberInTx( async function closeAcceptedInviteInTx( tx: Queryable, - args: { inviteId: string; cleanupId: string; userId: string; role: EventTeamRole; now: Date; from: CleanupMemberRole | null }, + args: { + inviteId: string + cleanupId: string + userId: string + role: EventTeamRole + now: Date + from: CleanupMemberRole | null + }, ): Promise { await tx` UPDATE cleanup_team_invites @@ -527,9 +537,7 @@ export function makeDrizzleHostTeamRepository(sql: Sql): HostTeamRepository { ): Promise<{ items: PendingInviteForUserRecord[]; nextCursor: string | null }> { const cursor = parseTimeCursor(args.cursor) const cursorFilter = - cursor !== null - ? sql`AND (i.created_at, i.id) < (${cursor.at}, ${cursor.id}::uuid)` - : sql`` + cursor !== null ? sql`AND (i.created_at, i.id) < (${cursor.at}, ${cursor.id}::uuid)` : sql`` const rows = await sql` SELECT i.id, @@ -631,9 +639,7 @@ export function makeDrizzleHostTeamRepository(sql: Sql): HostTeamRepository { now: Date }): Promise { return sql.begin(async (tx): Promise => { - const rows = await tx< - { id: string; cleanup_id: string; status: EventTeamInviteStatus }[] - >` + const rows = await tx<{ id: string; cleanup_id: string; status: EventTeamInviteStatus }[]>` SELECT id, cleanup_id, status FROM cleanup_team_invites WHERE id = ${args.inviteId} AND invited_user_id = ${args.userId} LIMIT 1 diff --git a/services/api/src/services/host/host-team-repository.memory.ts b/services/api/src/services/host/host-team-repository.memory.ts index 8dbf87f1..ecfcc12f 100644 --- a/services/api/src/services/host/host-team-repository.memory.ts +++ b/services/api/src/services/host/host-team-repository.memory.ts @@ -73,8 +73,12 @@ function compareIds(a: string, b: string): number { } export class InMemoryHostTeamRepository implements HostTeamRepository { - readonly members: { cleanupId: string; userId: string; role: CleanupMemberRole; joinedAt: Date }[] = - [] + readonly members: { + cleanupId: string + userId: string + role: CleanupMemberRole + joinedAt: Date + }[] = [] readonly invites: StoredInvite[] = [] readonly bans: { cleanupId: string; userId: string }[] = [] readonly closedEvents = new Set() @@ -387,9 +391,7 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { }), ) return Promise.resolve( - pageWith(rows, args.limit, (last) => - encodeTimeCursor({ at: last.createdAt, id: last.id }), - ), + pageWith(rows, args.limit, (last) => encodeTimeCursor({ at: last.createdAt, id: last.id })), ) } diff --git a/services/api/src/services/host/host-team-service.ts b/services/api/src/services/host/host-team-service.ts index e102d8ac..5903b2b4 100644 --- a/services/api/src/services/host/host-team-service.ts +++ b/services/api/src/services/host/host-team-service.ts @@ -68,11 +68,7 @@ export interface HostTeamMailer { } export interface HostTeamStandingLookup { - ( - cleanupId: string, - userId: string, - capability: HostCapability, - ): Promise + (cleanupId: string, userId: string, capability: HostCapability): Promise } export interface HostTeamNotifier { @@ -234,7 +230,11 @@ export function makeHostTeamService(deps: HostTeamServiceDeps): HostTeamService const base = deps.webOrigin ?? "https://civfix.org" const link = `${base}/cleanups/${cleanupId}#teamInvite=${encodeURIComponent(token)}` try { - await deps.mailer.sendTransactional(email, "action", teamInviteEmailVars({ title, role, link })) + await deps.mailer.sendTransactional( + email, + "action", + teamInviteEmailVars({ title, role, link }), + ) } catch (err) { deps.logger?.warn({ err, cleanupId }, "event team invite email failed (suppressed)") } @@ -271,12 +271,15 @@ export function makeHostTeamService(deps: HostTeamServiceDeps): HostTeamService deps.repo.listInvites(cleanupId, TEAM_INVITE_LIST_CAP), ]) const affiliations = deps.affiliations - ? await deps.affiliations([ - ...members.map((m) => m.person.id), - ...invites.flatMap((i) => - [i.invitee?.id, i.invitedBy?.id].filter((id): id is string => id !== undefined), - ), - ], actorId) + ? await deps.affiliations( + [ + ...members.map((m) => m.person.id), + ...invites.flatMap((i) => + [i.invitee?.id, i.invitedBy?.id].filter((id): id is string => id !== undefined), + ), + ], + actorId, + ) : NO_AFFILIATIONS return { members: members.map((m) => toMemberDTO(m, { canManage, actorId, affiliations })), @@ -442,10 +445,7 @@ export function makeHostTeamService(deps: HostTeamServiceDeps): HostTeamService return { ok: true, role: outcome.role, event } }, - async declineMyInvite( - userId: string, - inviteId: string, - ): Promise { + async declineMyInvite(userId: string, inviteId: string): Promise { const outcome = await deps.repo.declineInviteTx({ inviteId, userId, now: now() }) if (outcome === "not_found") throw AppError.notFound("That invitation is no longer valid.") if (outcome === "not_pending") { @@ -453,7 +453,6 @@ export function makeHostTeamService(deps: HostTeamServiceDeps): HostTeamService } return { ok: true } }, - } } diff --git a/services/api/src/services/host/insights-service.ts b/services/api/src/services/host/insights-service.ts index 699584d4..628c9d2a 100644 --- a/services/api/src/services/host/insights-service.ts +++ b/services/api/src/services/host/insights-service.ts @@ -24,7 +24,10 @@ import type { } from "./analytics-repository.drizzle.js" import { hostAnalyticsCacheKey, type HostAnalyticsCache } from "./host-analytics-cache.js" import { leaderboardEntryOf } from "../volunteer-hours-service.js" -import type { CheckinCountersRecord, HostRegistrationRepository } from "./registration-repository.types.js" +import type { + CheckinCountersRecord, + HostRegistrationRepository, +} from "./registration-repository.types.js" import { CHECKIN_COARSEN_DAYS } from "./registration-retention.js" export const INSIGHTS_LIVE_CACHE_TTL_SEC = 15 diff --git a/services/api/src/services/host/metrics-repository.drizzle.ts b/services/api/src/services/host/metrics-repository.drizzle.ts index 1f79cdb0..5ef8bb7e 100644 --- a/services/api/src/services/host/metrics-repository.drizzle.ts +++ b/services/api/src/services/host/metrics-repository.drizzle.ts @@ -22,7 +22,12 @@ export interface MetricsRepository { recomputeFromSource(cleanupId: string, timezone: string, since: Date): Promise upsertExact(rows: readonly MetricUpsert[]): Promise upsertGreatest(rows: readonly MetricUpsert[]): Promise - read(cleanupId: string, metrics: readonly string[], from: string, to: string): Promise + read( + cleanupId: string, + metrics: readonly string[], + from: string, + to: string, + ): Promise readMany( cleanupIds: readonly string[], metrics: readonly string[], diff --git a/services/api/src/services/host/metrics-service.ts b/services/api/src/services/host/metrics-service.ts index fdb9afdd..a2262e01 100644 --- a/services/api/src/services/host/metrics-service.ts +++ b/services/api/src/services/host/metrics-service.ts @@ -74,7 +74,11 @@ export function classifyPageViewSource(input: { if (SEARCH_HOSTS.some((prefix) => host.startsWith(prefix) || host.includes(`.${prefix}`))) { return "search" } - if (SOCIAL_HOSTS.some((prefix) => host === prefix || host.startsWith(prefix) || host.includes(`.${prefix}`))) { + if ( + SOCIAL_HOSTS.some( + (prefix) => host === prefix || host.startsWith(prefix) || host.includes(`.${prefix}`), + ) + ) { return "social" } return "referral" diff --git a/services/api/src/services/host/organization-repository.drizzle.ts b/services/api/src/services/host/organization-repository.drizzle.ts index a7bf9cb3..69502105 100644 --- a/services/api/src/services/host/organization-repository.drizzle.ts +++ b/services/api/src/services/host/organization-repository.drizzle.ts @@ -312,9 +312,7 @@ async function claimOrgLogoInTx( } } -export function documentMediaIdsOf( - documents: { mediaId?: string }[] | null | undefined, -): string[] { +export function documentMediaIdsOf(documents: { mediaId?: string }[] | null | undefined): string[] { const out: string[] = [] for (const doc of documents ?? []) { if (typeof doc.mediaId === "string" && !out.includes(doc.mediaId)) out.push(doc.mediaId) @@ -451,7 +449,11 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit actorId: args.createdBy, action: "org.verification_verified", target: `organization:${args.organizationId}`, - meta: { kind: verifiedKind, reason: args.operatorReason ?? null, source: "operator_create" }, + meta: { + kind: verifiedKind, + reason: args.operatorReason ?? null, + source: "operator_create", + }, }) } const created = await readById(tx, args.organizationId, ownerUserId) @@ -917,8 +919,7 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit cursor !== null ? sql`AND (v.submitted_at, v.id) < (${cursor.at}, ${cursor.id}::uuid)` : sql`` - const statusFilter = - query.status !== undefined ? sql`AND v.status = ${query.status}` : sql`` + const statusFilter = query.status !== undefined ? sql`AND v.status = ${query.status}` : sql`` const kindFilter = query.kind !== undefined ? sql`AND v.kind = ${query.kind}` : sql`` const qFilter = query.q !== undefined && query.q.length > 0 @@ -1004,9 +1005,7 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit ? sql`AND o.suspended_at IS NOT NULL` : sql`AND o.suspended_at IS NULL` const cursorFilter = - cursor !== null - ? sql`AND (o.created_at, o.id) < (${cursor.at}, ${cursor.id}::uuid)` - : sql`` + cursor !== null ? sql`AND (o.created_at, o.id) < (${cursor.at}, ${cursor.id}::uuid)` : sql`` const rows = await sql` SELECT ${adminOrganizationColumns(sql)} FROM organizations o @@ -1614,7 +1613,9 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit await writeHostAudit(tx, { actorId: args.reviewedBy, action: - args.decision === "verified" ? "org.verification_verified" : "org.verification_rejected", + args.decision === "verified" + ? "org.verification_verified" + : "org.verification_rejected", target: `organization:${args.organizationId}`, meta: { kind: grantedKind, reason: args.reason }, }) @@ -1805,7 +1806,10 @@ function toInviteRecord(row: InviteRowSelect): OrganizationInviteRecord { } } -async function readInvite(tag: Queryable, inviteId: string): Promise { +async function readInvite( + tag: Queryable, + inviteId: string, +): Promise { const rows = await tag` SELECT ${inviteColumns(tag)} FROM organization_invites i diff --git a/services/api/src/services/host/organization-repository.memory.ts b/services/api/src/services/host/organization-repository.memory.ts index 2ad7216a..4c036d4b 100644 --- a/services/api/src/services/host/organization-repository.memory.ts +++ b/services/api/src/services/host/organization-repository.memory.ts @@ -263,7 +263,11 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { actorId: args.createdBy, action: "org.verification_verified", target: `organization:${org.id}`, - meta: { kind: verifiedKind, reason: args.operatorReason ?? null, source: "operator_create" }, + meta: { + kind: verifiedKind, + reason: args.operatorReason ?? null, + source: "operator_create", + }, }) } return Promise.resolve(this.toRecord(org, ownerUserId)) @@ -276,7 +280,10 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { ) } - findOrganizationBySlug(slug: string, viewerId: string | null): Promise { + findOrganizationBySlug( + slug: string, + viewerId: string | null, + ): Promise { const org = [...this.organizations.values()].find( (o) => o.slug === slug && o.deletedAt === null, ) @@ -284,7 +291,9 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { } listMyOrganizations(userId: string, limit: number): Promise { - const ids = new Set(this.members.filter((m) => m.userId === userId).map((m) => m.organizationId)) + const ids = new Set( + this.members.filter((m) => m.userId === userId).map((m) => m.organizationId), + ) const records = [...this.organizations.values()] .filter((o) => ids.has(o.id) && o.deletedAt === null) .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)) @@ -642,7 +651,9 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { return Promise.resolve(row === undefined ? null : this.toAdminRecord(row)) } - adminGetVerifications(organizationIds: string[]): Promise> { + adminGetVerifications( + organizationIds: string[], + ): Promise> { const out = new Map() for (const id of organizationIds) { const row = this.latestFor(id) @@ -683,9 +694,7 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { const rows = searched .filter((o) => query.verified === undefined || o.verifiedStatus === query.verified) .filter((o) => query.kind === undefined || o.verifiedKind === query.kind) - .filter( - (o) => query.suspended === undefined || (o.suspendedAt !== null) === query.suspended, - ) + .filter((o) => query.suspended === undefined || (o.suspendedAt !== null) === query.suspended) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || b.id.localeCompare(a.id)) const start = query.cursor === null @@ -741,13 +750,19 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { const page = all.slice(start, start + args.limit) const next = all.length > start + args.limit ? page[page.length - 1] : undefined return Promise.resolve({ - items: page.map((m) => ({ user: this.actorOf(m.userId), role: m.role, joinedAt: m.joinedAt })), + items: page.map((m) => ({ + user: this.actorOf(m.userId), + role: m.role, + joinedAt: m.joinedAt, + })), nextCursor: next === undefined ? null : `${next.joinedAt.toISOString()}|${next.userId}`, }) } private demoteOwner(organizationId: string): string | null { - const owner = this.members.find((m) => m.organizationId === organizationId && m.role === "owner") + const owner = this.members.find( + (m) => m.organizationId === organizationId && m.role === "owner", + ) if (owner === undefined) return null owner.role = "admin" return owner.userId @@ -899,7 +914,11 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { return Promise.resolve({ kind: "created", invite: this.toInviteRecord(invite) }) } - listInvites(organizationId: string, now: Date, limit: number): Promise { + listInvites( + organizationId: string, + now: Date, + limit: number, + ): Promise { this.expireInvites(organizationId, now) const rows = this.invites .filter((i) => i.organizationId === organizationId) @@ -965,9 +984,7 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { if (invite.expiresAt.getTime() <= args.now.getTime()) continue const org = this.organizations.get(invite.organizationId) if (org === undefined || org.deletedAt !== null || org.suspendedAt !== null) continue - if ( - this.members.some((m) => m.organizationId === org.id && m.userId === args.userId) - ) { + if (this.members.some((m) => m.organizationId === org.id && m.userId === args.userId)) { continue } if (!this.inviteAddressesUser(invite, args.userId)) continue @@ -1026,7 +1043,8 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { const invite = byToken ? this.invites.find((i) => i.tokenHash === (args.by as { tokenHash: string }).tokenHash) : this.invites.find((i) => i.id === (args.by as { inviteId: string }).inviteId) - if (invite === undefined || invite.status !== "pending") return Promise.resolve({ kind: "invalid" }) + if (invite === undefined || invite.status !== "pending") + return Promise.resolve({ kind: "invalid" }) const org = this.organizations.get(invite.organizationId) if (org === undefined || org.deletedAt !== null) return Promise.resolve({ kind: "invalid" }) if (invite.expiresAt.getTime() <= args.now.getTime()) { diff --git a/services/api/src/services/host/organization-repository.types.ts b/services/api/src/services/host/organization-repository.types.ts index 73aa5079..d1194e4c 100644 --- a/services/api/src/services/host/organization-repository.types.ts +++ b/services/api/src/services/host/organization-repository.types.ts @@ -6,10 +6,7 @@ import type { OrgVerificationStatus, SocialLinks, } from "@civfix/shared" -import type { - CleanupOrganizationView, - CleanupPersonView, -} from "../cleanup-repository.types.js" +import type { CleanupOrganizationView, CleanupPersonView } from "../cleanup-repository.types.js" export interface OrganizationBaseRecord { id: string @@ -265,7 +262,12 @@ export interface PendingOrganizationInviteRecord { /** `role` is the SEATED role: for an existing member that is their current role, never an upgrade from the invite. */ export type AcceptOrganizationInviteOutcome = - | { kind: "accepted"; organizationId: string; role: OrganizationMemberRole; alreadyMember: boolean } + | { + kind: "accepted" + organizationId: string + role: OrganizationMemberRole + alreadyMember: boolean + } | { kind: "invalid" } | { kind: "expired" } | { kind: "wrong_recipient" } diff --git a/services/api/src/services/host/organization-service.ts b/services/api/src/services/host/organization-service.ts index a70dab53..087ec956 100644 --- a/services/api/src/services/host/organization-service.ts +++ b/services/api/src/services/host/organization-service.ts @@ -30,11 +30,7 @@ import { assertNoSlur } from "../../abuse/slur-filter.js" import { InMemoryCounterStore, type CounterStore } from "../../abuse/counter-store.js" import { generateToken, sha256Hex } from "../../auth/crypto.js" import { toAttendeePersonDTO, toOrganizationRef } from "../cleanup-dto.js" -import { - NO_AFFILIATIONS, - withAffiliation, - type AffiliationLoader, -} from "../affiliation.js" +import { NO_AFFILIATIONS, withAffiliation, type AffiliationLoader } from "../affiliation.js" import { hostForbiddenCopy } from "./authz.js" import { assertSlugAllowed } from "./slugs.js" import { mapWithLimit, PRESIGN_CONCURRENCY } from "../media-presign.js" @@ -618,7 +614,10 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza link: "/dashboard", }) } catch (err) { - deps.logger?.warn?.({ err, userId, organization: org.name }, "org invite notification failed (suppressed)") + deps.logger?.warn?.( + { err, userId, organization: org.name }, + "org invite notification failed (suppressed)", + ) } } @@ -757,7 +756,8 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza now(), ) if (updated === "not_found") notFoundOrganization() - if (updated === "slug_taken") throw AppError.conflict("That organization address is already taken.") + if (updated === "slug_taken") + throw AppError.conflict("That organization address is already taken.") const record = await deps.repo.findOrganizationById(id, actorId) if (record === null) notFoundOrganization() return dto(record) @@ -1013,10 +1013,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza if (record.verifiedStatus === "verified") { throw AppError.conflict("This organization is already verified.") } - const applications = await counters.incr( - `org:verify:${id}`, - ORG_VERIFICATION_WINDOW_SEC, - ) + const applications = await counters.incr(`org:verify:${id}`, ORG_VERIFICATION_WINDOW_SEC) if (applications > ORG_VERIFICATIONS_PER_DAY) { throw AppError.rateLimited( "This organization has submitted too many verification applications today.", @@ -1062,7 +1059,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza verified?: OrgVerificationStatus kind?: OrgVerificationKind suspended?: boolean - cursor: string | null + cursor: string | null limit: number }): Promise<{ items: AdminOrgDTO[]; nextCursor: string | null; counts?: AdminOrgCounts }> { const page = await deps.repo.adminListOrganizations(query) diff --git a/services/api/src/services/host/page-service.ts b/services/api/src/services/host/page-service.ts index 918115ac..c1e50221 100644 --- a/services/api/src/services/host/page-service.ts +++ b/services/api/src/services/host/page-service.ts @@ -17,18 +17,10 @@ import { assertNoSlur } from "../../abuse/slur-filter.js" import { mapWithLimit, PRESIGN_CONCURRENCY } from "../media-presign.js" import { RESERVED_SLUGS } from "./slugs.js" import type { CounterStore } from "../../abuse/counter-store.js" -import { - toEventPageDTO, - toEventQuestionDTO, - toPublicTicketType, -} from "./registration-dto.js" -import type { - HostRegistrationRepository, - PageRecord, -} from "./registration-repository.types.js" +import { toEventPageDTO, toEventQuestionDTO, toPublicTicketType } from "./registration-dto.js" +import type { HostRegistrationRepository, PageRecord } from "./registration-repository.types.js" import type { RegistrationAudit } from "./registration-service.js" - export const HOST_PAGE_PUBLISH_COUNTER_KEY = "host:pagePublish" export const HOST_PAGE_PUBLISH_PER_DAY = 20 @@ -260,7 +252,10 @@ export function makePageService(deps: PageServiceDeps): PageService { const urls = new Map() await mapWithLimit([...keys.entries()], PRESIGN_CONCURRENCY, async ([id, key]) => { try { - urls.set(id, (await (deps.presignCover as (k: string) => Promise<{ url: string }>)(key)).url) + urls.set( + id, + (await (deps.presignCover as (k: string) => Promise<{ url: string }>)(key)).url, + ) } catch (err) { deps.logger?.warn({ err }, "event page: block media presign failed (suppressed)") } @@ -306,10 +301,7 @@ export function makePageService(deps: PageServiceDeps): PageService { try { used = await deps.counters.incr(`${HOST_PAGE_PUBLISH_COUNTER_KEY}:${actorId}`, DAY_SECONDS) } catch (err) { - deps.logger?.warn( - { err }, - "event page: publish counter unavailable; refusing (fail closed)", - ) + deps.logger?.warn({ err }, "event page: publish counter unavailable; refusing (fail closed)") throw AppError.rateLimited("Publishing is temporarily unavailable.") } if (used > HOST_PAGE_PUBLISH_PER_DAY) { @@ -408,8 +400,7 @@ export function makePageService(deps: PageServiceDeps): PageService { const canManagePage = standing !== null && can(standing, "manage_page") const canViewPrivate = standing !== null && can(standing, "view_event_private") - const publiclyReadable = - record.page.status === "published" && record.page.flaggedAt === null + const publiclyReadable = record.page.status === "published" && record.page.flaggedAt === null if (!publiclyReadable && !canManagePage) throw notFound if (record.event.visibility === "private" && !canViewPrivate) throw notFound diff --git a/services/api/src/services/host/question-service.ts b/services/api/src/services/host/question-service.ts index c882f842..ae25b243 100644 --- a/services/api/src/services/host/question-service.ts +++ b/services/api/src/services/host/question-service.ts @@ -58,8 +58,7 @@ export function makeQuestionService(deps: QuestionServiceDeps): QuestionService helpText: question.helpText ?? null, required: question.required, options: "options" in question ? question.options : [], - maxSelections: - question.kind === "multi_select" ? (question.maxSelections ?? null) : null, + maxSelections: question.kind === "multi_select" ? (question.maxSelections ?? null) : null, consentText: question.kind === "consent" ? question.consentText : null, showIf: question.showIf ?? null, sortOrder: question.sortOrder ?? index, diff --git a/services/api/src/services/host/question-validation.ts b/services/api/src/services/host/question-validation.ts index b0aca154..d3c36b71 100644 --- a/services/api/src/services/host/question-validation.ts +++ b/services/api/src/services/host/question-validation.ts @@ -1,8 +1,4 @@ -import { - MAX_LONG_TEXT_ANSWER, - MAX_QUESTION_OPTIONS, - MAX_SHORT_TEXT_ANSWER, -} from "@civfix/shared" +import { MAX_LONG_TEXT_ANSWER, MAX_QUESTION_OPTIONS, MAX_SHORT_TEXT_ANSWER } from "@civfix/shared" import type { EventAnswerInput } from "@civfix/shared" import type { AnswerWrite, QuestionRecord } from "./registration-repository.types.js" @@ -68,8 +64,7 @@ export function validateAnswers( break } const trimmed = value.trim() - const max = - question.kind === "short_text" ? MAX_SHORT_TEXT_ANSWER : MAX_LONG_TEXT_ANSWER + const max = question.kind === "short_text" ? MAX_SHORT_TEXT_ANSWER : MAX_LONG_TEXT_ANSWER if (trimmed.length > max) { fields[question.id] = `must be at most ${max} characters` break diff --git a/services/api/src/services/host/registration-dto.ts b/services/api/src/services/host/registration-dto.ts index e536392e..a028db71 100644 --- a/services/api/src/services/host/registration-dto.ts +++ b/services/api/src/services/host/registration-dto.ts @@ -98,10 +98,7 @@ export function toTicketTypeDTO(record: TicketTypeRecord, now: Date): TicketType } } -export function toPublicTicketType( - record: TicketTypeRecord, - now: Date, -): PublicPageTicketType { +export function toPublicTicketType(record: TicketTypeRecord, now: Date): PublicPageTicketType { const remaining = remainingSeats(record) return { id: record.id, @@ -170,9 +167,7 @@ export function toEventRegistrationDTO( ...(projection.waitlistPosition !== undefined ? { waitlistPosition: projection.waitlistPosition } : {}), - ...(projection.includeAnswersPreview === true - ? { answersPreview: record.answersPreview } - : {}), + ...(projection.includeAnswersPreview === true ? { answersPreview: record.answersPreview } : {}), ...(projection.answers !== undefined ? { answers: projection.answers } : {}), ...(projection.includeHostNote === true ? { note: record.hostNote } : {}), } diff --git a/services/api/src/services/host/registration-jobs.ts b/services/api/src/services/host/registration-jobs.ts index beeef618..186b9af3 100644 --- a/services/api/src/services/host/registration-jobs.ts +++ b/services/api/src/services/host/registration-jobs.ts @@ -8,11 +8,7 @@ import { import { makeContainerRegistrationServices } from "./registration-wiring.js" import type { WaitlistPromoteJob } from "./waitlist-service.js" -export { - CHECKIN_NOSHOW_SWEEP_JOB, - WAITLIST_EXPIRE_SWEEP_JOB, - WAITLIST_PROMOTE_JOB, -} +export { CHECKIN_NOSHOW_SWEEP_JOB, WAITLIST_EXPIRE_SWEEP_JOB, WAITLIST_PROMOTE_JOB } export function parseWaitlistPromoteJob(data: unknown): WaitlistPromoteJob | null { if (typeof data !== "object" || data === null) return null diff --git a/services/api/src/services/host/registration-repository.drizzle.ts b/services/api/src/services/host/registration-repository.drizzle.ts index 8e8632ea..49bd007f 100644 --- a/services/api/src/services/host/registration-repository.drizzle.ts +++ b/services/api/src/services/host/registration-repository.drizzle.ts @@ -91,13 +91,13 @@ const ACTIVE_REGISTRATION_CONSTRAINTS = [ ] const ROSTER_SORTS = Object.freeze({ - registered_at_desc: (tag: Sql) => tag`r.registered_at DESC, r.id DESC`, - registered_at_asc: (tag: Sql) => tag`r.registered_at ASC, r.id ASC`, - name_asc: (tag: Sql) => - tag`lower(COALESCE(u.display_name, g.name, '')) ASC, r.registered_at DESC, r.id DESC`, - checked_in_at_desc: (tag: Sql) => - tag`COALESCE(ci.first_at, 'epoch'::timestamptz) DESC, r.registered_at DESC, r.id DESC`, - }) satisfies Readonly unknown>> + registered_at_desc: (tag: Sql) => tag`r.registered_at DESC, r.id DESC`, + registered_at_asc: (tag: Sql) => tag`r.registered_at ASC, r.id ASC`, + name_asc: (tag: Sql) => + tag`lower(COALESCE(u.display_name, g.name, '')) ASC, r.registered_at DESC, r.id DESC`, + checked_in_at_desc: (tag: Sql) => + tag`COALESCE(ci.first_at, 'epoch'::timestamptz) DESC, r.registered_at DESC, r.id DESC`, +}) satisfies Readonly unknown>> class RegistrationRefusal extends Error { readonly outcome: RegisterTxOutcome @@ -162,17 +162,12 @@ export function registrationIdempotencyOwner(subject: RegistrationSubject): stri return subjectOwner(subject) } -function withinSalesWindow( - now: Date, - opensAt: Date | null, - closesAt: Date | null, -): boolean { +function withinSalesWindow(now: Date, opensAt: Date | null, closesAt: Date | null): boolean { if (opensAt !== null && now < opensAt) return false if (closesAt !== null && now >= closesAt) return false return true } - export function emptyCheckinResult(outcome: CheckinResultRecord["outcome"]): CheckinResultRecord { return { outcome, @@ -608,10 +603,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio return null } - async function registerIn( - tx: TransactionSql, - args: RegisterTxArgs, - ): Promise { + async function registerIn(tx: TransactionSql, args: RegisterTxArgs): Promise { const partySize = args.seats.length const locked = await tx< { @@ -627,13 +619,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio const event = locked[0] if (event === undefined) return { kind: "not_found" as const } if (event.status === "cancelled") return { kind: "closed" as const } - if ( - !withinSalesWindow( - args.now, - event.registration_opens_at, - event.registration_closes_at, - ) - ) { + if (!withinSalesWindow(args.now, event.registration_opens_at, event.registration_closes_at)) { return { kind: "registration_closed" as const } } @@ -1159,8 +1145,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio cleanupId: string, opts?: { ticketTypeId?: string | null; includeArchived?: boolean }, ): Promise { - const archivedFilter = - opts?.includeArchived === true ? sql`` : sql`AND archived_at IS NULL` + const archivedFilter = opts?.includeArchived === true ? sql`` : sql`AND archived_at IS NULL` const scoped = opts !== undefined && "ticketTypeId" in opts const ticketTypeId = opts?.ticketTypeId ?? null const typeFilter = !scoped @@ -1569,9 +1554,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio const wanted = [ ...new Set( - [args.ticketTypeId, current.ticket_type_id].filter( - (id): id is string => id !== null, - ), + [args.ticketTypeId, current.ticket_type_id].filter((id): id is string => id !== null), ), ].sort() const types = await tx< @@ -1743,7 +1726,9 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio ` const existingId = existing[0]?.id const entry = - existingId === undefined ? null : await loadWaitlistEntry(sql, args.cleanupId, existingId) + existingId === undefined + ? null + : await loadWaitlistEntry(sql, args.cleanupId, existingId) if (entry === null) return { kind: "not_found" } return { kind: "already_waiting", entry } } @@ -1807,10 +1792,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio return listWaitlistIn(sql, args) }, - async findWaitlistEntry( - cleanupId: string, - waitlistId: string, - ): Promise { + async findWaitlistEntry(cleanupId: string, waitlistId: string): Promise { return loadWaitlistEntry(sql, cleanupId, waitlistId) }, @@ -1831,7 +1813,13 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio const expiresAt = new Date(args.now.getTime() + args.claimWindowMs) return sql.begin(async (tx) => { const candidates = await tx< - { id: string; cleanup_id: string; user_id: string | null; guest_id: string | null; party_size: number }[] + { + id: string + cleanup_id: string + user_id: string | null + guest_id: string | null + party_size: number + }[] >` SELECT id, cleanup_id, user_id, guest_id, party_size FROM cleanup_waitlist @@ -2188,7 +2176,13 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio async checkinCounters(cleanupId: string): Promise { const totals = await sql< - { registered: number; checked_in: number; no_show: number; waitlisted: number; capacity: number | null }[] + { + registered: number + checked_in: number + no_show: number + waitlisted: number + capacity: number | null + }[] >` SELECT COALESCE(( diff --git a/services/api/src/services/host/registration-repository.memory.ts b/services/api/src/services/host/registration-repository.memory.ts index b609e7e2..a9e387d1 100644 --- a/services/api/src/services/host/registration-repository.memory.ts +++ b/services/api/src/services/host/registration-repository.memory.ts @@ -65,7 +65,9 @@ const ARRIVAL_BUCKET_MS = ARRIVAL_BUCKET_MINUTES * 60_000 const SEEDED_EVENT_LEAD_MS = 7 * 24 * 60 * 60 * 1000 -function arrivalBuckets(seats: readonly { checkedInAt: Date | null }[]): { at: Date; count: number }[] { +function arrivalBuckets( + seats: readonly { checkedInAt: Date | null }[], +): { at: Date; count: number }[] { const byBucket = new Map() for (const seat of seats) { if (seat.checkedInAt === null) continue @@ -77,8 +79,13 @@ function arrivalBuckets(seats: readonly { checkedInAt: Date | null }[]): { at: D .map(([at, count]) => ({ at: new Date(at), count })) } -function subjectMatches(record: { userId: string | null; guestId: string | null }, subject: RegistrationSubject): boolean { - return subject.kind === "user" ? record.userId === subject.userId : record.guestId === subject.guestId +function subjectMatches( + record: { userId: string | null; guestId: string | null }, + subject: RegistrationSubject, +): boolean { + return subject.kind === "user" + ? record.userId === subject.userId + : record.guestId === subject.guestId } function withinWindow(now: Date, opensAt: Date | null, closesAt: Date | null): boolean { @@ -101,7 +108,9 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos constructor(private readonly newId: () => string = () => randomUUID()) {} - seedEvent(partial: Partial & { cleanupId: string }): EventRegistrationContext { + seedEvent( + partial: Partial & { cleanupId: string }, + ): EventRegistrationContext { const event: EventRegistrationContext = { status: "upcoming", visibility: "public", @@ -184,7 +193,9 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos return this.typesOf(cleanupId).map((t) => ({ ...t })) } - async listTicketTypesFor(cleanupIds: readonly string[]): Promise> { + async listTicketTypesFor( + cleanupIds: readonly string[], + ): Promise> { const out = new Map() for (const id of cleanupIds) out.set(id, await this.listTicketTypes(id)) return out @@ -303,7 +314,10 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos return { kind: "updated", record: { ...record } } } - async deleteTicketType(cleanupId: string, ticketTypeId: string): Promise { + async deleteTicketType( + cleanupId: string, + ticketTypeId: string, + ): Promise { const record = this.ticketTypes.get(ticketTypeId) if (record === undefined || record.cleanupId !== cleanupId) return { kind: "not_found" } const referenced = @@ -338,7 +352,9 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos .filter((q) => { if (opts === undefined || !("ticketTypeId" in opts)) return true const wanted = opts.ticketTypeId ?? null - return wanted === null ? q.ticketTypeId === null : q.ticketTypeId === null || q.ticketTypeId === wanted + return wanted === null + ? q.ticketTypeId === null + : q.ticketTypeId === null || q.ticketTypeId === wanted }) .sort((a, b) => a.sortOrder - b.sortOrder || a.id.localeCompare(b.id)) .map((q) => ({ ...q })) @@ -404,7 +420,8 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos }): RegistrationRecord | null { if ([...this.ticketTypes.values()].some((t) => t.cleanupId === args.cleanupId)) return null const active = [...this.registrations.values()].find( - (r) => r.cleanupId === args.cleanupId && r.status === "registered" && r.userId === args.userId, + (r) => + r.cleanupId === args.cleanupId && r.status === "registered" && r.userId === args.userId, ) if (active !== undefined) return null const id = this.newId() @@ -471,7 +488,8 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos } private toRecord(registration: MemoryRegistration): RegistrationRecord { - const type = registration.ticketTypeId === null ? null : this.ticketTypes.get(registration.ticketTypeId) + const type = + registration.ticketTypeId === null ? null : this.ticketTypes.get(registration.ticketTypeId) return { ...registration, ticketTypeName: type?.name ?? null, @@ -504,11 +522,17 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos const replayed = this.idempotency.get(idempotencyKey) if (replayed !== undefined) { const existing = this.registrations.get(replayed) - return { kind: "replayed", registration: existing === undefined ? null : this.toRecord(existing) } + return { + kind: "replayed", + registration: existing === undefined ? null : this.toRecord(existing), + } } const active = [...this.registrations.values()].find( - (r) => r.cleanupId === args.cleanupId && r.status === "registered" && subjectMatches(r, args.subject), + (r) => + r.cleanupId === args.cleanupId && + r.status === "registered" && + subjectMatches(r, args.subject), ) if (active !== undefined) return { kind: "already_registered" } @@ -531,7 +555,10 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos if (args.accessCodeHash !== expected) return { kind: "access_code_invalid" } } if (partySize > type.maxPartySize) return { kind: "party_too_large" } - if (args.waitlistId === null && !withinWindow(args.now, type.salesOpensAt, type.salesClosesAt)) { + if ( + args.waitlistId === null && + !withinWindow(args.now, type.salesOpensAt, type.salesClosesAt) + ) { return { kind: "sales_closed" } } if ( @@ -596,7 +623,11 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos slotId: args.slotId, slotTitle: null, seats, - answersPreview: args.answers.map((a) => a.valueText).filter((v) => v !== null).join(" | ") || null, + answersPreview: + args.answers + .map((a) => a.valueText) + .filter((v) => v !== null) + .join(" | ") || null, answers: args.answers.map((a) => ({ questionId: a.questionId, prompt: this.questions.get(a.questionId)?.prompt ?? "", @@ -609,11 +640,13 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos this.idempotency.set(idempotencyKey, id) if (type !== null) this.recomputeSold(type.id) - return { kind: "registered", registration: this.toRecord(registration) } } - async findRegistration(cleanupId: string, registrationId: string): Promise { + async findRegistration( + cleanupId: string, + registrationId: string, + ): Promise { const record = this.registrations.get(registrationId) if (record === undefined || record.cleanupId !== cleanupId) return null return this.toRecord(record) @@ -671,7 +704,9 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos rows = rows.filter((r) => r.seats.some((s) => s.checkedInAt !== null)) break case "not_checked_in": - rows = rows.filter((r) => r.status === "registered" && r.seats.every((s) => s.checkedInAt === null)) + rows = rows.filter( + (r) => r.status === "registered" && r.seats.every((s) => s.checkedInAt === null), + ) break case "no_show": rows = rows.filter((r) => r.seats.some((s) => s.noShowAt !== null)) @@ -679,8 +714,11 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos default: break } - if (query.ticketTypeId !== null) rows = rows.filter((r) => r.ticketTypeId === query.ticketTypeId) - rows.sort((a, b) => b.registeredAt.getTime() - a.registeredAt.getTime() || b.id.localeCompare(a.id)) + if (query.ticketTypeId !== null) + rows = rows.filter((r) => r.ticketTypeId === query.ticketTypeId) + rows.sort( + (a, b) => b.registeredAt.getTime() - a.registeredAt.getTime() || b.id.localeCompare(a.id), + ) const cursor = parseTimeCursor(query.cursor, { direction: "desc" }) if (cursor !== null) { rows = rows.filter( @@ -710,7 +748,11 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos return record.answers.map((a) => ({ ...a })) } - async setHostNote(cleanupId: string, registrationId: string, note: string | null): Promise { + async setHostNote( + cleanupId: string, + registrationId: string, + note: string | null, + ): Promise { const record = this.registrations.get(registrationId) if (record === undefined || record.cleanupId !== cleanupId) return false record.hostNote = note @@ -736,7 +778,11 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos this.recomputeSold(type.id) } } - return { kind: "cancelled", registration: this.toRecord(record), ticketTypeId: record.ticketTypeId } + return { + kind: "cancelled", + registration: this.toRecord(record), + ticketTypeId: record.ticketTypeId, + } } async removeRegistration(args: { @@ -763,7 +809,11 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos now: Date }): Promise { const record = this.registrations.get(args.registrationId) - if (record === undefined || record.cleanupId !== args.cleanupId || record.status !== "registered") { + if ( + record === undefined || + record.cleanupId !== args.cleanupId || + record.status !== "registered" + ) { return { kind: "not_found" } } if (record.ticketTypeId === args.ticketTypeId) return { kind: "same_type" } @@ -828,7 +878,10 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos if (args.accessCodeHash !== expected) return { kind: "access_code_invalid" } } const registered = [...this.registrations.values()].some( - (r) => r.cleanupId === args.cleanupId && r.status === "registered" && subjectMatches(r, args.subject), + (r) => + r.cleanupId === args.cleanupId && + r.status === "registered" && + subjectMatches(r, args.subject), ) if (registered) return { kind: "already_registered" } const existing = [...this.waitlist.values()].find( @@ -837,7 +890,8 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos (w.status === "waiting" || w.status === "offered") && subjectMatches(w, args.subject), ) - if (existing !== undefined) return { kind: "already_waiting", entry: this.waitlistView(existing) } + if (existing !== undefined) + return { kind: "already_waiting", entry: this.waitlistView(existing) } const entry: WaitlistRecord = { id: this.newId(), cleanupId: args.cleanupId, @@ -894,7 +948,9 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos let rows = [...this.waitlist.values()].filter((w) => w.cleanupId === args.cleanupId) if (args.ticketTypeId !== null) rows = rows.filter((w) => w.ticketTypeId === args.ticketTypeId) rows = rows.filter((w) => - args.status === null ? w.status === "waiting" || w.status === "offered" : w.status === args.status, + args.status === null + ? w.status === "waiting" || w.status === "offered" + : w.status === args.status, ) rows.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id)) const page = rows.slice(0, args.limit) @@ -934,7 +990,8 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos if (candidate === undefined) return null const type = this.ticketTypes.get(args.ticketTypeId) if (type === undefined) return null - if (type.capacity !== null && type.reservedSeats + candidate.partySize > type.capacity) return null + if (type.capacity !== null && type.reservedSeats + candidate.partySize > type.capacity) + return null type.reservedSeats += candidate.partySize const claimExpiresAt = new Date(args.now.getTime() + args.claimWindowMs) candidate.status = "offered" @@ -1036,7 +1093,8 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos waitlistId: entry.id, now: args.now, }) - if (outcome.kind === "registered") return { kind: "claimed", registration: outcome.registration } + if (outcome.kind === "registered") + return { kind: "claimed", registration: outcome.registration } if (outcome.kind === "replayed" && outcome.registration !== null) { return { kind: "claimed", registration: outcome.registration } } @@ -1044,7 +1102,10 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos return { kind: "not_offered" } } - private seatById(cleanupId: string, seatId: string): { seat: SeatRecord; registration: MemoryRegistration } | null { + private seatById( + cleanupId: string, + seatId: string, + ): { seat: SeatRecord; registration: MemoryRegistration } | null { for (const registration of this.registrations.values()) { if (registration.cleanupId !== cleanupId) continue const seat = registration.seats.find((s) => s.id === seatId) @@ -1136,7 +1197,8 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos for (const registration of this.registrations.values()) { if (registration.cleanupId !== args.cleanupId) continue for (const seat of registration.seats) { - if (seat.status !== "active" || seat.checkedInAt !== null || seat.noShowAt !== null) continue + if (seat.status !== "active" || seat.checkedInAt !== null || seat.noShowAt !== null) + continue if (args.seatIds !== null && !args.seatIds.includes(seat.id)) continue seat.noShowAt = args.now marked += 1 @@ -1154,7 +1216,8 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos if (endsAtMs === null || endsAtMs + LIVE_TAIL_MS > args.now.getTime()) continue for (const seat of registration.seats) { if (marked >= args.limit) return marked - if (seat.status !== "active" || seat.checkedInAt !== null || seat.noShowAt !== null) continue + if (seat.status !== "active" || seat.checkedInAt !== null || seat.noShowAt !== null) + continue seat.noShowAt = args.now marked += 1 } @@ -1171,7 +1234,9 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos registered: active.reduce((sum, r) => sum + r.partySize, 0), checkedIn: seats.filter((s) => s.status === "active" && s.checkedInAt !== null).length, waitlisted: [...this.waitlist.values()] - .filter((w) => w.cleanupId === cleanupId && (w.status === "waiting" || w.status === "offered")) + .filter( + (w) => w.cleanupId === cleanupId && (w.status === "waiting" || w.status === "offered"), + ) .reduce((sum, w) => sum + w.partySize, 0), noShow: seats.filter((s) => s.status === "active" && s.noShowAt !== null).length, capacity: @@ -1191,7 +1256,9 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos .flatMap((r) => r.seats) .filter((s) => s.checkedInAt !== null).length, waitlisted: [...this.waitlist.values()] - .filter((w) => w.ticketTypeId === type.id && (w.status === "waiting" || w.status === "offered")) + .filter( + (w) => w.ticketTypeId === type.id && (w.status === "waiting" || w.status === "offered"), + ) .reduce((sum, w) => sum + w.partySize, 0), capacity: type.capacity, })), @@ -1274,10 +1341,7 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos readonly mediaKeys = new Map() readonly pageMedia = new Map>() - async mediaKeysFor( - cleanupId: string, - mediaIds: readonly string[], - ): Promise> { + async mediaKeysFor(cleanupId: string, mediaIds: readonly string[]): Promise> { const out = new Map() const bound = this.pageMedia.get(cleanupId) ?? new Set() const cover = this.pages.get(cleanupId)?.coverMediaId ?? null diff --git a/services/api/src/services/host/registration-repository.types.ts b/services/api/src/services/host/registration-repository.types.ts index e1bd28f1..645f1a26 100644 --- a/services/api/src/services/host/registration-repository.types.ts +++ b/services/api/src/services/host/registration-repository.types.ts @@ -432,7 +432,10 @@ export interface HostRegistrationRepository { registerWalkupTx(args: WalkupRegisterArgs): Promise registerTx(args: RegisterTxArgs): Promise findRegistration(cleanupId: string, registrationId: string): Promise - findMyRegistration(cleanupId: string, subject: RegistrationSubject): Promise + findMyRegistration( + cleanupId: string, + subject: RegistrationSubject, + ): Promise findRegistrationsFor( cleanupIds: readonly string[], userId: string, diff --git a/services/api/src/services/host/registration-wiring.ts b/services/api/src/services/host/registration-wiring.ts index e56088eb..ad174adc 100644 --- a/services/api/src/services/host/registration-wiring.ts +++ b/services/api/src/services/host/registration-wiring.ts @@ -109,11 +109,9 @@ export function makeContainerRegistrationServices( logger?: HostServiceLogger, ): HostRegistrationServices { const sql = overrides?.repo === undefined ? container.getDb().sql : undefined - const repo = - overrides?.repo ?? makeDrizzleHostRegistrationRepository(sql as Sql) + const repo = overrides?.repo ?? makeDrizzleHostRegistrationRepository(sql as Sql) const tokens = overrides?.tokens ?? container.getTicketTokenSigner() - const audit = - overrides?.audit ?? (sql === undefined ? undefined : auditWriter(sql, logger)) + const audit = overrides?.audit ?? (sql === undefined ? undefined : auditWriter(sql, logger)) const teamUserIds = overrides?.teamUserIds ?? (sql === undefined @@ -124,9 +122,7 @@ export function makeContainerRegistrationServices( (sql === undefined ? undefined : async (hash: string) => { - const rows = await sql< - { id: string; cleanup_id: string; cancelled_at: Date | null }[] - >` + const rows = await sql<{ id: string; cleanup_id: string; cancelled_at: Date | null }[]>` SELECT id, cleanup_id, cancelled_at FROM cleanup_guests WHERE manage_token_hash = ${hash} LIMIT 1 @@ -220,8 +216,7 @@ export function makeContainerPageService( const sql = overrides?.repo === undefined ? container.getDb().sql : undefined const repo = overrides?.repo ?? makeDrizzleHostRegistrationRepository(sql as Sql) const presign = makeMediaPresigner(container.storage) - const presignCover = - overrides?.presignCover ?? (async (r2Key: string) => presign(r2Key, null)) + const presignCover = overrides?.presignCover ?? (async (r2Key: string) => presign(r2Key, null)) const counters = overrides?.counters ?? container.getCounterStore() const standingOf = overrides?.standingOf ?? diff --git a/services/api/src/services/host/ticket-type-service.ts b/services/api/src/services/host/ticket-type-service.ts index 1575cf57..80e17160 100644 --- a/services/api/src/services/host/ticket-type-service.ts +++ b/services/api/src/services/host/ticket-type-service.ts @@ -122,11 +122,16 @@ export function makeTicketTypeService(deps: TicketTypeServiceDeps): TicketTypeSe name: input.name, description: input.description ?? null, capacity: input.capacity ?? null, - salesOpensAt: input.salesOpensAt === undefined || input.salesOpensAt === null ? null : new Date(input.salesOpensAt), - salesClosesAt: input.salesClosesAt === undefined || input.salesClosesAt === null ? null : new Date(input.salesClosesAt), + salesOpensAt: + input.salesOpensAt === undefined || input.salesOpensAt === null + ? null + : new Date(input.salesOpensAt), + salesClosesAt: + input.salesClosesAt === undefined || input.salesClosesAt === null + ? null + : new Date(input.salesClosesAt), visibility: input.visibility, - accessCodeHash: - input.accessCode == null ? null : await sha256Hex(input.accessCode.trim()), + accessCodeHash: input.accessCode == null ? null : await sha256Hex(input.accessCode.trim()), clearAccessCode: false, maxPartySize: input.maxPartySize, sortOrder: input.sortOrder ?? null, diff --git a/services/api/src/services/host/waitlist-service.ts b/services/api/src/services/host/waitlist-service.ts index 74f288af..82558223 100644 --- a/services/api/src/services/host/waitlist-service.ts +++ b/services/api/src/services/host/waitlist-service.ts @@ -62,10 +62,7 @@ export interface WaitlistService { input: ClaimWaitlistOfferRequest, subject: RegistrationSubject, ): Promise - promote( - input: PromoteFromWaitlistRequest, - actorId: string, - ): Promise + promote(input: PromoteFromWaitlistRequest, actorId: string): Promise runPromote(job: WaitlistPromoteJob): Promise runExpireSweep(): Promise } diff --git a/services/api/src/services/jurisdiction-service.ts b/services/api/src/services/jurisdiction-service.ts index 3ab2dabc..339f4268 100644 --- a/services/api/src/services/jurisdiction-service.ts +++ b/services/api/src/services/jurisdiction-service.ts @@ -1,4 +1,3 @@ - import type { JurisdictionDTO } from "@civfix/shared" import type { Geocoder, Jobs } from "@civfix/shared/interfaces" import type { Sql } from "../db/client.js" diff --git a/services/api/src/services/legal-service.ts b/services/api/src/services/legal-service.ts index d1106197..78ab5c86 100644 --- a/services/api/src/services/legal-service.ts +++ b/services/api/src/services/legal-service.ts @@ -1,4 +1,9 @@ -import { AppError, ErrorCode, type LegalDocumentType, type LegalDocumentVersionDTO } from "@civfix/shared" +import { + AppError, + ErrorCode, + type LegalDocumentType, + type LegalDocumentVersionDTO, +} from "@civfix/shared" import { LEGAL_DOCUMENTS, legalDocument } from "@civfix/shared/legal" export const LEGAL_VERSIONS_CACHE_SECONDS = 300 diff --git a/services/api/src/services/media-authorization.ts b/services/api/src/services/media-authorization.ts index a78f7fdf..ada5987f 100644 --- a/services/api/src/services/media-authorization.ts +++ b/services/api/src/services/media-authorization.ts @@ -1,4 +1,3 @@ - import type { Sql } from "../db/client.js" import type { MediaAssetView, MediaOwner } from "./media-intake-service.js" import { isPubliclyVisibleStatus } from "./report-visibility.js" @@ -184,9 +183,7 @@ export async function authorizePostBound( postId: string, viewerId: string | null, ): Promise { - const rows = await sql< - { author_id: string; visibility: string; deleted_at: Date | null }[] - >` + const rows = await sql<{ author_id: string; visibility: string; deleted_at: Date | null }[]>` SELECT author_id, visibility, deleted_at FROM posts WHERE id = ${postId} LIMIT 1 ` const post = rows[0] @@ -218,7 +215,11 @@ export async function authorizeReportBound( return DENY } -async function reportVisible(sql: Sql, reportId: string, viewerId: string | null): Promise { +async function reportVisible( + sql: Sql, + reportId: string, + viewerId: string | null, +): Promise { const decision = await authorizeReportBound(sql, reportId, viewerId) return decision.allowed } diff --git a/services/api/src/services/media-etag.ts b/services/api/src/services/media-etag.ts index 83f1c440..e558ef17 100644 --- a/services/api/src/services/media-etag.ts +++ b/services/api/src/services/media-etag.ts @@ -1,4 +1,3 @@ - import type { StorageHead } from "@civfix/shared/interfaces" export interface StorageHeadWithEtag extends StorageHead { @@ -7,7 +6,11 @@ export interface StorageHeadWithEtag extends StorageHead { export function normalizeEtag(raw: string | null | undefined): string | null { if (typeof raw !== "string") return null - const trimmed = raw.trim().replace(/^W\//i, "").replace(/^"(.*)"$/s, "$1").trim() + const trimmed = raw + .trim() + .replace(/^W\//i, "") + .replace(/^"(.*)"$/s, "$1") + .trim() return trimmed.length > 0 ? trimmed.toLowerCase() : null } diff --git a/services/api/src/services/media-intake-service.ts b/services/api/src/services/media-intake-service.ts index 014cd80d..e3ea2426 100644 --- a/services/api/src/services/media-intake-service.ts +++ b/services/api/src/services/media-intake-service.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { AppError } from "@civfix/shared" import type { @@ -121,8 +120,7 @@ export interface MediaIntakeService { export function precheckUpload(input: CreateMediaUploadRequest): void { const max = input.kind === "video" ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES - const allowed = - input.kind === "video" ? ALLOWED_VIDEO_CONTENT_TYPES : ALLOWED_IMAGE_CONTENT_TYPES + const allowed = input.kind === "video" ? ALLOWED_VIDEO_CONTENT_TYPES : ALLOWED_IMAGE_CONTENT_TYPES if (!allowed.has(input.contentType)) { throw AppError.mediaRejected( diff --git a/services/api/src/services/media-presign.ts b/services/api/src/services/media-presign.ts index 2cb18a45..8e067ede 100644 --- a/services/api/src/services/media-presign.ts +++ b/services/api/src/services/media-presign.ts @@ -34,7 +34,9 @@ export function makeMediaPresigner(storage: PresignStorage): PresignMedia { */ export function makePrivateMediaPresigner(storage: PresignStorage): PresignMedia { return async (r2Key, thumbKey) => { - const url = await storage.presignGet(r2Key, MEDIA_PRIVATE_GET_URL_TTL_SEC, { forceSigned: true }) + const url = await storage.presignGet(r2Key, MEDIA_PRIVATE_GET_URL_TTL_SEC, { + forceSigned: true, + }) if (thumbKey === null) return { url } const thumbUrl = await storage.presignGet(thumbKey, MEDIA_PRIVATE_GET_URL_TTL_SEC, { forceSigned: true, diff --git a/services/api/src/services/media-repository.drizzle.ts b/services/api/src/services/media-repository.drizzle.ts index c3bb4d12..a40077e0 100644 --- a/services/api/src/services/media-repository.drizzle.ts +++ b/services/api/src/services/media-repository.drizzle.ts @@ -1,12 +1,7 @@ - import { and, eq, isNull, sql } from "drizzle-orm" import { mediaAssets } from "../db/schema/media.js" import type { Db } from "../db/client.js" -import type { - MediaAssetView, - MediaRepository, - NewMediaAsset, -} from "./media-intake-service.js" +import type { MediaAssetView, MediaRepository, NewMediaAsset } from "./media-intake-service.js" function toView(row: typeof mediaAssets.$inferSelect): MediaAssetView { return { diff --git a/services/api/src/services/media-served-key.ts b/services/api/src/services/media-served-key.ts index 453096f9..b8abeb83 100644 --- a/services/api/src/services/media-served-key.ts +++ b/services/api/src/services/media-served-key.ts @@ -1,4 +1,3 @@ - import type postgres from "postgres" import type { Queryable } from "../db/client.js" diff --git a/services/api/src/services/media-worker-repo.ts b/services/api/src/services/media-worker-repo.ts index fa4b111a..02effa94 100644 --- a/services/api/src/services/media-worker-repo.ts +++ b/services/api/src/services/media-worker-repo.ts @@ -165,10 +165,7 @@ export function makeDrizzleMediaWorkerRepo(db: Db, tag: Sql): MediaWorkerRepo { }) }, - async adoptLegacyServedKeys( - olderThan: Date, - limit: number, - ): Promise { + async adoptLegacyServedKeys(olderThan: Date, limit: number): Promise { const candidates = await db .select({ id: mediaAssets.id }) .from(mediaAssets) diff --git a/services/api/src/services/mention-resolver.drizzle.ts b/services/api/src/services/mention-resolver.drizzle.ts index d759ab4d..0a9feee0 100644 --- a/services/api/src/services/mention-resolver.drizzle.ts +++ b/services/api/src/services/mention-resolver.drizzle.ts @@ -1,4 +1,3 @@ - import type { Sql } from "../db/client.js" import type { UserMentionDTO } from "@civfix/shared" import { isUuid } from "../db/cursor-helpers.js" diff --git a/services/api/src/services/message-mentions.drizzle.ts b/services/api/src/services/message-mentions.drizzle.ts index 608ec5d9..681d30c4 100644 --- a/services/api/src/services/message-mentions.drizzle.ts +++ b/services/api/src/services/message-mentions.drizzle.ts @@ -57,7 +57,9 @@ export async function loadMentionsFor( ): Promise> { const byMessage = new Map() if (messageIds.length === 0) return byMessage - const rows = await tag<{ mkey: string; id: string; handle: string | null; display_name: string }[]>` + const rows = await tag< + { mkey: string; id: string; handle: string | null; display_name: string }[] + >` SELECT m.${tag(idColumn)} AS mkey, u.id, u.handle, u.display_name FROM ${tag(table)} m JOIN users u ON u.id = m.mentioned_user_id diff --git a/services/api/src/services/message-reactions.drizzle.ts b/services/api/src/services/message-reactions.drizzle.ts index 86c04a34..b3f3ee26 100644 --- a/services/api/src/services/message-reactions.drizzle.ts +++ b/services/api/src/services/message-reactions.drizzle.ts @@ -13,7 +13,10 @@ 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): Promise> + loadFor( + messageIds: string[], + viewerUserId: string | null, + ): Promise> } export function makeReactionRepo(sql: Sql, table: ReactionTable): MessageReactionRepo { diff --git a/services/api/src/services/notification-helpers.ts b/services/api/src/services/notification-helpers.ts index b243b3a1..03886e80 100644 --- a/services/api/src/services/notification-helpers.ts +++ b/services/api/src/services/notification-helpers.ts @@ -1,9 +1,4 @@ - -import type { - NotificationDTO, - NotificationPrefsDTO, - NotificationType, -} from "@civfix/shared" +import type { NotificationDTO, NotificationPrefsDTO, NotificationType } from "@civfix/shared" import type { NotificationPrefsRecord, NotificationRecord } from "./notification-service.js" export const DEFAULT_PREFS: NotificationPrefsRecord = { @@ -78,7 +73,10 @@ export function isWithinQuietHours( return t >= s || t < e } -export function typeAllowedByPrefs(type: NotificationType, prefs: NotificationPrefsRecord): boolean { +export function typeAllowedByPrefs( + type: NotificationType, + prefs: NotificationPrefsRecord, +): boolean { if (!prefs.push) return false switch (type) { case "report_update": diff --git a/services/api/src/services/notification-repository.drizzle.ts b/services/api/src/services/notification-repository.drizzle.ts index 63290f24..ed9e73ae 100644 --- a/services/api/src/services/notification-repository.drizzle.ts +++ b/services/api/src/services/notification-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Sql } from "../db/client.js" import { paginate, parseTimeCursor } from "../db/cursor-helpers.js" import type { @@ -115,9 +114,7 @@ export function makeDrizzleNotificationRepository(sql: Sql): NotificationReposit ): Promise<{ records: NotificationRecord[]; nextCursor: string | null }> { const parsed = parseTimeCursor(cursor) const cursorFilter = - parsed !== null - ? sql`AND (created_at, id) < (${parsed.at}, ${parsed.id}::uuid)` - : sql`` + parsed !== null ? sql`AND (created_at, id) < (${parsed.at}, ${parsed.id}::uuid)` : sql`` const rows = await sql` SELECT id, user_id, type, title, body, link, read_at, created_at FROM notifications @@ -224,7 +221,8 @@ export function makeDrizzleNotificationRepository(sql: Sql): NotificationReposit const setFragments: Array> = [] if (patch.push !== undefined) setFragments.push(sql`push = ${patch.push}`) - if (patch.cleanupChat !== undefined) setFragments.push(sql`cleanup_chat = ${patch.cleanupChat}`) + if (patch.cleanupChat !== undefined) + setFragments.push(sql`cleanup_chat = ${patch.cleanupChat}`) if (patch.reportUpdates !== undefined) setFragments.push(sql`report_updates = ${patch.reportUpdates}`) if (patch.follows !== undefined) setFragments.push(sql`follows = ${patch.follows}`) diff --git a/services/api/src/services/notification-service.ts b/services/api/src/services/notification-service.ts index e7e33e35..152c3979 100644 --- a/services/api/src/services/notification-service.ts +++ b/services/api/src/services/notification-service.ts @@ -1,4 +1,3 @@ - import { AppError } from "@civfix/shared" import type { ListNotificationsResponse, @@ -390,7 +389,10 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica try { return { prefs: await deps.repo.findPrefs(userId), readable: true } } catch (err) { - deps.logger?.warn({ err, userId }, "prefs lookup failed; suppressing push for this recipient") + deps.logger?.warn( + { err, userId }, + "prefs lookup failed; suppressing push for this recipient", + ) return { prefs: null, readable: false } } }) @@ -453,7 +455,10 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica try { await deps.userChannel.publishToUsers(userIds, { topic: "notifications" }) } catch (err) { - deps.logger?.warn({ err, count: userIds.length }, "notification signal publish failed (suppressed)") + deps.logger?.warn( + { err, count: userIds.length }, + "notification signal publish failed (suppressed)", + ) } } @@ -476,10 +481,15 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica return null } }) - const created = persisted.filter((p): p is { userId: string; record: NotificationRecord } => p !== null) + const created = persisted.filter( + (p): p is { userId: string; record: NotificationRecord } => p !== null, + ) if (created.length === 0) return void sendBatchedPush(created, input.push ?? "auto").catch((err: unknown) => { - deps.logger?.error({ err, count: created.length }, "batched push dispatch failed (suppressed)") + deps.logger?.error( + { err, count: created.length }, + "batched push dispatch failed (suppressed)", + ) }) void signalMany(created.map((c) => c.userId)) } @@ -526,10 +536,7 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica return toPrefsDTO(await deps.repo.upsertPrefs(userId, repoPatch)) }, - async registerPushToken( - userId: string, - req: RegisterPushTokenRequest, - ): Promise<{ ok: true }> { + async registerPushToken(userId: string, req: RegisterPushTokenRequest): Promise<{ ok: true }> { const shape = classifyPushToken(req.platform, req.token) if (!shape.ok) { throw AppError.validation({ [shape.field]: shape.reason }, "Invalid push token") @@ -558,7 +565,10 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica try { await deps.pushSender.registerToken(userId, req.token, req.platform, req.deviceId) } catch (err) { - deps.logger?.warn({ err, userId, platform: req.platform }, "pushSender.registerToken failed") + deps.logger?.warn( + { err, userId, platform: req.platform }, + "pushSender.registerToken failed", + ) } return { ok: true } }, @@ -579,11 +589,7 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica return doCreateNotifications(userIds, input) }, - async clearByTypeAndLink( - userId: string, - type: NotificationType, - link: string, - ): Promise { + async clearByTypeAndLink(userId: string, type: NotificationType, link: string): Promise { await deps.repo.clearByTypeAndLink(userId, type, link) await maybeSignalNotification(userId) }, @@ -616,7 +622,11 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica }) }, - async onPostRepost(a: { recipientId: string; actorName: string; postId: string }): Promise { + async onPostRepost(a: { + recipientId: string + actorName: string + postId: string + }): Promise { await doCreateNotification(a.recipientId, { type: "post_repost", titleKey: "notification.post.repost.title", @@ -627,7 +637,11 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica }) }, - async onPostReply(a: { recipientId: string; actorName: string; postId: string }): Promise { + async onPostReply(a: { + recipientId: string + actorName: string + postId: string + }): Promise { await doCreateNotification(a.recipientId, { type: "post_reply", titleKey: "notification.post.reply.title", @@ -637,7 +651,11 @@ export function makeNotificationService(deps: NotificationServiceDeps): Notifica }) }, - async onPostQuote(a: { recipientId: string; actorName: string; postId: string }): Promise { + async onPostQuote(a: { + recipientId: string + actorName: string + postId: string + }): Promise { await doCreateNotification(a.recipientId, { type: "post_quote", titleKey: "notification.post.quote.title", diff --git a/services/api/src/services/post-repository.drizzle.ts b/services/api/src/services/post-repository.drizzle.ts index 621dedf5..2ea77a63 100644 --- a/services/api/src/services/post-repository.drizzle.ts +++ b/services/api/src/services/post-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type postgres from "postgres" import { AppError, avatarGradient } from "@civfix/shared" import type { @@ -183,7 +182,6 @@ export interface PostRepository { listSaves(args: PostListArgs): Promise } - interface PostRowSelect { id: string author_id: string @@ -537,10 +535,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep return out } - async function loadAuthors( - ids: string[], - viewerId: string, - ): Promise> { + async function loadAuthors(ids: string[], viewerId: string): Promise> { const out = new Map() if (ids.length === 0) return out const rows = await sql` @@ -651,7 +646,9 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep return out } - async function loadReports(ids: string[]): Promise>> { + async function loadReports( + ids: string[], + ): Promise>> { const out = new Map>() if (ids.length === 0) return out const rows = await sql` @@ -811,7 +808,9 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep viewerId: string, ): Promise<{ liked: Set; saved: Set; repostedTargets: Set }> { const ids = rows.map((r) => r.id) - const subjectIds = rows.map((r) => (r.kind === "repost" && r.repost_of_id ? r.repost_of_id : r.id)) + const subjectIds = rows.map((r) => + r.kind === "repost" && r.repost_of_id ? r.repost_of_id : r.id, + ) const liked = new Set() const saved = new Set() const repostedTargets = new Set() @@ -889,7 +888,8 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep if (!author) continue const isRepost = r.kind === "repost" && r.repost_of_id !== null const targetId = isRepost ? r.repost_of_id! : r.id - const editedAt = r.updated_at.getTime() > r.created_at.getTime() ? r.updated_at.toISOString() : null + const editedAt = + r.updated_at.getTime() > r.created_at.getTime() ? r.updated_at.toISOString() : null const eventBase = r.event_id !== null ? events.get(r.event_id) : undefined const reportBase = r.report_id !== null ? reports.get(r.report_id) : undefined const repostOf = r.repost_of_id !== null ? (refs.get(r.repost_of_id) ?? null) : null @@ -1082,7 +1082,9 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep RETURNING upload_id ` if (claimed.length !== new Set(args.mediaUploadIds).size) { - throw AppError.validation({ mediaUploadIds: "One or more media uploads are unavailable." }) + throw AppError.validation({ + mediaUploadIds: "One or more media uploads are unavailable.", + }) } } @@ -1179,7 +1181,10 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep }) }, - async unrepost(postId: string, userId: string): Promise<{ targetId: string; removed: boolean }> { + async unrepost( + postId: string, + userId: string, + ): Promise<{ targetId: string; removed: boolean }> { return sql.begin(async (tx) => { const targetId = await resolveOriginalTarget(tx, postId) if (targetId === null) return { targetId: postId, removed: false } @@ -1361,7 +1366,9 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep async listSaves(args: PostListArgs): Promise { const cursor = parseTimeCursor(args.cursor) const cursorFilter = - cursor !== null ? sql`AND (ps.created_at, ps.post_id) < (${cursor.at}, ${cursor.id}::uuid)` : sql`` + cursor !== null + ? sql`AND (ps.created_at, ps.post_id) < (${cursor.at}, ${cursor.id}::uuid)` + : sql`` const rows = await sql<(PostRowSelect & { saved_at: Date })[]>` SELECT ${postColumns(sql)}, ps.created_at AS saved_at FROM post_saves ps diff --git a/services/api/src/services/post-service.ts b/services/api/src/services/post-service.ts index c00f9df1..a5ebb419 100644 --- a/services/api/src/services/post-service.ts +++ b/services/api/src/services/post-service.ts @@ -1,4 +1,3 @@ - import { AppError, DEFAULT_FEED_RANKING, @@ -108,11 +107,7 @@ export interface PostService { unsavePost(id: string, viewerId: string): Promise repostPost(id: string, viewerId: string): Promise unrepostPost(id: string, viewerId: string): Promise - homeFeed( - viewerId: string, - query: HomeFeedQuery, - location?: FeedViewerLocation, - ): Promise + homeFeed(viewerId: string, query: HomeFeedQuery, location?: FeedViewerLocation): Promise publicFeed(query: HomeFeedQuery, location?: FeedViewerLocation): Promise getFeedCounts(postIds: readonly string[], viewerId: string): Promise listUserPosts(authorId: string, viewerId: string, pagination: PaginationQuery): Promise @@ -469,7 +464,9 @@ export function makePostService(deps: PostServiceDeps): PostService { } for (const m of mentions) { if (await shouldNotify(authorId, m.id)) { - await safeNotify(() => deps.notifier!.onPostMention({ recipientId: m.id, actorName, postId })) + await safeNotify(() => + deps.notifier!.onPostMention({ recipientId: m.id, actorName, postId }), + ) } } @@ -491,7 +488,8 @@ export function makePostService(deps: PostServiceDeps): PostService { async deletePost(id: string, viewerId: string): Promise<{ ok: true }> { const brief = await deps.repo.getPostBrief(id) if (!brief || brief.deletedAt !== null) throw AppError.notFound("Post not found") - if (brief.authorId !== viewerId) throw AppError.forbidden("You can only delete your own post.") + if (brief.authorId !== viewerId) + throw AppError.forbidden("You can only delete your own post.") await deps.repo.softDeletePost(id) return { ok: true } }, @@ -517,7 +515,11 @@ export function makePostService(deps: PostServiceDeps): PostService { if (created && (await shouldNotify(viewerId, subject.authorId))) { const actorName = await deps.repo.actorNameOf(viewerId) await safeNotify(() => - deps.notifier!.onPostLike({ recipientId: subject.authorId, actorName, postId: subject.id }), + deps.notifier!.onPostLike({ + recipientId: subject.authorId, + actorName, + postId: subject.id, + }), ) } return hydrateOrThrow(id, viewerId) @@ -599,10 +601,7 @@ export function makePostService(deps: PostServiceDeps): PostService { return rankedFeed(NIL_VIEWER_ID, query, location) }, - async getFeedCounts( - postIds: readonly string[], - viewerId: string, - ): Promise { + async getFeedCounts(postIds: readonly string[], viewerId: string): Promise { const unique = [...new Set(postIds)] if (unique.length === 0) return { items: [] } const rows = await deps.repo.readableCounts(unique, viewerId) diff --git a/services/api/src/services/push-token-policy.ts b/services/api/src/services/push-token-policy.ts index cb7d9f29..9cc3ceb4 100644 --- a/services/api/src/services/push-token-policy.ts +++ b/services/api/src/services/push-token-policy.ts @@ -1,4 +1,3 @@ - import { isIP } from "node:net" import { lookup, Resolver } from "node:dns/promises" import type { PushPlatform } from "@civfix/shared/interfaces" @@ -140,7 +139,13 @@ function isPublicIpv6(addr: string): boolean { const first10Zero = b.slice(0, 10).every((x) => x === 0) if (first10Zero && b[10] === 0xff && b[11] === 0xff) return isPublicIpv4(embeddedV4()) if (first10Zero && b[10] === 0 && b[11] === 0) return isPublicIpv4(embeddedV4()) - if (b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b && b.slice(4, 12).every((x) => x === 0)) { + if ( + b[0] === 0 && + b[1] === 0x64 && + b[2] === 0xff && + b[3] === 0x9b && + b.slice(4, 12).every((x) => x === 0) + ) { return isPublicIpv4(embeddedV4()) } if (b[0] === 0xfe && (b[1]! & 0xc0) === 0x80) return false diff --git a/services/api/src/services/report-chat-notifier.ts b/services/api/src/services/report-chat-notifier.ts index 361d497e..3e3061d5 100644 --- a/services/api/src/services/report-chat-notifier.ts +++ b/services/api/src/services/report-chat-notifier.ts @@ -1,4 +1,3 @@ - import type { ChatMessageDTO } from "@civfix/shared" import type { NotificationService } from "./notification-service.js" import { makeRoomFanoutNotifier } from "./chat-room-fanout-notifier.js" diff --git a/services/api/src/services/report-chat-repository.drizzle.ts b/services/api/src/services/report-chat-repository.drizzle.ts index 4af96e63..69b25f96 100644 --- a/services/api/src/services/report-chat-repository.drizzle.ts +++ b/services/api/src/services/report-chat-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type { Sql } from "../db/client.js" import { ReportStatusSchema, @@ -128,7 +127,11 @@ export function makeReportChatRepository( return rows[0]?.role ?? null }, - async join(reportId: string, userId: string, role: "owner" | "member" = "member"): Promise { + async join( + reportId: string, + userId: string, + role: "owner" | "member" = "member", + ): Promise { await sql` INSERT INTO report_chat_members (report_id, user_id, role) VALUES (${reportId}, ${userId}, ${role}) @@ -143,7 +146,11 @@ export function makeReportChatRepository( ` }, - async advanceReadWatermark(reportId: string, userId: string, upToMessageId: string): Promise { + async advanceReadWatermark( + reportId: string, + userId: string, + upToMessageId: string, + ): Promise { await monotonicReadWatermarkUpdate( sql, "report_chat_members", diff --git a/services/api/src/services/report-chat-send-wiring.ts b/services/api/src/services/report-chat-send-wiring.ts index 5aecb684..d34a2eaa 100644 --- a/services/api/src/services/report-chat-send-wiring.ts +++ b/services/api/src/services/report-chat-send-wiring.ts @@ -84,7 +84,8 @@ export function makeContainerReportChatSendDeps( (cleanupRepo ??= makeDrizzleCleanupRepository(sql)).isMember(cleanupId, userId), isReportChatMember: (reportId, userId) => reportChatRepo.isMember(reportId, userId), isChatGroupMember: async (groupId, userId) => - ((await (groupRepo ??= makeChatGroupRepository(sql)).roleOf(groupId, userId)) ?? null) !== null, + ((await (groupRepo ??= makeChatGroupRepository(sql)).roleOf(groupId, userId)) ?? null) !== + null, isBlockedEitherWay, roomKeyFor, } diff --git a/services/api/src/services/report-chat-send.ts b/services/api/src/services/report-chat-send.ts index 4bf3101b..5b7a59a4 100644 --- a/services/api/src/services/report-chat-send.ts +++ b/services/api/src/services/report-chat-send.ts @@ -1,6 +1,9 @@ import type { ChatMessageDTO, UserMentionDTO } from "@civfix/shared" import type { PersistChatInput } from "@civfix/shared/interfaces" -import { resolveAndRecordChatMentions, type ChatMentionRecordSeam } from "./chat-mention-resolver.js" +import { + resolveAndRecordChatMentions, + type ChatMentionRecordSeam, +} from "./chat-mention-resolver.js" import { neutralizeChatViewerFields } from "./chat-viewer-fields.js" import { mapWithLimit } from "./media-presign.js" import { roomKeyFor } from "../ws/gateway.js" diff --git a/services/api/src/services/report-city-forward.ts b/services/api/src/services/report-city-forward.ts index db9f3d85..15c71b31 100644 --- a/services/api/src/services/report-city-forward.ts +++ b/services/api/src/services/report-city-forward.ts @@ -72,7 +72,8 @@ export async function forwardReportCityMention( opts: CityForwardOptions = {}, ): Promise { const jurisdiction = ctx.jurisdiction - if (jurisdiction === null) return { mentioned: false, geoid: null, forwarded: false, forwardedAt: null } + if (jurisdiction === null) + return { mentioned: false, geoid: null, forwarded: false, forwardedAt: null } const handle = effectiveJurisdictionHandle(jurisdiction) if (handle === null || parseCityMention(body, handle) === null) { return { mentioned: false, geoid: jurisdiction.geoid, forwarded: false, forwardedAt: null } @@ -87,7 +88,10 @@ export async function forwardReportCityMention( if (thread === null) { return { mentioned: true, geoid, forwarded: false, forwardedAt: null } } - if (opts.canForward !== undefined && !(await opts.canForward(ctx.reportId, geoid, ctx.actorUserId))) { + if ( + opts.canForward !== undefined && + !(await opts.canForward(ctx.reportId, geoid, ctx.actorUserId)) + ) { return { mentioned: true, geoid, forwarded: false, forwardedAt: null } } const packet = buildDiscussionForwardPacket( diff --git a/services/api/src/services/report-clustering.ts b/services/api/src/services/report-clustering.ts index 087c8f98..550714ad 100644 --- a/services/api/src/services/report-clustering.ts +++ b/services/api/src/services/report-clustering.ts @@ -140,7 +140,11 @@ export function clusterByZoom( const clusters: ReportClusterDTO[] = [] for (const cell of cells.values()) { - clusters.push({ lat: cell.latSum / cell.count, lng: cell.lngSum / cell.count, count: cell.count }) + clusters.push({ + lat: cell.latSum / cell.count, + lng: cell.lngSum / cell.count, + count: cell.count, + }) } return { clusters, pins: [] } } diff --git a/services/api/src/services/report-service.ts b/services/api/src/services/report-service.ts index 33d47ea0..97fcfbab 100644 --- a/services/api/src/services/report-service.ts +++ b/services/api/src/services/report-service.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { AppError, REPORT_TYPE_TO_CATEGORY } from "@civfix/shared" import type { @@ -128,8 +127,7 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { chatMeta?: ReportChatMeta | null }, ): Promise { - const reportIsPublic = - isPubliclyVisibleStatus(record.status) && record.visibility === "public" + const reportIsPublic = isPubliclyVisibleStatus(record.status) && record.visibility === "public" const mediaDTOs = await mapWithLimit(media, PRESIGN_CONCURRENCY, (view) => { const usePrivate = !reportIsPublic || view.status === "validating" return toMediaDTO(view, usePrivate ? privatePresign : publicPresign) @@ -195,7 +193,10 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { } } - async function chatMetaFor(reportId: string, viewerId: string | null): Promise { + async function chatMetaFor( + reportId: string, + viewerId: string | null, + ): Promise { if (deps.loadReportChatMeta === undefined) return null try { return await deps.loadReportChatMeta(reportId, viewerId) @@ -261,7 +262,11 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { publishedAt, mediaUploadIds: input.mediaUploadIds, timelineNote: null, - idempotency: { key: input.idempotencyKey, scope: REPORT_CREATE_SCOPE, userOrAnon: owner.userId }, + idempotency: { + key: input.idempotencyKey, + scope: REPORT_CREATE_SCOPE, + userOrAnon: owner.userId, + }, buildSnapshot: (record, media, timeline) => toReportDTO(record, media, timeline, { mine: true }), }) @@ -313,7 +318,10 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { }) }, - async listMyReports(userId: string, pagination: PaginationQuery): Promise { + async listMyReports( + userId: string, + pagination: PaginationQuery, + ): Promise { const cursor = pagination.cursor ?? null const limit = pagination.limit ?? REPORTS_DEFAULT_LIMIT const { records, nextCursor } = await deps.repo.listMyReports(userId, cursor, limit) @@ -343,11 +351,20 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { types: ReportType[] | null, zoom: number, ): Promise { - const points = await deps.repo.findMapCandidates(bbox, categories, types, MAP_REPORTS_CANDIDATE_CAP) + const points = await deps.repo.findMapCandidates( + bbox, + categories, + types, + MAP_REPORTS_CANDIDATE_CAP, + ) const { clusters, pins: unsignedPins } = clusterByZoom(points, effectiveMapZoom(bbox, zoom)) const counts = countByCategory(points) - const pins: ReportPinDTO[] = await mapWithLimit(unsignedPins, PRESIGN_CONCURRENCY, toMapPinDTO) + const pins: ReportPinDTO[] = await mapWithLimit( + unsignedPins, + PRESIGN_CONCURRENCY, + toMapPinDTO, + ) return { clusters, @@ -359,12 +376,20 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { async searchReports(request: ReportSearchInput): Promise { const q = request.q?.trim() ? request.q.trim() : null const categories = - request.categories !== undefined && request.categories.length > 0 ? request.categories : null + request.categories !== undefined && request.categories.length > 0 + ? request.categories + : null const types = request.types !== undefined && request.types.length > 0 ? request.types : null const cursor = request.cursor ?? null const limit = request.limit ?? REPORTS_SEARCH_DEFAULT_LIMIT - const { points, nextCursor } = await deps.repo.searchReports({ q, categories, types, cursor, limit }) + const { points, nextCursor } = await deps.repo.searchReports({ + q, + categories, + types, + cursor, + limit, + }) const items: ReportPinDTO[] = await mapWithLimit(points, PRESIGN_CONCURRENCY, (p) => toMapPinDTO(mapPointToUnsignedPin(p)), @@ -391,7 +416,9 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { async unlistReport(userId: string, reportId: string, unlisted: boolean): Promise { const visibility: ReportVisibility = unlisted ? "hidden" : "public" const kind = REPORT_VISIBILITY_TIMELINE_KIND[visibility] - const note = unlisted ? "Hidden from the public map by the reporter" : "Re-listed by the reporter" + const note = unlisted + ? "Hidden from the public map by the reporter" + : "Re-listed by the reporter" const outcome = await deps.repo.setVisibilityByOwner(reportId, userId, { visibility, note, @@ -420,11 +447,9 @@ async function maybeEnqueueAutoForward( try { const verified = await deps.isReportVerified(reporterUserId) if (!verified) return - await deps.jobs.enqueue( - REPORT_AUTOFORWARD_JOB, - { reportId } satisfies ReportAutoForwardJob, - { singletonKey: reportId }, - ) + await deps.jobs.enqueue(REPORT_AUTOFORWARD_JOB, { reportId } satisfies ReportAutoForwardJob, { + singletonKey: reportId, + }) } catch (err) { deps.logger?.warn({ err, reportId }, "report.autoforward enqueue failed") } diff --git a/services/api/src/services/report-service.types.ts b/services/api/src/services/report-service.types.ts index 59f019d3..683e6f3f 100644 --- a/services/api/src/services/report-service.types.ts +++ b/services/api/src/services/report-service.types.ts @@ -145,7 +145,10 @@ export interface ReportRepository { findReportByReferenceCode(code: string): Promise findMediaForReport(reportId: string, ownerView?: boolean): Promise countValidatingMediaForReport(reportId: string): Promise - findMediaForReports(reportIds: string[], ownerView?: boolean): Promise> + findMediaForReports( + reportIds: string[], + ownerView?: boolean, + ): Promise> findTimelineForReport(reportId: string): Promise findTimelineForReports(reportIds: string[]): Promise> listMyReports( diff --git a/services/api/src/services/report-timeline-event.ts b/services/api/src/services/report-timeline-event.ts index 4cf70e83..c42b94cd 100644 --- a/services/api/src/services/report-timeline-event.ts +++ b/services/api/src/services/report-timeline-event.ts @@ -72,7 +72,10 @@ export function makeReportChatSystemEmitter( await deps.broadcast(deps.roomKeyFor("report", event.reportId), msg) await deps.notify(event.reportId, msg) } catch (err) { - warn({ err, reportId: event.reportId }, "report-chat: system-message emit failed (suppressed)") + warn( + { err, reportId: event.reportId }, + "report-chat: system-message emit failed (suppressed)", + ) } }, } diff --git a/services/api/src/services/room-read-service.ts b/services/api/src/services/room-read-service.ts index 35a09449..73fc3e87 100644 --- a/services/api/src/services/room-read-service.ts +++ b/services/api/src/services/room-read-service.ts @@ -1,4 +1,3 @@ - import type { RoomKind } from "@civfix/shared" import type { ConversationBellKind } from "./conversation-bell.js" diff --git a/services/api/src/services/route-geo-helpers.ts b/services/api/src/services/route-geo-helpers.ts index 80c7dae2..fa601e4a 100644 --- a/services/api/src/services/route-geo-helpers.ts +++ b/services/api/src/services/route-geo-helpers.ts @@ -99,4 +99,3 @@ export function makeCachedAddressResolver(container: Container): AddressResolver cache: makeGeocodeCache({ getSql: () => container.getDb().sql }), }) } - diff --git a/services/api/src/services/social-repository.drizzle.ts b/services/api/src/services/social-repository.drizzle.ts index c80a9870..c349516a 100644 --- a/services/api/src/services/social-repository.drizzle.ts +++ b/services/api/src/services/social-repository.drizzle.ts @@ -1,4 +1,3 @@ - import type postgres from "postgres" import type { Queryable, Sql } from "../db/client.js" import type { @@ -30,10 +29,7 @@ import { escapeLike } from "./admin/like.js" import { cleanupStatusExpr, goingScalar } from "./cleanup-sql.js" import { servedKeyExpr } from "./media-served-key.js" -export { - searchByHandlePrefix, - searchMentionable, -} from "./user-search.drizzle.js" +export { searchByHandlePrefix, searchMentionable } from "./user-search.drizzle.js" export { resolveHandles, resolveMentionTargets, @@ -262,9 +258,7 @@ async function connectionsPage( const cursor = parseTimeCursor(args.cursor) const viewerId = args.viewerId const cursorFilter = - cursor !== null - ? sql`AND (f.created_at, u.id) < (${cursor.at}, ${cursor.id}::uuid)` - : sql`` + cursor !== null ? sql`AND (f.created_at, u.id) < (${cursor.at}, ${cursor.id}::uuid)` : sql`` const followingExpr = viewerId !== null ? sql`EXISTS (SELECT 1 FROM follows_people ff WHERE ff.follower_id = ${viewerId} AND ff.followee_id = u.id)` @@ -488,8 +482,7 @@ export function makeDrizzleSocialRepository(sql: Sql): SocialRepository { const viewerId = args.viewerId const qFilter = args.q !== null - ? - sql`AND ((u.handle::text) ILIKE ${"%" + escapeLike(args.q) + "%"} ESCAPE '\\' OR u.display_name ILIKE ${"%" + escapeLike(args.q) + "%"} ESCAPE '\\')` + ? sql`AND ((u.handle::text) ILIKE ${"%" + escapeLike(args.q) + "%"} ESCAPE '\\' OR u.display_name ILIKE ${"%" + escapeLike(args.q) + "%"} ESCAPE '\\')` : sql`` const selfFilter = viewerId !== null ? sql`AND u.id <> ${viewerId}` : sql`` const cursorFilter = @@ -627,13 +620,17 @@ export function makeDrizzleSocialRepository(sql: Sql): SocialRepository { return rows[0]?.count ?? 0 }, - async pastEventsPageFor(userId: string, args: ProfileEventsPageArgs): Promise { + async pastEventsPageFor( + userId: string, + args: ProfileEventsPageArgs, + ): Promise { const cursor = parseTimeCursor(args.cursor) const rows = await profileEventRows(sql, { ids: organizedOrAttendedIds(sql, userId), - where: cursor !== null - ? sql`AND c.scheduled_at < now() AND (c.scheduled_at, c.id) < (${cursor.at}, ${cursor.id}::uuid)` - : sql`AND c.scheduled_at < now()`, + where: + cursor !== null + ? sql`AND c.scheduled_at < now() AND (c.scheduled_at, c.id) < (${cursor.at}, ${cursor.id}::uuid)` + : sql`AND c.scheduled_at < now()`, order: sql`ORDER BY c.scheduled_at DESC, c.id DESC`, limit: args.limit + 1, }) diff --git a/services/api/src/services/social-service.ts b/services/api/src/services/social-service.ts index 5d8c6f4a..c4705ce2 100644 --- a/services/api/src/services/social-service.ts +++ b/services/api/src/services/social-service.ts @@ -1,4 +1,3 @@ - import { AppError, avatarGradient } from "@civfix/shared" import type { CleanupDTO, @@ -112,10 +111,7 @@ export interface SocialRepository { isFollowing(followerId: string, followeeId: string): Promise - addFollow( - followerId: string, - followeeId: string, - ): Promise<{ exists: boolean; created: boolean }> + addFollow(followerId: string, followeeId: string): Promise<{ exists: boolean; created: boolean }> removeFollow(followerId: string, followeeId: string): Promise<{ exists: boolean }> @@ -129,10 +125,7 @@ export interface SocialRepository { } export interface SocialNotifier { - onNewFollower(args: { - followeeId: string - follower: PersonView - }): Promise + onNewFollower(args: { followeeId: string; follower: PersonView }): Promise } export interface SocialViewer { @@ -259,7 +252,10 @@ export function makeSocialService(deps: SocialServiceDeps): SocialService { viewerId: string | null cursor: string | null limit: number - }) => Promise<{ items: Array; nextCursor: string | null }>, + }) => Promise<{ + items: Array + nextCursor: string | null + }>, id: string, viewer: SocialViewer, req: ConnectionsListQuery, @@ -335,9 +331,7 @@ export function makeSocialService(deps: SocialServiceDeps): SocialService { : {}), stats, ...(volunteerHours !== undefined ? { volunteerHours } : {}), - ...(view.showVolunteerHours !== null - ? { showVolunteerHours: view.showVolunteerHours } - : {}), + ...(view.showVolunteerHours !== null ? { showVolunteerHours: view.showVolunteerHours } : {}), } } @@ -358,10 +352,7 @@ export function makeSocialService(deps: SocialServiceDeps): SocialService { } } - async function resolveProfile( - view: PersonView, - viewer: SocialViewer, - ): Promise { + async function resolveProfile(view: PersonView, viewer: SocialViewer): Promise { const isSelf = viewer.userId === view.id if (!isSelf && viewer.userId !== null && deps.blockState) { const { blockedByViewer, blockedByTarget } = await deps.blockState(viewer.userId, view.id) @@ -447,7 +438,10 @@ export function makeSocialService(deps: SocialServiceDeps): SocialService { void 0 } } else { - deps.logger?.warn({ viewerId, targetId }, "social: new follower row missing, notification skipped") + deps.logger?.warn( + { viewerId, targetId }, + "social: new follower row missing, notification skipped", + ) } } diff --git a/services/api/src/services/social-suggestions-wiring.ts b/services/api/src/services/social-suggestions-wiring.ts index 60efd9c2..0e4fa369 100644 --- a/services/api/src/services/social-suggestions-wiring.ts +++ b/services/api/src/services/social-suggestions-wiring.ts @@ -1,4 +1,3 @@ - import type { Container } from "../di.js" import { dropSuggestionsFor, type SuggestionsCache } from "./social-service.js" diff --git a/services/api/src/services/threads-repository.drizzle.ts b/services/api/src/services/threads-repository.drizzle.ts index aad12a3e..a0365565 100644 --- a/services/api/src/services/threads-repository.drizzle.ts +++ b/services/api/src/services/threads-repository.drizzle.ts @@ -1,4 +1,3 @@ - import { REPORT_CATEGORY_LABELS } from "@civfix/shared" import type { ReportCategory } from "@civfix/shared" import type postgres from "postgres" @@ -121,7 +120,9 @@ async function listThreadFamily( ` } -function lastOf(r: ThreadFamilyRow): { body: string | null; createdAt: Date; senderId: string | null } | null { +function lastOf( + r: ThreadFamilyRow, +): { body: string | null; createdAt: Date; senderId: string | null } | null { return r.last_created_at !== null ? { body: r.last_body, createdAt: r.last_created_at, senderId: r.last_sender_id } : null @@ -175,9 +176,10 @@ export function makeDrizzleThreadsRepository(sql: Sql): ThreadsRepository { joinedAt: r.joined_at, members: r.members, unread: r.unread, - last: r.last_created_at !== null - ? { body: r.last_body, createdAt: r.last_created_at, senderId: r.last_sender_id! } - : null, + last: + r.last_created_at !== null + ? { body: r.last_body, createdAt: r.last_created_at, senderId: r.last_sender_id! } + : null, })) }, diff --git a/services/api/src/services/threads-service.ts b/services/api/src/services/threads-service.ts index bef78c5a..ba5a2f9f 100644 --- a/services/api/src/services/threads-service.ts +++ b/services/api/src/services/threads-service.ts @@ -1,4 +1,3 @@ - import { relativeAgo, avatarGradient } from "@civfix/shared" import type { MessageThreadDTO, PersonDTO } from "@civfix/shared" import { @@ -269,35 +268,33 @@ export function makeThreadsService(deps: ThreadsServiceDeps): ThreadsService { }), ) - const dmEntries = dmAggregates.map( - (agg): { dto: MessageThreadDTO; activity: number } => { - const lastFromMe = agg.last !== null && agg.last.senderId === userId - const peer = peerOf(agg.peer) - const title = - agg.peer.displayName.trim() !== "" - ? agg.peer.displayName - : agg.peer.handle !== null - ? `@${agg.peer.handle}` - : agg.peer.displayName - return { - dto: { - id: agg.threadId, - kind: "dm", - refId: agg.threadId, - title, - peer, - last: agg.last !== null ? (agg.last.body ?? "") : null, - ago: agg.last !== null ? relativeAgo(agg.last.createdAt, now()) : null, - lastMessageAt: agg.last !== null ? agg.last.createdAt.toISOString() : null, - lastFromMe, - unread: agg.unread, - members: 2, - muted: mutedDm.has(agg.threadId), - }, - activity: (agg.last?.createdAt ?? agg.createdAt).getTime(), - } - }, - ) + const dmEntries = dmAggregates.map((agg): { dto: MessageThreadDTO; activity: number } => { + const lastFromMe = agg.last !== null && agg.last.senderId === userId + const peer = peerOf(agg.peer) + const title = + agg.peer.displayName.trim() !== "" + ? agg.peer.displayName + : agg.peer.handle !== null + ? `@${agg.peer.handle}` + : agg.peer.displayName + return { + dto: { + id: agg.threadId, + kind: "dm", + refId: agg.threadId, + title, + peer, + last: agg.last !== null ? (agg.last.body ?? "") : null, + ago: agg.last !== null ? relativeAgo(agg.last.createdAt, now()) : null, + lastMessageAt: agg.last !== null ? agg.last.createdAt.toISOString() : null, + lastFromMe, + unread: agg.unread, + members: 2, + muted: mutedDm.has(agg.threadId), + }, + activity: (agg.last?.createdAt ?? agg.createdAt).getTime(), + } + }) const reportEntries = reportAggregates.map( (agg): { dto: MessageThreadDTO; activity: number } => { @@ -321,28 +318,26 @@ export function makeThreadsService(deps: ThreadsServiceDeps): ThreadsService { }, ) - const groupEntries = groupAggregates.map( - (agg): { dto: MessageThreadDTO; activity: number } => { - const lastFromMe = agg.last !== null && agg.last.senderId === userId - return { - dto: { - id: agg.groupId, - kind: "group", - refId: agg.groupId, - title: agg.title, - last: agg.last !== null ? (agg.last.body ?? "") : null, - ago: agg.last !== null ? relativeAgo(agg.last.createdAt, now()) : null, - lastMessageAt: agg.last !== null ? agg.last.createdAt.toISOString() : null, - lastFromMe, - unread: agg.unread, - members: agg.members, - muted: mutedGroup.has(agg.groupId), - ...(agg.kind === "channel" ? { channel: true as const } : {}), - }, - activity: (agg.last?.createdAt ?? agg.joinedAt).getTime(), - } - }, - ) + const groupEntries = groupAggregates.map((agg): { dto: MessageThreadDTO; activity: number } => { + const lastFromMe = agg.last !== null && agg.last.senderId === userId + return { + dto: { + id: agg.groupId, + kind: "group", + refId: agg.groupId, + title: agg.title, + last: agg.last !== null ? (agg.last.body ?? "") : null, + ago: agg.last !== null ? relativeAgo(agg.last.createdAt, now()) : null, + lastMessageAt: agg.last !== null ? agg.last.createdAt.toISOString() : null, + lastFromMe, + unread: agg.unread, + members: agg.members, + muted: mutedGroup.has(agg.groupId), + ...(agg.kind === "channel" ? { channel: true as const } : {}), + }, + activity: (agg.last?.createdAt ?? agg.joinedAt).getTime(), + } + }) const merged = [...cleanupEntries, ...dmEntries, ...reportEntries, ...groupEntries] .filter((e) => beforeCursor(cursor, e.activity, e.dto.id)) diff --git a/services/api/src/services/volunteer-hours-anomaly.ts b/services/api/src/services/volunteer-hours-anomaly.ts index 05d42e5e..94515c84 100644 --- a/services/api/src/services/volunteer-hours-anomaly.ts +++ b/services/api/src/services/volunteer-hours-anomaly.ts @@ -1,4 +1,3 @@ - import type { VolunteerHoursAnomalyKind } from "./volunteer-hours-service.js" export const HOURS_ANOMALY_FLAG = "Volunteer hours anomaly" @@ -27,9 +26,7 @@ export interface HoursAnomalyModerationItem { dedupeOpen: true } -export function toHoursAnomalyModerationItem( - input: HoursAnomalyInput, -): HoursAnomalyModerationItem { +export function toHoursAnomalyModerationItem(input: HoursAnomalyInput): HoursAnomalyModerationItem { const desc = input.kind === "weekly_hours" ? `${round2(input.hours ?? 0)} volunteer hours credited to this account in the last 7 days; most recent credit on event ${input.cleanupId}.` diff --git a/services/api/src/services/volunteer-hours-repository.memory.ts b/services/api/src/services/volunteer-hours-repository.memory.ts index 8601b9a2..16b35ee9 100644 --- a/services/api/src/services/volunteer-hours-repository.memory.ts +++ b/services/api/src/services/volunteer-hours-repository.memory.ts @@ -121,7 +121,12 @@ export class InMemoryVolunteerHoursRepository implements VolunteerHoursRepositor if (row) row.voidedAt = this.now() } - seedLegacyReportEntry(userId: string, reportId: string, geoid: string | null, hours = 0.1): string { + seedLegacyReportEntry( + userId: string, + reportId: string, + geoid: string | null, + hours = 0.1, + ): string { const id = this.newId() this.entries.push({ id, diff --git a/services/api/src/services/volunteer-hours-service.ts b/services/api/src/services/volunteer-hours-service.ts index 99ea35fa..99c2b136 100644 --- a/services/api/src/services/volunteer-hours-service.ts +++ b/services/api/src/services/volunteer-hours-service.ts @@ -49,7 +49,6 @@ export const MAX_ORG_CHIPS_FETCH = 20 export const HOURS_NOTIFY_CONCURRENCY = 8 - export const EVENT_WINDOW_GRACE_MS = 60 * 60 * 1000 export const DAILY_HOURS_CAP = 24 @@ -421,8 +420,7 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu const notifier = deps.notifier if (notifier === undefined) return const recipients = changed.filter( - (c) => - c.userId !== actorId && (c.previousHours === null || c.hours > c.previousHours), + (c) => c.userId !== actorId && (c.previousHours === null || c.hours > c.previousHours), ) await mapWithLimit(recipients, HOURS_NOTIFY_CONCURRENCY, async (c) => { try { diff --git a/services/api/src/versioning/version-gate.ts b/services/api/src/versioning/version-gate.ts index d1879842..3d09e847 100644 --- a/services/api/src/versioning/version-gate.ts +++ b/services/api/src/versioning/version-gate.ts @@ -1,4 +1,3 @@ - import { AppError } from "@civfix/shared" import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify" import { diff --git a/services/api/src/ws/frame-handler.ts b/services/api/src/ws/frame-handler.ts index 6cbb8e01..ead93250 100644 --- a/services/api/src/ws/frame-handler.ts +++ b/services/api/src/ws/frame-handler.ts @@ -73,7 +73,9 @@ export function roomKeyFor(kind: RoomKind, id: string): string { } export function broadcastMessageUpdate( - chat: { broadcastEvent?: ((roomKey: string, frame: WsServerMessage) => Promise | void) | undefined }, + chat: { + broadcastEvent?: ((roomKey: string, frame: WsServerMessage) => Promise | void) | undefined + }, roomKind: RoomKind, roomId: string, message: ChatMessageDTO, @@ -95,7 +97,10 @@ function decodeRoomKey(roomKey: string): { kind: RoomKind; id: string } { return { kind: "cleanup", id: roomKey } } -export async function leaveRoomAndAnnounce(session: GatewaySession, roomKey: string): Promise { +export async function leaveRoomAndAnnounce( + session: GatewaySession, + roomKey: string, +): Promise { const { conn, deps, userId } = session const { kind, id } = decodeRoomKey(roomKey) await deps.chat.leaveRoom(roomKey, conn) @@ -126,7 +131,9 @@ async function authorizeRoom( ): Promise { if (kind === "cleanup") { const ok = await deps.isMember(id, userId) - return ok ? { ok: true } : { ok: false, code: "FORBIDDEN", message: "You are not a member of this cleanup." } + return ok + ? { ok: true } + : { ok: false, code: "FORBIDDEN", message: "You are not a member of this cleanup." } } if (kind === "report") { if (deps.reportVisible && !(await deps.reportVisible(id, userId))) { @@ -158,7 +165,11 @@ async function authorizeRoom( return { ok: false, code: "FORBIDDEN", message: "You are not a member of this group." } } if (!access.canPost) { - return { ok: false, code: "channel_read_only", message: "Only owners and admins can post in this channel." } + return { + ok: false, + code: "channel_read_only", + message: "Only owners and admins can post in this channel.", + } } return { ok: true } } @@ -192,7 +203,10 @@ export async function reauthorizeJoinedRooms(session: GatewaySession): Promise {}) } } @@ -211,7 +225,12 @@ async function handleJoin(session: GatewaySession, frame: ExtractFrame<"join">): const online = await deps.presence.online(roomKey) if (session.closed) return conn.send( - serverFrame({ type: "presence_snapshot", cleanupId: id, ...stampRoomKind(kind), userIds: online }), + serverFrame({ + type: "presence_snapshot", + cleanupId: id, + ...stampRoomKind(kind), + userIds: online, + }), ) } return @@ -241,7 +260,12 @@ async function handleJoin(session: GatewaySession, frame: ExtractFrame<"join">): return } conn.send( - serverFrame({ type: "presence_snapshot", cleanupId: id, ...stampRoomKind(kind), userIds: online }), + serverFrame({ + type: "presence_snapshot", + cleanupId: id, + ...stampRoomKind(kind), + userIds: online, + }), ) if (userJoined) { await deps.chat.broadcastEvent?.( @@ -317,7 +341,10 @@ async function handleSend(session: GatewaySession, frame: ExtractFrame<"send">): return } if (deps.reportSendLimiter && !deps.reportSendLimiter.tryConsume(`${userId}:${roomKey}`)) { - sendError(conn, "RATE_LIMITED", "You're sending messages too fast. Please slow down.", { kind, id }) + sendError(conn, "RATE_LIMITED", "You're sending messages too fast. Please slow down.", { + kind, + id, + }) return } const resilience = deps.chat.sendResilience ?? PASSTHROUGH_SEND_RESILIENCE @@ -376,7 +403,12 @@ async function handleSend(session: GatewaySession, frame: ExtractFrame<"send">): conn.send(serverFrame({ type: "ack", clientId: frame.clientId, message })) - await resilience.broadcastMessage(deps.chat, roomKey, neutralizeChatViewerFields(message), conn.id) + await resilience.broadcastMessage( + deps.chat, + roomKey, + neutralizeChatViewerFields(message), + conn.id, + ) const replyTargetUserId = replyBellTarget(message, userId) fireMentionBells(deps, kind, id, userId, mentions, message, replyTargetUserId) @@ -490,13 +522,7 @@ async function handleTyping(session: GatewaySession, frame: ExtractFrame<"typing if (oldest !== undefined) session.typingThrottle.delete(oldest) } session.typingThrottle.set(roomKey, now) - const auth = await authorizeRoom( - deps, - kind, - id, - userId, - kind === "report" || kind === "group", - ) + const auth = await authorizeRoom(deps, kind, id, userId, kind === "report" || kind === "group") if (!auth.ok) { sendError(conn, auth.code, auth.message, { kind, id }) return @@ -576,7 +602,10 @@ export async function handleClientFrame(session: GatewaySession, raw: string): P return } const frame = result.data - await (dispatch[frame.type] as (s: GatewaySession, f: ClientFrame) => Promise)(session, frame) + await (dispatch[frame.type] as (s: GatewaySession, f: ClientFrame) => Promise)( + session, + frame, + ) } export { serverFrame, sendError, decodeRoomKey } diff --git a/services/api/src/ws/report-rate-limit.ts b/services/api/src/ws/report-rate-limit.ts index f8553c85..4976450d 100644 --- a/services/api/src/ws/report-rate-limit.ts +++ b/services/api/src/ws/report-rate-limit.ts @@ -1,4 +1,3 @@ - export interface RateLimiter { tryConsume(key: string): boolean } @@ -30,7 +29,10 @@ export function makeTokenBucketLimiter(opts: TokenBucketOptions): RateLimiter { for (const [k, b] of buckets) { if (scanned >= EVICT_PREFER_FULL_WINDOW) break scanned += 1 - const refilled = Math.min(opts.capacity, b.tokens + ((t - b.last) / 1000) * opts.refillPerSec) + const refilled = Math.min( + opts.capacity, + b.tokens + ((t - b.last) / 1000) * opts.refillPerSec, + ) if (refilled >= opts.capacity) { victim = k break diff --git a/services/api/src/ws/send-resilience.ts b/services/api/src/ws/send-resilience.ts index 03a39555..13d74e84 100644 --- a/services/api/src/ws/send-resilience.ts +++ b/services/api/src/ws/send-resilience.ts @@ -267,7 +267,11 @@ export function makeSendResilience(deps: SendResilienceDeps = {}): SendResilienc let localRecipients = 0 if (deps.deliverLocally) { try { - localRecipients = deps.deliverLocally(roomKey, { type: "message", message }, excludeConnId) + localRecipients = deps.deliverLocally( + roomKey, + { type: "message", message }, + excludeConnId, + ) } catch { localRecipients = 0 } diff --git a/services/api/src/ws/socket-lifecycle.ts b/services/api/src/ws/socket-lifecycle.ts index 6c363366..ca62755e 100644 --- a/services/api/src/ws/socket-lifecycle.ts +++ b/services/api/src/ws/socket-lifecycle.ts @@ -191,7 +191,9 @@ export function registerChatGateway(app: FastifyInstance, opts: RegisterGatewayO request.log.warn({ origin: originHeader(request) }, "ws: rejected cross-site Origin") } try { - socket.send(serverFrame({ type: "error", code: handshake.code, message: handshake.message })) + socket.send( + serverFrame({ type: "error", code: handshake.code, message: handshake.message }), + ) } catch (err) { request.log.debug({ err }, "ws: handshake-reject send failed (socket already closing)") } @@ -207,9 +209,18 @@ export function registerChatGateway(app: FastifyInstance, opts: RegisterGatewayO ) { dropPending() try { - socket.send(serverFrame({ type: "error", code: "RATE_LIMITED", message: "Too many open connections." })) + socket.send( + serverFrame({ + type: "error", + code: "RATE_LIMITED", + message: "Too many open connections.", + }), + ) } catch (err) { - request.log.debug({ err }, "ws: connection-cap reject send failed (socket already closing)") + request.log.debug( + { err }, + "ws: connection-cap reject send failed (socket already closing)", + ) } socket.close(WS_CLOSE_POLICY_VIOLATION, "too many connections") return @@ -233,11 +244,7 @@ export function registerChatGateway(app: FastifyInstance, opts: RegisterGatewayO : {}), ...(handshake.sessionHash !== undefined && opts.sessions !== undefined ? { - revalidateStatus: makeStatusRevalidator( - opts.sessions, - userId, - handshake.sessionHash, - ), + revalidateStatus: makeStatusRevalidator(opts.sessions, userId, handshake.sessionHash), } : {}), conn: wrapSocket(socket), @@ -265,7 +272,12 @@ export function registerChatGateway(app: FastifyInstance, opts: RegisterGatewayO }, } - let unsubscribeUser = await subscribeUserChannel(opts.userChannel, userId, session.conn, request.log) + let unsubscribeUser = await subscribeUserChannel( + opts.userChannel, + userId, + session.conn, + request.log, + ) if (socket.readyState !== READY_STATE_OPEN) { dropPending() diff --git a/services/api/test/helpers/anon.ts b/services/api/test/helpers/anon.ts index a9305717..35e4ac16 100644 --- a/services/api/test/helpers/anon.ts +++ b/services/api/test/helpers/anon.ts @@ -34,11 +34,7 @@ import type { ReleaseMediaView, } from "../../src/services/anon-hold-release.js" import type { ClaimRepository, PendingAnonReport } from "../../src/services/claim-service.js" -import { - formatReferenceCode, - reportScopeKey, - typeCodeFor, -} from "../../src/db/reference-code.js" +import { formatReferenceCode, reportScopeKey, typeCodeFor } from "../../src/db/reference-code.js" import type { ReportType } from "@civfix/shared" /** A stored anon report row (the subset the anon flow reads). */ @@ -398,12 +394,16 @@ export class InMemoryAnonStore { report.claimCodeHash = claimCodeHash return Promise.resolve({ reportId: report.id }) }, - claimByCode: (claimCodeHash: string, userId: string): Promise<{ reportId: string } | null> => { + claimByCode: ( + claimCodeHash: string, + userId: string, + ): Promise<{ reportId: string } | null> => { // Match the report whose stored DIGEST equals the hash of the presented code (0091), not the // token. Single-use: a cleared digest no longer matches. anon_session_id is kept as an audit // trail. const report = [...this.reports.values()].find( - (r) => r.claimCodeHash === claimCodeHash && r.reporterUserId === null && r.deletedAt === null, + (r) => + r.claimCodeHash === claimCodeHash && r.reporterUserId === null && r.deletedAt === null, ) if (!report) return Promise.resolve(null) report.reporterUserId = userId diff --git a/services/api/test/helpers/auth-harness.test.ts b/services/api/test/helpers/auth-harness.test.ts index f8806980..08f58713 100644 --- a/services/api/test/helpers/auth-harness.test.ts +++ b/services/api/test/helpers/auth-harness.test.ts @@ -23,9 +23,7 @@ afterEach(async () => { open = undefined }) -async function harness( - opts: Parameters[0] = {}, -): Promise { +async function harness(opts: Parameters[0] = {}): Promise { const h = await makeAuthHarness(opts) open = h.app return h diff --git a/services/api/test/helpers/chat.ts b/services/api/test/helpers/chat.ts index 33ced7a1..56890678 100644 --- a/services/api/test/helpers/chat.ts +++ b/services/api/test/helpers/chat.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { avatarGradient, AppError } from "@civfix/shared" import type { ChatConnection, ChatHistoryPage, PersistChatInput } from "@civfix/shared/interfaces" @@ -124,7 +123,9 @@ export class InMemoryChatRepository implements ChatRepository { const idx = allDesc.findIndex((m) => m.dto.id === before) if (idx >= 0) afterAnchor = allDesc.slice(idx + 1) } - const ordered = afterAnchor.filter((m) => !m.deleted).map((m) => this.withReply(cleanupId, m.dto)) + const ordered = afterAnchor + .filter((m) => !m.deleted) + .map((m) => this.withReply(cleanupId, m.dto)) const page = ordered.slice(0, limit) const nextCursor = ordered.length > limit ? (page[page.length - 1]?.id ?? null) : null return Promise.resolve({ items: page, nextCursor }) @@ -179,7 +180,10 @@ export class InMemoryChatRepository implements ChatRepository { const stored = (this.log.get(cleanupId) ?? []).find((m) => m.dto.id === messageId && !m.deleted) if (!stored) return Promise.resolve(null) return Promise.resolve( - this.withReply(cleanupId, { ...stored.dto, reactions: this.reactionsFor(messageId, viewerUserId) }), + this.withReply(cleanupId, { + ...stored.dto, + reactions: this.reactionsFor(messageId, viewerUserId), + }), ) } @@ -434,9 +438,7 @@ export class InMemoryThreadsRepository implements ThreadsRepository { cleanupId: string, msg: { senderId: string; body: string | null; createdAt: Date; deleted?: boolean }, ): void { - this.cleanups - .get(cleanupId) - ?.messages.push({ ...msg, deleted: msg.deleted ?? false }) + this.cleanups.get(cleanupId)?.messages.push({ ...msg, deleted: msg.deleted ?? false }) } async listThreadsFor( diff --git a/services/api/test/helpers/cleanups.ts b/services/api/test/helpers/cleanups.ts index 6ec3b8b4..0bd0dc8e 100644 --- a/services/api/test/helpers/cleanups.ts +++ b/services/api/test/helpers/cleanups.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { AppError } from "@civfix/shared" import type { Sql } from "../../src/db/client.js" @@ -273,8 +272,7 @@ export function haversineMeters(a: NearPoint, b: NearPoint): number { const dLng = toRad(b.lng - a.lng) const lat1 = toRad(a.lat) const lat2 = toRad(b.lat) - const h = - Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2 + const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2 return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))) } @@ -433,8 +431,14 @@ export class InMemoryCleanupRepository implements CleanupRepository { if (!this.users.has(cleanup.organizerUserId)) { this.seedUser({ id: cleanup.organizerUserId }) } - if (!this.members.some((m) => m.cleanupId === cleanup.id && m.userId === cleanup.organizerUserId)) { - this.members.push({ cleanupId: cleanup.id, userId: cleanup.organizerUserId, role: "organizer" }) + if ( + !this.members.some((m) => m.cleanupId === cleanup.id && m.userId === cleanup.organizerUserId) + ) { + this.members.push({ + cleanupId: cleanup.id, + userId: cleanup.organizerUserId, + role: "organizer", + }) } if (over.withDefaultSlot !== false) { this.seedSlot({ @@ -725,9 +729,7 @@ export class InMemoryCleanupRepository implements CleanupRepository { if (cleanup === undefined) return NO_HOST_STANDING const eventRole = await this.roleOf(cleanupId, userId) const orgRole = - cleanup.organizationId === null - ? null - : await this.orgRoleOf(cleanup.organizationId, userId) + cleanup.organizationId === null ? null : await this.orgRoleOf(cleanup.organizationId, userId) if (eventRole === null && orgRole === null) return NO_HOST_STANDING return { eventRole, orgRole } } @@ -883,9 +885,7 @@ export class InMemoryCleanupRepository implements CleanupRepository { const have = this.links.filter((l) => l.cleanupId === cleanupId).map((l) => l.reportId) const want = new Set(desiredIds) const toAdd = desiredIds.filter((id) => !have.includes(id)) - const toRemove = have.filter( - (id) => !want.has(id) && this.reportVisible(this.reports.get(id)), - ) + const toRemove = have.filter((id) => !want.has(id) && this.reportVisible(this.reports.get(id))) const added = this.linkInner(cleanupId, toAdd, actorId) for (const reportId of toRemove) { const idx = this.links.findIndex((l) => l.cleanupId === cleanupId && l.reportId === reportId) @@ -960,9 +960,7 @@ export class InMemoryCleanupRepository implements CleanupRepository { } filterVisibleReportIds(reportIds: string[]): Promise> { - const visible = new Set( - reportIds.filter((id) => this.reportVisible(this.reports.get(id))), - ) + const visible = new Set(reportIds.filter((id) => this.reportVisible(this.reports.get(id)))) return Promise.resolve(visible) } @@ -1000,7 +998,10 @@ export class InMemoryCleanupRepository implements CleanupRepository { } if (filters.when === "attending") { const viewerId = filters.viewerId ?? null - if (viewerId === null || !this.members.some((m) => m.cleanupId === c.id && m.userId === viewerId)) + if ( + viewerId === null || + !this.members.some((m) => m.cleanupId === c.id && m.userId === viewerId) + ) return false } if (filters.bbox !== undefined && !inBox(c, filters.bbox)) return false @@ -1203,9 +1204,7 @@ export class InMemoryCleanupRepository implements CleanupRepository { } private deleteClaim(cleanupId: string, userId: string): string | null { - const idx = this.slotClaims.findIndex( - (c) => c.cleanupId === cleanupId && c.userId === userId, - ) + const idx = this.slotClaims.findIndex((c) => c.cleanupId === cleanupId && c.userId === userId) if (idx < 0) return null const [claim] = this.slotClaims.splice(idx, 1) return claim?.slotId ?? null @@ -1220,7 +1219,13 @@ export class InMemoryCleanupRepository implements CleanupRepository { if (c.status === "cancelled") return Promise.resolve("already_cancelled") if (c.endsAt.getTime() <= this.now().getTime()) return Promise.resolve("already_ended") c.status = "cancelled" - this.timeline.push({ cleanupId: id, kind: "cancel", reportId: "", note: input.note, actorId: input.actorId }) + this.timeline.push({ + cleanupId: id, + kind: "cancel", + reportId: "", + note: input.note, + actorId: input.actorId, + }) return Promise.resolve("cancelled") } @@ -1257,11 +1262,12 @@ export class InMemoryCleanupRepository implements CleanupRepository { return Promise.resolve(views.slice(0, limit)) } - private slotViews(cleanupId: string, viewerId: string | null): EventSlotView[] { return this.slots .filter((s) => s.cleanupId === cleanupId) - .sort((a, b) => (a.sortOrder !== b.sortOrder ? a.sortOrder - b.sortOrder : a.id < b.id ? -1 : 1)) + .sort((a, b) => + a.sortOrder !== b.sortOrder ? a.sortOrder - b.sortOrder : a.id < b.id ? -1 : 1, + ) .map((s) => ({ cleanupId: s.cleanupId, id: s.id, @@ -1272,8 +1278,9 @@ export class InMemoryCleanupRepository implements CleanupRepository { endsAt: s.endsAt, sortOrder: s.sortOrder, claimed: this.slotClaims.filter((c) => c.slotId === s.id).length, - mine: viewerId !== null - && this.slotClaims.some((c) => c.slotId === s.id && c.userId === viewerId), + mine: + viewerId !== null && + this.slotClaims.some((c) => c.slotId === s.id && c.userId === viewerId), })) } @@ -1443,4 +1450,3 @@ export class InMemoryCleanupRepository implements CleanupRepository { return Promise.resolve() } } - diff --git a/services/api/test/helpers/fake-sql.ts b/services/api/test/helpers/fake-sql.ts index 7eeb6400..c9011556 100644 --- a/services/api/test/helpers/fake-sql.ts +++ b/services/api/test/helpers/fake-sql.ts @@ -1,4 +1,3 @@ - export interface RecordedStatement { sql: string values: unknown[] @@ -29,7 +28,9 @@ export interface FakeSqlControl { } function isNode(v: unknown): v is SqlNode { - return typeof v === "object" && v !== null && (v as { __isFakeSql?: boolean }).__isFakeSql === true + return ( + typeof v === "object" && v !== null && (v as { __isFakeSql?: boolean }).__isFakeSql === true + ) } export function makeFakeSql(handlers: SqlHandler[] = []): FakeSqlControl { @@ -47,7 +48,10 @@ export function makeFakeSql(handlers: SqlHandler[] = []): FakeSqlControl { return Promise.resolve([]) } - const tag = ((strings: TemplateStringsArray | readonly unknown[], ...values: unknown[]): SqlNode => { + const tag = (( + strings: TemplateStringsArray | readonly unknown[], + ...values: unknown[] + ): SqlNode => { if (!Array.isArray((strings as TemplateStringsArray).raw)) { const helper = strings as unknown const isList = Array.isArray(helper) diff --git a/services/api/test/helpers/guest-rsvp.ts b/services/api/test/helpers/guest-rsvp.ts index 247e070a..4ac2f292 100644 --- a/services/api/test/helpers/guest-rsvp.ts +++ b/services/api/test/helpers/guest-rsvp.ts @@ -96,7 +96,9 @@ export class InMemoryGuestRsvpRepository implements GuestRsvpRepository, GuestCo } goingCount(cleanupId: string): Promise { - return Promise.resolve((this.memberCounts.get(cleanupId) ?? 0) + this.activeGuestCount(cleanupId)) + return Promise.resolve( + (this.memberCounts.get(cleanupId) ?? 0) + this.activeGuestCount(cleanupId), + ) } isPhoneOptedOut(phone: string): Promise { @@ -176,7 +178,9 @@ export class InMemoryGuestRsvpRepository implements GuestRsvpRepository, GuestCo upsertVerifiedGuest(args: UpsertGuestArgs): Promise<{ id: string }> { const existing = this.guests.find( (g) => - g.cleanupId === args.cleanupId && g.cancelledAt === null && g.contactKey === args.contactKey, + g.cleanupId === args.cleanupId && + g.cancelledAt === null && + g.contactKey === args.contactKey, ) if (existing !== undefined) { existing.name = args.name @@ -333,11 +337,7 @@ export class InMemoryGuestRsvpRepository implements GuestRsvpRepository, GuestCo return Promise.resolve(rows) } - scrubExpiredGuestContacts(args: { - cutoff: Date - now: Date - batchSize: number - }): Promise { + scrubExpiredGuestContacts(args: { cutoff: Date; now: Date; batchSize: number }): Promise { let scrubbed = 0 for (const guest of this.guests) { if (scrubbed >= args.batchSize) break diff --git a/services/api/test/helpers/media.ts b/services/api/test/helpers/media.ts index 2d398955..4eff6b44 100644 --- a/services/api/test/helpers/media.ts +++ b/services/api/test/helpers/media.ts @@ -1,4 +1,3 @@ - import type { MediaAssetView, MediaRepository, diff --git a/services/api/test/helpers/notifications.ts b/services/api/test/helpers/notifications.ts index 69a68c55..566524ab 100644 --- a/services/api/test/helpers/notifications.ts +++ b/services/api/test/helpers/notifications.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import type { CreateNotificationInput, @@ -325,4 +324,3 @@ export class InMemoryNotificationRepository implements NotificationRepository { return Promise.resolve(out) } } - diff --git a/services/api/test/helpers/pg-container.ts b/services/api/test/helpers/pg-container.ts index 70582084..2c8595e4 100644 --- a/services/api/test/helpers/pg-container.ts +++ b/services/api/test/helpers/pg-container.ts @@ -63,9 +63,7 @@ export interface SharedPg { stop(): Promise } -export type StartSharedPgResult = - | { ok: true; pg: SharedPg } - | { ok: false; reason: string } +export type StartSharedPgResult = { ok: true; pg: SharedPg } | { ok: false; reason: string } /** Values an environment variable uses to mean "on". Different CI providers pick different ones. */ const TRUTHY = new Set(["1", "true", "yes", "on"]) diff --git a/services/api/test/helpers/pg-selection.test.ts b/services/api/test/helpers/pg-selection.test.ts index a4baf366..975bf0be 100644 --- a/services/api/test/helpers/pg-selection.test.ts +++ b/services/api/test/helpers/pg-selection.test.ts @@ -120,17 +120,17 @@ describe("pgSkipDecision", () => { it("lets CIVFIX_ALLOW_PG_SKIP override both (a CI job that intentionally has no Docker)", () => { expect(pgSkipDecision({ CI: "true", CIVFIX_ALLOW_PG_SKIP: "1" }).allowed).toBe(true) - expect( - pgSkipDecision({ CIVFIX_REQUIRE_PG: "1", CIVFIX_ALLOW_PG_SKIP: "true" }).allowed, - ).toBe(true) + expect(pgSkipDecision({ CIVFIX_REQUIRE_PG: "1", CIVFIX_ALLOW_PG_SKIP: "true" }).allowed).toBe( + true, + ) }) }) describe("assertPgSkipAllowed", () => { it("throws where a skip is forbidden, quoting the underlying Docker failure", () => { - expect(() => assertPgSkipAllowed("connect ENOENT /var/run/docker.sock", { CI: "true" })).toThrow( - /REQUIRED in this environment \(CI=true\)[\s\S]*docker\.sock/, - ) + expect(() => + assertPgSkipAllowed("connect ENOENT /var/run/docker.sock", { CI: "true" }), + ).toThrow(/REQUIRED in this environment \(CI=true\)[\s\S]*docker\.sock/) }) it("names the escape hatch in the message, so the failure is actionable", () => { diff --git a/services/api/test/helpers/reports.ts b/services/api/test/helpers/reports.ts index f996e133..cef39957 100644 --- a/services/api/test/helpers/reports.ts +++ b/services/api/test/helpers/reports.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import type { BBox, @@ -11,11 +10,7 @@ import type { ReportTimelineView, ReportVisibilityTimelineKind, } from "../../src/services/report-service.js" -import { - formatReferenceCode, - reportScopeKey, - typeCodeFor, -} from "../../src/db/reference-code.js" +import { formatReferenceCode, reportScopeKey, typeCodeFor } from "../../src/db/reference-code.js" import { isPubliclyVisibleStatus } from "../../src/services/report-visibility.js" import { paginate, parseTimeCursor } from "../../src/db/cursor-helpers.js" import type { ReportDTO } from "@civfix/shared" @@ -270,9 +265,7 @@ export class InMemoryReportRepository implements ReportRepository { return this.loadTimeline(reportId) } - async findTimelineForReports( - reportIds: string[], - ): Promise> { + async findTimelineForReports(reportIds: string[]): Promise> { const grouped = new Map() for (const id of reportIds) { grouped.set(id, await this.loadTimeline(id)) @@ -463,4 +456,3 @@ function notOwnerOutcome(r: { function idempotencyMapKey(scope: string, key: string, userOrAnon: string | null): string { return `${scope}:${key}:${userOrAnon ?? ""}` } - diff --git a/services/api/test/helpers/social.ts b/services/api/test/helpers/social.ts index 92d6e7e7..5e500616 100644 --- a/services/api/test/helpers/social.ts +++ b/services/api/test/helpers/social.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import type { PersonView, @@ -69,8 +68,14 @@ export class InMemorySocialRepository implements SocialRepository { } private bumpCounters(followerId: string, followeeId: string, delta: number): void { - this.followerCounts.set(followeeId, Math.max((this.followerCounts.get(followeeId) ?? 0) + delta, 0)) - this.followingCounts.set(followerId, Math.max((this.followingCounts.get(followerId) ?? 0) + delta, 0)) + this.followerCounts.set( + followeeId, + Math.max((this.followerCounts.get(followeeId) ?? 0) + delta, 0), + ) + this.followingCounts.set( + followerId, + Math.max((this.followingCounts.get(followerId) ?? 0) + delta, 0), + ) } seedCleanup(record: CleanupRecord, attendees: string[] = []): void { @@ -158,7 +163,10 @@ export class InMemorySocialRepository implements SocialRepository { ) } - private activityPoint(userId: string, organizedOnly: boolean): { lat: number; lng: number } | null { + private activityPoint( + userId: string, + organizedOnly: boolean, + ): { lat: number; lng: number } | null { const mine = this.cleanups .filter((c) => organizedOnly @@ -188,7 +196,10 @@ export class InMemorySocialRepository implements SocialRepository { const NEARBY_METERS = 25_000 const RADIUS_METERS = SUGGEST_CANDIDATE_RADIUS_DEG * 111_320 const viewerPoint = this.activityPoint(args.viewerId, false) - const haversine = (a: { lat: number; lng: number }, b: { lat: number; lng: number }): number => { + const haversine = ( + a: { lat: number; lng: number }, + b: { lat: number; lng: number }, + ): number => { const toRad = (d: number): number => (d * Math.PI) / 180 const dLat = toRad(b.lat - a.lat) const dLng = toRad(b.lng - a.lng) @@ -312,7 +323,8 @@ export class InMemorySocialRepository implements SocialRepository { const needle = handle.toLowerCase() for (const u of this.users.values()) { if (u.deletedAt !== null) continue - if (u.handle !== null && u.handle.toLowerCase() === needle) return Promise.resolve(this.toView(u)) + if (u.handle !== null && u.handle.toLowerCase() === needle) + return Promise.resolve(this.toView(u)) } return Promise.resolve(null) } @@ -387,8 +399,7 @@ export class InMemorySocialRepository implements SocialRepository { const mine = this.cleanups .filter( (c) => - c.record.organizerUserId === userId || - (args.includeAttending && c.attendees.has(userId)), + c.record.organizerUserId === userId || (args.includeAttending && c.attendees.has(userId)), ) .map((c) => c.record) .filter((r) => r.scheduledAt.getTime() >= now && r.status !== "cancelled") @@ -421,7 +432,9 @@ function parseNameCursor(cursor: string | null): { name: string; id: string } | return { name, id } } -export function makeCleanupRecord(over: Partial & { organizerUserId: string }): CleanupRecord { +export function makeCleanupRecord( + over: Partial & { organizerUserId: string }, +): CleanupRecord { const id = over.id ?? randomUUID() const scheduledAt = over.scheduledAt ?? new Date("2025-01-01T10:00:00.000Z") return { diff --git a/services/api/test/helpers/sql-predicate.ts b/services/api/test/helpers/sql-predicate.ts index 2ad71d99..f52285b6 100644 --- a/services/api/test/helpers/sql-predicate.ts +++ b/services/api/test/helpers/sql-predicate.ts @@ -1,4 +1,3 @@ - export type PredicateRow = Record export function evalSqlPredicate(text: string, row: PredicateRow): boolean { diff --git a/services/api/test/integration/account-deletion-cascade-pg.test.ts b/services/api/test/integration/account-deletion-cascade-pg.test.ts index 159ecfd0..a4d10ebe 100644 --- a/services/api/test/integration/account-deletion-cascade-pg.test.ts +++ b/services/api/test/integration/account-deletion-cascade-pg.test.ts @@ -10,7 +10,6 @@ import { makeDataExportService } from "../../src/services/data-export-service.js import { insertModerationItem } from "../../src/services/admin/moderation-repository.drizzle.js" import type { TranscriptModel } from "../../src/services/certificate-model.js" - const pg = await withPg() async function insertUser(h: PgHarness, name: string, email?: string): Promise { @@ -106,313 +105,317 @@ async function metaOf(h: PgHarness, itemId: string): Promise { - afterAll(async () => { - await pg?.teardown() - }) +describe.skipIf(!pg)( + "account deletion content cascade (PgUserStore.softDeleteAndAnonymize)", + () => { + afterAll(async () => { + await pg?.teardown() + }) - it("unlists the deleted user's public reports + active events, sparing others and past events", async () => { - const h = pg! - const victim = await insertUser(h, "Victim") - const bystander = await insertUser(h, "Bystander") - - const victimReport = await insertReport(h, { reporterId: victim, visibility: "public" }) - const bystanderReport = await insertReport(h, { reporterId: bystander, visibility: "public" }) - const anonReport = await insertReport(h, { reporterId: null, visibility: "public" }) - const upcoming = await insertCleanup(h, { organizerId: victim, status: "upcoming" }) - const active = await insertCleanup(h, { organizerId: victim, status: "active" }) - const past = await insertCleanup(h, { organizerId: victim, status: "done" }) - const bystanderEvent = await insertCleanup(h, { organizerId: bystander, status: "upcoming" }) - - await new PgUserStore(h.db).softDeleteAndAnonymize(victim) - - const reportVis = async (id: string) => - (await h.sql<{ visibility: string }[]>`SELECT visibility FROM reports WHERE id = ${id}`)[0]!.visibility - const cleanupStatus = async (id: string) => - (await h.sql<{ status: string }[]>`SELECT status FROM cleanups WHERE id = ${id}`)[0]!.status - - expect(await reportVis(victimReport)).toBe("hidden") - expect(await cleanupStatus(upcoming)).toBe("cancelled") - expect(await cleanupStatus(active)).toBe("cancelled") - expect(await cleanupStatus(past)).toBe("done") - expect(await reportVis(bystanderReport)).toBe("public") - expect(await reportVis(anonReport)).toBe("public") - expect(await cleanupStatus(bystanderEvent)).toBe("upcoming") - - const rows = await h.sql< - { deleted_at: Date | null; email: string | null; display_name: string; handle: string }[] - >` + it("unlists the deleted user's public reports + active events, sparing others and past events", async () => { + const h = pg! + const victim = await insertUser(h, "Victim") + const bystander = await insertUser(h, "Bystander") + + const victimReport = await insertReport(h, { reporterId: victim, visibility: "public" }) + const bystanderReport = await insertReport(h, { reporterId: bystander, visibility: "public" }) + const anonReport = await insertReport(h, { reporterId: null, visibility: "public" }) + const upcoming = await insertCleanup(h, { organizerId: victim, status: "upcoming" }) + const active = await insertCleanup(h, { organizerId: victim, status: "active" }) + const past = await insertCleanup(h, { organizerId: victim, status: "done" }) + const bystanderEvent = await insertCleanup(h, { organizerId: bystander, status: "upcoming" }) + + await new PgUserStore(h.db).softDeleteAndAnonymize(victim) + + const reportVis = async (id: string) => + (await h.sql<{ visibility: string }[]>`SELECT visibility FROM reports WHERE id = ${id}`)[0]! + .visibility + const cleanupStatus = async (id: string) => + (await h.sql<{ status: string }[]>`SELECT status FROM cleanups WHERE id = ${id}`)[0]!.status + + expect(await reportVis(victimReport)).toBe("hidden") + expect(await cleanupStatus(upcoming)).toBe("cancelled") + expect(await cleanupStatus(active)).toBe("cancelled") + expect(await cleanupStatus(past)).toBe("done") + expect(await reportVis(bystanderReport)).toBe("public") + expect(await reportVis(anonReport)).toBe("public") + expect(await cleanupStatus(bystanderEvent)).toBe("upcoming") + + const rows = await h.sql< + { deleted_at: Date | null; email: string | null; display_name: string; handle: string }[] + >` SELECT deleted_at, email, display_name, handle FROM users WHERE id = ${victim} ` - expect(rows[0]!.deleted_at).not.toBeNull() - expect(rows[0]!.email).toBeNull() - expect(rows[0]!.display_name).toBe("Deleted User") - expect(rows[0]!.handle).toMatch(/^deleted_[0-9a-f]{12}$/) - }) + expect(rows[0]!.deleted_at).not.toBeNull() + expect(rows[0]!.email).toBeNull() + expect(rows[0]!.display_name).toBe("Deleted User") + expect(rows[0]!.handle).toMatch(/^deleted_[0-9a-f]{12}$/) + }) + + it("revokes + scrubs issued certificates, keeps the verifiable facts, and drops their R2 objects", async () => { + const h = pg! + const victim = await insertUser(h, "Certified Victim") + const bystander = await insertUser(h, "Certified Bystander") + const repo = makeDrizzleCertificateRepository(h.sql) + + const seed = async (userId: string, code: string): Promise => { + const id = randomUUID() + const row = await repo.insert({ + id, + userId, + code, + locale: "en", + holderName: "Certified", + holderHandle: "certified", + holderVerified: true, + totalHours: 8, + entryCount: 3, + periodStart: new Date("2026-01-01T00:00:00.000Z"), + periodEnd: new Date("2026-02-01T00:00:00.000Z"), + ledgerFingerprint: `fp-${id}`, + snapshot: { v: 1, holder: { displayName: "Certified" } } as unknown as TranscriptModel, + r2Key: `certificates/service-hours/2026/02/${id}.pdf`, + documentSha256: "b".repeat(64), + byteSize: 40000, + issuedAt: new Date("2026-02-02T00:00:00.000Z"), + }) + return row.r2Key + } + const liveKey = await seed(victim, "V1CT1MC0DE00") + const alreadyRevokedKey = await seed(victim, "V1CT1MOLD001") + const holderRevokedAt = new Date("2026-03-03T00:00:00.000Z") + await repo.revoke(victim, "V1CT1MOLD001", "holder", holderRevokedAt) + const bystanderKey = await seed(bystander, "BYSTANDER001") + + const objects = new SpyObjectStore() + await new PgUserStore(h.db, { certificateObjects: objects }).softDeleteAndAnonymize(victim) + + const counted = await h.sql<{ count: number }[]>` + SELECT count(*)::int AS count FROM service_hours_certificates WHERE user_id = ${victim} + ` + expect(counted[0]!.count).toBe(2) + + const victimRows = await h.sql< + { + code: string + holder_name: string + holder_handle: string | null + snapshot: unknown + revoked_at: Date | null + revoked_reason: string | null + document_sha256: string + total_hours: string + entry_count: number + issued_at: Date + }[] + >` + SELECT code, holder_name, holder_handle, snapshot, revoked_at, revoked_reason, + document_sha256, total_hours, entry_count, issued_at + FROM service_hours_certificates WHERE user_id = ${victim} ORDER BY code + ` + const live = victimRows.find((r) => r.code === "V1CT1MC0DE00")! + const old = victimRows.find((r) => r.code === "V1CT1MOLD001")! + expect(live.revoked_at).not.toBeNull() + expect(live.revoked_reason).toBe("account_closed") + expect(old.revoked_reason).toBe("holder") + expect(old.revoked_at?.toISOString()).toBe(holderRevokedAt.toISOString()) + + for (const row of victimRows) { + expect(row.holder_name).toBe("Deleted User") + expect(row.holder_handle).toBeNull() + expect(row.snapshot).toEqual({}) + } + + expect(live.document_sha256).toBe("b".repeat(64)) + expect(Number(live.total_hours)).toBe(8) + expect(live.entry_count).toBe(3) + expect(live.issued_at.toISOString()).toBe("2026-02-02T00:00:00.000Z") + + expect(objects.deleted.sort()).toEqual([liveKey, alreadyRevokedKey].sort()) + expect(objects.deleted).not.toContain(bystanderKey) + + const victimCert = await repo.findByCode("V1CT1MC0DE00") + expect(victimCert?.holderDeleted).toBe(true) + const bystanderCert = await repo.findByCode("BYSTANDER001") + expect(bystanderCert?.holderDeleted).toBe(false) + expect(bystanderCert?.holderName).toBe("Certified") + expect(bystanderCert?.revokedAt).toBeNull() + }) + + it("a failing R2 delete does not fail the erasure (the DB scrub already committed)", async () => { + const h = pg! + const victim = await insertUser(h, "Unluckily Certified") + const repo = makeDrizzleCertificateRepository(h.sql) + const id = randomUUID() + const r2Key = `certificates/service-hours/2026/02/${id}.pdf` + await repo.insert({ + id, + userId: victim, + code: "FA1L1NGK3Y00", + locale: "en", + holderName: "Unluckily Certified", + holderHandle: "unlucky", + holderVerified: false, + totalHours: 2, + entryCount: 1, + periodStart: null, + periodEnd: null, + ledgerFingerprint: `fp-${id}`, + snapshot: { v: 1 } as unknown as TranscriptModel, + r2Key, + documentSha256: "c".repeat(64), + byteSize: 1000, + issuedAt: new Date("2026-02-02T00:00:00.000Z"), + }) - it("revokes + scrubs issued certificates, keeps the verifiable facts, and drops their R2 objects", async () => { - const h = pg! - const victim = await insertUser(h, "Certified Victim") - const bystander = await insertUser(h, "Certified Bystander") - const repo = makeDrizzleCertificateRepository(h.sql) + const objects = new SpyObjectStore() + objects.failOn = r2Key + const warnings: unknown[] = [] + await expect( + new PgUserStore(h.db, { + certificateObjects: objects, + logger: { warn: (obj: unknown) => warnings.push(obj) }, + }).softDeleteAndAnonymize(victim), + ).resolves.toMatchObject({ id: victim }) + + expect(warnings).toHaveLength(1) + const row = await repo.findByCode("FA1L1NGK3Y00") + expect(row?.revokedReason).toBe("account_closed") + expect(row?.holderName).toBe("Deleted User") + }) - const seed = async (userId: string, code: string): Promise => { + it("includes the certificates in the data export, without the snapshot or the object key", async () => { + const h = pg! + const email = `dsar-${randomUUID()}@example.test` + const holder = await insertUser(h, "Exporting Holder", email) + const repo = makeDrizzleCertificateRepository(h.sql) const id = randomUUID() - const row = await repo.insert({ + await repo.insert({ id, - userId, - code, + userId: holder, + code: "3XP0RTC0DE00", locale: "en", - holderName: "Certified", - holderHandle: "certified", + holderName: "Exporting Holder", + holderHandle: "exporter", holderVerified: true, - totalHours: 8, - entryCount: 3, + totalHours: 12.5, + entryCount: 4, periodStart: new Date("2026-01-01T00:00:00.000Z"), periodEnd: new Date("2026-02-01T00:00:00.000Z"), ledgerFingerprint: `fp-${id}`, - snapshot: { v: 1, holder: { displayName: "Certified" } } as unknown as TranscriptModel, + snapshot: { v: 1, secret: "itinerary" } as unknown as TranscriptModel, r2Key: `certificates/service-hours/2026/02/${id}.pdf`, - documentSha256: "b".repeat(64), - byteSize: 40000, + documentSha256: "d".repeat(64), + byteSize: 2048, issuedAt: new Date("2026-02-02T00:00:00.000Z"), }) - return row.r2Key - } - const liveKey = await seed(victim, "V1CT1MC0DE00") - const alreadyRevokedKey = await seed(victim, "V1CT1MOLD001") - const holderRevokedAt = new Date("2026-03-03T00:00:00.000Z") - await repo.revoke(victim, "V1CT1MOLD001", "holder", holderRevokedAt) - const bystanderKey = await seed(bystander, "BYSTANDER001") - - const objects = new SpyObjectStore() - await new PgUserStore(h.db, { certificateObjects: objects }).softDeleteAndAnonymize(victim) - - const counted = await h.sql<{ count: number }[]>` - SELECT count(*)::int AS count FROM service_hours_certificates WHERE user_id = ${victim} - ` - expect(counted[0]!.count).toBe(2) - - const victimRows = await h.sql< - { - code: string - holder_name: string - holder_handle: string | null - snapshot: unknown - revoked_at: Date | null - revoked_reason: string | null - document_sha256: string - total_hours: string - entry_count: number - issued_at: Date - }[] - >` - SELECT code, holder_name, holder_handle, snapshot, revoked_at, revoked_reason, - document_sha256, total_hours, entry_count, issued_at - FROM service_hours_certificates WHERE user_id = ${victim} ORDER BY code - ` - const live = victimRows.find((r) => r.code === "V1CT1MC0DE00")! - const old = victimRows.find((r) => r.code === "V1CT1MOLD001")! - expect(live.revoked_at).not.toBeNull() - expect(live.revoked_reason).toBe("account_closed") - expect(old.revoked_reason).toBe("holder") - expect(old.revoked_at?.toISOString()).toBe(holderRevokedAt.toISOString()) - - for (const row of victimRows) { - expect(row.holder_name).toBe("Deleted User") - expect(row.holder_handle).toBeNull() - expect(row.snapshot).toEqual({}) - } - - expect(live.document_sha256).toBe("b".repeat(64)) - expect(Number(live.total_hours)).toBe(8) - expect(live.entry_count).toBe(3) - expect(live.issued_at.toISOString()).toBe("2026-02-02T00:00:00.000Z") - - expect(objects.deleted.sort()).toEqual([liveKey, alreadyRevokedKey].sort()) - expect(objects.deleted).not.toContain(bystanderKey) - - const victimCert = await repo.findByCode("V1CT1MC0DE00") - expect(victimCert?.holderDeleted).toBe(true) - const bystanderCert = await repo.findByCode("BYSTANDER001") - expect(bystanderCert?.holderDeleted).toBe(false) - expect(bystanderCert?.holderName).toBe("Certified") - expect(bystanderCert?.revokedAt).toBeNull() - }) - it("a failing R2 delete does not fail the erasure (the DB scrub already committed)", async () => { - const h = pg! - const victim = await insertUser(h, "Unluckily Certified") - const repo = makeDrizzleCertificateRepository(h.sql) - const id = randomUUID() - const r2Key = `certificates/service-hours/2026/02/${id}.pdf` - await repo.insert({ - id, - userId: victim, - code: "FA1L1NGK3Y00", - locale: "en", - holderName: "Unluckily Certified", - holderHandle: "unlucky", - holderVerified: false, - totalHours: 2, - entryCount: 1, - periodStart: null, - periodEnd: null, - ledgerFingerprint: `fp-${id}`, - snapshot: { v: 1 } as unknown as TranscriptModel, - r2Key, - documentSha256: "c".repeat(64), - byteSize: 1000, - issuedAt: new Date("2026-02-02T00:00:00.000Z"), + const mailer = new FakeMailer() + const service = makeDataExportService({ + sql: h.sql, + mailer, + users: new PgUserStore(h.db), + fromNoReply: "no-reply@civfix.org", + supportEmail: "support@civfix.org", + }) + const result = await service.exportData(holder) + expect(result).toEqual({ ok: true, email }) + + const attachment = mailer.lastOutbound()!.attachments![0]! + const parsed = JSON.parse(new TextDecoder().decode(attachment.content)) as { + certificates: Array> + } + expect(parsed.certificates).toHaveLength(1) + const cert = parsed.certificates[0]! + expect(cert.code).toBe("3XP0RTC0DE00") + expect(cert.holder_name).toBe("Exporting Holder") + expect(cert.total_hours).toBe(12.5) + expect(cert.entry_count).toBe(4) + expect(cert.document_sha256).toBe("d".repeat(64)) + expect(cert.revoked_at).toBeNull() + expect("snapshot" in cert).toBe(false) + expect("r2_key" in cert).toBe(false) }) - const objects = new SpyObjectStore() - objects.failOn = r2Key - const warnings: unknown[] = [] - await expect( - new PgUserStore(h.db, { - certificateObjects: objects, - logger: { warn: (obj: unknown) => warnings.push(obj) }, - }).softDeleteAndAnonymize(victim), - ).resolves.toMatchObject({ id: victim }) - - expect(warnings).toHaveLength(1) - const row = await repo.findByCode("FA1L1NGK3Y00") - expect(row?.revokedReason).toBe("account_closed") - expect(row?.holderName).toBe("Deleted User") - }) - - it("includes the certificates in the data export, without the snapshot or the object key", async () => { - const h = pg! - const email = `dsar-${randomUUID()}@example.test` - const holder = await insertUser(h, "Exporting Holder", email) - const repo = makeDrizzleCertificateRepository(h.sql) - const id = randomUUID() - await repo.insert({ - id, - userId: holder, - code: "3XP0RTC0DE00", - locale: "en", - holderName: "Exporting Holder", - holderHandle: "exporter", - holderVerified: true, - totalHours: 12.5, - entryCount: 4, - periodStart: new Date("2026-01-01T00:00:00.000Z"), - periodEnd: new Date("2026-02-01T00:00:00.000Z"), - ledgerFingerprint: `fp-${id}`, - snapshot: { v: 1, secret: "itinerary" } as unknown as TranscriptModel, - r2Key: `certificates/service-hours/2026/02/${id}.pdf`, - documentSha256: "d".repeat(64), - byteSize: 2048, - issuedAt: new Date("2026-02-02T00:00:00.000Z"), - }) + it("scrubs the frozen identity snapshot moderation items froze, sparing other subjects", async () => { + const h = pg! + const victim = await insertIdentity(h, { + name: "Jane Q. Smith", + handle: `jqsmith${Date.now().toString(36)}`, + device: "iPhone 15 Pro / iOS 18.2", + }) + const bystander = await insertIdentity(h, { + name: "Neighborly Nate", + handle: `nate${Date.now().toString(36)}`, + device: "Pixel 8 / Android 15", + }) - const mailer = new FakeMailer() - const service = makeDataExportService({ - sql: h.sql, - mailer, - users: new PgUserStore(h.db), - fromNoReply: "no-reply@civfix.org", - supportEmail: "support@civfix.org", + const aboutVictim = await insertModerationItem(h.sql, { + kind: "pattern", + subjectType: "user", + subjectId: victim, + reporter: "Neighborly Nate", + reporterUserId: bystander, + desc: "keeps posting the same pin", + }) + const bystanderReport = await insertReport(h, { reporterId: bystander }) + const filedByVictim = await insertModerationItem(h.sql, { + kind: "image", + subjectType: "report", + subjectId: bystanderReport, + reporter: "Jane Q. Smith", + reporterUserId: victim, + desc: "my neighbour at 12 Elm St dumped this", + }) + const unrelated = await insertModerationItem(h.sql, { + kind: "pattern", + subjectType: "user", + subjectId: bystander, + reporter: "Anonymous", + desc: "unrelated", + }) + const unrelatedBefore = await metaOf(h, unrelated) + + await new PgUserStore(h.db).softDeleteAndAnonymize(victim) + + const scrubbed = await metaOf(h, aboutVictim) + const scrubbedUser = scrubbed.user as Record + expect(scrubbedUser.id).toBe(victim) + expect(scrubbedUser.name).toBe("Deleted User") + expect(scrubbedUser.handle).toBe("") + expect(scrubbedUser.device).toBe("") + expect(scrubbedUser.joined).toBe("") + expect(scrubbedUser.strikes).toBe(2) + expect(scrubbed.reporter).toBe("Neighborly Nate") + + const asReporter = await metaOf(h, filedByVictim) + expect(asReporter.reporter).toBe("Deleted User") + expect(asReporter.desc).toBe("") + expect((asReporter.user as Record).name).toBe("Neighborly Nate") + + expect(await metaOf(h, unrelated)).toEqual(unrelatedBefore) }) - const result = await service.exportData(holder) - expect(result).toEqual({ ok: true, email }) - - const attachment = mailer.lastOutbound()!.attachments![0]! - const parsed = JSON.parse(new TextDecoder().decode(attachment.content)) as { - certificates: Array> - } - expect(parsed.certificates).toHaveLength(1) - const cert = parsed.certificates[0]! - expect(cert.code).toBe("3XP0RTC0DE00") - expect(cert.holder_name).toBe("Exporting Holder") - expect(cert.total_hours).toBe(12.5) - expect(cert.entry_count).toBe(4) - expect(cert.document_sha256).toBe("d".repeat(64)) - expect(cert.revoked_at).toBeNull() - expect("snapshot" in cert).toBe(false) - expect("r2_key" in cert).toBe(false) - }) - it("scrubs the frozen identity snapshot moderation items froze, sparing other subjects", async () => { - const h = pg! - const victim = await insertIdentity(h, { - name: "Jane Q. Smith", - handle: `jqsmith${Date.now().toString(36)}`, - device: "iPhone 15 Pro / iOS 18.2", - }) - const bystander = await insertIdentity(h, { - name: "Neighborly Nate", - handle: `nate${Date.now().toString(36)}`, - device: "Pixel 8 / Android 15", - }) + it("unlists the deleted user's public posts, leaving other authors' posts in the feed", async () => { + const h = pg! + const victim = await insertUser(h, "Posting Victim") + const bystander = await insertUser(h, "Posting Bystander") + const victimPublic = await insertPost(h, { authorId: victim, body: "public post" }) + const victimAlreadyHidden = await insertPost(h, { + authorId: victim, + body: "already hidden", + visibility: "hidden", + }) + const bystanderPublic = await insertPost(h, { authorId: bystander, body: "bystander post" }) - const aboutVictim = await insertModerationItem(h.sql, { - kind: "pattern", - subjectType: "user", - subjectId: victim, - reporter: "Neighborly Nate", - reporterUserId: bystander, - desc: "keeps posting the same pin", - }) - const bystanderReport = await insertReport(h, { reporterId: bystander }) - const filedByVictim = await insertModerationItem(h.sql, { - kind: "image", - subjectType: "report", - subjectId: bystanderReport, - reporter: "Jane Q. Smith", - reporterUserId: victim, - desc: "my neighbour at 12 Elm St dumped this", - }) - const unrelated = await insertModerationItem(h.sql, { - kind: "pattern", - subjectType: "user", - subjectId: bystander, - reporter: "Anonymous", - desc: "unrelated", - }) - const unrelatedBefore = await metaOf(h, unrelated) - - await new PgUserStore(h.db).softDeleteAndAnonymize(victim) - - const scrubbed = await metaOf(h, aboutVictim) - const scrubbedUser = scrubbed.user as Record - expect(scrubbedUser.id).toBe(victim) - expect(scrubbedUser.name).toBe("Deleted User") - expect(scrubbedUser.handle).toBe("") - expect(scrubbedUser.device).toBe("") - expect(scrubbedUser.joined).toBe("") - expect(scrubbedUser.strikes).toBe(2) - expect(scrubbed.reporter).toBe("Neighborly Nate") - - const asReporter = await metaOf(h, filedByVictim) - expect(asReporter.reporter).toBe("Deleted User") - expect(asReporter.desc).toBe("") - expect((asReporter.user as Record).name).toBe("Neighborly Nate") - - expect(await metaOf(h, unrelated)).toEqual(unrelatedBefore) - }) + await new PgUserStore(h.db).softDeleteAndAnonymize(victim) - it("unlists the deleted user's public posts, leaving other authors' posts in the feed", async () => { - const h = pg! - const victim = await insertUser(h, "Posting Victim") - const bystander = await insertUser(h, "Posting Bystander") - const victimPublic = await insertPost(h, { authorId: victim, body: "public post" }) - const victimAlreadyHidden = await insertPost(h, { - authorId: victim, - body: "already hidden", - visibility: "hidden", + const visibilityOf = async (id: string) => + (await h.sql<{ visibility: string }[]>`SELECT visibility FROM posts WHERE id = ${id}`)[0]! + .visibility + expect(await visibilityOf(victimPublic)).toBe("hidden") + expect(await visibilityOf(victimAlreadyHidden)).toBe("hidden") + expect(await visibilityOf(bystanderPublic)).toBe("public") }) - const bystanderPublic = await insertPost(h, { authorId: bystander, body: "bystander post" }) - - await new PgUserStore(h.db).softDeleteAndAnonymize(victim) - - const visibilityOf = async (id: string) => - (await h.sql<{ visibility: string }[]>`SELECT visibility FROM posts WHERE id = ${id}`)[0]! - .visibility - expect(await visibilityOf(victimPublic)).toBe("hidden") - expect(await visibilityOf(victimAlreadyHidden)).toBe("hidden") - expect(await visibilityOf(bystanderPublic)).toBe("public") - }) -}) + }, +) diff --git a/services/api/test/integration/admin-activity.test.ts b/services/api/test/integration/admin-activity.test.ts index 59c226c8..3d362d82 100644 --- a/services/api/test/integration/admin-activity.test.ts +++ b/services/api/test/integration/admin-activity.test.ts @@ -81,11 +81,13 @@ describe.skipIf(!pg)("admin activity repository (integration: real schema)", () 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 }[]>` + const threadId = ( + await h.sql<{ id: string }[]>` INSERT INTO mail_threads (thread_token, org, subject, status) VALUES ('tok-1', 'Waynesboro', 'Outreach', 'bounced') RETURNING id - `)[0]!.id + ` + )[0]!.id await h.sql` INSERT INTO mail_events (thread_id, type, created_at) VALUES (${threadId}, 'bounced', now() - interval '30 minutes') diff --git a/services/api/test/integration/admin-analytics.test.ts b/services/api/test/integration/admin-analytics.test.ts index dd753505..d46fed3a 100644 --- a/services/api/test/integration/admin-analytics.test.ts +++ b/services/api/test/integration/admin-analytics.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" diff --git a/services/api/test/integration/admin-audit.test.ts b/services/api/test/integration/admin-audit.test.ts index d96fba3b..eab98f5f 100644 --- a/services/api/test/integration/admin-audit.test.ts +++ b/services/api/test/integration/admin-audit.test.ts @@ -51,7 +51,13 @@ describe.skipIf(!pg)("admin audit repository (integration: real schema)", () => }) await writeAudit(h.sql, { actorId: actor, action: "user.banned", target: "user:222" }) - const page = await repo.list({ actor: null, action: null, target: null, cursor: null, limit: 25 }) + const page = await repo.list({ + actor: null, + action: null, + target: null, + cursor: null, + limit: 25, + }) expect(page.records).toHaveLength(2) // Newest first. expect(page.records[0]?.action).toBe("user.banned") @@ -119,7 +125,13 @@ describe.skipIf(!pg)("admin audit repository (integration: real schema)", () => for (let i = 0; i < 5; i++) { await writeAudit(h.sql, { actorId: actor, action: `a${i}`, target: `t${i}` }) } - const first = await repo.list({ actor: null, action: null, target: null, cursor: null, limit: 2 }) + const first = await repo.list({ + actor: null, + action: null, + target: null, + cursor: null, + limit: 2, + }) expect(first.records).toHaveLength(2) expect(first.nextCursor).not.toBeNull() const second = await repo.list({ diff --git a/services/api/test/integration/admin-discovery.test.ts b/services/api/test/integration/admin-discovery.test.ts index fec20ff0..0a4132f2 100644 --- a/services/api/test/integration/admin-discovery.test.ts +++ b/services/api/test/integration/admin-discovery.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { makeDrizzleDiscoveryRepository } from "../../src/services/admin/discovery-repository.drizzle.js" @@ -294,7 +293,6 @@ describe.skipIf(!pg)("admin discovery + contacts repositories (integration: real { actorId: null }, ) - const waiting = await h.sql<{ count: string }[]>` SELECT COUNT(*)::text AS count FROM reports WHERE jurisdiction_geoid = ${GEOID} diff --git a/services/api/test/integration/admin-home.test.ts b/services/api/test/integration/admin-home.test.ts index c535fb25..490ce6ac 100644 --- a/services/api/test/integration/admin-home.test.ts +++ b/services/api/test/integration/admin-home.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" @@ -62,7 +61,10 @@ describe.skipIf(!pg)("admin home repository (integration: real schema)", () => { }) it("discoverySummary: queue counts open discovery tasks; reportsWaiting/overSla over unrouted waiting reports", async () => { - await insertReport(h, { status: "submitted", createdAt: new Date(Date.now() - 30 * 3600 * 1000) }) + await insertReport(h, { + status: "submitted", + createdAt: new Date(Date.now() - 30 * 3600 * 1000), + }) await insertReport(h, { status: "published", createdAt: new Date() }) await h.sql`INSERT INTO jurisdiction_discovery_tasks (geoid) VALUES (${GEOID})` diff --git a/services/api/test/integration/admin-jurisdiction-directory.test.ts b/services/api/test/integration/admin-jurisdiction-directory.test.ts index 5edef4dd..e665c671 100644 --- a/services/api/test/integration/admin-jurisdiction-directory.test.ts +++ b/services/api/test/integration/admin-jurisdiction-directory.test.ts @@ -126,8 +126,8 @@ describe.skipIf(!pg)("admin jurisdiction directory (integration: real schema)", ` const other = others[0] if (other === undefined) return - await expect(repo.patch(other.geoid, { handle: "SF" }, { actorId: null })).rejects.toMatchObject( - { httpStatus: 409 }, - ) + await expect( + repo.patch(other.geoid, { handle: "SF" }, { actorId: null }), + ).rejects.toMatchObject({ httpStatus: 409 }) }) }) diff --git a/services/api/test/integration/admin-mail-repository.test.ts b/services/api/test/integration/admin-mail-repository.test.ts index f2219f56..2ad53a12 100644 --- a/services/api/test/integration/admin-mail-repository.test.ts +++ b/services/api/test/integration/admin-mail-repository.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { @@ -246,7 +245,9 @@ describe.skipIf(!pg)("admin mail repository (integration: real schema)", () => { messageId: "", }) - expect(await repo.claimMessageEffects(msg!.id, { leaseBefore: new Date(Date.now() - 600_000) })).toBe(0) + expect( + await repo.claimMessageEffects(msg!.id, { leaseBefore: new Date(Date.now() - 600_000) }), + ).toBe(0) await repo.setMessageEffectsStage(msg!.id, 1) expect( diff --git a/services/api/test/integration/admin-moderation.test.ts b/services/api/test/integration/admin-moderation.test.ts index 83e8c240..b788781f 100644 --- a/services/api/test/integration/admin-moderation.test.ts +++ b/services/api/test/integration/admin-moderation.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, testHandle, type PgHarness } from "../helpers/pg.js" @@ -64,11 +63,7 @@ async function insertGroupMessage( return rows[0]!.id } -async function insertDmMessage( - h: PgHarness, - senderId: string, - peerId: string, -): Promise { +async function insertDmMessage(h: PgHarness, senderId: string, peerId: string): Promise { const thread = await h.sql<{ id: string }[]>` INSERT INTO dm_threads (user_lo, user_hi) VALUES ( @@ -288,7 +283,11 @@ describe.skipIf(!pg)("admin moderation repository (integration: real schema)", ( it("remove strikes the reporter and createItem captures a real user snapshot", async () => { const userId = await insertUser(h, "rmreporter") const reportId = await insertReport(h, { status: "held", reporterUserId: userId }) - const id = (await repo.createItem({ kind: "image", subjectType: "report", subjectId: reportId }))! + const id = (await repo.createItem({ + kind: "image", + subjectType: "report", + subjectId: reportId, + }))! const detail = await repo.getItem(id) expect(detail?.user?.handle).toBe("rmreporter") diff --git a/services/api/test/integration/admin-outreach.test.ts b/services/api/test/integration/admin-outreach.test.ts index 3a879088..9a5f92f8 100644 --- a/services/api/test/integration/admin-outreach.test.ts +++ b/services/api/test/integration/admin-outreach.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { FakeMailer } from "@civfix/shared/fakes" import type { OutboundEmail } from "@civfix/shared/interfaces" @@ -151,7 +150,9 @@ describe.skipIf(!pg)("outreach pipeline (integration: real schema)", () => { expect(mailer.sent).toHaveLength(1) expect(mailer.sent[0]?.to).toBe("clerk@lacity.gov") - const threads = await h.sql<{ id: string }[]>`SELECT id FROM mail_threads WHERE jurisdiction_geoid = ${GEOID}` + const threads = await h.sql< + { id: string }[] + >`SELECT id FROM mail_threads WHERE jurisdiction_geoid = ${GEOID}` expect(threads).toHaveLength(1) const messages = await h.sql<{ direction: string }[]>`SELECT direction FROM mail_messages` expect(messages).toHaveLength(1) @@ -178,7 +179,9 @@ describe.skipIf(!pg)("outreach pipeline (integration: real schema)", () => { expect(mailer.sent).toHaveLength(0) }) - async function storedState(): Promise<{ last_outreach_at: Date | null; suppressed: boolean } | undefined> { + async function storedState(): Promise< + { last_outreach_at: Date | null; suppressed: boolean } | undefined + > { const rows = await h.sql<{ last_outreach_at: Date | null; suppressed: boolean }[]>` SELECT last_outreach_at, suppressed FROM outreach_state WHERE geoid = ${GEOID} ` diff --git a/services/api/test/integration/admin-reports.test.ts b/services/api/test/integration/admin-reports.test.ts index 49e31a30..b05503e4 100644 --- a/services/api/test/integration/admin-reports.test.ts +++ b/services/api/test/integration/admin-reports.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" import { makeDrizzleAdminReportRepository } from "../../src/services/admin/admin-report-repository.drizzle.js" @@ -144,7 +143,11 @@ describe.skipIf(!pg)("admin report repository (integration: real schema)", () => addr: "1200 S Figueroa St", referenceCode: "PD-42-000001", }) - await insertReport(h, { title: "Graffiti", addr: "44 Sunset Blvd", referenceCode: "GR-42-000007" }) + await insertReport(h, { + title: "Graffiti", + addr: "44 Sunset Blvd", + referenceCode: "GR-42-000007", + }) const list = async (q: string): Promise => ( diff --git a/services/api/test/integration/admin-users.test.ts b/services/api/test/integration/admin-users.test.ts index ff9a6f01..28491d92 100644 --- a/services/api/test/integration/admin-users.test.ts +++ b/services/api/test/integration/admin-users.test.ts @@ -258,7 +258,12 @@ describe.skipIf(!pg)("admin user repository (integration: real schema)", () => { const messages = await repo.listUserMessages(u, null, 20) expect(messages.records).toEqual( expect.arrayContaining([ - expect.objectContaining({ text: "hello", source: "chat", sourceId: cleanupId, thread: "Park" }), + expect.objectContaining({ + text: "hello", + source: "chat", + sourceId: cleanupId, + thread: "Park", + }), expect.objectContaining({ text: "group hello", source: "group", diff --git a/services/api/test/integration/anon-hold-release-sweep-pg.test.ts b/services/api/test/integration/anon-hold-release-sweep-pg.test.ts index 8d3474e3..5b8715d8 100644 --- a/services/api/test/integration/anon-hold-release-sweep-pg.test.ts +++ b/services/api/test/integration/anon-hold-release-sweep-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { makeDrizzleAnonHoldReleaseRepo } from "../../src/services/anon-hold-release-repo.drizzle.js" diff --git a/services/api/test/integration/anon-pg.test.ts b/services/api/test/integration/anon-pg.test.ts index 2a641be0..bf0e518f 100644 --- a/services/api/test/integration/anon-pg.test.ts +++ b/services/api/test/integration/anon-pg.test.ts @@ -60,7 +60,9 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = } const presignMedia = (r2Key: string, thumbKey: string | null) => Promise.resolve( - thumbKey === null ? { url: `memory://${r2Key}` } : { url: `memory://${r2Key}`, thumbUrl: `memory://${thumbKey}` }, + thumbKey === null + ? { url: `memory://${r2Key}` } + : { url: `memory://${r2Key}`, thumbUrl: `memory://${thumbKey}` }, ) reports = makeReportService({ @@ -120,7 +122,13 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = // The row is held, anon, not published, jurisdiction resolved. const [row] = await h.sql< - { reporter_user_id: string | null; anon_session_id: string | null; status: string; published_at: Date | null; jurisdiction_geoid: string | null }[] + { + reporter_user_id: string | null + anon_session_id: string | null + status: string + published_at: Date | null + jurisdiction_geoid: string | null + }[] >` SELECT reporter_user_id, anon_session_id, status, published_at, jurisdiction_geoid FROM reports WHERE id = ${response.reportId} @@ -138,12 +146,19 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = expect(tl.map((t) => t.status)).toEqual(["submitted", "held"]) // Absent from the public map candidates (a wide bbox around the point). - const bbox = { west: PROBE_INSIDE_CITY.lng - 0.5, south: PROBE_INSIDE_CITY.lat - 0.5, east: PROBE_INSIDE_CITY.lng + 0.5, north: PROBE_INSIDE_CITY.lat + 0.5 } + const bbox = { + west: PROBE_INSIDE_CITY.lng - 0.5, + south: PROBE_INSIDE_CITY.lat - 0.5, + east: PROBE_INSIDE_CITY.lng + 0.5, + north: PROBE_INSIDE_CITY.lat + 0.5, + } 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({ + await expect( + reports.getReport(response.reportId, { userId: "stranger" }), + ).rejects.toMatchObject({ code: "NOT_FOUND", }) @@ -242,13 +257,17 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = abuseChecks: new FakeAbuseChecks(), }) expect(result.outcome).toBe("media_blocked") - const [row] = await h.sql<{ status: string }[]>`SELECT status FROM reports WHERE id = ${response.reportId}` + const [row] = await h.sql< + { status: string }[] + >`SELECT status FROM reports WHERE id = ${response.reportId}` expect(row!.status).toBe("held") }) it("claims a held anon report into a user (single-use) by hash match, then mine=true", async () => { const submit = await anon.submitAnonReport(req(), { ip: "203.0.113.8", cfGeo: {} }) - const [u] = await h.sql<{ id: string }[]>`INSERT INTO users (display_name) VALUES ('Claimer') RETURNING id` + const [u] = await h.sql< + { id: string }[] + >`INSERT INTO users (display_name) VALUES ('Claimer') RETURNING id` const userId = u!.id const res = await claim.claimReport(submit.response.claimCode, userId) @@ -268,7 +287,10 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = it("replays the original response for a duplicate idempotency key from the SAME anon session", async () => { const key = randomUUID() - const first = await anon.submitAnonReport(req({ idempotencyKey: key }), { ip: "203.0.113.9", cfGeo: {} }) + const first = await anon.submitAnonReport(req({ idempotencyKey: key }), { + ip: "203.0.113.9", + cfGeo: {}, + }) // The retry carries the token the first submit issued: the stored snapshot is owner-scoped by it. const second = await anon.submitAnonReport( req({ idempotencyKey: key, anonToken: first.issuedAnonToken! }), @@ -283,7 +305,10 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = it("F028: a DIFFERENT anon session reusing the key gets a 409, never the first session's snapshot", async () => { const key = randomUUID() - const first = await anon.submitAnonReport(req({ idempotencyKey: key }), { ip: "203.0.113.20", cfGeo: {} }) + const first = await anon.submitAnonReport(req({ idempotencyKey: key }), { + ip: "203.0.113.20", + cfGeo: {}, + }) // A second session (its own freshly issued token) presenting the squatted key must NOT be handed // the first submitter's reportId + claim code; the owner-scoped snapshot read misses and the @@ -330,7 +355,9 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = }) it("F150: a pre-0091 plaintext row whose hash was BACKFILLED stays claimable by its old code", async () => { - const [u] = await h.sql<{ id: string }[]>`INSERT INTO users (display_name) VALUES ('Legacy') RETURNING id` + const [u] = await h.sql< + { id: string }[] + >`INSERT INTO users (display_name) VALUES ('Legacy') RETURNING id` const legacyCode = `legacy-${randomUUID()}` const [seeded] = await h.sql<{ id: string }[]>` INSERT INTO reports (idempotency_key, geom, geom_source, category, type, status, h3_cell, claim_code) @@ -362,7 +389,9 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = it("F150: the nudge mints a FRESH code (it cannot read one back) and that code claims the report", async () => { const submit = await anon.submitAnonReport(req(), { ip: "203.0.113.23", cfGeo: {} }) - const [u] = await h.sql<{ id: string }[]>`INSERT INTO users (display_name) VALUES ('Nudged') RETURNING id` + const [u] = await h.sql< + { id: string }[] + >`INSERT INTO users (display_name) VALUES ('Nudged') RETURNING id` const nudge = await claim.claimNudge(submit.issuedAnonToken!) expect(nudge.reportId).toBe(submit.response.reportId) diff --git a/services/api/test/integration/avatar-media-pg.test.ts b/services/api/test/integration/avatar-media-pg.test.ts index 98b592d9..0ac188a9 100644 --- a/services/api/test/integration/avatar-media-pg.test.ts +++ b/services/api/test/integration/avatar-media-pg.test.ts @@ -123,44 +123,60 @@ describe.skipIf(!pg)("CVX-004 avatar media validation (integration)", () => { it("rejects a rejected upload (moderation cannot be laundered)", async () => { const media = await seedMedia({ status: "rejected" }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject(rejects422) + await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( + rejects422, + ) }) it("rejects a still-validating (unfinalized) upload", async () => { const media = await seedMedia({ status: "validating" }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject(rejects422) + await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( + rejects422, + ) }) it("rejects a ready asset the worker never published (served_key still NULL)", async () => { const media = await seedMedia({ servedKey: null }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject(rejects422) + await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( + rejects422, + ) }) it("rejects a non-image (video) upload", async () => { const media = await seedMedia({ kind: "video" }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject(rejects422) + await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( + rejects422, + ) }) it("rejects media bound to a chat/DM message (another user's private attachment)", async () => { const media = await seedMedia({ chatMessageId: randomUUID() }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject(rejects422) + await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( + rejects422, + ) }) it("rejects media bound to another user's post", async () => { const postId = await seedForeignPost() const media = await seedMedia({ postId }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject(rejects422) + await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( + rejects422, + ) }) it("rejects media bound to another user's report", async () => { const reportId = await seedForeignReport() const media = await seedMedia({ reportId }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject(rejects422) + await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( + rejects422, + ) }) it("F074: rejects an upload older than the claim window (a leaked id is not a permanent capability)", async () => { const media = await seedMedia({ ageHours: 7 }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject(rejects422) + await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( + rejects422, + ) }) it("F074: accepts an upload still inside the claim window", async () => { @@ -171,14 +187,18 @@ describe.skipIf(!pg)("CVX-004 avatar media validation (integration)", () => { it("F074: rejects a verification document (it cannot be laundered into a public avatar)", async () => { const media = await seedMedia({ purpose: "verification" }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject(rejects422) + await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( + rejects422, + ) }) it("F074: rejects an uploadId already claimed as another user's avatar", async () => { const media = await seedMedia() const owner = await seedUser() await claimUserAvatar(owner, media.id) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject(rejects422) + await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( + rejects422, + ) const stranger = await seedUser() await expect( resolveAvatarMediaOrThrow(h.sql, media.uploadId, { userId: stranger }), @@ -189,7 +209,9 @@ describe.skipIf(!pg)("CVX-004 avatar media validation (integration)", () => { const media = await seedMedia() const group = await seedGroup() await claimGroupAvatar(group, media.id) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject(rejects422) + await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( + rejects422, + ) const otherGroup = await seedGroup() await expect( resolveAvatarMediaOrThrow(h.sql, media.uploadId, { groupId: otherGroup }), @@ -258,7 +280,11 @@ describe.skipIf(!pg)("CVX-004 avatar media validation (integration)", () => { it("rejects a nonexistent uploadId and leaves the avatar untouched", async () => { const { store, id, handle } = await makeUser() await expect( - store.updateProfile(id, { handle, displayName: "Avatar User", avatarUploadId: randomUUID() }), + store.updateProfile(id, { + handle, + displayName: "Avatar User", + avatarUploadId: randomUUID(), + }), ).rejects.toMatchObject(rejects422) expect(await avatarMediaIdOf(id)).toBeNull() }) diff --git a/services/api/test/integration/block-identity-redaction-pg.test.ts b/services/api/test/integration/block-identity-redaction-pg.test.ts index 5b9561e7..8a5e792b 100644 --- a/services/api/test/integration/block-identity-redaction-pg.test.ts +++ b/services/api/test/integration/block-identity-redaction-pg.test.ts @@ -191,7 +191,9 @@ describe.skipIf(!pg)("block identity redaction (integration)", () => { const open = await repo.listAttendees(args) expect(open).toHaveLength(3) expect(open.find((a) => a.id === other)!.displayName).toBe("RosterOther") - expect(open.find((a) => a.id === other)!.avatarUrl).toBe("https://cdn.example/RosterOther.jpg") + expect(open.find((a) => a.id === other)!.avatarUrl).toBe( + "https://cdn.example/RosterOther.jpg", + ) for (const direction of BOTH_BLOCK_DIRECTIONS) { await withBlockDirection(direction, viewer, other, async () => { diff --git a/services/api/test/integration/chat-around-pg.test.ts b/services/api/test/integration/chat-around-pg.test.ts index 64e15b01..b3a7be9e 100644 --- a/services/api/test/integration/chat-around-pg.test.ts +++ b/services/api/test/integration/chat-around-pg.test.ts @@ -1,4 +1,3 @@ - import { TEST_TICKET_SIGNER } from "../helpers/ticket-signer.js" import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" @@ -221,7 +220,11 @@ describe.skipIf(!pg)("around-mode history windows (integration)", () => { const base = Date.now() - 120_000 const m: ChatMessageDTO[] = [] for (let i = 0; i < 8; i++) { - const sent = await dm.persist({ threadId: thread.id, senderId: i % 2 === 0 ? a : b, body: `m${i + 1}` }) + const sent = await dm.persist({ + threadId: thread.id, + senderId: i % 2 === 0 ? a : b, + body: `m${i + 1}`, + }) await h.sql`UPDATE dm_messages SET created_at = ${new Date(base + i * 1000)} WHERE id = ${sent.id}` m.push(sent) } diff --git a/services/api/test/integration/chat-channels-pg.test.ts b/services/api/test/integration/chat-channels-pg.test.ts index 0cc0b120..6fc01d4e 100644 --- a/services/api/test/integration/chat-channels-pg.test.ts +++ b/services/api/test/integration/chat-channels-pg.test.ts @@ -41,11 +41,7 @@ import { buildAuthServices, type AuthServices } from "../../src/auth/auth-servic import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryThreadsRepository, MockConnection } from "../helpers/chat.js" import type { ChatGatewayOverrides } from "../../src/routes/chat.routes.js" -import { - handleClientFrame, - type GatewayDeps, - type GatewaySession, -} from "../../src/ws/gateway.js" +import { handleClientFrame, type GatewayDeps, type GatewaySession } from "../../src/ws/gateway.js" import { WsChatService } from "../../src/adapters/chat-service.ws.js" import { InMemoryChatPubSub } from "../../src/adapters/chat-pubsub.js" import { InMemoryChatPresence } from "../../src/adapters/chat-presence.js" @@ -158,12 +154,7 @@ describe.skipIf(!pg)("chat channels: read-only enforcement + public join (integr // Per-request remoteAddress keeps the IP-keyed rate limiters (join 20/min etc.) out of the way. let injectSeq = 0 - function inject( - tok: string, - method: "GET" | "POST" | "PATCH", - url: string, - payload?: unknown, - ) { + function inject(tok: string, method: "GET" | "POST" | "PATCH", url: string, payload?: unknown) { injectSeq += 1 return app.inject({ method, @@ -303,7 +294,13 @@ describe.skipIf(!pg)("chat channels: read-only enforcement + public join (integr } function sessionFor(userId: string, conn: MockConnection, deps: GatewayDeps): GatewaySession { - return { userId, conn, joined: new Set(), typingThrottle: new Map(), deps } + return { + userId, + conn, + joined: new Set(), + typingThrottle: new Map(), + deps, + } } const frame = (f: Record) => JSON.stringify(f) @@ -326,14 +323,26 @@ describe.skipIf(!pg)("chat channels: read-only enforcement + public join (integr const adminSession = sessionFor(adminId, adminConn, deps) const memberSession = sessionFor(memberId, memberConn, deps) // Both are members, so both may JOIN (read-only for the plain member). - await handleClientFrame(adminSession, frame({ type: "join", cleanupId: channelId, roomKind: "group" })) - await handleClientFrame(memberSession, frame({ type: "join", cleanupId: channelId, roomKind: "group" })) + await handleClientFrame( + adminSession, + frame({ type: "join", cleanupId: channelId, roomKind: "group" }), + ) + await handleClientFrame( + memberSession, + frame({ type: "join", cleanupId: channelId, roomKind: "group" }), + ) expect(memberConn.framesOfType("error")).toHaveLength(0) // Read-only member send -> channel_read_only, no ack, nothing persisted. await handleClientFrame( memberSession, - frame({ type: "send", cleanupId: channelId, roomKind: "group", clientId: "m1", body: "let me post" }), + frame({ + type: "send", + cleanupId: channelId, + roomKind: "group", + clientId: "m1", + body: "let me post", + }), ) expect(memberConn.framesOfType("ack")).toHaveLength(0) const memberErr = memberConn.framesOfType("error") @@ -343,13 +352,21 @@ describe.skipIf(!pg)("chat channels: read-only enforcement + public join (integr // Admin send -> ack to the admin, broadcast to the read-only member, row persisted group-scoped. await handleClientFrame( adminSession, - frame({ type: "send", cleanupId: channelId, roomKind: "group", clientId: "a1", body: "the news" }), + frame({ + type: "send", + cleanupId: channelId, + roomKind: "group", + clientId: "a1", + body: "the news", + }), ) const acks = adminConn.framesOfType("ack") expect(acks).toHaveLength(1) const acked = (acks[0] as { message: { id: string; body: string } }).message expect(acked.body).toBe("the news") - expect((memberConn.framesOfType("message")[0] as { message: { id: string } }).message.id).toBe(acked.id) + expect( + (memberConn.framesOfType("message")[0] as { message: { id: string } }).message.id, + ).toBe(acked.id) const rows = await h.sql<{ count: number }[]>` SELECT COUNT(*)::int AS count FROM chat_messages WHERE group_id = ${channelId} @@ -368,8 +385,14 @@ describe.skipIf(!pg)("chat channels: read-only enforcement + public join (integr const conn = new MockConnection("member") const session = sessionFor(memberId, conn, deps) - await handleClientFrame(session, frame({ type: "join", cleanupId: channelId, roomKind: "group" })) - await handleClientFrame(session, frame({ type: "typing", cleanupId: channelId, roomKind: "group" })) + await handleClientFrame( + session, + frame({ type: "join", cleanupId: channelId, roomKind: "group" }), + ) + await handleClientFrame( + session, + frame({ type: "typing", cleanupId: channelId, roomKind: "group" }), + ) const errors = conn.framesOfType("error") expect(errors).toHaveLength(1) @@ -385,7 +408,10 @@ describe.skipIf(!pg)("chat channels: read-only enforcement + public join (integr const conn = new MockConnection("stranger") const session = sessionFor(strangerId, conn, deps) // Public read-only join: admitted, no error, presence snapshot delivered. - await handleClientFrame(session, frame({ type: "join", cleanupId: channelId, roomKind: "group" })) + await handleClientFrame( + session, + frame({ type: "join", cleanupId: channelId, roomKind: "group" }), + ) expect(conn.framesOfType("error")).toHaveLength(0) expect(session.joined.has(`group:${channelId}`)).toBe(true) expect(conn.framesOfType("presence_snapshot")).toHaveLength(1) @@ -393,7 +419,13 @@ describe.skipIf(!pg)("chat channels: read-only enforcement + public join (integr // ...but they're not a member, so a send is rejected and nothing persists. await handleClientFrame( session, - frame({ type: "send", cleanupId: channelId, roomKind: "group", clientId: "x", body: "sneak in" }), + frame({ + type: "send", + cleanupId: channelId, + roomKind: "group", + clientId: "x", + body: "sneak in", + }), ) expect(conn.framesOfType("ack")).toHaveLength(0) expect(conn.framesOfType("error")).toHaveLength(1) diff --git a/services/api/test/integration/chat-groups-pg.test.ts b/services/api/test/integration/chat-groups-pg.test.ts index d1929f43..ffcc0689 100644 --- a/services/api/test/integration/chat-groups-pg.test.ts +++ b/services/api/test/integration/chat-groups-pg.test.ts @@ -444,7 +444,11 @@ describe.skipIf(!pg)("chat groups service + routes (integration)", () => { expect(p1.members[0]).toMatchObject({ user: { id: ownerId }, role: "owner" }) expect(p1.nextCursor).toBe(p1.members[2]!.user.id) - const page2 = await inject(tok, "GET", `/v1/groups/${dto.id}/members?limit=3&cursor=${p1.nextCursor}`) + const page2 = await inject( + tok, + "GET", + `/v1/groups/${dto.id}/members?limit=3&cursor=${p1.nextCursor}`, + ) expect(page2.statusCode).toBe(200) const p2 = page2.json() as { members: Array<{ user: { id: string } }> 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 d8ebd99c..3d30612e 100644 --- a/services/api/test/integration/chat-groups-threads-pg.test.ts +++ b/services/api/test/integration/chat-groups-threads-pg.test.ts @@ -46,7 +46,10 @@ import { InMemoryChatPresence } from "../../src/adapters/chat-presence.js" import { makeDrizzleChatRepository } from "../../src/services/chat-repository.drizzle.js" import { makeDrizzleDmRepository } from "../../src/services/dm-repository.drizzle.js" import { makeDrizzleBlocksRepository } from "../../src/services/blocks-repository.drizzle.js" -import { canPostToGroup, makeChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" +import { + canPostToGroup, + makeChatGroupRepository, +} from "../../src/services/chat-group-repository.drizzle.js" import { makeConversationMutesRepository } from "../../src/services/conversation-mutes-repository.drizzle.js" import { makeDrizzleNotificationRepository } from "../../src/services/notification-repository.drizzle.js" import { makeNotificationService } from "../../src/services/notification-service.js" @@ -63,10 +66,7 @@ import { makeDrizzleGroupThreadsSource, makeDrizzleThreadsRepository, } from "../../src/services/threads-repository.drizzle.js" -import { - makeThreadsService, - InMemoryChatReadState, -} from "../../src/services/threads-service.js" +import { makeThreadsService, InMemoryChatReadState } from "../../src/services/threads-service.js" const pg = await withPg() @@ -132,7 +132,9 @@ describe.skipIf(!pg)("chat groups: threads inbox + group_chat bells (integration async function bellsFor( userId: string, ): Promise> { - return await h.sql>` + return await h.sql< + Array<{ type: string; title: string; body: string | null; link: string | null }> + >` SELECT type, title, body, link FROM notifications WHERE user_id = ${userId} ORDER BY created_at ASC ` } @@ -196,7 +198,9 @@ describe.skipIf(!pg)("chat groups: threads inbox + group_chat bells (integration it("unread rides the last_read_at watermark: acking the FIRST of two messages leaves unread 1", async () => { const me = await newUser("Watermark Member") const other = await newUser("Watermark Other") - const groupId = await newGroup(other, [me], { joinedAt: new Date("2026-06-01T09:00:00.000Z") }) + const groupId = await newGroup(other, [me], { + joinedAt: new Date("2026-06-01T09:00:00.000Z"), + }) const m1 = await addGroupMessage(groupId, other, "one", new Date("2026-06-01T10:00:00.000Z")) await addGroupMessage(groupId, other, "two", new Date("2026-06-01T11:00:00.000Z")) @@ -215,10 +219,22 @@ describe.skipIf(!pg)("chat groups: threads inbox + group_chat bells (integration it("watermark regression: acking an OLDER message after a newer one never moves last_read_at back", async () => { const me = await newUser("Regress Member") const other = await newUser("Regress Other") - const groupId = await newGroup(other, [me], { joinedAt: new Date("2026-06-01T09:00:00.000Z") }) + const groupId = await newGroup(other, [me], { + joinedAt: new Date("2026-06-01T09:00:00.000Z"), + }) - const older = await addGroupMessage(groupId, other, "older", new Date("2026-06-01T10:00:00.000Z")) - const newer = await addGroupMessage(groupId, other, "newer", new Date("2026-06-01T11:00:00.000Z")) + const older = await addGroupMessage( + groupId, + other, + "older", + new Date("2026-06-01T10:00:00.000Z"), + ) + const newer = await addGroupMessage( + groupId, + other, + "newer", + new Date("2026-06-01T11:00:00.000Z"), + ) const readAt = async (): Promise => { const rows = await h.sql<{ last_read_at: Date | null }[]>` @@ -243,7 +259,9 @@ describe.skipIf(!pg)("chat groups: threads inbox + group_chat bells (integration it("a conversation_mutes ('group') row flips the thread's muted to true", async () => { const me = await newUser("Mute Member") const other = await newUser("Mute Owner") - const groupId = await newGroup(other, [me], { joinedAt: new Date("2026-06-01T09:00:00.000Z") }) + const groupId = await newGroup(other, [me], { + joinedAt: new Date("2026-06-01T09:00:00.000Z"), + }) await addGroupMessage(groupId, other, "hello", new Date("2026-06-01T10:00:00.000Z")) const before = (await threadsService().listThreads(me)).items.find((x) => x.id === groupId) @@ -277,7 +295,8 @@ describe.skipIf(!pg)("chat groups: threads inbox + group_chat bells (integration isMutedFor: (userId, kind, roomId) => mutes.isMuted(userId, kind, roomId), isCleanupMember: () => Promise.resolve(false), isReportChatMember: () => Promise.resolve(false), - isChatGroupMember: async (groupId, userId) => (await groupRepo.roleOf(groupId, userId)) !== null, + isChatGroupMember: async (groupId, userId) => + (await groupRepo.roleOf(groupId, userId)) !== null, isBlockedEitherWay: (a, b) => blocks.isBlockedEitherWay(a, b), presence, roomKeyFor, @@ -309,7 +328,11 @@ describe.skipIf(!pg)("chat groups: threads inbox + group_chat bells (integration markReadOnOpen: async (kind, id, userId) => { if (kind !== "group") return await groupRepo.markRead(id, userId, new Date()) - await notificationService.clearByTypeAndLink(userId, "group_chat", `/messages/group/${id}`) + await notificationService.clearByTypeAndLink( + userId, + "group_chat", + `/messages/group/${id}`, + ) }, chatMentions: { resolveChatMentions: makeChatMentionResolver({ @@ -331,13 +354,22 @@ describe.skipIf(!pg)("chat groups: threads inbox + group_chat bells (integration } function sessionFor(userId: string, conn: MockConnection, deps: GatewayDeps): GatewaySession { - return { userId, conn, joined: new Set(), typingThrottle: new Map(), deps } + return { + userId, + conn, + joined: new Set(), + typingThrottle: new Map(), + deps, + } } const frame = (f: Record) => JSON.stringify(f) async function join(session: GatewaySession, groupId: string): Promise { - await handleClientFrame(session, frame({ type: "join", cleanupId: groupId, roomKind: "group" })) + await handleClientFrame( + session, + frame({ type: "join", cleanupId: groupId, roomKind: "group" }), + ) } it("a send bells the UNMUTED absent member exactly once; sender, PRESENT, and MUTED members get nothing", async () => { @@ -356,7 +388,13 @@ describe.skipIf(!pg)("chat groups: threads inbox + group_chat bells (integration await handleClientFrame( senderSession, - frame({ type: "send", cleanupId: groupId, roomKind: "group", clientId: "c1", body: "hello bells" }), + frame({ + type: "send", + cleanupId: groupId, + roomKind: "group", + clientId: "c1", + body: "hello bells", + }), ) const bells = await waitFor(async () => { 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 1d816228..fe56d3a6 100644 --- a/services/api/test/integration/chat-groups-ws-pg.test.ts +++ b/services/api/test/integration/chat-groups-ws-pg.test.ts @@ -52,7 +52,10 @@ import { makeDrizzleDmRepository } from "../../src/services/dm-repository.drizzl import { makeDrizzleBlocksRepository } from "../../src/services/blocks-repository.drizzle.js" import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" import { makeReportChatRepository } from "../../src/services/report-chat-repository.drizzle.js" -import { canPostToGroup, makeChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" +import { + canPostToGroup, + makeChatGroupRepository, +} from "../../src/services/chat-group-repository.drizzle.js" import { makeCleanupService } from "../../src/services/cleanup-service.js" import { makeChatMentionResolver } from "../../src/services/chat-mention-resolver.js" import { recordChatMentions } from "../../src/services/chat-mentions.drizzle.js" @@ -147,7 +150,13 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () } function sessionFor(userId: string, conn: MockConnection, deps: GatewayDeps): GatewaySession { - return { userId, conn, joined: new Set(), typingThrottle: new Map(), deps } + return { + userId, + conn, + joined: new Set(), + typingThrottle: new Map(), + deps, + } } const frame = (f: Record) => JSON.stringify(f) @@ -162,19 +171,32 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () const bConn = new MockConnection("B") const aSession = sessionFor(aId, aConn, deps) const bSession = sessionFor(bId, bConn, deps) - await handleClientFrame(aSession, frame({ type: "join", cleanupId: groupId, roomKind: "group" })) - await handleClientFrame(bSession, frame({ type: "join", cleanupId: groupId, roomKind: "group" })) + await handleClientFrame( + aSession, + frame({ type: "join", cleanupId: groupId, roomKind: "group" }), + ) + await handleClientFrame( + bSession, + frame({ type: "join", cleanupId: groupId, roomKind: "group" }), + ) expect(aConn.framesOfType("error")).toHaveLength(0) expect(aSession.joined.has(`group:${groupId}`)).toBe(true) await handleClientFrame( aSession, - frame({ type: "send", cleanupId: groupId, roomKind: "group", clientId: "c1", body: "hello group" }), + frame({ + type: "send", + cleanupId: groupId, + roomKind: "group", + clientId: "c1", + body: "hello group", + }), ) const acks = aConn.framesOfType("ack") expect(acks).toHaveLength(1) - const acked = (acks[0] as { message: { id: string; roomKind?: string; body: string } }).message + const acked = (acks[0] as { message: { id: string; roomKind?: string; body: string } }) + .message expect(acked.roomKind).toBe("group") expect(acked.body).toBe("hello group") @@ -197,7 +219,10 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () const conn = new MockConnection("S") const session = sessionFor(strangerId, conn, deps) - await handleClientFrame(session, frame({ type: "join", cleanupId: groupId, roomKind: "group" })) + await handleClientFrame( + session, + frame({ type: "join", cleanupId: groupId, roomKind: "group" }), + ) const errors = conn.framesOfType("error") expect(errors).toHaveLength(1) @@ -215,7 +240,13 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () const session = sessionFor(strangerId, conn, deps) await handleClientFrame( session, - frame({ type: "send", cleanupId: groupId, roomKind: "group", clientId: "cx", body: "let me in" }), + frame({ + type: "send", + cleanupId: groupId, + roomKind: "group", + clientId: "cx", + body: "let me in", + }), ) expect(conn.framesOfType("ack")).toHaveLength(0) @@ -239,17 +270,29 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () const bConn = new MockConnection("B") const aSession = sessionFor(aId, aConn, deps) const bSession = sessionFor(bId, bConn, deps) - await handleClientFrame(aSession, frame({ type: "join", cleanupId: groupId, roomKind: "group" })) - await handleClientFrame(bSession, frame({ type: "join", cleanupId: groupId, roomKind: "group" })) + await handleClientFrame( + aSession, + frame({ type: "join", cleanupId: groupId, roomKind: "group" }), + ) + await handleClientFrame( + bSession, + frame({ type: "join", cleanupId: groupId, roomKind: "group" }), + ) - await handleClientFrame(aSession, frame({ type: "typing", cleanupId: groupId, roomKind: "group" })) + await handleClientFrame( + aSession, + frame({ type: "typing", cleanupId: groupId, roomKind: "group" }), + ) const typing = bConn.framesOfType("typing") expect(typing).toHaveLength(1) expect(typing[0]).toMatchObject({ cleanupId: groupId, roomKind: "group", userId: aId }) const sConn = new MockConnection("S") const sSession = sessionFor(strangerId, sConn, deps) - await handleClientFrame(sSession, frame({ type: "typing", cleanupId: groupId, roomKind: "group" })) + await handleClientFrame( + sSession, + frame({ type: "typing", cleanupId: groupId, roomKind: "group" }), + ) expect(sConn.framesOfType("error")).toHaveLength(1) }) @@ -265,7 +308,10 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () const bConn = new MockConnection("B") const aSession = sessionFor(aId, aConn, deps) const bSession = sessionFor(bId, bConn, deps) - await handleClientFrame(bSession, frame({ type: "join", cleanupId: groupId, roomKind: "group" })) + await handleClientFrame( + bSession, + frame({ type: "join", cleanupId: groupId, roomKind: "group" }), + ) // Mark-read-on-join (mirrors the cleanup lane) is FIRE-AND-FORGET behind a real SQL round-trip, // so poll briefly rather than racing a single microtask flush. let afterJoin: Date | null = null @@ -276,12 +322,22 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () expect(afterJoin).not.toBeNull() // A message lands AFTER the join stamp; B acks it -> watermark advances to its created_at. - await handleClientFrame(aSession, frame({ type: "join", cleanupId: groupId, roomKind: "group" })) await handleClientFrame( aSession, - frame({ type: "send", cleanupId: groupId, roomKind: "group", clientId: "c1", body: "new msg" }), + frame({ type: "join", cleanupId: groupId, roomKind: "group" }), ) - const acked = (aConn.framesOfType("ack")[0] as { message: { id: string; createdAt: string } }).message + await handleClientFrame( + aSession, + frame({ + type: "send", + cleanupId: groupId, + roomKind: "group", + clientId: "c1", + body: "new msg", + }), + ) + const acked = (aConn.framesOfType("ack")[0] as { message: { id: string; createdAt: string } }) + .message await handleClientFrame( bSession, frame({ type: "ack", cleanupId: groupId, roomKind: "group", upToId: acked.id }), @@ -303,7 +359,10 @@ describe.skipIf(!pg)("chat groups WS lane + unified reactions (integration)", () const conn = new MockConnection("A") const session = sessionFor(authorId, conn, deps) - await handleClientFrame(session, frame({ type: "join", cleanupId: groupId, roomKind: "group" })) + await handleClientFrame( + session, + frame({ type: "join", cleanupId: groupId, roomKind: "group" }), + ) // Member mention: resolved (scoped to chat_group_members), recorded, and riding the ack DTO. await handleClientFrame( diff --git a/services/api/test/integration/chat-media-attach-pg.test.ts b/services/api/test/integration/chat-media-attach-pg.test.ts index 692650f3..ce0d4bb4 100644 --- a/services/api/test/integration/chat-media-attach-pg.test.ts +++ b/services/api/test/integration/chat-media-attach-pg.test.ts @@ -1,4 +1,3 @@ - import { TEST_TICKET_SIGNER } from "../helpers/ticket-signer.js" import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" @@ -79,7 +78,9 @@ describe.skipIf(!pg)("chat media attach (integration)", () => { ) expect(dto.attachments).toHaveLength(1) - const [row] = await h.sql<{ chat_message_id: string | null; chat_message_created_at: Date | null }[]>` + const [row] = await h.sql< + { chat_message_id: string | null; chat_message_created_at: Date | null }[] + >` SELECT chat_message_id, chat_message_created_at FROM media_assets WHERE upload_id = ${uploadId} ` expect(row!.chat_message_id).toBe(messageId) diff --git a/services/api/test/integration/chat-pins-pg.test.ts b/services/api/test/integration/chat-pins-pg.test.ts index 7f730ad8..ec48b6f9 100644 --- a/services/api/test/integration/chat-pins-pg.test.ts +++ b/services/api/test/integration/chat-pins-pg.test.ts @@ -180,7 +180,10 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { tickets: TEST_TICKET_SIGNER, repo: makeDrizzleCleanupRepository(h.sql), }).joinCleanup(cleanupId, memberId) - const msg = await chat().insertMessage({ cleanupId, userId: memberId, body: "pin me" }, randomUUID()) + const msg = await chat().insertMessage( + { cleanupId, userId: memberId, body: "pin me" }, + randomUUID(), + ) const watcher = new MockConnection("pin-watcher") await container.chatService.joinRoom(roomKeyFor("cleanup", cleanupId), watcher, memberId) @@ -216,7 +219,10 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { tickets: TEST_TICKET_SIGNER, repo: makeDrizzleCleanupRepository(h.sql), }).joinCleanup(cleanupId, memberId) - const msg = await chat().insertMessage({ cleanupId, userId: organizerId, body: "no pin for you" }, randomUUID()) + const msg = await chat().insertMessage( + { cleanupId, userId: organizerId, body: "no pin for you" }, + randomUUID(), + ) const res = await pin(await token(memberId), { roomKind: "cleanup", @@ -284,7 +290,11 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { const aliceId = await newUser("Pin DM Alice") const bobId = await newUser("Pin DM Bob") const thread = await dmRepo().openOrCreateThread(aliceId, bobId) - const msg = await dmRepo().persist({ threadId: thread.id, senderId: bobId, body: "keep this" }) + const msg = await dmRepo().persist({ + threadId: thread.id, + senderId: bobId, + body: "keep this", + }) const aliceToken = await token(aliceId) const pinned = await pin(aliceToken, { @@ -317,16 +327,29 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { it("pinning is IDEMPOTENT: a second pin returns the SAME pinnedAt (no refresh)", async () => { const organizerId = await newUser("Pin Idem Org") const cleanupId = await newCleanup(organizerId) - const msg = await chat().insertMessage({ cleanupId, userId: organizerId, body: "once" }, randomUUID()) + const msg = await chat().insertMessage( + { cleanupId, userId: organizerId, body: "once" }, + randomUUID(), + ) const tok = await token(organizerId) - const first = await pin(tok, { roomKind: "cleanup", roomId: cleanupId, messageId: msg.id, pinned: true }) + const first = await pin(tok, { + roomKind: "cleanup", + roomId: cleanupId, + messageId: msg.id, + pinned: true, + }) expect(first.statusCode).toBe(200) const firstAt = first.json().pinnedAt expect(firstAt).toBeTruthy() // A repeat pin is a no-op: 200 with the ORIGINAL stamp, not a refreshed one. - const second = await pin(tok, { roomKind: "cleanup", roomId: cleanupId, messageId: msg.id, pinned: true }) + const second = await pin(tok, { + roomKind: "cleanup", + roomId: cleanupId, + messageId: msg.id, + pinned: true, + }) expect(second.statusCode).toBe(200) expect(second.json().pinnedAt).toBe(firstAt) }) @@ -342,7 +365,12 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { body: "Status changed", }) const tok = await token(ownerId) - const sysRes = await pin(tok, { roomKind: "report", roomId: reportId, messageId: sys.id, pinned: true }) + const sysRes = await pin(tok, { + roomKind: "report", + roomId: reportId, + messageId: sys.id, + pinned: true, + }) expect(sysRes.statusCode).toBe(422) const msg = await chat().insertMessage( @@ -350,7 +378,12 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { randomUUID(), ) await chat().softDeleteReport(reportId, msg.id, ownerId) - const delRes = await pin(tok, { roomKind: "report", roomId: reportId, messageId: msg.id, pinned: true }) + const delRes = await pin(tok, { + roomKind: "report", + roomId: reportId, + messageId: msg.id, + pinned: true, + }) expect(delRes.statusCode).toBe(422) }) }) @@ -360,9 +393,18 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { const organizerId = await newUser("Hist Org") const cleanupId = await newCleanup(organizerId) const repo = chat() - const m1 = await repo.insertMessage({ cleanupId, userId: organizerId, body: "one" }, randomUUID()) - const m2 = await repo.insertMessage({ cleanupId, userId: organizerId, body: "two" }, randomUUID()) - const m3 = await repo.insertMessage({ cleanupId, userId: organizerId, body: "three" }, randomUUID()) + const m1 = await repo.insertMessage( + { cleanupId, userId: organizerId, body: "one" }, + randomUUID(), + ) + const m2 = await repo.insertMessage( + { cleanupId, userId: organizerId, body: "two" }, + randomUUID(), + ) + const m3 = await repo.insertMessage( + { cleanupId, userId: organizerId, body: "three" }, + randomUUID(), + ) await repo.setPinned(cleanupId, m1.id, organizerId, true) await repo.setPinned(cleanupId, m2.id, organizerId, true) @@ -471,7 +513,10 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { tickets: TEST_TICKET_SIGNER, repo: makeDrizzleCleanupRepository(h.sql), }).joinCleanup(cleanupId, memberId) - const msg = await chat().insertMessage({ cleanupId, userId: memberId, body: "rule-breaking" }, randomUUID()) + const msg = await chat().insertMessage( + { cleanupId, userId: memberId, body: "rule-breaking" }, + randomUUID(), + ) const watcher = new MockConnection("del-override-watcher") await container.chatService.joinRoom(roomKeyFor("cleanup", cleanupId), watcher, memberId) @@ -502,7 +547,10 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { tickets: TEST_TICKET_SIGNER, repo: makeDrizzleCleanupRepository(h.sql), }).joinCleanup(cleanupId, memberId) - const msg = await chat().insertMessage({ cleanupId, userId: organizerId, body: "keep out" }, randomUUID()) + const msg = await chat().insertMessage( + { cleanupId, userId: organizerId, body: "keep out" }, + randomUUID(), + ) const res = await app.inject({ method: "DELETE", @@ -561,7 +609,11 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { const aliceId = await newUser("Del DM Alice") const bobId = await newUser("Del DM Bob") const thread = await dmRepo().openOrCreateThread(aliceId, bobId) - const msg = await dmRepo().persist({ threadId: thread.id, senderId: bobId, body: "bob's words" }) + const msg = await dmRepo().persist({ + threadId: thread.id, + senderId: bobId, + body: "bob's words", + }) const res = await app.inject({ method: "DELETE", diff --git a/services/api/test/integration/chat-polls-pg.test.ts b/services/api/test/integration/chat-polls-pg.test.ts index 53da90d1..f767790c 100644 --- a/services/api/test/integration/chat-polls-pg.test.ts +++ b/services/api/test/integration/chat-polls-pg.test.ts @@ -183,7 +183,11 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration }) } - const createPollBody = (roomKind: string, roomId: string, extra: Record = {}) => ({ + const createPollBody = ( + roomKind: string, + roomId: string, + extra: Record = {}, + ) => ({ roomKind, roomId, question: "Best day?", @@ -247,11 +251,21 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration ], }) - const denied = await inject(await token(memberId), "POST", "/v1/messages/poll", createPollBody("group", channelId)) + const denied = await inject( + await token(memberId), + "POST", + "/v1/messages/poll", + createPollBody("group", channelId), + ) expect(denied.statusCode).toBe(403) expect(denied.json().fields).toMatchObject({ code: "poll_forbidden" }) - const ok = await inject(await token(adminId), "POST", "/v1/messages/poll", createPollBody("group", channelId)) + const ok = await inject( + await token(adminId), + "POST", + "/v1/messages/poll", + createPollBody("group", channelId), + ) expect(ok.statusCode).toBe(200) expect(ok.json().kind).toBe("poll") }) @@ -261,7 +275,12 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration const memberId = await newUser("Cl Poll Member") const cleanupId = await newCleanup(organizerId, [memberId]) - const res = await inject(await token(memberId), "POST", "/v1/messages/poll", createPollBody("cleanup", cleanupId)) + const res = await inject( + await token(memberId), + "POST", + "/v1/messages/poll", + createPollBody("cleanup", cleanupId), + ) expect(res.statusCode).toBe(200) expect(res.json().poll.options).toHaveLength(2) }) @@ -313,7 +332,12 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration const strangerId = await newUser("Rep Poll Stranger") const reportId = await newReport() - const res = await inject(await token(strangerId), "POST", "/v1/messages/poll", createPollBody("report", reportId)) + const res = await inject( + await token(strangerId), + "POST", + "/v1/messages/poll", + createPollBody("report", reportId), + ) expect(res.statusCode).toBe(403) expect(res.json().fields).toMatchObject({ code: "poll_forbidden" }) }) @@ -325,12 +349,23 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration const bId = await newUser("Hydr B") const groupId = await newGroup(ownerId, { members: [{ id: bId }] }) - const created = await inject(await token(ownerId), "POST", "/v1/messages/poll", createPollBody("group", groupId)) + const created = await inject( + await token(ownerId), + "POST", + "/v1/messages/poll", + createPollBody("group", groupId), + ) const pollId = created.json().id // Owner votes idx 0, B votes idx 1. - await inject(await token(ownerId), "PUT", "/v1/messages/poll/vote", { messageId: pollId, optionIdxs: [0] }) - await inject(await token(bId), "PUT", "/v1/messages/poll/vote", { messageId: pollId, optionIdxs: [1] }) + await inject(await token(ownerId), "PUT", "/v1/messages/poll/vote", { + messageId: pollId, + optionIdxs: [0], + }) + await inject(await token(bId), "PUT", "/v1/messages/poll/vote", { + messageId: pollId, + optionIdxs: [1], + }) // History as the owner: counts reflect both votes, myVote is the owner's, totalVoters distinct = 2. const hist = await inject(await token(ownerId), "GET", `/v1/groups/${groupId}/messages`) @@ -358,13 +393,19 @@ 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] }) + const a = await inject(await token(ownerId), "PUT", "/v1/messages/poll/vote", { + messageId: pollId, + optionIdxs: [0], + }) expect(a.statusCode).toBe(200) expect(a.json().poll.options.map((o: { count: number }) => o.count)).toEqual([1, 0]) expect(a.json().poll.myVote).toEqual([0]) // Switch A->B: A decrements, B increments. - const b = await inject(await token(ownerId), "PUT", "/v1/messages/poll/vote", { messageId: pollId, optionIdxs: [1] }) + const b = await inject(await token(ownerId), "PUT", "/v1/messages/poll/vote", { + messageId: pollId, + optionIdxs: [1], + }) expect(b.json().poll.options.map((o: { count: number }) => o.count)).toEqual([0, 1]) expect(b.json().poll.myVote).toEqual([1]) @@ -411,7 +452,9 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration expect(voted.json().poll.options[0].mine).toBe(true) await flush() - type Framed = { message: { poll: { myVote: number[]; options: Array<{ count: number; mine: boolean }> } } } + type Framed = { + message: { poll: { myVote: number[]; options: Array<{ count: number; mine: boolean }> } } + } 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 @@ -474,7 +517,12 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration it("a multi-idx ballot on a single-choice poll 422s; an unknown idx 422s", async () => { const ownerId = await newUser("Val Owner") const groupId = await newGroup(ownerId, {}) - const created = await inject(await token(ownerId), "POST", "/v1/messages/poll", createPollBody("group", groupId)) + const created = await inject( + await token(ownerId), + "POST", + "/v1/messages/poll", + createPollBody("group", groupId), + ) const pollId = created.json().id const multi = await inject(await token(ownerId), "PUT", "/v1/messages/poll/vote", { @@ -494,12 +542,22 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration it("voting on a CLOSED poll 409s (poll_closed)", async () => { const ownerId = await newUser("Closed Owner") const groupId = await newGroup(ownerId, {}) - const created = await inject(await token(ownerId), "POST", "/v1/messages/poll", createPollBody("group", groupId)) + const created = await inject( + await token(ownerId), + "POST", + "/v1/messages/poll", + createPollBody("group", groupId), + ) const pollId = created.json().id - const closed = await inject(await token(ownerId), "POST", "/v1/messages/poll/close", { messageId: pollId }) + const closed = await inject(await token(ownerId), "POST", "/v1/messages/poll/close", { + messageId: pollId, + }) expect(closed.statusCode).toBe(200) - const vote = await inject(await token(ownerId), "PUT", "/v1/messages/poll/vote", { messageId: pollId, optionIdxs: [0] }) + const vote = await inject(await token(ownerId), "PUT", "/v1/messages/poll/vote", { + messageId: pollId, + optionIdxs: [0], + }) expect(vote.statusCode).toBe(409) expect(vote.json().fields).toMatchObject({ code: "poll_closed" }) }) @@ -513,16 +571,27 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration visibility: "public", members: [{ id: readerId, role: "member" }], }) - const created = await inject(await token(ownerId), "POST", "/v1/messages/poll", createPollBody("group", channelId)) + const created = await inject( + await token(ownerId), + "POST", + "/v1/messages/poll", + createPollBody("group", channelId), + ) const pollId = created.json().id // A read-only channel member can't POST but CAN vote (membership suffices). - const reader = await inject(await token(readerId), "PUT", "/v1/messages/poll/vote", { messageId: pollId, optionIdxs: [0] }) + const reader = await inject(await token(readerId), "PUT", "/v1/messages/poll/vote", { + messageId: pollId, + optionIdxs: [0], + }) expect(reader.statusCode).toBe(200) expect(reader.json().poll.options[0].count).toBe(1) // A public non-member reader is NOT a member -> can't vote. - const stranger = await inject(await token(strangerId), "PUT", "/v1/messages/poll/vote", { messageId: pollId, optionIdxs: [1] }) + const stranger = await inject(await token(strangerId), "PUT", "/v1/messages/poll/vote", { + messageId: pollId, + optionIdxs: [1], + }) expect(stranger.statusCode).toBe(403) expect(stranger.json().fields).toMatchObject({ code: "poll_not_member" }) }) @@ -533,18 +602,27 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration const ownerId = await newUser("Close Owner") const memberId = await newUser("Close Member") const groupId = await newGroup(ownerId, { members: [{ id: memberId }] }) - const created = await inject(await token(ownerId), "POST", "/v1/messages/poll", createPollBody("group", groupId)) + const created = await inject( + await token(ownerId), + "POST", + "/v1/messages/poll", + createPollBody("group", groupId), + ) const pollId = created.json().id // A plain member (not author, not moderator) can't close. - const denied = await inject(await token(memberId), "POST", "/v1/messages/poll/close", { messageId: pollId }) + const denied = await inject(await token(memberId), "POST", "/v1/messages/poll/close", { + messageId: pollId, + }) expect(denied.statusCode).toBe(403) expect(denied.json().fields).toMatchObject({ code: "poll_close_forbidden" }) const watcher = new MockConnection("close-watcher") await container.chatService.joinRoom(roomKeyFor("group", groupId), watcher, ownerId) - const closed = await inject(await token(ownerId), "POST", "/v1/messages/poll/close", { messageId: pollId }) + const closed = await inject(await token(ownerId), "POST", "/v1/messages/poll/close", { + messageId: pollId, + }) expect(closed.statusCode).toBe(200) expect(closed.json().poll.closed).toBe(true) @@ -564,10 +642,17 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration const memberId = await newUser("Cl Close Member") const cleanupId = await newCleanup(organizerId, [memberId]) // Poll authored by the plain member; the organizer closes it as a moderator. - const created = await inject(await token(memberId), "POST", "/v1/messages/poll", createPollBody("cleanup", cleanupId)) + const created = await inject( + await token(memberId), + "POST", + "/v1/messages/poll", + createPollBody("cleanup", cleanupId), + ) const pollId = created.json().id - const closed = await inject(await token(organizerId), "POST", "/v1/messages/poll/close", { messageId: pollId }) + const closed = await inject(await token(organizerId), "POST", "/v1/messages/poll/close", { + messageId: pollId, + }) expect(closed.statusCode).toBe(200) expect(closed.json().poll.closed).toBe(true) @@ -575,7 +660,9 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration SELECT closed_at FROM chat_polls WHERE message_id = ${pollId} ` // Re-close is idempotent: still 200, closed_at unchanged. - const again = await inject(await token(organizerId), "POST", "/v1/messages/poll/close", { messageId: pollId }) + const again = await inject(await token(organizerId), "POST", "/v1/messages/poll/close", { + messageId: pollId, + }) expect(again.statusCode).toBe(200) const [secondClose] = await h.sql<{ closed_at: Date }[]>` SELECT closed_at FROM chat_polls WHERE message_id = ${pollId} @@ -593,11 +680,18 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration // 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), "POST", "/v1/messages/poll", createPollBody("report", reportId)) + const created = await inject( + await token(memberId), + "POST", + "/v1/messages/poll", + createPollBody("report", reportId), + ) expect(created.statusCode).toBe(200) const pollId = created.json().id - const closed = await inject(await token(reportOwnerId), "POST", "/v1/messages/poll/close", { messageId: pollId }) + const closed = await inject(await token(reportOwnerId), "POST", "/v1/messages/poll/close", { + messageId: pollId, + }) expect(closed.statusCode).toBe(200) expect(closed.json().poll.closed).toBe(true) }) @@ -607,7 +701,12 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration 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, {}) - const created = await inject(await token(ownerId), "POST", "/v1/messages/poll", createPollBody("group", groupId)) + const created = await inject( + await token(ownerId), + "POST", + "/v1/messages/poll", + createPollBody("group", groupId), + ) const pollId = created.json().id // Tombstone the poll message (sender-only soft delete). 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 d1310dfe..a5de2f3c 100644 --- a/services/api/test/integration/chat-polls-schema-pg.test.ts +++ b/services/api/test/integration/chat-polls-schema-pg.test.ts @@ -72,7 +72,9 @@ describe.skipIf(!pg)("chat polls schema (0048, integration)", () => { it("creates the poll trio with sensible defaults", async () => { const pollId = await newPoll(2) - const [p] = await h.sql<{ allow_multiple: boolean; anonymous: boolean; closed_at: string | null }[]>` + const [p] = await h.sql< + { allow_multiple: boolean; anonymous: boolean; closed_at: string | null }[] + >` SELECT allow_multiple, anonymous, closed_at FROM chat_polls WHERE message_id = ${pollId} ` expect(p).toEqual({ allow_multiple: false, anonymous: true, closed_at: null }) 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 f9d1f8bb..e1f0b0cb 100644 --- a/services/api/test/integration/chat-reply-notifications-pg.test.ts +++ b/services/api/test/integration/chat-reply-notifications-pg.test.ts @@ -94,8 +94,12 @@ describe.skipIf(!pg)("reply notifications + report @mentions (integration)", () } /** All notification rows for a user, oldest-first. */ - async function bellsFor(userId: string): Promise> { - return await h.sql>` + async function bellsFor( + userId: string, + ): Promise> { + return await h.sql< + Array<{ type: string; title: string; body: string | null; link: string | null }> + >` SELECT type, title, body, link FROM notifications WHERE user_id = ${userId} ORDER BY created_at ASC ` } @@ -224,7 +228,13 @@ describe.skipIf(!pg)("reply notifications + report @mentions (integration)", () randomUUID(), ) const reply = await chatRepo.insertMessage( - { cleanupId: reportId, roomKind: "report", userId: actor, body: "report reply", replyToId: original.id }, + { + cleanupId: reportId, + roomKind: "report", + userId: actor, + body: "report reply", + replyToId: original.id, + }, randomUUID(), ) @@ -312,7 +322,11 @@ describe.skipIf(!pg)("reply notifications + report @mentions (integration)", () { cleanupId: reportId, roomKind: "report", userId: author, body: `hey @${memberHandle}` }, randomUUID(), ) - await recordChatMentions(h.sql, message.id, resolved.map((m) => m.id)) + await recordChatMentions( + h.sql, + message.id, + resolved.map((m) => m.id), + ) const rows = await h.sql<{ mentioned_user_id: string }[]>` SELECT mentioned_user_id FROM chat_message_mentions WHERE message_id = ${message.id} ` @@ -351,7 +365,12 @@ describe.skipIf(!pg)("reply notifications + report @mentions (integration)", () // Reply to B's message: pierces the mute, reply-flavored title. const bMsg = await dm.persist({ threadId: thread.id, senderId: b, body: "b's message" }) - const reply = await dm.persist({ threadId: thread.id, senderId: a, body: "re: b", replyToId: bMsg.id }) + const reply = await dm.persist({ + threadId: thread.id, + senderId: a, + body: "re: b", + replyToId: bMsg.id, + }) await onDmDelivered(thread.id, b, reply) const bells = await bellsFor(b) expect(bells).toHaveLength(1) @@ -399,7 +418,12 @@ describe.skipIf(!pg)("reply notifications + report @mentions (integration)", () const onDmDelivered = makeDmBellNotifier(makeBellDeps()) const bMsg = await dm.persist({ threadId: thread.id, senderId: b, body: "hello" }) - const reply = await dm.persist({ threadId: thread.id, senderId: a, body: "re: hello", replyToId: bMsg.id }) + const reply = await dm.persist({ + threadId: thread.id, + senderId: a, + body: "re: hello", + replyToId: bMsg.id, + }) await onDmDelivered(thread.id, b, reply) const bells = await bellsFor(b) diff --git a/services/api/test/integration/chat-tombstone-pg.test.ts b/services/api/test/integration/chat-tombstone-pg.test.ts index cd03adcd..241f1594 100644 --- a/services/api/test/integration/chat-tombstone-pg.test.ts +++ b/services/api/test/integration/chat-tombstone-pg.test.ts @@ -128,7 +128,9 @@ describe.skipIf(!pg)("soft-deleted messages hydrate as tombstones (integration)" const victim = await seedLoadedMessage(cleanupId, organizerId, peerId) await repo.insertMessage({ cleanupId, userId: organizerId, body: "after" }, randomUUID()) - expect(await repo.editMessage(cleanupId, victim, organizerId, "doxxing text and a photo")).not.toBeNull() + expect( + await repo.editMessage(cleanupId, victim, organizerId, "doxxing text and a photo"), + ).not.toBeNull() expect(await repo.setPinned(cleanupId, victim, organizerId, true)).not.toBeNull() const live = await repo.history(cleanupId, undefined, 20, peerId, victim) 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 001e3313..e29f6055 100644 --- a/services/api/test/integration/cleanup-default-slot-pg.test.ts +++ b/services/api/test/integration/cleanup-default-slot-pg.test.ts @@ -94,7 +94,9 @@ describe.skipIf(!pg)("0169 default event slot backfill (integration)", () => { async function boardOf( cleanupId: string, - ): Promise<{ title: string; capacity: number | null; startsAt: Date | null; endsAt: Date | null }[]> { + ): Promise< + { title: string; capacity: number | null; startsAt: Date | null; endsAt: Date | null }[] + > { const rows = await h.sql< { title: string; capacity: number | null; starts_at: Date | null; ends_at: Date | null }[] >` @@ -168,7 +170,10 @@ describe.skipIf(!pg)("0169 default event slot backfill (integration)", () => { const organizer = await newUser("Olive Organizer") const member = await newUser("Mel Member") const startsAt = new Date(Date.now() - 8 * HOUR) - const ended = await newCleanup(organizer, { startsAt, endsAt: new Date(startsAt.getTime() + 4 * HOUR) }) + const ended = await newCleanup(organizer, { + startsAt, + endsAt: new Date(startsAt.getTime() + 4 * HOUR), + }) const cancelled = await newCleanup(organizer, { status: "cancelled" }) await addMember(ended, member) await addMember(cancelled, member) diff --git a/services/api/test/integration/cleanup-links-pg.test.ts b/services/api/test/integration/cleanup-links-pg.test.ts index 1f53796d..5c12509f 100644 --- a/services/api/test/integration/cleanup-links-pg.test.ts +++ b/services/api/test/integration/cleanup-links-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" @@ -92,9 +91,7 @@ describe.skipIf(!pg)("cleanup<->report link bounds (integration)", () => { const remaining = await h.sql<{ report_id: string }[]>` SELECT report_id FROM cleanup_reports WHERE cleanup_id = ${cleanupId} ORDER BY report_id ` - expect(remaining.map((r) => r.report_id).sort()).toEqual( - [visibleKept, invisible, added].sort(), - ) + expect(remaining.map((r) => r.report_id).sort()).toEqual([visibleKept, invisible, added].sort()) const unlinked = await h.sql<{ note: string | null }[]>` SELECT note FROM cleanup_timeline WHERE cleanup_id = ${cleanupId} AND kind = 'report_unlinked' diff --git a/services/api/test/integration/cleanup-slots-pg.test.ts b/services/api/test/integration/cleanup-slots-pg.test.ts index 86d358c0..8f78d357 100644 --- a/services/api/test/integration/cleanup-slots-pg.test.ts +++ b/services/api/test/integration/cleanup-slots-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" @@ -86,7 +85,9 @@ describe.skipIf(!pg)("signup slots (integration)", () => { endsAt: new Date(FUTURE.getTime() + 2 * SHIFT_MS), } - async function windowsOf(cleanupId: string): Promise<[string, string, number | null, number | null][]> { + async function windowsOf( + cleanupId: string, + ): Promise<[string, string, number | null, number | null][]> { const board = await repo.listSlots(cleanupId, null) return board.map((s) => [ s.id, @@ -132,9 +133,7 @@ describe.skipIf(!pg)("signup slots (integration)", () => { const org = await newUser("Race Host") const cleanupId = await newCleanup(org) const slotId = await newSlot(cleanupId, { capacity: 1 }) - const racers = await Promise.all( - Array.from({ length: 8 }, (_, i) => newUser(`Racer ${i}`)), - ) + const racers = await Promise.all(Array.from({ length: 8 }, (_, i) => newUser(`Racer ${i}`))) const outcomes = await Promise.all( racers.map((userId) => repo.claimSlot(cleanupId, userId, slotId, signupSeat())), @@ -170,12 +169,21 @@ describe.skipIf(!pg)("signup slots (integration)", () => { const user = await newUser("Mover") const nextInLine = await newUser("Next in line") - expect(await repo.claimSlot(cleanupId, user, a, signupSeat())).toEqual({ kind: "claimed", slotId: a }) - expect(await repo.claimSlot(cleanupId, user, b, signupSeat())).toEqual({ kind: "claimed", slotId: b }) + expect(await repo.claimSlot(cleanupId, user, a, signupSeat())).toEqual({ + kind: "claimed", + slotId: a, + }) + expect(await repo.claimSlot(cleanupId, user, b, signupSeat())).toEqual({ + kind: "claimed", + slotId: b, + }) expect(await claimCount(a)).toBe(0) expect(await claimCount(b)).toBe(1) - expect(await repo.claimSlot(cleanupId, nextInLine, a, signupSeat())).toEqual({ kind: "claimed", slotId: a }) + expect(await repo.claimSlot(cleanupId, nextInLine, a, signupSeat())).toEqual({ + kind: "claimed", + slotId: a, + }) expect(await repo.slotOf(cleanupId, user)).toBe(b) }) @@ -203,7 +211,9 @@ describe.skipIf(!pg)("signup slots (integration)", () => { const foreignSlot = await newSlot(theirs, { title: "Their grill" }) const user = await newUser("FK User") - expect(await repo.claimSlot(mine, user, foreignSlot, signupSeat())).toEqual({ kind: "slot_not_found" }) + expect(await repo.claimSlot(mine, user, foreignSlot, signupSeat())).toEqual({ + kind: "slot_not_found", + }) await expect( h.sql` INSERT INTO cleanup_slot_claims (cleanup_id, user_id, slot_id) @@ -276,7 +286,9 @@ describe.skipIf(!pg)("signup slots (integration)", () => { expect(outcome.kind).toBe("removed") expect(await claimCount(slotId)).toBe(0) - expect(await repo.claimSlot(cleanupId, target, slotId, signupSeat())).toEqual({ kind: "banned" }) + expect(await repo.claimSlot(cleanupId, target, slotId, signupSeat())).toEqual({ + kind: "banned", + }) }) it("claiming auto-RSVPs, and is refused on a done or cancelled event", async () => { @@ -298,7 +310,9 @@ describe.skipIf(!pg)("signup slots (integration)", () => { const closed = await newCleanup(org, { status }) const closedSlot = await newSlot(closed) const latecomer = await newUser(`Late ${status}`) - expect(await repo.claimSlot(closed, latecomer, closedSlot, signupSeat())).toEqual(claimOutcome) + expect(await repo.claimSlot(closed, latecomer, closedSlot, signupSeat())).toEqual( + claimOutcome, + ) expect(await repo.isMember(closed, latecomer)).toBe(false) expect(await repo.releaseSlot(closed, latecomer)).toEqual(releaseOutcome) } @@ -311,7 +325,10 @@ describe.skipIf(!pg)("signup slots (integration)", () => { const user = await newUser("Holder") await repo.claimSlot(cleanupId, user, slotId, signupSeat()) - expect(await repo.claimSlot(cleanupId, user, slotId, signupSeat())).toEqual({ kind: "claimed", slotId }) + expect(await repo.claimSlot(cleanupId, user, slotId, signupSeat())).toEqual({ + kind: "claimed", + slotId, + }) expect(await claimCount(slotId)).toBe(1) }) @@ -348,7 +365,10 @@ describe.skipIf(!pg)("signup slots (integration)", () => { jurisdictionGeoid: null, jurCode: 0, linkedReportIds: [], - slots: [slot({ title: "Grill", capacity: 2, sortOrder: 0 }), slot({ title: "Sign-in", sortOrder: 1 })], + slots: [ + slot({ title: "Grill", capacity: 2, sortOrder: 0 }), + slot({ title: "Sign-in", sortOrder: 1 }), + ], host: { endsAt: new Date(FUTURE.getTime() + 4 * 60 * 60 * 1000) }, }) @@ -371,7 +391,10 @@ describe.skipIf(!pg)("signup slots (integration)", () => { const result = await repo.reconcileSlots( cleanupId, - [slot({ id: keep, title: "Grill duty", capacity: 4 }), slot({ title: "Sign-in", sortOrder: 2 })], + [ + slot({ id: keep, title: "Grill duty", capacity: 4 }), + slot({ title: "Sign-in", sortOrder: 2 }), + ], org, ) @@ -408,7 +431,6 @@ describe.skipIf(!pg)("signup slots (integration)", () => { expect(foreignRow[0]).toEqual({ cleanup_id: theirs, title: "Theirs" }) }) - it("reconcileSlots SWAPS two slot titles in one save", async () => { const org = await newUser("Swap Host") const cleanupId = await newCleanup(org) @@ -446,9 +468,7 @@ describe.skipIf(!pg)("signup slots (integration)", () => { ) expect(result.added).toHaveLength(1) - expect(result.removed).toEqual([ - { slotId: old, title: "Grill", claimantUserIds: [claimant] }, - ]) + expect(result.removed).toEqual([{ slotId: old, title: "Grill", claimantUserIds: [claimant] }]) const board = await repo.listSlots(cleanupId, null) expect(board.map((s) => [s.title, s.capacity])).toEqual([["Grill", 5]]) expect(board[0]!.id).not.toBe(old) @@ -589,7 +609,6 @@ describe.skipIf(!pg)("signup slots (integration)", () => { expect(await titlesOf(cleanupId)).toEqual([[existing, "Grill"]]) }) - it("a `slot_not_found` claim commits NO cleanup_members row", async () => { const org = await newUser("No-RSVP Host") const cleanupId = await newCleanup(org) @@ -627,7 +646,9 @@ describe.skipIf(!pg)("signup slots (integration)", () => { const foreignSlot = await newSlot(theirs, { title: "Their grill" }) const stranger = await newUser("Foreign RSVP Stranger") - expect(await repo.claimSlot(mine, stranger, foreignSlot, signupSeat())).toEqual({ kind: "slot_not_found" }) + expect(await repo.claimSlot(mine, stranger, foreignSlot, signupSeat())).toEqual({ + kind: "slot_not_found", + }) expect(await membershipCount(mine, stranger)).toBe(0) expect(await membershipCount(theirs, stranger)).toBe(0) @@ -703,16 +724,16 @@ describe.skipIf(!pg)("signup slots (integration)", () => { const org = await newUser("Shrink Host") const cleanupId = await newCleanup(org) const slotId = await newSlot(cleanupId, { title: "Grill", capacity: 3 }) - const claimants = await Promise.all( - Array.from({ length: 3 }, (_, i) => newUser(`Shrink ${i}`)), - ) + const claimants = await Promise.all(Array.from({ length: 3 }, (_, i) => newUser(`Shrink ${i}`))) for (const u of claimants) await repo.claimSlot(cleanupId, u, slotId, signupSeat()) await repo.reconcileSlots(cleanupId, [slot({ id: slotId, title: "Grill", capacity: 1 })], org) expect(await claimCount(slotId)).toBe(3) const latecomer = await newUser("Shrink latecomer") - expect(await repo.claimSlot(cleanupId, latecomer, slotId, signupSeat())).toEqual({ kind: "full" }) + expect(await repo.claimSlot(cleanupId, latecomer, slotId, signupSeat())).toEqual({ + kind: "full", + }) }) it("the capacity CHECK constraint refuses a zero or negative capacity", async () => { @@ -727,8 +748,12 @@ describe.skipIf(!pg)("signup slots (integration)", () => { const org = await newUser("Missing Host") const cleanupId = await newCleanup(org) const user = await newUser("Missing User") - expect(await repo.claimSlot(randomUUID(), user, randomUUID(), signupSeat())).toEqual({ kind: "not_found" }) - expect(await repo.claimSlot(cleanupId, user, randomUUID(), signupSeat())).toEqual({ kind: "slot_not_found" }) + expect(await repo.claimSlot(randomUUID(), user, randomUUID(), signupSeat())).toEqual({ + kind: "not_found", + }) + expect(await repo.claimSlot(cleanupId, user, randomUUID(), signupSeat())).toEqual({ + kind: "slot_not_found", + }) expect(await repo.releaseSlot(randomUUID(), user)).toEqual({ kind: "not_found" }) }) }) diff --git a/services/api/test/integration/cleanups-chat-pg.test.ts b/services/api/test/integration/cleanups-chat-pg.test.ts index e8422108..01a6fd83 100644 --- a/services/api/test/integration/cleanups-chat-pg.test.ts +++ b/services/api/test/integration/cleanups-chat-pg.test.ts @@ -190,7 +190,10 @@ describe.skipIf(!pg)("cleanups + chat (integration)", () => { const chatRepo = makeDrizzleChatRepository(h.sql) // Room A has 3 messages; room B has 1 message whose id we will (mis)use as a cursor against room A. for (let i = 1; i <= 3; i++) { - await chatRepo.insertMessage({ cleanupId: roomA.id, userId: organizerId, body: `a${i}` }, randomUUID()) + await chatRepo.insertMessage( + { cleanupId: roomA.id, userId: organizerId, body: `a${i}` }, + randomUUID(), + ) } const bMsg = await chatRepo.insertMessage( { cleanupId: roomB.id, userId: organizerId, body: "b1" }, @@ -289,7 +292,11 @@ describe.skipIf(!pg)("cleanups + chat (integration)", () => { ) // Seed a message via the chat seam so history is non-empty. - await container.chatService.persist({ cleanupId: created.id, userId: organizerId, body: "hello" }) + await container.chatService.persist({ + cleanupId: created.id, + userId: organizerId, + body: "hello", + }) // Member (organizer) -> 200. const ok = await app.inject({ @@ -377,7 +384,9 @@ describe.skipIf(!pg)("cleanups + chat (integration)", () => { payload: { emoji: "laugh" }, }) expect(off.statusCode).toBe(200) - expect((off.json().reactions as { emoji: string }[]).some((r) => r.emoji === "laugh")).toBe(false) + expect((off.json().reactions as { emoji: string }[]).some((r) => r.emoji === "laugh")).toBe( + false, + ) // The allowlist is widened, not open: a name outside the 8 still fails schema parse. const rejected = await app.inject({ diff --git a/services/api/test/integration/content-report-visibility-pg.test.ts b/services/api/test/integration/content-report-visibility-pg.test.ts index 7ec7bfd2..5c2a80e7 100644 --- a/services/api/test/integration/content-report-visibility-pg.test.ts +++ b/services/api/test/integration/content-report-visibility-pg.test.ts @@ -32,7 +32,11 @@ describe.skipIf(!pg)("content-report subject gate (integration: real visibility) return u!.id } - async function seedReport(reporterId: string, status: string, visibility: string): Promise { + async function seedReport( + reporterId: string, + status: string, + visibility: string, + ): Promise { const [r] = await h.sql<{ id: string }[]>` INSERT INTO reports ( reporter_user_id, idempotency_key, geom, geom_source, category, status, visibility, h3_cell diff --git a/services/api/test/integration/dm-pg.test.ts b/services/api/test/integration/dm-pg.test.ts index 78f0c732..a501027f 100644 --- a/services/api/test/integration/dm-pg.test.ts +++ b/services/api/test/integration/dm-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { DELETED_USER_LABEL } from "@civfix/shared" 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 3cdf731b..e61f1bf1 100644 --- a/services/api/test/integration/erasure-host-ladder-pg.test.ts +++ b/services/api/test/integration/erasure-host-ladder-pg.test.ts @@ -4,7 +4,6 @@ import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" import { PgUserStore } from "../../src/auth/pg-stores.js" - const pg = await withPg() async function user(h: PgHarness, name: string): Promise { @@ -178,7 +177,9 @@ describe.skipIf(!pg)("erasure: the host-transfer ladder", () => { ` expect(orgRows[0]!.deleted_at).not.toBeNull() - const eventRows = await h.sql<{ organization_id: string | null; donation_url: string | null }[]>` + const eventRows = await h.sql< + { organization_id: string | null; donation_url: string | null }[] + >` SELECT organization_id, donation_url FROM cleanups WHERE id = ${orgEvent} ` expect(eventRows[0]!.organization_id).toBeNull() @@ -289,7 +290,12 @@ describe.skipIf(!pg)("erasure: the host-transfer ladder", () => { await new PgUserStore(h.db).softDeleteAndAnonymize(donor) const rows = await h.sql< - { id: string; user_id: string | null; profile_unlinked_at: Date | null; donor_email: string | null }[] + { + id: string + user_id: string | null + profile_unlinked_at: Date | null + donor_email: string | null + }[] >`SELECT id, user_id, profile_unlinked_at, donor_email FROM donations WHERE organization_id = ${org}` const byId = new Map(rows.map((row) => [row.id, row])) @@ -415,7 +421,9 @@ describe.skipIf(!pg)("erasure: the host-transfer ladder", () => { expect(await eventRoleOf(h, staffed, leaving)).toBe("member") expect(await eventRoleOf(h, coordinated, leaving)).toBe("member") - const rows = await h.sql<{ target: string; actor_id: string | null; meta: Record }[]>` + const rows = await h.sql< + { target: string; actor_id: string | null; meta: Record }[] + >` SELECT target, actor_id, meta FROM audit_log WHERE action = 'event.team_role_changed' AND meta->>'targetUserId' = ${leaving} diff --git a/services/api/test/integration/feed-ranked-pg.test.ts b/services/api/test/integration/feed-ranked-pg.test.ts index bd8337b5..0a3f8cd1 100644 --- a/services/api/test/integration/feed-ranked-pg.test.ts +++ b/services/api/test/integration/feed-ranked-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { DEFAULT_FEED_RANKING } from "@civfix/shared" import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" @@ -17,7 +16,9 @@ const pg = await withPg() const echoPresign = (r2Key: string, thumbKey: string | null) => Promise.resolve( - thumbKey === null ? { url: `m://${r2Key}` } : { url: `m://${r2Key}`, thumbUrl: `m://${thumbKey}` }, + thumbKey === null + ? { url: `m://${r2Key}` } + : { url: `m://${r2Key}`, thumbUrl: `m://${thumbKey}` }, ) const echoAvatar = (k: string) => Promise.resolve(`m://${k}`) @@ -118,7 +119,13 @@ describe.skipIf(!pg)("ranked home feed: candidate SQL (integration)", () => { viewer, ) const reply = await svc.createPost( - { kind: "post", body: "a reply", replyToId: post.id, mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + body: "a reply", + replyToId: post.id, + mediaUploadIds: [], + mentionedUserIds: [], + }, viewer, ) diff --git a/services/api/test/integration/guest-rsvp-pg.test.ts b/services/api/test/integration/guest-rsvp-pg.test.ts index 4f36dce2..d71f53c9 100644 --- a/services/api/test/integration/guest-rsvp-pg.test.ts +++ b/services/api/test/integration/guest-rsvp-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import type { CleanupStatus } from "@civfix/shared" @@ -188,7 +187,10 @@ describe.skipIf(!pg)("guest rsvp storage (integration)", () => { expect(strip!.going).toBe(3) await repo.cancelGuest(guestId, new Date()) - const afterCancel = await social.upcomingEventsFor(hostId, { limit: 10, includeAttending: true }) + const afterCancel = await social.upcomingEventsFor(hostId, { + limit: 10, + includeAttending: true, + }) expect(afterCancel.find((r) => r.id === cleanupId)!.going).toBe(2) await h.sql`UPDATE cleanups SET scheduled_at = now() - interval '2 days' WHERE id = ${cleanupId}` @@ -266,7 +268,11 @@ describe.skipIf(!pg)("guest rsvp storage (integration)", () => { await verifyGuest(upcoming, "fresh@example.org", "s3") const cutoff = new Date(Date.now() - 30 * 86_400_000) - const firstBatch = await repo.scrubExpiredGuestContacts({ cutoff, now: new Date(), batchSize: 1 }) + const firstBatch = await repo.scrubExpiredGuestContacts({ + cutoff, + now: new Date(), + batchSize: 1, + }) expect(firstBatch).toBe(1) const rest = await repo.scrubExpiredGuestContacts({ cutoff, now: new Date(), batchSize: 50 }) diff --git a/services/api/test/integration/host-capacity-race-pg.test.ts b/services/api/test/integration/host-capacity-race-pg.test.ts index 9895dc4c..d7ad10c1 100644 --- a/services/api/test/integration/host-capacity-race-pg.test.ts +++ b/services/api/test/integration/host-capacity-race-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" diff --git a/services/api/test/integration/host-checkin-idempotency-pg.test.ts b/services/api/test/integration/host-checkin-idempotency-pg.test.ts index d3e1f02d..ffa182e6 100644 --- a/services/api/test/integration/host-checkin-idempotency-pg.test.ts +++ b/services/api/test/integration/host-checkin-idempotency-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import type { CleanupStatus } from "@civfix/shared" diff --git a/services/api/test/integration/host-lock-order-pg.test.ts b/services/api/test/integration/host-lock-order-pg.test.ts index 97485e9e..d4fd7549 100644 --- a/services/api/test/integration/host-lock-order-pg.test.ts +++ b/services/api/test/integration/host-lock-order-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" @@ -76,7 +75,8 @@ describe.skipIf(!pg)("host lock order (integration)", () => { function deadlocks(errors: unknown[]): unknown[] { return errors.filter( - (err) => typeof err === "object" && err !== null && (err as { code?: unknown }).code === DEADLOCK, + (err) => + typeof err === "object" && err !== null && (err as { code?: unknown }).code === DEADLOCK, ) } @@ -428,7 +428,12 @@ describe.skipIf(!pg)("host lock order (integration)", () => { now, }) } - const offer = await repo.offerWaitlistEntry({ cleanupId, waitlistId, now, claimWindowMs: 600_000 }) + const offer = await repo.offerWaitlistEntry({ + cleanupId, + waitlistId, + now, + claimWindowMs: 600_000, + }) expect(offer).not.toBeNull() const claimed = await repo.claimWaitlistOffer({ diff --git a/services/api/test/integration/host-orgs-team-pg.test.ts b/services/api/test/integration/host-orgs-team-pg.test.ts index 5ee2bf49..3147423c 100644 --- a/services/api/test/integration/host-orgs-team-pg.test.ts +++ b/services/api/test/integration/host-orgs-team-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { testHandle, withPg, type PgHarness } from "../helpers/pg.js" @@ -134,17 +133,19 @@ describe.skipIf(!pg)("host organizations + team (integration)", () => { const b = await newUser("B") const slug = `slug-race-${randomUUID().slice(0, 8)}` const first = await newOrg(a, slug) - expect(await orgs.createOrganizationTx({ - organizationId: randomUUID(), - slug, - name: "Duplicate", - description: null, - websiteUrl: null, - logoMediaId: null, - socialLinks: null, - createdBy: b, - now: new Date(), - })).toBe("slug_taken") + expect( + await orgs.createOrganizationTx({ + organizationId: randomUUID(), + slug, + name: "Duplicate", + description: null, + websiteUrl: null, + logoMediaId: null, + socialLinks: null, + createdBy: b, + now: new Date(), + }), + ).toBe("slug_taken") await h.sql`UPDATE organizations SET deleted_at = now() WHERE id = ${first}` const reused = await orgs.createOrganizationTx({ @@ -267,7 +268,8 @@ describe.skipIf(!pg)("host organizations + team (integration)", () => { CHECK_VIOLATION, ) await expectPgError( - () => h.sql`UPDATE cleanups SET donation_url = 'http://give.example.org' WHERE id = ${eventId}`, + () => + h.sql`UPDATE cleanups SET donation_url = 'http://give.example.org' WHERE id = ${eventId}`, CHECK_VIOLATION, ) await expectPgError( diff --git a/services/api/test/integration/host-page-media-pg.test.ts b/services/api/test/integration/host-page-media-pg.test.ts index 965cf117..8c16bd00 100644 --- a/services/api/test/integration/host-page-media-pg.test.ts +++ b/services/api/test/integration/host-page-media-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" diff --git a/services/api/test/integration/host-waitlist-promotion-pg.test.ts b/services/api/test/integration/host-waitlist-promotion-pg.test.ts index e7a1cbbf..011a4656 100644 --- a/services/api/test/integration/host-waitlist-promotion-pg.test.ts +++ b/services/api/test/integration/host-waitlist-promotion-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" diff --git a/services/api/test/integration/inbound-mail-webhook.test.ts b/services/api/test/integration/inbound-mail-webhook.test.ts index c319afa1..cb24e4da 100644 --- a/services/api/test/integration/inbound-mail-webhook.test.ts +++ b/services/api/test/integration/inbound-mail-webhook.test.ts @@ -56,7 +56,8 @@ function rfc822(opts: { } /** A DMARC-aligned pass, as Cloudflare Email Routing's MTA stamps it. */ -const DMARC_PASS = "mx.cloudflare.net; spf=pass; dkim=pass header.d=lacity.gov; dmarc=pass header.from=lacity.gov" +const DMARC_PASS = + "mx.cloudflare.net; spf=pass; dkim=pass header.d=lacity.gov; dmarc=pass header.from=lacity.gov" describe.skipIf(!pg)("inbound-mail webhook (integration: real schema)", () => { let h: PgHarness @@ -104,7 +105,10 @@ describe.skipIf(!pg)("inbound-mail webhook (integration: real schema)", () => { } it("threads an inbound reply onto an existing thread, marks unread, records an event", async () => { - const seeded = await makeDrizzleMailRepository(h.sql).createThread({ threadToken: "0a0a0a0a0a0a0a0a0a0a0a0a", subject: "Pothole" }) + const seeded = await makeDrizzleMailRepository(h.sql).createThread({ + threadToken: "0a0a0a0a0a0a0a0a0a0a0a0a", + subject: "Pothole", + }) const key = `${INBOUND_PENDING_PREFIX}reply-1.eml` const res = await ingest( rfc822({ @@ -124,7 +128,9 @@ describe.skipIf(!pg)("inbound-mail webhook (integration: real schema)", () => { expect(dto?.messages).toHaveLength(1) expect(dto?.messages[0]?.dir).toBe("in") expect((await repo.getThreadRecord(seeded.id))?.unread).toBe(true) - const events = await h.sql<{ type: string }[]>`SELECT type FROM mail_events WHERE thread_id = ${seeded.id}` + const events = await h.sql< + { type: string }[] + >`SELECT type FROM mail_events WHERE thread_id = ${seeded.id}` expect(events[0]?.type).toBe("delivered") // Pending object consumed. expect(storage.get(key)).toBeNull() @@ -201,7 +207,12 @@ describe.skipIf(!pg)("inbound-mail webhook (integration: real schema)", () => { it("lands a no-token message in inbound_emails (catch-all inbox)", async () => { const key = `${INBOUND_PENDING_PREFIX}cold-1.eml` const res = await ingest( - rfc822({ from: "resident@example.com", to: "support@civfix.org", subject: "Help", body: "a question" }), + rfc822({ + from: "resident@example.com", + to: "support@civfix.org", + subject: "Help", + body: "a question", + }), key, ) expect(res.statusCode).toBe(202) diff --git a/services/api/test/integration/inbound-repository.test.ts b/services/api/test/integration/inbound-repository.test.ts index d20addb0..c32d7656 100644 --- a/services/api/test/integration/inbound-repository.test.ts +++ b/services/api/test/integration/inbound-repository.test.ts @@ -1,4 +1,3 @@ - import { randomUUID } from "node:crypto" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" @@ -50,7 +49,10 @@ describe.skipIf(!pg)("inbound repository (integration: real schema)", () => { }) it("insertIdempotent dedups on the UNIQUE message_id", async () => { - const input = insert({ messageId: "", attachments: [{ key: "k", filename: "f.pdf", size: 3 }] }) + const input = insert({ + messageId: "", + attachments: [{ key: "k", filename: "f.pdf", size: 3 }], + }) const first = await repo.insertIdempotent(input) expect(first.inserted).toBe(true) const second = await repo.insertIdempotent(input) @@ -62,9 +64,15 @@ describe.skipIf(!pg)("inbound repository (integration: real schema)", () => { }) it("lists newest-first, paginates by cursor, and filters by status + localPart", async () => { - await repo.insertIdempotent(insert({ messageId: "", recipient: "support@civfix.org", receivedAt: new Date(1000) })) - await repo.insertIdempotent(insert({ messageId: "", recipient: "hello@civfix.org", receivedAt: new Date(2000) })) - const third = await repo.insertIdempotent(insert({ messageId: "", recipient: "support@civfix.org", receivedAt: new Date(3000) })) + await repo.insertIdempotent( + insert({ messageId: "", recipient: "support@civfix.org", receivedAt: new Date(1000) }), + ) + await repo.insertIdempotent( + insert({ messageId: "", recipient: "hello@civfix.org", receivedAt: new Date(2000) }), + ) + const third = await repo.insertIdempotent( + insert({ messageId: "", recipient: "support@civfix.org", receivedAt: new Date(3000) }), + ) const page1 = await repo.list({ limit: 2 }) expect(page1.items).toHaveLength(2) @@ -100,7 +108,9 @@ describe.skipIf(!pg)("inbound repository (integration: real schema)", () => { ` const actorId = actor[0]!.id expect(await repo.setStatus(r.id, "archived", actorId)).toBe(true) - const audit = await h.sql<{ actor_id: string; target: string; meta: Record }[]>` + const audit = await h.sql< + { actor_id: string; target: string; meta: Record }[] + >` SELECT actor_id, target, meta FROM audit_log WHERE action = 'inbox.status_changed' AND target = ${`inbound_email:${r.id}`} ` @@ -118,7 +128,9 @@ describe.skipIf(!pg)("inbound repository (integration: real schema)", () => { expect(again).toHaveLength(2) expect(again[1]?.meta).toMatchObject({ status: "archived", priorStatus: "archived" }) - expect(await repo.setStatus("00000000-0000-0000-0000-000000000000", "read", actorId)).toBe(false) + expect(await repo.setStatus("00000000-0000-0000-0000-000000000000", "read", actorId)).toBe( + false, + ) const missing = await h.sql<{ n: number }[]>` SELECT count(*)::int AS n FROM audit_log WHERE action = 'inbox.status_changed' diff --git a/services/api/test/integration/ingest-jurisdictions-pg.test.ts b/services/api/test/integration/ingest-jurisdictions-pg.test.ts index 4409e1ff..6c8d5963 100644 --- a/services/api/test/integration/ingest-jurisdictions-pg.test.ts +++ b/services/api/test/integration/ingest-jurisdictions-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { withPg, type PgHarness } from "../helpers/pg.js" import { @@ -64,9 +63,11 @@ describe.skipIf(!pg)("jurisdiction ingest (integration: real upsert)", () => { return rows[0] } - it("stamps a JURCODE from jurisdiction_code_seq on a NEW jurisdiction (never NULL)", async () => { - await upsertJurisdiction(h.sql, row({ geoid: "PADUS-IN-1", name: "Ingest Park", layer: "federal" })) + await upsertJurisdiction( + h.sql, + row({ geoid: "PADUS-IN-1", name: "Ingest Park", layer: "federal" }), + ) const created = await read("PADUS-IN-1") expect(created).toBeDefined() expect(created!.code).not.toBeNull() @@ -122,7 +123,6 @@ describe.skipIf(!pg)("jurisdiction ingest (integration: real upsert)", () => { expect(after!.population).toBe(777) }) - it("CLEARS an all-placeholder contact list (the dev seed's example.gov addresses) on re-ingest", async () => { await h.sql` UPDATE jurisdictions SET contact_emails = ARRAY['trash@example.gov', 'graffiti@example.gov'] @@ -130,7 +130,12 @@ describe.skipIf(!pg)("jurisdiction ingest (integration: real upsert)", () => { ` await upsertJurisdiction( h.sql, - row({ geoid: "0644000", name: "Los Angeles", layer: "place", geometry: square(-118.35, 34.1, 0.15) }), + row({ + geoid: "0644000", + name: "Los Angeles", + layer: "place", + geometry: square(-118.35, 34.1, 0.15), + }), ) const after = await read("0644000") expect(after!.contact_emails).toBeNull() @@ -151,15 +156,23 @@ describe.skipIf(!pg)("jurisdiction ingest (integration: real upsert)", () => { WHERE geoid = 'PADUS-IN-7' ` await upsertJurisdiction(h.sql, row({ geoid: "PADUS-IN-7", name: "Refreshed" })) - expect((await read("PADUS-IN-7"))!.contact_emails).toEqual(["real@lacity.org", "dev@example.gov"]) + expect((await read("PADUS-IN-7"))!.contact_emails).toEqual([ + "real@lacity.org", + "dev@example.gov", + ]) }) - it("resolves overlapping SAME-LAYER polygons deterministically (priority, then geoid)", async () => { const pt = { lng: -101.25, lat: 41.25 } const geom = square(pt.lng, pt.lat, 0.25) - await upsertJurisdiction(h.sql, row({ geoid: "PADUS-OVERLAP-B", name: "Parcel B", geometry: geom })) - await upsertJurisdiction(h.sql, row({ geoid: "PADUS-OVERLAP-A", name: "Parcel A", geometry: geom })) + await upsertJurisdiction( + h.sql, + row({ geoid: "PADUS-OVERLAP-B", name: "Parcel B", geometry: geom }), + ) + await upsertJurisdiction( + h.sql, + row({ geoid: "PADUS-OVERLAP-A", name: "Parcel A", geometry: geom }), + ) const first = await resolveJurisdiction(h.sql, pt.lng, pt.lat) expect(first?.geoid).toBe("PADUS-OVERLAP-A") @@ -181,13 +194,20 @@ describe.skipIf(!pg)("jurisdiction ingest (integration: real upsert)", () => { expect(r?.layer).toBe("federal") }) - it("ingestGeoJsonFile loads a FeatureCollection in one transaction, prefixing geoids and stamping codes", async () => { const fc = JSON.stringify({ type: "FeatureCollection", features: [ - { type: "Feature", properties: { geoid: "9001", name: "File One" }, geometry: square(-105, 45, 0.2) }, - { type: "Feature", properties: { geoid: "9002", name: "File Two" }, geometry: square(-106, 45, 0.2) }, + { + type: "Feature", + properties: { geoid: "9001", name: "File One" }, + geometry: square(-105, 45, 0.2), + }, + { + type: "Feature", + properties: { geoid: "9002", name: "File Two" }, + geometry: square(-106, 45, 0.2), + }, { type: "Feature", properties: { geoid: "9003" }, geometry: square(-107, 45, 0.2) }, ], }) @@ -206,7 +226,11 @@ describe.skipIf(!pg)("jurisdiction ingest (integration: real upsert)", () => { it("ingestGeoJsonFile REJECTS a non-FeatureCollection payload and writes nothing", async () => { await expect( - ingestGeoJsonFile(h.sql, JSON.stringify({ type: "Feature", properties: {}, geometry: null }), "federal"), + ingestGeoJsonFile( + h.sql, + JSON.stringify({ type: "Feature", properties: {}, geometry: null }), + "federal", + ), ).rejects.toThrow(/not a GeoJSON FeatureCollection/) await expect(ingestGeoJsonFile(h.sql, "{not json", "federal")).rejects.toThrow() }) @@ -215,7 +239,11 @@ describe.skipIf(!pg)("jurisdiction ingest (integration: real upsert)", () => { const fc = JSON.stringify({ type: "FeatureCollection", features: [ - { type: "Feature", properties: { geoid: "8001", name: "Atomic Good" }, geometry: square(-108, 46, 0.2) }, + { + type: "Feature", + properties: { geoid: "8001", name: "Atomic Good" }, + geometry: square(-108, 46, 0.2), + }, { type: "Feature", properties: { geoid: "8002", name: "Atomic Bad" }, @@ -244,8 +272,16 @@ describe.skipIf(!pg)("jurisdiction ingest (integration: real upsert)", () => { const fc = JSON.stringify({ type: "FeatureCollection", features: [ - { type: "Feature", properties: { geoid: "7001", name: "Valid Unit" }, geometry: square(-92, 41, 0.2) }, - { type: "Feature", properties: { geoid: "7002", name: "Self Intersecting" }, geometry: bowtie }, + { + type: "Feature", + properties: { geoid: "7001", name: "Valid Unit" }, + geometry: square(-92, 41, 0.2), + }, + { + type: "Feature", + properties: { geoid: "7002", name: "Self Intersecting" }, + geometry: bowtie, + }, ], }) const res = await ingestGeoJsonFile(h.sql, fc, "federal", "VALID-") @@ -267,8 +303,15 @@ describe.skipIf(!pg)("jurisdiction ingest (integration: real upsert)", () => { ], ], } - expect(await upsertJurisdiction(h.sql, row({ geoid: "PADUS-VALID-1", geometry: square(-71, 41, 0.2) }))).toBe(true) - expect(await upsertJurisdiction(h.sql, row({ geoid: "PADUS-INVALID-1", geometry: bowtie }))).toBe(false) + expect( + await upsertJurisdiction( + h.sql, + row({ geoid: "PADUS-VALID-1", geometry: square(-71, 41, 0.2) }), + ), + ).toBe(true) + expect( + await upsertJurisdiction(h.sql, row({ geoid: "PADUS-INVALID-1", geometry: bowtie })), + ).toBe(false) expect(await read("PADUS-INVALID-1")).toBeUndefined() }) }) diff --git a/services/api/test/integration/messages-edit-pg.test.ts b/services/api/test/integration/messages-edit-pg.test.ts index ee579c9e..d757f239 100644 --- a/services/api/test/integration/messages-edit-pg.test.ts +++ b/services/api/test/integration/messages-edit-pg.test.ts @@ -28,7 +28,11 @@ import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" import { buildAuthServices, type AuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" -import { InMemoryChatRepository, InMemoryThreadsRepository, MockConnection } from "../helpers/chat.js" +import { + InMemoryChatRepository, + InMemoryThreadsRepository, + MockConnection, +} from "../helpers/chat.js" import { roomKeyFor } from "../../src/ws/gateway.js" import type { ChatGatewayOverrides } from "../../src/routes/chat.routes.js" import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" @@ -131,7 +135,10 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { const organizerId = await newUser("Edit Org") const cleanupId = await newCleanup(organizerId) const chat = makeDrizzleChatRepository(h.sql) - const msg = await chat.insertMessage({ cleanupId, userId: organizerId, body: "first draft" }, randomUUID()) + const msg = await chat.insertMessage( + { cleanupId, userId: organizerId, body: "first draft" }, + randomUUID(), + ) const frames: { roomKey: string; frame: WsServerMessage }[] = [] const service = makeService(frames) @@ -184,14 +191,21 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { expect(updated.body).toBe("typo here") expect(updated.editedAt).toBeTruthy() expect(frames[0]!.roomKey).toBe(`report:${reportId}`) - expect(frames[0]!.frame).toMatchObject({ type: "message_update", roomKind: "report", roomId: reportId }) + expect(frames[0]!.frame).toMatchObject({ + type: "message_update", + roomKind: "report", + roomId: reportId, + }) }) it("rejects a non-sender with 403 code not_sender", async () => { const organizerId = await newUser("Edit Org NotSender") const cleanupId = await newCleanup(organizerId) const chat = makeDrizzleChatRepository(h.sql) - const msg = await chat.insertMessage({ cleanupId, userId: organizerId, body: "mine" }, randomUUID()) + const msg = await chat.insertMessage( + { cleanupId, userId: organizerId, body: "mine" }, + randomUUID(), + ) const mallory = await newUser("Edit Mallory") await makeCleanupService({ @@ -214,7 +228,10 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { const organizerId = await newUser("Edit Org Old") const cleanupId = await newCleanup(organizerId) const chat = makeDrizzleChatRepository(h.sql) - const msg = await chat.insertMessage({ cleanupId, userId: organizerId, body: "old" }, randomUUID()) + const msg = await chat.insertMessage( + { cleanupId, userId: organizerId, body: "old" }, + randomUUID(), + ) // Backdate past the 48h window (60h). 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}` @@ -233,7 +250,10 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { const organizerId = await newUser("Edit Org Deleted") const cleanupId = await newCleanup(organizerId) const chat = makeDrizzleChatRepository(h.sql) - const msg = await chat.insertMessage({ cleanupId, userId: organizerId, body: "gone" }, randomUUID()) + const msg = await chat.insertMessage( + { cleanupId, userId: organizerId, body: "gone" }, + randomUUID(), + ) await chat.softDelete(cleanupId, msg.id, organizerId) await expect( @@ -293,7 +313,10 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { const cleanupA = await newCleanup(organizerId) const cleanupB = await newCleanup(organizerId) const chat = makeDrizzleChatRepository(h.sql) - const msg = await chat.insertMessage({ cleanupId: cleanupA, userId: organizerId, body: "in A" }, randomUUID()) + const msg = await chat.insertMessage( + { cleanupId: cleanupA, userId: organizerId, body: "in A" }, + randomUUID(), + ) await expect( makeService().editMessage({ @@ -321,7 +344,10 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { const organizerId = await newUser("Edit Org Left") const cleanupId = await newCleanup(organizerId) const chat = makeDrizzleChatRepository(h.sql) - const msg = await chat.insertMessage({ cleanupId, userId: organizerId, body: "was member" }, randomUUID()) + const msg = await chat.insertMessage( + { cleanupId, userId: organizerId, body: "was member" }, + randomUUID(), + ) // Revoke the membership out from under the sender (same check the WS send path uses). await h.sql`DELETE FROM cleanup_members WHERE cleanup_id = ${cleanupId} AND user_id = ${organizerId}` @@ -514,7 +540,10 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { const organizerId = await newUser("Route Del Org") const cleanupId = await newCleanup(organizerId) const chat = makeDrizzleChatRepository(h.sql) - const msg = await chat.insertMessage({ cleanupId, userId: organizerId, body: "delete me" }, randomUUID()) + const msg = await chat.insertMessage( + { cleanupId, userId: organizerId, body: "delete me" }, + randomUUID(), + ) // Cleanup rooms use the BARE cleanupId as their room key. const watcher = new MockConnection("del-watcher") @@ -545,7 +574,10 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { const organizerId = await newUser("Route Edit Limit") const cleanupId = await newCleanup(organizerId) const chat = makeDrizzleChatRepository(h.sql) - const msg = await chat.insertMessage({ cleanupId, userId: organizerId, body: "v0" }, randomUUID()) + const msg = await chat.insertMessage( + { cleanupId, userId: organizerId, body: "v0" }, + randomUUID(), + ) 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 @@ -669,7 +701,10 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { }): Promise>]>> { const ref = { roomKind: "report", roomId: room.reportId, messageId: room.messageId } return [ - ["PATCH /messages", await call(room.tok, "PATCH", "/v1/messages", { ...ref, body: "edited" })], + [ + "PATCH /messages", + await call(room.tok, "PATCH", "/v1/messages", { ...ref, body: "edited" }), + ], [ "POST /messages/reactions", await call(room.tok, "POST", "/v1/messages/reactions", { ...ref, emoji: "like" }), 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 d6f17a1c..76f0c5a9 100644 --- a/services/api/test/integration/moderation-destination-refs-pg.test.ts +++ b/services/api/test/integration/moderation-destination-refs-pg.test.ts @@ -96,10 +96,7 @@ describe.skipIf(!pg)("F160: batched destination refs match the per-item lookup", return m!.id } - async function newMedia(binding: { - reportId?: string - chatMessageId?: string - }): Promise { + async function newMedia(binding: { reportId?: string; chatMessageId?: string }): Promise { const [m] = await h.sql<{ id: string }[]>` INSERT INTO media_assets (upload_id, kind, r2_key, status, purpose, report_id, chat_message_id) VALUES ( @@ -152,7 +149,10 @@ describe.skipIf(!pg)("F160: batched destination refs match the per-item lookup", } const bySubject = new Map(records.map((r) => [r.subjectId, r])) - expect(bySubject.get(reportId)).toMatchObject({ destinationKind: "report", destinationId: reportId }) + expect(bySubject.get(reportId)).toMatchObject({ + destinationKind: "report", + destinationId: reportId, + }) expect(bySubject.get(reportChat)).toMatchObject({ destinationKind: "report", destinationId: reportId, @@ -187,8 +187,10 @@ describe.skipIf(!pg)("F160: batched destination refs match the per-item lookup", const seen = new Map() let cursor: string | null = null for (let page = 0; page < 10; page++) { - const res: { records: Array<{ id: string; destinationId: string | null }>; nextCursor: string | null } = - await repo.listOpen({ q: null, filter: "all", cursor, limit: 2 }) + const res: { + records: Array<{ id: string; destinationId: string | null }> + nextCursor: string | null + } = await repo.listOpen({ q: null, filter: "all", cursor, limit: 2 }) for (const r of res.records) seen.set(r.id, r.destinationId) if (res.nextCursor === null) break cursor = res.nextCursor @@ -302,9 +304,7 @@ describe.skipIf(!pg)("F160: batched destination refs match the per-item lookup", await spy.end() } - const batched = statements.filter( - (q) => /=\s*ANY\(/i.test(q) && /chat_messages/i.test(q), - ) + const batched = statements.filter((q) => /=\s*ANY\(/i.test(q) && /chat_messages/i.test(q)) expect(batched).toHaveLength(1) }) diff --git a/services/api/test/integration/notification-coalescing-pg.test.ts b/services/api/test/integration/notification-coalescing-pg.test.ts index 12c8ad49..65f344de 100644 --- a/services/api/test/integration/notification-coalescing-pg.test.ts +++ b/services/api/test/integration/notification-coalescing-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" import { makeDrizzleNotificationRepository } from "../../src/services/notification-repository.drizzle.js" @@ -59,7 +58,13 @@ describe.skipIf(!pg)("notification coalescing (integration)", () => { const repo = makeDrizzleNotificationRepository(h.sql) const userId = await newUser("Coalesce Read") - await repo.insertNotification({ userId, type: "group_chat", title: "Dana", body: "a", link: LINK }) + await repo.insertNotification({ + userId, + type: "group_chat", + title: "Dana", + body: "a", + link: LINK, + }) await repo.clearByTypeAndLink(userId, "group_chat", LINK) const refreshed = await repo.refreshUnreadNotification({ @@ -176,7 +181,13 @@ describe.skipIf(!pg)("notification coalescing (integration)", () => { const theirs = await newUser("Coalesce Theirs") const since = new Date(Date.now() - 10 * 60 * 1000) - await repo.insertNotification({ userId: mine, type: "group_chat", title: "D", body: "a", link: LINK }) + await repo.insertNotification({ + userId: mine, + type: "group_chat", + title: "D", + body: "a", + link: LINK, + }) expect( await repo.refreshUnreadNotification({ diff --git a/services/api/test/integration/posts-repost-feed-pg.test.ts b/services/api/test/integration/posts-repost-feed-pg.test.ts index ce6333f3..21389066 100644 --- a/services/api/test/integration/posts-repost-feed-pg.test.ts +++ b/services/api/test/integration/posts-repost-feed-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { withPg, type PgHarness, testHandle } from "../helpers/pg.js" import { makeDrizzlePostRepository } from "../../src/services/post-repository.drizzle.js" @@ -7,7 +6,11 @@ import { makePostService, type PostService } from "../../src/services/post-servi const pg = await withPg() const echoPresign = (r2Key: string, thumbKey: string | null) => - Promise.resolve(thumbKey === null ? { url: `m://${r2Key}` } : { url: `m://${r2Key}`, thumbUrl: `m://${thumbKey}` }) + Promise.resolve( + thumbKey === null + ? { url: `m://${r2Key}` } + : { url: `m://${r2Key}`, thumbUrl: `m://${thumbKey}` }, + ) const echoAvatar = (k: string) => Promise.resolve(`m://${k}`) describe.skipIf(!pg)("posts: repost lifecycle + public feed (integration)", () => { diff --git a/services/api/test/integration/posts-unrepost-pg.test.ts b/services/api/test/integration/posts-unrepost-pg.test.ts index 0981ae42..ababf0f5 100644 --- a/services/api/test/integration/posts-unrepost-pg.test.ts +++ b/services/api/test/integration/posts-unrepost-pg.test.ts @@ -78,7 +78,9 @@ describe.skipIf(!pg)("F148: un-reposting never destroys other users' content", ( return m!.id } - async function postRow(id: string): Promise<{ deleted_at: Date | null; repost_count: number } | undefined> { + async function postRow( + id: string, + ): Promise<{ deleted_at: Date | null; repost_count: number } | undefined> { const rows = await h.sql<{ deleted_at: Date | null; repost_count: number }[]>` SELECT deleted_at, repost_count FROM posts WHERE id = ${id} ` diff --git a/services/api/test/integration/posts.test.ts b/services/api/test/integration/posts.test.ts index 7ac3ce26..92a98805 100644 --- a/services/api/test/integration/posts.test.ts +++ b/services/api/test/integration/posts.test.ts @@ -31,7 +31,11 @@ import { makeCleanupService } from "../../src/services/cleanup-service.js" const pg = await withPg() const echoPresign = (r2Key: string, thumbKey: string | null) => - Promise.resolve(thumbKey === null ? { url: `m://${r2Key}` } : { url: `m://${r2Key}`, thumbUrl: `m://${thumbKey}` }) + Promise.resolve( + thumbKey === null + ? { url: `m://${r2Key}` } + : { url: `m://${r2Key}`, thumbUrl: `m://${thumbKey}` }, + ) const echoAvatar = (k: string) => Promise.resolve(`m://${k}`) describe.skipIf(!pg)("posts (integration: real transaction path)", () => { @@ -224,7 +228,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { // reply const reply = await svc.createPost( - { kind: "reply", replyToId: created.id, body: "nice one", mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "reply", + replyToId: created.id, + body: "nice one", + mediaUploadIds: [], + mentionedUserIds: [], + }, actor, ) expect(reply.kind).toBe("reply") @@ -266,7 +276,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(parent.counts.replies).toBe(0) const reply = await svc.createPost( - { kind: "post", replyToId: parent.id, body: "count me in", mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + replyToId: parent.id, + body: "count me in", + mediaUploadIds: [], + mentionedUserIds: [], + }, replier, ) expect(reply.replyToId).toBe(parent.id) @@ -276,7 +292,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(replies.items.map((p) => p.id)).toContain(reply.id) const second = await svc.createPost( - { kind: "post", replyToId: parent.id, body: "me too", mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + replyToId: parent.id, + body: "me too", + mediaUploadIds: [], + mentionedUserIds: [], + }, author, ) expect((await svc.getPost(parent.id, author)).counts.replies).toBe(2) @@ -298,7 +320,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const post = await svc.createPost( parentId === null ? { kind: "post", body: "root", mediaUploadIds: [], mentionedUserIds: [] } - : { kind: "post", replyToId: parentId, body: `depth ${depth}`, mediaUploadIds: [], mentionedUserIds: [] }, + : { + kind: "post", + replyToId: parentId, + body: `depth ${depth}`, + mediaUploadIds: [], + mentionedUserIds: [], + }, author, ) chain.push(post.id) @@ -322,11 +350,23 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { ) await svc.createPost( - { kind: "reply", replyToId: parent.id, body: "explicit reply", mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "reply", + replyToId: parent.id, + body: "explicit reply", + mediaUploadIds: [], + mentionedUserIds: [], + }, author, ) await svc.createPost( - { kind: "reply", replyToId: parent.id, body: "cross-author reply", mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "reply", + replyToId: parent.id, + body: "cross-author reply", + mediaUploadIds: [], + mentionedUserIds: [], + }, other, ) @@ -339,7 +379,10 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const author = await newUser("Answering Author") const other = await newUser("Answered Other") const reply = (replyToId: string, body: string, by: string) => - svc.createPost({ kind: "reply", replyToId, body, mediaUploadIds: [], mentionedUserIds: [] }, by) + svc.createPost( + { kind: "reply", replyToId, body, mediaUploadIds: [], mentionedUserIds: [] }, + by, + ) const post = await svc.createPost( { kind: "post", body: "root", mediaUploadIds: [], mentionedUserIds: [] }, @@ -367,12 +410,17 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const head = await svc.listReplies(post.id, other, { limit: 1 }) expect(head.items.map((p) => p.id)).toEqual([first.id]) expect(head.authorReplies.map((p) => p.id)).toEqual([latest.id]) - const tail = await svc.listReplies(post.id, other, { limit: 1, cursor: head.nextCursor ?? undefined }) + const tail = await svc.listReplies(post.id, other, { + limit: 1, + cursor: head.nextCursor ?? undefined, + }) expect(tail.items.map((p) => p.id)).toEqual([second.id]) expect(tail.authorReplies).toEqual([]) await svc.deletePost(latest.id, author) - expect((await svc.listReplies(post.id, other, {})).authorReplies.map((p) => p.id)).toEqual([older.id]) + expect((await svc.listReplies(post.id, other, {})).authorReplies.map((p) => p.id)).toEqual([ + older.id, + ]) const inner = await svc.listReplies(first.id, author, {}) expect(inner.items.map((p) => p.id)).toEqual([older.id]) @@ -395,7 +443,12 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const media = await seedMedia() const created = await svc.createPost( - { kind: "post", body: "look at this photo", mediaUploadIds: [media.uploadId], mentionedUserIds: [] }, + { + kind: "post", + body: "look at this photo", + mediaUploadIds: [media.uploadId], + mentionedUserIds: [], + }, author, ) expect(created.media).toHaveLength(1) @@ -408,7 +461,9 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { 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 }[]>` + const [row] = await h.sql< + { post_id: string | null; purpose: string; report_id: string | null }[] + >` SELECT post_id, purpose, report_id FROM media_assets WHERE id = ${media.id} ` expect(row!.post_id).toBe(created.id) @@ -429,11 +484,22 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const media = await seedMedia() const target = await svc.createPost( - { kind: "post", body: "the original photo", mediaUploadIds: [media.uploadId], mentionedUserIds: [] }, + { + kind: "post", + body: "the original photo", + mediaUploadIds: [media.uploadId], + mentionedUserIds: [], + }, author, ) const quote = await svc.createPost( - { kind: "quote", repostOfId: target.id, body: "look at this", mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "quote", + repostOfId: target.id, + body: "look at this", + mediaUploadIds: [], + mentionedUserIds: [], + }, quoter, ) @@ -445,7 +511,9 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { // 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]) + expect(listed.items.find((p) => p.id === quote.id)?.repostOf?.media.map((m) => m.id)).toEqual([ + media.id, + ]) // Deleting the target tombstones the ref: no excerpt AND no media. Surfacing a deleted post's photos // through a quote card would undo the delete. (`getPost` on the quote 404s instead - `requireReadable` @@ -472,7 +540,12 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { ) await expect( svc.createPost( - { kind: "post", body: "also mine?", mediaUploadIds: [media.uploadId], mentionedUserIds: [] }, + { + kind: "post", + body: "also mine?", + mediaUploadIds: [media.uploadId], + mentionedUserIds: [], + }, thief, ), ).rejects.toMatchObject({ @@ -560,12 +633,19 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { await expect( svc.createPost( - { kind: "post", body: "recycling a report photo", mediaUploadIds: [media.uploadId], mentionedUserIds: [] }, + { + kind: "post", + body: "recycling a report photo", + mediaUploadIds: [media.uploadId], + mentionedUserIds: [], + }, author, ), ).rejects.toMatchObject({ httpStatus: 422, code: "VALIDATION" }) - const [row] = await h.sql<{ report_id: string | null; post_id: string | null; purpose: string }[]>` + const [row] = await h.sql< + { report_id: string | null; post_id: string | null; purpose: string }[] + >` SELECT report_id, post_id, purpose FROM media_assets WHERE id = ${media.id} ` expect(row!.report_id).toBe(report) @@ -658,12 +738,24 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { ) // Arm 1: an author the viewer FOLLOWS replies. const theirReply = await svc.createPost( - { kind: "reply", replyToId: parent.id, body: "count me in", mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "reply", + replyToId: parent.id, + body: "count me in", + mediaUploadIds: [], + mentionedUserIds: [], + }, author, ) // Arm 2: the VIEWER themself replies (the `p.author_id = viewerId` arm). const myReply = await svc.createPost( - { kind: "reply", replyToId: parent.id, body: "me too", mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "reply", + replyToId: parent.id, + body: "me too", + mediaUploadIds: [], + mentionedUserIds: [], + }, viewer, ) expect(theirReply.replyToId).toBe(parent.id) @@ -713,7 +805,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { author, ) const reply = await svc.createPost( - { kind: "post", replyToId: parent.id, body: "count me in", mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + replyToId: parent.id, + body: "count me in", + mediaUploadIds: [], + mentionedUserIds: [], + }, replier, ) @@ -748,7 +846,12 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const event = await cleanupSvc.createCleanup( { - title: "Park Sweep", type: "site", eventKind: "cleanup", lat: 34.0, lng: -118.0, scheduledAt: "2025-06-01T10:00:00.000Z", + title: "Park Sweep", + type: "site", + eventKind: "cleanup", + lat: 34.0, + lng: -118.0, + scheduledAt: "2025-06-01T10:00:00.000Z", slots: [{ title: "General volunteers", capacity: null }], }, host, @@ -756,7 +859,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { // host is auto-joined as organizer → can attach const withEvent = await svc.createPost( - { kind: "post", body: "join us", eventId: event.id, mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + body: "join us", + eventId: event.id, + mediaUploadIds: [], + mentionedUserIds: [], + }, host, ) expect(withEvent.event?.id).toBe(event.id) @@ -790,7 +899,12 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const event = await cleanupSvc.createCleanup( { - title: "Alley Sweep", type: "site", eventKind: "cleanup", lat: 34.05, lng: -118.25, scheduledAt: "2026-08-01T17:00:00.000Z", + title: "Alley Sweep", + type: "site", + eventKind: "cleanup", + lat: 34.05, + lng: -118.25, + scheduledAt: "2026-08-01T17:00:00.000Z", slots: [{ title: "General volunteers", capacity: null }], }, host, @@ -804,7 +918,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { expect(member?.role).toBe("organizer") const announcement = await svc.createPost( - { kind: "post", body: "come help out", eventId: event.id, mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + body: "come help out", + eventId: event.id, + mediaUploadIds: [], + mentionedUserIds: [], + }, host, ) expect(announcement.event?.id).toBe(event.id) @@ -862,7 +982,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { await expect( svc.createPost( - { kind: "post", body: "look at this", reportId: held, mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + body: "look at this", + reportId: held, + mediaUploadIds: [], + mentionedUserIds: [], + }, author, ), ).rejects.toMatchObject({ httpStatus: 404 }) @@ -892,7 +1018,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const report = await insertReport(author, "published", "public", "Public report") const post = await svc.createPost( - { kind: "post", body: "my report", reportId: report, mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + body: "my report", + reportId: report, + mediaUploadIds: [], + mentionedUserIds: [], + }, author, ) expect(post.report?.id).toBe(report) @@ -926,7 +1058,10 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { // 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): Promise { + async function attachReportMedia( + reportId: string, + status: SeedMediaStatus, + ): Promise { const uploadId = randomUUID() return await seedMediaAsset(h.sql, { reportId, @@ -980,12 +1115,16 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { // 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. 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) + expect((await svc.homeFeed(reader, { filter: "all" })).items.map((p) => p.id)).toContain( + post.id, + ) expect((await svc.homeFeed(reader, { filter: "fixes" })).items.map((p) => p.id)).not.toContain( post.id, ) await h.sql`UPDATE reports SET status = 'resolved' WHERE id = ${reportId}` - expect((await svc.homeFeed(reader, { filter: "fixes" })).items.map((p) => p.id)).toContain(post.id) + expect((await svc.homeFeed(reader, { filter: "fixes" })).items.map((p) => p.id)).toContain( + post.id, + ) }) it("SHARE: a slur in the caption is rejected and NO post row is written", async () => { @@ -995,7 +1134,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { await expect( svc.createPost( - { kind: "post", body: "these retards again", reportId, mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + body: "these retards again", + reportId, + mediaUploadIds: [], + mentionedUserIds: [], + }, author, ), ).rejects.toMatchObject({ httpStatus: 422, code: "VALIDATION" }) @@ -1007,7 +1152,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { // The same caption without the slur posts fine — the filter is slurs only, not profanity. const ok = await svc.createPost( - { kind: "post", body: "this damn light again", reportId, mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + body: "this damn light again", + reportId, + mediaUploadIds: [], + mentionedUserIds: [], + }, author, ) expect(ok.report?.id).toBe(reportId) @@ -1030,7 +1181,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { // isReportAttachable must accept `resolved` — this is the create that used to 404. const post = await svc.createPost( - { kind: "post", body: "the city fixed it", reportId: resolved, mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + body: "the city fixed it", + reportId: resolved, + mediaUploadIds: [], + mentionedUserIds: [], + }, author, ) expect(post.report?.id).toBe(resolved) @@ -1051,7 +1208,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { for (const status of ["published", "acknowledged", "in_progress", "resolved"]) { const id = await insertReport(author, status, "public", `Report ${status}`) const post = await svc.createPost( - { kind: "post", body: `status ${status}`, reportId: id, mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + body: `status ${status}`, + reportId: id, + mediaUploadIds: [], + mentionedUserIds: [], + }, author, ) expect(post.report?.id, `attach failed for status ${status}`).toBe(id) @@ -1063,7 +1226,13 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const id = await insertReport(author, status, "public", `Report ${status}`) await expect( svc.createPost( - { kind: "post", body: `status ${status}`, reportId: id, mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + body: `status ${status}`, + reportId: id, + mediaUploadIds: [], + mentionedUserIds: [], + }, author, ), ).rejects.toMatchObject({ httpStatus: 404 }) @@ -1080,11 +1249,23 @@ describe.skipIf(!pg)("posts (integration: real transaction path)", () => { const working = await insertReport(author, "in_progress", "public", "Still being worked") const fixPost = await svc.createPost( - { kind: "post", body: "fixed!", reportId: resolved, mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + body: "fixed!", + reportId: resolved, + mediaUploadIds: [], + mentionedUserIds: [], + }, author, ) const wipPost = await svc.createPost( - { kind: "post", body: "in progress", reportId: working, mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "post", + body: "in progress", + reportId: working, + mediaUploadIds: [], + mentionedUserIds: [], + }, author, ) const plainPost = await svc.createPost( 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 099f395b..06019921 100644 --- a/services/api/test/integration/report-chat-members-pg.test.ts +++ b/services/api/test/integration/report-chat-members-pg.test.ts @@ -90,7 +90,11 @@ describe.skipIf(!pg)("report chat membership + system messages (integration)", ( // 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, status: "acknowledged", body: "newer" }) + const newer = await repo.insertSystemMessage({ + reportId, + status: "acknowledged", + body: "newer", + }) await repo.advanceReadWatermark(reportId, userId, newer.id) const [afterNewer] = await h.sql<{ last_read_at: Date | null }[]>` @@ -139,7 +143,9 @@ describe.skipIf(!pg)("report chat membership + system messages (integration)", ( expect(dto.body).toBe("Report was acknowledged.") // The raw row is sender-less with a NULL cleanup_id (report/cleanup XOR holds). - const [raw] = await h.sql<{ sender_id: string | null; cleanup_id: string | null; kind: string }[]>` + const [raw] = await h.sql< + { sender_id: string | null; cleanup_id: string | null; kind: string }[] + >` SELECT sender_id, cleanup_id, kind FROM chat_messages WHERE id = ${dto.id} ` expect(raw!.sender_id).toBeNull() 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 1e52c421..cc6c5e43 100644 --- a/services/api/test/integration/report-forward-audit-pg.test.ts +++ b/services/api/test/integration/report-forward-audit-pg.test.ts @@ -84,118 +84,126 @@ describe.skipIf(!pg)("report_message_forwards audit writes (integration)", () => * 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)", () => { - let h: PgHarness - - beforeAll(() => { - 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. - beforeEach(async () => { - await h.sql`TRUNCATE jurisdictions RESTART IDENTITY CASCADE` - }) - - const HANDLE = "sfaudit" - - async function seedJurisdiction(): Promise { - const geoid = `T${randomUUID().slice(0, 6)}` - await h.sql` +describe.skipIf(!pg)( + "report message mapper surfaces forwardedToCity + cityMention (integration)", + () => { + let h: PgHarness + + beforeAll(() => { + 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. + beforeEach(async () => { + await h.sql`TRUNCATE jurisdictions RESTART IDENTITY CASCADE` + }) + + const HANDLE = "sfaudit" + + async function seedJurisdiction(): Promise { + const geoid = `T${randomUUID().slice(0, 6)}` + await h.sql` INSERT INTO jurisdictions (geoid, name, layer, priority, handle, geom) VALUES (${geoid}, 'City of San Francisco', 'place', 1, ${HANDLE}, ST_SetSRID(ST_GeomFromText('MULTIPOLYGON(((0 0,0 1,1 1,1 0,0 0)))'), 4326)) ` - return geoid - } + return geoid + } - async function newReport(geoid: string): Promise { - const [r] = await h.sql<{ id: string }[]>` + async function newReport(geoid: string): Promise { + const [r] = await h.sql<{ id: string }[]>` INSERT INTO reports (idempotency_key, geom, geom_source, category, type, status, visibility, h3_cell, jurisdiction_geoid) VALUES (${randomUUID()}, ST_SetSRID(ST_MakePoint(0.5, 0.5), 4326), 'manual', 'trash', 'dump', 'published', 'public', 'h0', ${geoid}) RETURNING id ` - return r!.id - } + return r!.id + } - async function newUser(): Promise { - const [u] = await h.sql<{ id: string }[]>` + async function newUser(): Promise { + const [u] = await h.sql<{ id: string }[]>` INSERT INTO users (display_name) VALUES ('Reporter') RETURNING id ` - return u!.id - } - - it("reportHistory reports forwardedToCity:true + a forwarded cityMention once a matching forward is stamped", async () => { - const chat = makeDrizzleChatRepository(h.sql) - const audit = makeReportForwardAudit(h.sql) - const geoid = await seedJurisdiction() - 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. - 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() - expect(found!.forwardedToCity).toBe(true) - expect(found!.cityMention).toMatchObject({ handle: HANDLE, geoid, name: "City of San Francisco", 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) - }) - - it("a mentioned-but-not-forwarded audit row (forwarded_at NULL) keeps forwardedToCity false", async () => { - const chat = makeDrizzleChatRepository(h.sql) - const audit = makeReportForwardAudit(h.sql) - const geoid = await seedJurisdiction() - const reportId = await newReport(geoid) - const userId = await newUser() - - const msgId = randomUUID() - await chat.insertMessage( - { 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 }) - }) - - it("a report message that does NOT @mention the city omits cityMention and is not forwarded", async () => { - const chat = makeDrizzleChatRepository(h.sql) - const geoid = await seedJurisdiction() - const reportId = await newReport(geoid) - const userId = await newUser() - - const msgId = randomUUID() - await chat.insertMessage( - { cleanupId: reportId, roomKind: "report", userId, body: "just a normal update" }, - msgId, - ) - const single = await chat.findReportMessage(reportId, msgId, userId) - expect(single!.forwardedToCity).toBe(false) - expect(single!.cityMention).toBeNull() - }) -}) + return u!.id + } + + it("reportHistory reports forwardedToCity:true + a forwarded cityMention once a matching forward is stamped", async () => { + const chat = makeDrizzleChatRepository(h.sql) + const audit = makeReportForwardAudit(h.sql) + const geoid = await seedJurisdiction() + 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. + 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() + expect(found!.forwardedToCity).toBe(true) + expect(found!.cityMention).toMatchObject({ + handle: HANDLE, + geoid, + name: "City of San Francisco", + 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) + }) + + it("a mentioned-but-not-forwarded audit row (forwarded_at NULL) keeps forwardedToCity false", async () => { + const chat = makeDrizzleChatRepository(h.sql) + const audit = makeReportForwardAudit(h.sql) + const geoid = await seedJurisdiction() + const reportId = await newReport(geoid) + const userId = await newUser() + + const msgId = randomUUID() + await chat.insertMessage( + { 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 }) + }) + + it("a report message that does NOT @mention the city omits cityMention and is not forwarded", async () => { + const chat = makeDrizzleChatRepository(h.sql) + const geoid = await seedJurisdiction() + const reportId = await newReport(geoid) + const userId = await newUser() + + const msgId = randomUUID() + await chat.insertMessage( + { cleanupId: reportId, roomKind: "report", userId, body: "just a normal update" }, + msgId, + ) + const single = await chat.findReportMessage(reportId, msgId, userId) + expect(single!.forwardedToCity).toBe(false) + expect(single!.cityMention).toBeNull() + }) + }, +) // 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, diff --git a/services/api/test/integration/report-route-stale-claim.test.ts b/services/api/test/integration/report-route-stale-claim.test.ts index 600f3923..c5f23963 100644 --- a/services/api/test/integration/report-route-stale-claim.test.ts +++ b/services/api/test/integration/report-route-stale-claim.test.ts @@ -229,7 +229,11 @@ describe.skipIf(!pg)("report outreach: stranded route claims are recoverable", ( expect((await reports.getReport(routable))?.status).toBe("acknowledged") const moved = await seedReport("guarded-advance-moved") - await reports.setStatus(moved, { status: "resolved", note: "operator closed it", actorId: null }) + await reports.setStatus(moved, { + status: "resolved", + note: "operator closed it", + actorId: null, + }) expect( await reports.advanceStatusIfIn(moved, { from: ["submitted", "held", "published"], diff --git a/services/api/test/integration/reports-pg.test.ts b/services/api/test/integration/reports-pg.test.ts index e16e3068..7a7cacbd 100644 --- a/services/api/test/integration/reports-pg.test.ts +++ b/services/api/test/integration/reports-pg.test.ts @@ -151,7 +151,12 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { // Submit the SAME key again (with a different-looking body to prove the original wins). const second = await service.createReport( - createReq({ idempotencyKey: key, category: "hazard", description: "changed", mediaUploadIds: [media.uploadId] }), + createReq({ + idempotencyKey: key, + category: "hazard", + description: "changed", + mediaUploadIds: [media.uploadId], + }), { userId }, ) @@ -187,9 +192,12 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { ` await expect( - service.createReport(createReq({ idempotencyKey: randomUUID(), mediaUploadIds: [uploadId] }), { - userId, - }), + service.createReport( + createReq({ idempotencyKey: randomUUID(), mediaUploadIds: [uploadId] }), + { + userId, + }, + ), ).rejects.toMatchObject({ httpStatus: 422, code: "VALIDATION", @@ -224,10 +232,9 @@ describe.skipIf(!pg)("reports (integration: real transaction path)", () => { // to get a 201 with their photo missing from the gallery and no way to tell that had happened. const keyB = randomUUID() await expect( - service.createReport( - createReq({ idempotencyKey: keyB, mediaUploadIds: [media.uploadId] }), - { userId }, - ), + service.createReport(createReq({ idempotencyKey: keyB, mediaUploadIds: [media.uploadId] }), { + userId, + }), ).rejects.toMatchObject({ httpStatus: 422, code: "VALIDATION", 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 ea7cd429..84f0dec1 100644 --- a/services/api/test/integration/reports-preview-still-pg.test.ts +++ b/services/api/test/integration/reports-preview-still-pg.test.ts @@ -96,7 +96,9 @@ describe.skipIf(!pg)("first-visible-still preview policy (integration)", () => { } /** The map pin's view of the same report (selectPublicPins -> firstReadyStillLateral). */ - async function pinKeys(reportId: string): Promise<{ thumbKey: string | null; r2Key: string | null }> { + async function pinKeys( + reportId: string, + ): Promise<{ thumbKey: string | null; r2Key: string | null }> { const repo = makeDrizzleReportRepository(h.sql) const [west, south, east, north] = LA_CITY.bbox const pins = await repo.findMapCandidates({ west, south, east, north }, null, null, 500) diff --git a/services/api/test/integration/schema.test.ts b/services/api/test/integration/schema.test.ts index 444fe2ab..6562ed0d 100644 --- a/services/api/test/integration/schema.test.ts +++ b/services/api/test/integration/schema.test.ts @@ -1,4 +1,3 @@ - import { afterAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { getTableName, is } from "drizzle-orm" @@ -120,10 +119,7 @@ const EXPECTED_TABLES = [ "geocode_cache", ] as const -const FOREIGN_TABLES = new Set([ - "_civfix_migrations", - "spatial_ref_sys", -]) +const FOREIGN_TABLES = new Set(["_civfix_migrations", "spatial_ref_sys"]) const DROPPED_TABLES = [ "report_discussion_messages", @@ -350,7 +346,9 @@ describe.skipIf(!pg)("schema (0009): DM + privacy migration produces the expecte }) it("added users.allow_direct_messages (NOT NULL default true)", async () => { - const rows = await h.sql<{ data_type: string; is_nullable: string; column_default: string | null }[]>` + const rows = await h.sql< + { data_type: string; is_nullable: string; column_default: string | null }[] + >` SELECT data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'allow_direct_messages' ` @@ -511,7 +509,9 @@ describe.skipIf(!pg)("schema (0061): users.show_volunteer_hours is a NULLable tr const h = pg as PgHarness it("is boolean, NULLable, and carries NO column default", async () => { - const rows = await h.sql<{ data_type: string; is_nullable: string; column_default: string | null }[]>` + const rows = await h.sql< + { data_type: string; is_nullable: string; column_default: string | null }[] + >` SELECT data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'show_volunteer_hours' ` @@ -542,7 +542,6 @@ describe.skipIf(!pg)("schema (0061): users.show_volunteer_hours is a NULLable tr }) }) - describe.skipIf(!pg)("schema (0167): cleanup_slots carries an optional time window", () => { const h = pg as PgHarness @@ -561,7 +560,12 @@ describe.skipIf(!pg)("schema (0167): cleanup_slots carries an optional time wind it("added starts_at and ends_at as NULLable timestamptz with no default", async () => { const rows = await h.sql< - { column_name: string; data_type: string; is_nullable: string; column_default: string | null }[] + { + column_name: string + data_type: string + is_nullable: string + column_default: string | null + }[] >` SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'cleanup_slots' @@ -670,25 +674,53 @@ const MIRRORED_CHECKS: readonly MirroredCheck[] = [ { table: "moderation_items", column: "kind", mirror: schema.MODERATION_KIND_VALUES }, { table: "moderation_items", column: "priority", mirror: schema.MODERATION_PRIORITY_VALUES }, { table: "moderation_items", column: "status", mirror: schema.MODERATION_STATUS_VALUES }, - { table: "moderation_items", column: "subject_type", mirror: schema.MODERATION_SUBJECT_TYPE_VALUES }, + { + table: "moderation_items", + column: "subject_type", + mirror: schema.MODERATION_SUBJECT_TYPE_VALUES, + }, { table: "user_moderation", column: "account_status", mirror: schema.USER_ACCOUNT_STATUS_VALUES }, { table: "user_moderation", column: "risk", mirror: schema.USER_RISK_VALUES }, { table: "cleanups", column: "visibility", mirror: schema.EVENT_VISIBILITY_VALUES }, - { table: "organizations", column: "verified_status", mirror: schema.ORG_VERIFICATION_STATUS_VALUES }, + { + table: "organizations", + column: "verified_status", + mirror: schema.ORG_VERIFICATION_STATUS_VALUES, + }, { table: "organizations", column: "verified_kind", mirror: schema.ORG_VERIFICATION_KIND_VALUES }, { table: "organization_members", column: "role", mirror: schema.ORGANIZATION_MEMBER_ROLE_VALUES }, { table: "organization_invites", column: "role", mirror: schema.ORGANIZATION_INVITE_ROLE_VALUES }, - { table: "organization_invites", column: "status", mirror: schema.ORGANIZATION_INVITE_STATUS_VALUES }, + { + table: "organization_invites", + column: "status", + mirror: schema.ORGANIZATION_INVITE_STATUS_VALUES, + }, { table: "org_verifications", column: "status", mirror: schema.ORG_VERIFICATION_STATUS_VALUES }, { table: "org_verifications", column: "kind", mirror: schema.ORG_VERIFICATION_KIND_VALUES }, - { table: "event_consents", column: "subject_type", mirror: schema.EVENT_CONSENT_SUBJECT_TYPE_VALUES }, + { + table: "event_consents", + column: "subject_type", + mirror: schema.EVENT_CONSENT_SUBJECT_TYPE_VALUES, + }, { table: "cleanup_team_invites", column: "role", mirror: schema.EVENT_TEAM_ROLE_VALUES }, - { table: "cleanup_team_invites", column: "status", mirror: schema.EVENT_TEAM_INVITE_STATUS_VALUES }, - { table: "cleanup_ticket_types", column: "visibility", mirror: schema.TICKET_TYPE_VISIBILITY_VALUES }, + { + table: "cleanup_team_invites", + column: "status", + mirror: schema.EVENT_TEAM_INVITE_STATUS_VALUES, + }, + { + table: "cleanup_ticket_types", + column: "visibility", + mirror: schema.TICKET_TYPE_VISIBILITY_VALUES, + }, { table: "cleanup_registrations", column: "status", mirror: schema.REGISTRATION_STATUS_VALUES }, { table: "cleanup_registrations", column: "source", mirror: schema.REGISTRATION_SOURCE_VALUES }, { table: "cleanup_registration_seats", column: "status", mirror: schema.SEAT_STATUS_VALUES }, - { table: "cleanup_registration_seats", column: "checkin_method", mirror: schema.CHECKIN_METHOD_VALUES }, + { + table: "cleanup_registration_seats", + column: "checkin_method", + mirror: schema.CHECKIN_METHOD_VALUES, + }, { table: "cleanup_waitlist", column: "status", mirror: schema.WAITLIST_STATUS_VALUES }, { table: "cleanup_questions", column: "kind", mirror: schema.EVENT_QUESTION_KIND_VALUES }, { table: "cleanup_pages", column: "status", mirror: schema.EVENT_PAGE_STATUS_VALUES }, @@ -697,13 +729,21 @@ const MIRRORED_CHECKS: readonly MirroredCheck[] = [ { table: "broadcasts", column: "status", mirror: schema.BROADCAST_STATUS_VALUES }, { table: "broadcast_deliveries", column: "channel", mirror: schema.BROADCAST_CHANNEL_VALUES }, { table: "broadcast_deliveries", column: "status", mirror: schema.DELIVERY_STATUS_VALUES }, - { table: "broadcast_deliveries", column: "recipient_kind", mirror: schema.BROADCAST_RECIPIENT_KIND_VALUES }, + { + table: "broadcast_deliveries", + column: "recipient_kind", + mirror: schema.BROADCAST_RECIPIENT_KIND_VALUES, + }, { table: "broadcast_deliveries", column: "suppression_reason", mirror: schema.DELIVERY_SUPPRESSION_REASON_VALUES, }, - { table: "broadcast_deliveries", column: "failure_kind", mirror: schema.DELIVERY_FAILURE_KIND_VALUES }, + { + table: "broadcast_deliveries", + column: "failure_kind", + mirror: schema.DELIVERY_FAILURE_KIND_VALUES, + }, { table: "broadcast_unsubscribes", column: "scope", mirror: schema.UNSUBSCRIBE_SCOPE_VALUES }, { table: "broadcast_unsubscribes", column: "reason", mirror: schema.UNSUBSCRIBE_REASON_VALUES }, { table: "email_suppressions", column: "reason", mirror: schema.EMAIL_SUPPRESSION_REASON_VALUES }, @@ -752,7 +792,9 @@ function parseValueSet(def: string): { column: string; values: string[] } | null const expression = def.replace(CONSTRAINT_QUALIFIERS, "") const m = VALUE_SET_CHECK.exec(expression) ?? NULLABLE_VALUE_SET_CHECK.exec(expression) if (m === null) return null - const values = [...m[2]!.matchAll(/'((?:[^']|'')*)'::text/g)].map((x) => x[1]!.replace(/''/g, "'")) + const values = [...m[2]!.matchAll(/'((?:[^']|'')*)'::text/g)].map((x) => + x[1]!.replace(/''/g, "'"), + ) return { column: m[1]!, values } } @@ -777,7 +819,10 @@ describe.skipIf(!pg)("schema: enum mirrors match the DDL CHECK constraints", () if (RETIRED_PAYMENT_TABLES.has(r.tbl)) continue const key = `${r.tbl}.${parsed.column}` const prior = out.get(key) - out.set(key, prior === undefined ? parsed.values : prior.filter((v) => parsed.values.includes(v))) + out.set( + key, + prior === undefined ? parsed.values : prior.filter((v) => parsed.values.includes(v)), + ) } return out } @@ -798,7 +843,10 @@ describe.skipIf(!pg)("schema: enum mirrors match the DDL CHECK constraints", () const inDb = await dbValueSets() for (const c of MIRRORED_CHECKS) { const key = `${c.table}.${c.column}` - const expected = [...c.mirror.filter((v) => !(c.omitted ?? []).includes(v)), ...(c.retired ?? [])].sort() + const expected = [ + ...c.mirror.filter((v) => !(c.omitted ?? []).includes(v)), + ...(c.retired ?? []), + ].sort() expect(inDb.get(key)?.slice().sort(), `value-set drift on ${key}`).toEqual(expected) } }) 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 4d9e80ee..2fa8dfbb 100644 --- a/services/api/test/integration/service-hours-certificates-pg.test.ts +++ b/services/api/test/integration/service-hours-certificates-pg.test.ts @@ -203,7 +203,9 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { expect(second.certificate.code).not.toBe(first.certificate.code) expect(second.reused).toBe(false) - const rows = await h.sql<{ code: string; revoked_at: Date | null; revoked_reason: string | null }[]>` + const rows = await h.sql< + { code: string; revoked_at: Date | null; revoked_reason: string | null }[] + >` SELECT code, revoked_at, revoked_reason FROM service_hours_certificates WHERE user_id = ${holder} ORDER BY issued_at ASC ` @@ -237,7 +239,12 @@ describe.skipIf(!pg)("service-hours certificates (integration)", () => { const repo = makeDrizzleCertificateRepository(h.sql) const found = await repo.findByCode(issued.certificate.code) expect(found?.userId).toBe(holder) - const row = await repo.revoke(found!.userId, issued.certificate.code, "ledger_corrected", new Date()) + const row = await repo.revoke( + found!.userId, + issued.certificate.code, + "ledger_corrected", + new Date(), + ) expect(row?.revokedReason).toBe("ledger_corrected") // The person holding the paper is told WHY, and is not told the volunteer withdrew it. diff --git a/services/api/test/integration/social-connections-pg.test.ts b/services/api/test/integration/social-connections-pg.test.ts index d1af8745..428a31ce 100644 --- a/services/api/test/integration/social-connections-pg.test.ts +++ b/services/api/test/integration/social-connections-pg.test.ts @@ -64,7 +64,12 @@ describe.skipIf(!pg)("F158: connections pages key on the follow edge, not displa const oldestButAlphabeticallyFirst = await newUser("Aaron") const middle = await newUser("Mallory") const newest = await newUser("Zoe") - await seedFollowEdge(h.sql, oldestButAlphabeticallyFirst, target, new Date("2026-01-01T00:00:00Z")) + await seedFollowEdge( + h.sql, + oldestButAlphabeticallyFirst, + target, + new Date("2026-01-01T00:00:00Z"), + ) await seedFollowEdge(h.sql, middle, target, new Date("2026-02-01T00:00:00Z")) await seedFollowEdge(h.sql, newest, target, new Date("2026-03-01T00:00:00Z")) diff --git a/services/api/test/integration/social-notifications-pg.test.ts b/services/api/test/integration/social-notifications-pg.test.ts index d2730721..f18f1ba9 100644 --- a/services/api/test/integration/social-notifications-pg.test.ts +++ b/services/api/test/integration/social-notifications-pg.test.ts @@ -1,4 +1,3 @@ - import { TEST_TICKET_SIGNER } from "../helpers/ticket-signer.js" import { afterAll, beforeAll, describe, expect, it } from "vitest" import { FakePushSender } from "@civfix/shared/fakes" @@ -68,13 +67,23 @@ describe.skipIf(!pg)("social + notifications (integration)", () => { await h.sql`INSERT INTO user_blocks (blocker_id, blocked_id) VALUES (${viewer}, ${blockedByViewer})` await h.sql`INSERT INTO user_blocks (blocker_id, blocked_id) VALUES (${blockerOfViewer}, ${viewer})` - const followers = await repo.listFollowers({ id: subject, viewerId: viewer, cursor: null, limit: 50 }) + const followers = await repo.listFollowers({ + id: subject, + viewerId: viewer, + cursor: null, + limit: 50, + }) const followerIds = followers.items.map((p) => p.id) expect(followerIds).toContain(innocent) expect(followerIds).not.toContain(blockedByViewer) expect(followerIds).not.toContain(blockerOfViewer) - const following = await repo.listFollowing({ id: subject, viewerId: viewer, cursor: null, limit: 50 }) + const following = await repo.listFollowing({ + id: subject, + viewerId: viewer, + cursor: null, + limit: 50, + }) const followingIds = following.items.map((p) => p.id) expect(followingIds).toContain(innocent) expect(followingIds).not.toContain(blockedByViewer) @@ -115,14 +124,24 @@ describe.skipIf(!pg)("social + notifications (integration)", () => { const older = await cleanupService.createCleanup( { - title: "Older Sweep", type: "site", eventKind: "cleanup", lat: 34.0, lng: -118.0, scheduledAt: "2025-01-01T10:00:00.000Z", + title: "Older Sweep", + type: "site", + eventKind: "cleanup", + lat: 34.0, + lng: -118.0, + scheduledAt: "2025-01-01T10:00:00.000Z", slots: [{ title: "General volunteers", capacity: null }], }, organizer, ) const newer = await cleanupService.createCleanup( { - title: "Newer Sweep", type: "site", eventKind: "cleanup", lat: 34.1, lng: -118.1, scheduledAt: "2025-03-01T10:00:00.000Z", + title: "Newer Sweep", + type: "site", + eventKind: "cleanup", + lat: 34.1, + lng: -118.1, + scheduledAt: "2025-03-01T10:00:00.000Z", slots: [{ title: "General volunteers", capacity: null }], }, organizer, @@ -342,37 +361,59 @@ describe.skipIf(!pg)("social + notifications (integration)", () => { device_id: string | null revoked_at: Date | null }> => { - const rows = await h.sql<{ user_id: string; device_id: string | null; revoked_at: Date | null }[]>` + const rows = await h.sql< + { user_id: string; device_id: string | null; revoked_at: Date | null }[] + >` SELECT user_id, device_id, revoked_at FROM push_tokens WHERE platform = 'ios' AND token = ${"tok-int"} ` expect(rows).toHaveLength(1) return rows[0]! } - expect(await repo.upsertPushToken({ userId: userA, platform: "ios", token: "tok-int", deviceId: "d1" })).toBe( - "stored", - ) + expect( + await repo.upsertPushToken({ + userId: userA, + platform: "ios", + token: "tok-int", + deviceId: "d1", + }), + ).toBe("stored") await h.sql`UPDATE push_tokens SET revoked_at = now() WHERE token = ${"tok-int"}` - expect(await repo.upsertPushToken({ userId: userA, platform: "ios", token: "tok-int", deviceId: "d1" })).toBe( - "stored", - ) + expect( + await repo.upsertPushToken({ + userId: userA, + platform: "ios", + token: "tok-int", + deviceId: "d1", + }), + ).toBe("stored") let row = await readRow() expect(row.user_id).toBe(userA) expect(row.device_id).toBe("d1") expect(row.revoked_at).toBeNull() - expect(await repo.upsertPushToken({ userId: userB, platform: "ios", token: "tok-int", deviceId: "d1" })).toBe( - "conflict", - ) + expect( + await repo.upsertPushToken({ + userId: userB, + platform: "ios", + token: "tok-int", + deviceId: "d1", + }), + ).toBe("conflict") row = await readRow() expect(row.user_id).toBe(userA) expect(row.revoked_at).toBeNull() await h.sql`UPDATE push_tokens SET revoked_at = now() WHERE token = ${"tok-int"}` - expect(await repo.upsertPushToken({ userId: userB, platform: "ios", token: "tok-int", deviceId: "d2" })).toBe( - "stored", - ) + expect( + await repo.upsertPushToken({ + userId: userB, + platform: "ios", + token: "tok-int", + deviceId: "d2", + }), + ).toBe("stored") row = await readRow() expect(row.user_id).toBe(userB) expect(row.device_id).toBe("d2") diff --git a/services/api/test/integration/spatial.test.ts b/services/api/test/integration/spatial.test.ts index 6f11cdc5..79a60ff9 100644 --- a/services/api/test/integration/spatial.test.ts +++ b/services/api/test/integration/spatial.test.ts @@ -61,7 +61,11 @@ 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, PROBE_ANGELES_OVER_CITY.lat) + const r = await resolveJurisdiction( + h.sql, + PROBE_ANGELES_OVER_CITY.lng, + PROBE_ANGELES_OVER_CITY.lat, + ) expect(r).not.toBeNull() expect(r?.geoid).toBe(PROBE_ANGELES_OVER_CITY.expectGeoid) expect(r?.layer).toBe("federal") diff --git a/services/api/test/integration/suggest-follows-pg.test.ts b/services/api/test/integration/suggest-follows-pg.test.ts index 5478ac03..6bdbd1fe 100644 --- a/services/api/test/integration/suggest-follows-pg.test.ts +++ b/services/api/test/integration/suggest-follows-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { withPg, testHandle, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" @@ -90,10 +89,9 @@ describe.skipIf(!pg)("H18: follow suggestions are bounded before ranking", () => line.includes(`Index Scan using ${SUGGEST_KNN_INDEX}`), ) expect(knnScan, `no Index Scan using ${SUGGEST_KNN_INDEX} in:\n${text}`).toBeGreaterThan(-1) - expect( - nodeDetail(lines, knnScan), - `KNN scan without an Order By in:\n${text}`, - ).toContain("Order By:") + expect(nodeDetail(lines, knnScan), `KNN scan without an Order By in:\n${text}`).toContain( + "Order By:", + ) expect(nodeDetail(lines, knnScan)).toContain("last_activity_geom <->") }) diff --git a/services/api/test/integration/threads-lateral-bound-pg.test.ts b/services/api/test/integration/threads-lateral-bound-pg.test.ts index 2c44f47b..ceaf1657 100644 --- a/services/api/test/integration/threads-lateral-bound-pg.test.ts +++ b/services/api/test/integration/threads-lateral-bound-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import postgres from "postgres" @@ -94,7 +93,8 @@ describe.skipIf(!pg)("threads inbox last-message LATERAL (integration)", () => { `EXPLAIN (ANALYZE, FORMAT JSON) ${captured!.query}`, captured!.params as never[], ) - const root = (plan[0] as unknown as { "QUERY PLAN": { Plan: PlanNode }[] })["QUERY PLAN"][0]!.Plan + const root = (plan[0] as unknown as { "QUERY PLAN": { Plan: PlanNode }[] })["QUERY PLAN"][0]! + .Plan const probes = flatten(root, false).filter((f) => isMessageProbe(f.node)) expect(probes.length).toBeGreaterThan(0) diff --git a/services/api/test/integration/threads-report-pg.test.ts b/services/api/test/integration/threads-report-pg.test.ts index 6d55e5a8..69f65ea9 100644 --- a/services/api/test/integration/threads-report-pg.test.ts +++ b/services/api/test/integration/threads-report-pg.test.ts @@ -21,10 +21,7 @@ import { makeDrizzleThreadsRepository, } from "../../src/services/threads-repository.drizzle.js" import { makeConversationMutesRepository } from "../../src/services/conversation-mutes-repository.drizzle.js" -import { - makeThreadsService, - InMemoryChatReadState, -} from "../../src/services/threads-service.js" +import { makeThreadsService, InMemoryChatReadState } from "../../src/services/threads-service.js" const pg = await withPg() @@ -122,7 +119,12 @@ describe.skipIf(!pg)("report chats in the threads inbox (integration)", () => { // 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. await addReportMessage(reportId, "please look", other, new Date("2026-06-01T11:00:00.000Z")) - await addReportMessage(reportId, "Report was acknowledged.", null, new Date("2026-06-01T11:30:00.000Z")) + await addReportMessage( + reportId, + "Report was acknowledged.", + null, + new Date("2026-06-01T11:30:00.000Z"), + ) const { items } = await service().listThreads(me) const t = items.find((x) => x.id === reportId) diff --git a/services/api/test/integration/volunteer-hours-integrity-pg.test.ts b/services/api/test/integration/volunteer-hours-integrity-pg.test.ts index 6ae4595e..292aca56 100644 --- a/services/api/test/integration/volunteer-hours-integrity-pg.test.ts +++ b/services/api/test/integration/volunteer-hours-integrity-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" @@ -188,7 +187,8 @@ describe.skipIf(!pg)("H9: volunteer-hours integrity bounds (integration)", () => }) it("keeps a legacy completed_at as the hours window and falls back to ends_at without one", async () => { - const { creditableHoursForEvent } = await import("../../src/services/volunteer-hours-service.js") + const { creditableHoursForEvent } = + await import("../../src/services/volunteer-hours-service.js") const org = await newUser("Window Org") const legacyId = randomUUID() @@ -213,7 +213,9 @@ describe.skipIf(!pg)("H9: volunteer-hours integrity bounds (integration)", () => ) ` - const rows = await h.sql<{ id: string; scheduled_at: Date; ends_at: Date; completed_at: Date | null }[]>` + const rows = await h.sql< + { id: string; scheduled_at: Date; ends_at: Date; completed_at: Date | null }[] + >` SELECT id, scheduled_at, ends_at, completed_at FROM cleanups WHERE id IN (${legacyId}, ${plainId}) ` diff --git a/services/api/test/integration/volunteer-hours-pg.test.ts b/services/api/test/integration/volunteer-hours-pg.test.ts index 5041ae5f..cf18715e 100644 --- a/services/api/test/integration/volunteer-hours-pg.test.ts +++ b/services/api/test/integration/volunteer-hours-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" @@ -160,8 +159,18 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { status: "done", }) const repo = makeDrizzleVolunteerHoursRepository(h.sql) - await repo.logEventHours({ actorId: org, cleanupId: mapped, geoid: GEOID, entries: [{ userId: alice, hours: 2 }] }) - await repo.logEventHours({ actorId: org, cleanupId: unmapped, geoid: null, entries: [{ userId: alice, hours: 1.5 }] }) + await repo.logEventHours({ + actorId: org, + cleanupId: mapped, + geoid: GEOID, + entries: [{ userId: alice, hours: 2 }], + }) + await repo.logEventHours({ + actorId: org, + cleanupId: unmapped, + geoid: null, + entries: [{ userId: alice, hours: 1.5 }], + }) const ledgerRows = await h.sql<{ total: number }[]>` SELECT COALESCE(SUM(hours), 0)::float8 AS total FROM volunteer_hours @@ -232,7 +241,12 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { status: "done", }) const repo = makeDrizzleVolunteerHoursRepository(h.sql) - await repo.logEventHours({ actorId: org, cleanupId, geoid: null, entries: [{ userId: org, hours: 1 }] }) + await repo.logEventHours({ + actorId: org, + cleanupId, + geoid: null, + entries: [{ userId: org, hours: 1 }], + }) const credited = await repo.logEventHours({ actorId: org, cleanupId, @@ -272,13 +286,33 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { ` } - await repo.logEventHours({ actorId: org, cleanupId, geoid: GEOID, entries: [{ userId: alice, hours: 2 }] }) + await repo.logEventHours({ + actorId: org, + cleanupId, + geoid: GEOID, + entries: [{ userId: alice, hours: 2 }], + }) let rows = await auditRows() expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ user_id: alice, actor_user_id: org, previous_hours: null, new_hours: 2 }) + expect(rows[0]).toMatchObject({ + user_id: alice, + actor_user_id: org, + previous_hours: null, + new_hours: 2, + }) - await repo.logEventHours({ actorId: org, cleanupId, geoid: GEOID, entries: [{ userId: alice, hours: 20 }] }) - await repo.logEventHours({ actorId: cohost, cleanupId, geoid: GEOID, entries: [{ userId: alice, hours: 2 }] }) + await repo.logEventHours({ + actorId: org, + cleanupId, + geoid: GEOID, + entries: [{ userId: alice, hours: 20 }], + }) + await repo.logEventHours({ + actorId: cohost, + cleanupId, + geoid: GEOID, + entries: [{ userId: alice, hours: 2 }], + }) rows = await auditRows() expect(rows).toHaveLength(3) @@ -308,8 +342,18 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { status: "done", }) const repo = makeDrizzleVolunteerHoursRepository(h.sql) - await repo.logEventHours({ actorId: org, cleanupId, geoid: null, entries: [{ userId: alice, hours: 1 }] }) - await repo.logEventHours({ actorId: org, cleanupId, geoid: null, entries: [{ userId: alice, hours: 5 }] }) + await repo.logEventHours({ + actorId: org, + cleanupId, + geoid: null, + entries: [{ userId: alice, hours: 1 }], + }) + await repo.logEventHours({ + actorId: org, + cleanupId, + geoid: null, + entries: [{ userId: alice, hours: 5 }], + }) const rows = await h.sql<{ previous_hours: number | null; new_hours: number }[]>` SELECT previous_hours::float8 AS previous_hours, new_hours::float8 AS new_hours @@ -321,7 +365,6 @@ describe.skipIf(!pg)("volunteer hours (integration)", () => { ]) }) - async function newUserWithFlag(name: string, flag: boolean | null): Promise { const [u] = await h.sql<{ id: string }[]>` INSERT INTO users (display_name, show_volunteer_hours) diff --git a/services/api/test/unit/abuse-checks-adapter.test.ts b/services/api/test/unit/abuse-checks-adapter.test.ts index e0d87bf3..ac2d609a 100644 --- a/services/api/test/unit/abuse-checks-adapter.test.ts +++ b/services/api/test/unit/abuse-checks-adapter.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, it, expect, vi } from "vitest" import { RealAbuseChecks } from "../../src/adapters/abuse-checks.js" - const IMG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4, 5, 6, 7, 8]) describe("RealAbuseChecks.nsfwScore (default benign, never throws)", () => { @@ -65,9 +64,7 @@ describe("RealAbuseChecks.isNearDuplicate (benign default, never throws)", () => }) it("delegates to the injected lookup when provided", async () => { - const findPhashDuplicate = vi - .fn() - .mockResolvedValue({ dup: true, ofReportId: "report-1" }) + const findPhashDuplicate = vi.fn().mockResolvedValue({ dup: true, ofReportId: "report-1" }) const abuse = new RealAbuseChecks({ findPhashDuplicate }) await expect(abuse.isNearDuplicate("abc123")).resolves.toEqual({ dup: true, @@ -136,7 +133,9 @@ describe("RealAbuseChecks.verifyTurnstile hostname/action binding (L16)", () => turnstileHostnames: ["civfix.org"], log: () => {}, }) - await expect(abuse.verifyTurnstile("tok", "1.2.3.4", { action: "home-turf" })).resolves.toBe(false) + await expect(abuse.verifyTurnstile("tok", "1.2.3.4", { action: "home-turf" })).resolves.toBe( + false, + ) await expect(abuse.verifyTurnstile("tok", "1.2.3.4", { action: "login" })).resolves.toBe(true) }) @@ -148,8 +147,12 @@ describe("RealAbuseChecks.verifyTurnstile hostname/action binding (L16)", () => turnstileHostnames: ["civfix.org"], log: (l) => lines.push(l), }) - await expect(abuse.verifyTurnstile("tok", "1.2.3.4", { action: "anon-report" })).resolves.toBe(true) - await expect(abuse.verifyTurnstile("tok", "1.2.3.4", { action: "anon-report" })).resolves.toBe(true) + await expect(abuse.verifyTurnstile("tok", "1.2.3.4", { action: "anon-report" })).resolves.toBe( + true, + ) + await expect(abuse.verifyTurnstile("tok", "1.2.3.4", { action: "anon-report" })).resolves.toBe( + true, + ) expect(lines.filter((l) => l.includes("carried no action"))).toHaveLength(1) }) diff --git a/services/api/test/unit/abuse.test.ts b/services/api/test/unit/abuse.test.ts index 8ecdd02e..1c900c49 100644 --- a/services/api/test/unit/abuse.test.ts +++ b/services/api/test/unit/abuse.test.ts @@ -340,7 +340,9 @@ describe("issueAnonToken / resolveAnonToken", () => { it("does not resolve an unknown or unsigned token", async () => { const store = new MemTokenStore() expect(await resolveAnonToken(undefined, { store, signingKey: KEY })).toBeNull() - expect(await resolveAnonToken(signAnonToken("ghost", KEY), { store, signingKey: KEY })).toBeNull() + expect( + await resolveAnonToken(signAnonToken("ghost", KEY), { store, signingKey: KEY }), + ).toBeNull() }) }) diff --git a/services/api/test/unit/adapters-http-fetch.test.ts b/services/api/test/unit/adapters-http-fetch.test.ts index 384dd8b0..41935b88 100644 --- a/services/api/test/unit/adapters-http-fetch.test.ts +++ b/services/api/test/unit/adapters-http-fetch.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect, vi, afterEach } from "vitest" import { fetchJsonWithTimeout, @@ -7,7 +6,11 @@ import { } from "../../src/adapters/http-fetch.js" function jsonResponse(body: unknown, status = 200): Response { - return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as unknown as Response } function streamResponse( @@ -241,14 +244,20 @@ describe("fetchJsonWithTimeout", () => { it("still parses a bare {ok,json} double (no body) via the res.json() fallback under the cap", async () => { const fetchImpl = vi.fn(async () => jsonResponse({ ok: 1 })) as unknown as typeof fetch - const result = await fetchJsonWithTimeout("https://x/y", { timeoutMs: 50, maxBytes: 10, fetchImpl }) + const result = await fetchJsonWithTimeout("https://x/y", { + timeoutMs: 50, + maxBytes: 10, + fetchImpl, + }) expect(result).toEqual({ ok: true, status: 200, json: { ok: 1 } }) }) it("resolves globalThis.fetch at CALL time when no fetchImpl is injected", async () => { const original = globalThis.fetch try { - globalThis.fetch = vi.fn(async () => jsonResponse({ via: "global" })) as unknown as typeof fetch + globalThis.fetch = vi.fn(async () => + jsonResponse({ via: "global" }), + ) as unknown as typeof fetch const result = await fetchJsonWithTimeout<{ via: string }>("https://x/y", { timeoutMs: 50 }) expect(result.ok && result.json).toEqual({ via: "global" }) } finally { @@ -267,7 +276,9 @@ describe("fetchJsonOrNull (fail-open wrapper)", () => { const http = vi.fn( async () => ({ ok: false, status: 500, json: async () => ({}) }) as unknown as Response, ) as unknown as typeof fetch - await expect(fetchJsonOrNull("https://x", { timeoutMs: 50, fetchImpl: http })).resolves.toBeNull() + await expect( + fetchJsonOrNull("https://x", { timeoutMs: 50, fetchImpl: http }), + ).resolves.toBeNull() const transport = vi.fn(async () => { throw new Error("net") diff --git a/services/api/test/unit/adapters-i18n-messages.test.ts b/services/api/test/unit/adapters-i18n-messages.test.ts index dc6af79d..8dd494b8 100644 --- a/services/api/test/unit/adapters-i18n-messages.test.ts +++ b/services/api/test/unit/adapters-i18n-messages.test.ts @@ -96,7 +96,9 @@ describe("renderMessage", () => { it("clamps the locale argument, so a raw users.locale is safe to pass", () => { expect(renderMessage("es-MX", "notification.follower.title")).toBe("Nuevo seguidor") - expect(renderMessage("fr", "notification.follower.title")).toBe(en["notification.follower.title"]) + expect(renderMessage("fr", "notification.follower.title")).toBe( + en["notification.follower.title"], + ) expect(renderMessage(undefined, "notification.follower.title")).toBe( en["notification.follower.title"], ) diff --git a/services/api/test/unit/adapters-mailer-oci.test.ts b/services/api/test/unit/adapters-mailer-oci.test.ts index 855b5bf6..9b1bf453 100644 --- a/services/api/test/unit/adapters-mailer-oci.test.ts +++ b/services/api/test/unit/adapters-mailer-oci.test.ts @@ -1,11 +1,7 @@ - import { describe, it, expect, vi, beforeEach } from "vitest" import { AppError, ErrorCode, MailSendError } from "@civfix/shared" import type { OutboundEmail } from "@civfix/shared/interfaces" -import { - OciMailer, - OCI_MAILER_DEFAULT_TIMEOUT_MS, -} from "../../src/adapters/mailer.oci.js" +import { OciMailer, OCI_MAILER_DEFAULT_TIMEOUT_MS } from "../../src/adapters/mailer.oci.js" import { mailFailure } from "../../src/adapters/mail-failure.js" interface SentMailArgs { diff --git a/services/api/test/unit/adapters-push-classification.test.ts b/services/api/test/unit/adapters-push-classification.test.ts index 1db0eda4..e2ae0a93 100644 --- a/services/api/test/unit/adapters-push-classification.test.ts +++ b/services/api/test/unit/adapters-push-classification.test.ts @@ -141,7 +141,9 @@ describe("expo dispatcher retry (429/5xx)", () => { it("gives up after ONE retry (never loops) and reports the status", async () => { const { logger, errors } = recordingLogger() - const fetchImpl = vi.fn(async () => new Response("boom", { status: 502 })) as unknown as typeof fetch + const fetchImpl = vi.fn( + async () => new Response("boom", { status: 502 }), + ) as unknown as typeof fetch const dispatch = makeExpoDispatcher({ fetchImpl, retryDelayMs: 1 }, logger) await expect(dispatch(["ExponentPushToken[a]"], { title: "Hi" })).resolves.toEqual({ @@ -153,7 +155,9 @@ describe("expo dispatcher retry (429/5xx)", () => { it("does NOT retry a 4xx that is not 429", async () => { const { logger } = recordingLogger() - const fetchImpl = vi.fn(async () => new Response("bad", { status: 400 })) as unknown as typeof fetch + const fetchImpl = vi.fn( + async () => new Response("bad", { status: 400 }), + ) as unknown as typeof fetch const dispatch = makeExpoDispatcher({ fetchImpl, retryDelayMs: 1 }, logger) await dispatch(["ExponentPushToken[a]"], { title: "Hi" }) diff --git a/services/api/test/unit/address-resolver.test.ts b/services/api/test/unit/address-resolver.test.ts index 408e60e1..a4e1866b 100644 --- a/services/api/test/unit/address-resolver.test.ts +++ b/services/api/test/unit/address-resolver.test.ts @@ -216,7 +216,11 @@ describe("makeAddressResolver caching", () => { }) it("keys on the SHARED 5-decimal point key, so a sub-metre pin nudge is a hit", async () => { - const provider = chain({ line: "Main St, Inglewood, CA", precision: "intersection", provider: "photon" }) + const provider = chain({ + line: "Main St, Inglewood, CA", + precision: "intersection", + provider: "photon", + }) const cache = memoryCache() const resolve = makeAddressResolver({ streetReverseGeocode: provider, @@ -346,7 +350,11 @@ describe("makeAddressResolver caching", () => { }) it("an UNREACHABLE cache degrades to no caching, never to an error", async () => { - const provider = chain({ line: "123 Main St, Inglewood, CA", precision: "street", provider: "photon" }) + const provider = chain({ + line: "123 Main St, Inglewood, CA", + precision: "street", + provider: "photon", + }) const resolve = makeAddressResolver({ streetReverseGeocode: provider, geocoder: new FakeGeocoder(), @@ -515,7 +523,11 @@ describe("addressProvenance", () => { it("keeps a locality-grade snapshot, labelled honestly rather than dropped", () => { expect( - addressProvenance("", { address: "Los Angeles, CA", precision: "locality", cityStateLabel: "Los Angeles, CA" }), + addressProvenance("", { + address: "Los Angeles, CA", + precision: "locality", + cityStateLabel: "Los Angeles, CA", + }), ).toEqual({ addr: "Los Angeles, CA", addrSource: "resolved", addrPrecision: "locality" }) }) diff --git a/services/api/test/unit/admin-activity.test.ts b/services/api/test/unit/admin-activity.test.ts index 0b3cf05b..b5c9f56a 100644 --- a/services/api/test/unit/admin-activity.test.ts +++ b/services/api/test/unit/admin-activity.test.ts @@ -8,7 +8,6 @@ import { type ActivityService, } from "../../src/services/admin/activity-service.js" - const NOW = new Date("2026-06-15T12:00:00.000Z") function harness(): { repo: InMemoryActivityRepository; svc: ActivityService } { @@ -72,7 +71,14 @@ describe("classifyActivity (per source)", () => { it("classifies a cleanup as cleanup_plan", () => { const dto = classifyActivity( - { source: "cleanup", id: "c1", ts: hoursAgo(1), who: "Bob", where: "Park", subject: "River cleanup" }, + { + source: "cleanup", + id: "c1", + ts: hoursAgo(1), + who: "Bob", + where: "Park", + subject: "River cleanup", + }, NOW, ) expect(dto.kind).toBe("cleanup_plan") @@ -81,28 +87,56 @@ describe("classifyActivity (per source)", () => { it("labels mail events distinctly: sent, failed, bounced, and inbound replies", () => { const bounced = classifyActivity( - { source: "mail_event", id: "m1", ts: hoursAgo(1), who: "city@x.gov", where: "x", eventType: "bounced" }, + { + source: "mail_event", + id: "m1", + ts: hoursAgo(1), + who: "city@x.gov", + where: "x", + eventType: "bounced", + }, NOW, ) expect(bounced.kind).toBe("outreach_bounce") expect(bounced.what).toBe("Outreach bounced") const failed = classifyActivity( - { source: "mail_event", id: "m2", ts: hoursAgo(1), who: "city@x.gov", where: "x", eventType: "failed" }, + { + source: "mail_event", + id: "m2", + ts: hoursAgo(1), + who: "city@x.gov", + where: "x", + eventType: "failed", + }, NOW, ) expect(failed.kind).toBe("outreach_bounce") expect(failed.what).toBe("Outreach failed") const replied = classifyActivity( - { source: "mail_event", id: "m3", ts: hoursAgo(1), who: "city@x.gov", where: "x", eventType: "delivered" }, + { + source: "mail_event", + id: "m3", + ts: hoursAgo(1), + who: "city@x.gov", + where: "x", + eventType: "delivered", + }, NOW, ) expect(replied.kind).toBe("outreach_open") expect(replied.what).toBe("City replied") const sent = classifyActivity( - { source: "mail_event", id: "m4", ts: hoursAgo(1), who: "city@x.gov", where: "x", eventType: "sent" }, + { + source: "mail_event", + id: "m4", + ts: hoursAgo(1), + who: "city@x.gov", + where: "x", + eventType: "sent", + }, NOW, ) expect(sent.kind).toBe("outreach_open") @@ -131,8 +165,10 @@ describe("classifyActivity (per source)", () => { classifyActivity({ source: "report", id: "r", ts: NOW, who: "", where: "" }, NOW).who, ).toBe("A neighbor") expect( - classifyActivity({ source: "audit", id: "a", ts: NOW, who: "", where: "", action: "x.y" }, NOW) - .who, + classifyActivity( + { source: "audit", id: "a", ts: NOW, who: "", where: "", action: "x.y" }, + NOW, + ).who, ).toBe("Operator") }) }) @@ -140,7 +176,14 @@ describe("classifyActivity (per source)", () => { describe("activity service wiring", () => { it("merges sources newest-first and returns a null cursor (capped recent window)", async () => { const { repo, svc } = harness() - repo.seedRecord({ source: "report", id: "r1", ts: hoursAgo(5), who: "A", where: "LA", subject: "trash" }) + repo.seedRecord({ + source: "report", + id: "r1", + ts: hoursAgo(5), + who: "A", + where: "LA", + subject: "trash", + }) repo.seedRecord({ source: "audit", id: "a1", @@ -167,7 +210,14 @@ describe("activity service wiring", () => { it("respects the limit", async () => { const { repo, svc } = harness() for (let i = 0; i < 5; i++) { - repo.seedRecord({ source: "report", id: `r${i}`, ts: hoursAgo(i), who: "A", where: "LA", subject: "trash" }) + repo.seedRecord({ + source: "report", + id: `r${i}`, + ts: hoursAgo(i), + who: "A", + where: "LA", + subject: "trash", + }) } const res = await svc.list({ limit: 2 }) expect(res.items).toHaveLength(2) diff --git a/services/api/test/unit/admin-analytics.test.ts b/services/api/test/unit/admin-analytics.test.ts index 64247369..566f5c53 100644 --- a/services/api/test/unit/admin-analytics.test.ts +++ b/services/api/test/unit/admin-analytics.test.ts @@ -25,7 +25,6 @@ import { type AnalyticsService, } from "../../src/services/admin/analytics-service.js" - const NOW = new Date("2026-06-15T12:00:00.000Z") function harness(): { repo: InMemoryAnalyticsRepository; svc: AnalyticsService } { diff --git a/services/api/test/unit/admin-auth-guard.test.ts b/services/api/test/unit/admin-auth-guard.test.ts index 1e14ceab..8e89c5ec 100644 --- a/services/api/test/unit/admin-auth-guard.test.ts +++ b/services/api/test/unit/admin-auth-guard.test.ts @@ -243,7 +243,11 @@ describe("H2: operator authority is re-checked against ADMIN_EMAILS on EVERY adm headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(200) - const body = res.json() as { authenticated: boolean; operator?: { email: string }; csrfToken?: string } + const body = res.json() as { + authenticated: boolean + operator?: { email: string } + csrfToken?: string + } expect(body.authenticated).toBe(true) expect(body.operator?.email).toBe(ALLOWED) expect(body.csrfToken).toBeTruthy() diff --git a/services/api/test/unit/admin-discovery.test.ts b/services/api/test/unit/admin-discovery.test.ts index 97adf744..09957431 100644 --- a/services/api/test/unit/admin-discovery.test.ts +++ b/services/api/test/unit/admin-discovery.test.ts @@ -77,7 +77,10 @@ describe("discovery pure helpers", () => { expect(derivePriority({ ...base, total: 0 }, NOW)).toBe("low") expect(derivePriority({ ...base, total: 2, oldestWaitingAt: hoursAgo(2) }, NOW)).toBe("med") expect( - derivePriority({ ...base, total: 2, oldestWaitingAt: hoursAgo(DISCOVERY_SLA_HOURS + 5) }, NOW), + derivePriority( + { ...base, total: 2, oldestWaitingAt: hoursAgo(DISCOVERY_SLA_HOURS + 5) }, + NOW, + ), ).toBe("high") }) @@ -136,8 +139,20 @@ describe("discovery list", () => { it("sorts by population desc by default and by reports when asked", async () => { const { repo, svc } = harness() - repo.seedTask({ id: "A", geoid: "1", place: "Alpha", population: 100, perCategory: { trash: 9 } }) - repo.seedTask({ id: "B", geoid: "2", place: "Bravo", population: 900, perCategory: { trash: 1 } }) + repo.seedTask({ + id: "A", + geoid: "1", + place: "Alpha", + population: 100, + perCategory: { trash: 9 }, + }) + repo.seedTask({ + id: "B", + geoid: "2", + place: "Bravo", + population: 900, + perCategory: { trash: 1 }, + }) const byPop = await svc.list({ sort: "pop" }) expect(byPop.items.map((i) => i.id)).toEqual(["B", "A"]) @@ -176,7 +191,12 @@ describe("discovery list", () => { */ it("search matches place or geoid (case-insensitive) and NEVER the task id", async () => { const { repo, svc } = harness() - repo.seedTask({ id: "JUR-1", geoid: "0644000", place: "Los Angeles", perCategory: { trash: 1 } }) + repo.seedTask({ + id: "JUR-1", + geoid: "0644000", + place: "Los Angeles", + perCategory: { trash: 1 }, + }) repo.seedTask({ id: "JUR-2", geoid: "0666000", place: "San Diego", perCategory: { trash: 1 } }) expect((await svc.list({ q: "angeles" })).items.map((i) => i.id)).toEqual(["JUR-1"]) @@ -283,7 +303,11 @@ describe("discovery mutations", () => { const { repo, svc } = harness() repo.seedTask({ id: "JUR-1", geoid: "1", place: "LA", perCategory: { trash: 1 } }) - const note = await svc.addNote("JUR-1", { text: "Called the clerk", actorId: "op-1", who: "jane" }) + const note = await svc.addNote("JUR-1", { + text: "Called the clerk", + actorId: "op-1", + who: "jane", + }) expect(note.text).toBe("Called the clerk") expect(note.who).toBe("jane") expect(note.when).toBe("now") diff --git a/services/api/test/unit/admin-events.test.ts b/services/api/test/unit/admin-events.test.ts index a36a7d47..f8ec04fe 100644 --- a/services/api/test/unit/admin-events.test.ts +++ b/services/api/test/unit/admin-events.test.ts @@ -198,7 +198,9 @@ describe("admin events list", () => { // 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"]) + expect((await svc.list({ filter: "completed" })).items.map((i) => i.id)).toEqual([ + "legacy-done", + ]) expect((await svc.list({ filter: "in_progress" })).items.map((i) => i.id).sort()).toEqual([ "legacy-active", "p2-progress", diff --git a/services/api/test/unit/admin-gov-claims.test.ts b/services/api/test/unit/admin-gov-claims.test.ts index dd672dc8..e82a9fa8 100644 --- a/services/api/test/unit/admin-gov-claims.test.ts +++ b/services/api/test/unit/admin-gov-claims.test.ts @@ -11,7 +11,6 @@ import { type GovClaimsService, } from "../../src/services/admin/gov-claims-service.js" - const NOW = new Date("2026-06-06T00:00:00.000Z") function harness(): { @@ -297,9 +296,9 @@ describe("gov claim approve", () => { status: "approved", }) - await expect( - svc.approve("GOV-1", { actorId: "op-1", note: null }), - ).rejects.toMatchObject({ httpStatus: 409 }) + await expect(svc.approve("GOV-1", { actorId: "op-1", note: null })).rejects.toMatchObject({ + httpStatus: 409, + }) const placeholder = await users.findByEmail("dana@waynesboro-va.gov") expect(placeholder?.role ?? "citizen").toBe("citizen") @@ -338,7 +337,9 @@ describe("gov claim approve", () => { revokeSessions: () => Promise.reject(new Error("redis down")), now: () => NOW, }) - await expect(svc.approve("GOV-1", { actorId: "op-1", note: null })).rejects.toThrow("redis down") + await expect(svc.approve("GOV-1", { actorId: "op-1", note: null })).rejects.toThrow( + "redis down", + ) expect(users.users.get(existing.id)?.role).toBe("gov_admin") expect(repo.claims.get("GOV-1")?.status).toBe("approved") }) diff --git a/services/api/test/unit/admin-guard-cache.test.ts b/services/api/test/unit/admin-guard-cache.test.ts index a1b5ebfb..951d70f7 100644 --- a/services/api/test/unit/admin-guard-cache.test.ts +++ b/services/api/test/unit/admin-guard-cache.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" @@ -45,7 +44,10 @@ class SpyUserStore implements UserStore { setRole(id: string, role: Parameters[1]): Promise { return this.inner.setRole(id, role) } - updateSettings(id: string, input: Parameters[1]): Promise { + updateSettings( + id: string, + input: Parameters[1], + ): Promise { return this.inner.updateSettings(id, input) } softDeleteAndAnonymize(id: string): Promise { @@ -200,7 +202,11 @@ describe("operator allowlist verdict caching", () => { } expect(h.users.lookups).toBe(1) expect(h.cache.sets).toEqual([ - { key: ALLOWLIST_KEY_PREFIX + userId, value: "1", ttlSeconds: OPERATOR_ALLOWLIST_TTL_SECONDS }, + { + key: ALLOWLIST_KEY_PREFIX + userId, + value: "1", + ttlSeconds: OPERATOR_ALLOWLIST_TTL_SECONDS, + }, ]) }) @@ -215,7 +221,11 @@ describe("operator allowlist verdict caching", () => { } expect(h.users.lookups).toBe(1) expect(h.cache.sets).toEqual([ - { key: ALLOWLIST_KEY_PREFIX + userId, value: "0", ttlSeconds: OPERATOR_ALLOWLIST_TTL_SECONDS }, + { + key: ALLOWLIST_KEY_PREFIX + userId, + value: "0", + ttlSeconds: OPERATOR_ALLOWLIST_TTL_SECONDS, + }, ]) }) @@ -316,7 +326,11 @@ describe("fail-closed", () => { const { userId, token } = await h.operator(null) expect((await get(h, token)).statusCode).toBe(403) expect(h.cache.sets).toEqual([ - { key: ALLOWLIST_KEY_PREFIX + userId, value: "0", ttlSeconds: OPERATOR_ALLOWLIST_TTL_SECONDS }, + { + key: ALLOWLIST_KEY_PREFIX + userId, + value: "0", + ttlSeconds: OPERATOR_ALLOWLIST_TTL_SECONDS, + }, ]) }) @@ -329,9 +343,9 @@ describe("fail-closed", () => { it("never consults the allowlist cache for an anonymous or citizen caller", async () => { const h = await makeHarness() - expect( - (await h.app.inject({ method: "GET", url: "/v1/admin/moderation" })).statusCode, - ).toBe(401) + expect((await h.app.inject({ method: "GET", url: "/v1/admin/moderation" })).statusCode).toBe( + 401, + ) const inner = await h.services.sessions.createSession( (await h.services.users.create("citizen@example.com", { displayName: "C" })).id, diff --git a/services/api/test/unit/admin-home.test.ts b/services/api/test/unit/admin-home.test.ts index 9840ca5b..a42908a8 100644 --- a/services/api/test/unit/admin-home.test.ts +++ b/services/api/test/unit/admin-home.test.ts @@ -14,7 +14,6 @@ import { type AnalyticsRepository, } from "../../src/services/admin/analytics-types.js" - const NOW = new Date("2026-06-15T12:00:00.000Z") function harness(): { 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 c494fa33..66ae3d74 100644 --- a/services/api/test/unit/admin-host-platform-routes.test.ts +++ b/services/api/test/unit/admin-host-platform-routes.test.ts @@ -105,11 +105,7 @@ async function harness(options: { media?: MediaAssetView | null } = {}): Promise const mediaRepo = { findById: (id: string) => Promise.resolve( - options.media === undefined - ? id === MEDIA - ? mediaAsset() - : null - : options.media, + options.media === undefined ? (id === MEDIA ? mediaAsset() : null) : options.media, ), } as unknown as MediaRepository @@ -403,7 +399,12 @@ describe("admin host list", () => { const res = await h.app.inject({ method: "GET", url: "/v1/admin/hosts" }) expect(res.statusCode).toBe(200) const body = res.json() as { - items: { host: { id: string }; messagingSuspended: boolean; broadcastCount: number; windowDays: number }[] + items: { + host: { id: string } + messagingSuspended: boolean + broadcastCount: number + windowDays: number + }[] } expect(body.items).toHaveLength(1) expect(body.items[0]?.host.id).toBe(HOST) @@ -477,7 +478,12 @@ describe("admin org management (0.41.0)", () => { const noOwner = await h.app.inject({ method: "POST", url: "/v1/admin/orgs", - payload: { name: "X", slug: "x-org", ownerUserId: "99999999-9999-4999-8999-999999999999", reason: "r" }, + payload: { + name: "X", + slug: "x-org", + ownerUserId: "99999999-9999-4999-8999-999999999999", + reason: "r", + }, }) expect(noOwner.statusCode).toBe(422) await createOrg(h) @@ -501,15 +507,26 @@ describe("admin org management (0.41.0)", () => { }) const all = await h.app.inject({ method: "GET", url: "/v1/admin/orgs" }) expect(all.statusCode).toBe(200) - const body = all.json() as { items: { slug: string }[]; counts: unknown; nextCursor: string | null } + const body = all.json() as { + items: { slug: string }[] + counts: unknown + nextCursor: string | null + } expect(body.items.map((o) => o.slug).sort()).toEqual(["reach-out-la", "second"]) expect(body.counts).toEqual({ all: 2, verified: 1, pending: 0, suspended: 1 }) expect(h.audits.map((a) => a.action)).toContain("org.list_viewed") const suspended = await h.app.inject({ method: "GET", url: "/v1/admin/orgs?suspended=true" }) - expect((suspended.json() as { items: { slug: string }[] }).items.map((o) => o.slug)).toEqual(["second"]) - const verified = await h.app.inject({ method: "GET", url: "/v1/admin/orgs?verified=verified&kind=nonprofit" }) - expect((verified.json() as { items: { slug: string }[] }).items.map((o) => o.slug)).toEqual(["reach-out-la"]) + expect((suspended.json() as { items: { slug: string }[] }).items.map((o) => o.slug)).toEqual([ + "second", + ]) + const verified = await h.app.inject({ + method: "GET", + url: "/v1/admin/orgs?verified=verified&kind=nonprofit", + }) + expect((verified.json() as { items: { slug: string }[] }).items.map((o) => o.slug)).toEqual([ + "reach-out-la", + ]) const q = await h.app.inject({ method: "GET", url: "/v1/admin/orgs?q=reach" }) expect((q.json() as { items: unknown[] }).items).toHaveLength(1) const bad = await h.app.inject({ method: "GET", url: "/v1/admin/orgs?suspended=maybe" }) @@ -524,10 +541,17 @@ describe("admin org management (0.41.0)", () => { const res = await h.app.inject({ method: "PATCH", url: `/v1/admin/orgs/${a.id}`, - payload: { name: "Reach Out Los Angeles", slug: "reach-out-los-angeles", reason: "legal name" }, + payload: { + name: "Reach Out Los Angeles", + slug: "reach-out-los-angeles", + reason: "legal name", + }, }) expect(res.statusCode).toBe(200) - expect(res.json()).toMatchObject({ name: "Reach Out Los Angeles", slug: "reach-out-los-angeles" }) + expect(res.json()).toMatchObject({ + name: "Reach Out Los Angeles", + slug: "reach-out-los-angeles", + }) expect(h.orgs.audits.find((x) => x.action === "org.updated")?.meta).toEqual({ reason: "legal name", changed: ["name", "slug"], @@ -566,8 +590,14 @@ describe("admin org management (0.41.0)", () => { url: `/v1/admin/orgs/${a.id}/suspend`, payload: { suspended: false, reason: "resolved with the org" }, }) - expect(off.json()).toMatchObject({ suspendedAt: null, suspendedReason: null, verifiedStatus: "verified" }) - expect(h.orgs.audits.filter((x) => x.action === "org.suspended" || x.action === "org.unsuspended")).toHaveLength(2) + expect(off.json()).toMatchObject({ + suspendedAt: null, + suspendedReason: null, + verifiedStatus: "verified", + }) + expect( + h.orgs.audits.filter((x) => x.action === "org.suspended" || x.action === "org.unsuspended"), + ).toHaveLength(2) }) it("manages members: list, add, owner transfer, sole-owner demotion, remove owner", async () => { @@ -698,10 +728,20 @@ describe("admin org management (0.41.0)", () => { expect(items.map((e) => e.title)).toEqual(["Future sweep", "Past sweep"]) expect(items[0]?.id).toBe(CLEANUP) - const upcoming = await h.app.inject({ method: "GET", url: `/v1/admin/orgs/${a.id}/events?when=upcoming` }) - expect((upcoming.json() as { items: { title: string }[] }).items.map((e) => e.title)).toEqual(["Future sweep"]) - const past = await h.app.inject({ method: "GET", url: `/v1/admin/orgs/${a.id}/events?when=past` }) - expect((past.json() as { items: { title: string }[] }).items.map((e) => e.title)).toEqual(["Past sweep"]) + const upcoming = await h.app.inject({ + method: "GET", + url: `/v1/admin/orgs/${a.id}/events?when=upcoming`, + }) + expect((upcoming.json() as { items: { title: string }[] }).items.map((e) => e.title)).toEqual([ + "Future sweep", + ]) + const past = await h.app.inject({ + method: "GET", + url: `/v1/admin/orgs/${a.id}/events?when=past`, + }) + expect((past.json() as { items: { title: string }[] }).items.map((e) => e.title)).toEqual([ + "Past sweep", + ]) expect(h.audits.map((x) => x.action)).toContain("org.events_viewed") const missing = await h.app.inject({ @@ -709,7 +749,10 @@ describe("admin org management (0.41.0)", () => { url: "/v1/admin/orgs/99999999-9999-4999-8999-999999999999/events", }) expect(missing.statusCode).toBe(404) - const bad = await h.app.inject({ method: "GET", url: `/v1/admin/orgs/${a.id}/events?when=someday` }) + const bad = await h.app.inject({ + method: "GET", + url: `/v1/admin/orgs/${a.id}/events?when=someday`, + }) expect(bad.statusCode).toBe(422) }) diff --git a/services/api/test/unit/admin-inbound-bounce-verdict.test.ts b/services/api/test/unit/admin-inbound-bounce-verdict.test.ts index 3d1d8ec7..bf1b6107 100644 --- a/services/api/test/unit/admin-inbound-bounce-verdict.test.ts +++ b/services/api/test/unit/admin-inbound-bounce-verdict.test.ts @@ -10,7 +10,6 @@ import { import type { Container } from "../../src/di.js" import { makeFakeSql, type FakeSqlControl, type SqlHandler } from "../helpers/fake-sql.js" - const TOKEN = "0123456789abcdef01234567" function dsn(opts: { diff --git a/services/api/test/unit/admin-inbox.test.ts b/services/api/test/unit/admin-inbox.test.ts index c0e60304..0a5537d1 100644 --- a/services/api/test/unit/admin-inbox.test.ts +++ b/services/api/test/unit/admin-inbox.test.ts @@ -300,7 +300,7 @@ describe("GET /admin/inbox/:id (detail)", () => { harness = await makeHarness() const missing = await get(harness, "/v1/admin/inbox/2f9d3c11-0000-4000-8000-000000000000") expect(missing.statusCode).toBe(404) - expect((missing.json() as { code: string; message: string })).toMatchObject({ + expect(missing.json() as { code: string; message: string }).toMatchObject({ code: "NOT_FOUND", message: "Inbound email not found.", }) @@ -371,9 +371,9 @@ describe("POST /admin/inbox/:id/status", () => { payload: { id, status: "deleted" }, }) expect(badStatus.statusCode).toBe(422) - expect((badStatus.json() as { code: string; fields?: Record }).fields).toHaveProperty( - "status", - ) + expect( + (badStatus.json() as { code: string; fields?: Record }).fields, + ).toHaveProperty("status") expect(harness.repo.rows.find((r) => r.id === id)?.status).toBe("unread") expect(harness.repo.audits).toHaveLength(0) diff --git a/services/api/test/unit/admin-jurisdiction-contacts.test.ts b/services/api/test/unit/admin-jurisdiction-contacts.test.ts index b46f5920..3c9d1855 100644 --- a/services/api/test/unit/admin-jurisdiction-contacts.test.ts +++ b/services/api/test/unit/admin-jurisdiction-contacts.test.ts @@ -74,9 +74,9 @@ function record(over: Partial = {}): JurisdictionDi describe("contacts pure helpers", () => { it("hasAnyContact is true when any field is filled", () => { expect(hasAnyContact({ contacts: {}, defaultEmails: [], formUrl: null })).toBe(false) - expect(hasAnyContact({ contacts: { trash: "a@b.gov" }, defaultEmails: [], formUrl: null })).toBe( - true, - ) + expect( + hasAnyContact({ contacts: { trash: "a@b.gov" }, defaultEmails: [], formUrl: null }), + ).toBe(true) expect(hasAnyContact({ contacts: {}, defaultEmails: ["a@b.gov"], formUrl: null })).toBe(true) expect(hasAnyContact({ contacts: {}, defaultEmails: [], formUrl: "https://x.gov" })).toBe(true) }) @@ -105,9 +105,9 @@ describe("contacts pure helpers", () => { it("directoryStatus: bounced > verified (saved) > pending", () => { expect(directoryStatus(record())).toBe("pending") - expect( - directoryStatus(record({ defaultEmails: ["a@b.gov"], contactUpdatedAt: NOW })), - ).toBe("verified") + expect(directoryStatus(record({ defaultEmails: ["a@b.gov"], contactUpdatedAt: NOW }))).toBe( + "verified", + ) expect(directoryStatus(record({ bounced: true, defaultEmails: ["a@b.gov"] }))).toBe("bounced") }) }) @@ -324,7 +324,11 @@ describe("C1/C2: save -> enqueue -> worker send -> stamp -> second save throttle // 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({ geoid: "0644000", org: "Los Angeles", defaultEmail: "311@lacity.gov" }) + outreachRepo.seedJurisdiction({ + geoid: "0644000", + org: "Los Angeles", + defaultEmail: "311@lacity.gov", + }) outreachRepo.seedReport({ geoid: "0644000", category: "trash" }) // 1) Save & route: persists the contact + enqueues the immediate (throttled) outreach. @@ -499,8 +503,12 @@ describe("listDirectory", () => { // 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) - expect((await svc.listDirectory({ filter: "email" })).items.some((i) => i.geoid === "__unmapped__")).toBe(false) + expect( + (await svc.listDirectory({ filter: "none" })).items.some((i) => i.geoid === "__unmapped__"), + ).toBe(true) + expect( + (await svc.listDirectory({ filter: "email" })).items.some((i) => i.geoid === "__unmapped__"), + ).toBe(false) }) it("suppresses the 'Unmapped' row when every report resolves to a known jurisdiction", async () => { @@ -555,9 +563,19 @@ 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", layer: "state", defaultEmails: ["gov@ca.gov"] }) + repo.seedJurisdiction({ + geoid: "06", + name: "California", + layer: "state", + defaultEmails: ["gov@ca.gov"], + }) repo.seedJurisdiction({ geoid: "06037", name: "Los Angeles County", layer: "county" }) - repo.seedJurisdiction({ geoid: "0644000", name: "Los Angeles", layer: "place", defaultEmails: ["311@lacity.gov"] }) + repo.seedJurisdiction({ + geoid: "0644000", + name: "Los Angeles", + layer: "place", + defaultEmails: ["311@lacity.gov"], + }) 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). @@ -572,7 +590,9 @@ describe("listDirectory", () => { // 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"]) + expect((await svc.listDirectory({ layer: "county" })).items.map((i) => i.geoid)).toEqual([ + "06037", + ]) }) it("scopes the total to the needs_mapping filter", async () => { diff --git a/services/api/test/unit/admin-mail-repository.test.ts b/services/api/test/unit/admin-mail-repository.test.ts index f415b587..50e77e91 100644 --- a/services/api/test/unit/admin-mail-repository.test.ts +++ b/services/api/test/unit/admin-mail-repository.test.ts @@ -11,7 +11,6 @@ import { type MailThreadRecord, } from "../../src/services/admin/mail-repository.drizzle.js" - describe("mintThreadToken", () => { it("produces a 12-char lowercase base32 token, distinct per call", () => { const a = mintThreadToken() @@ -352,12 +351,22 @@ describe("InMemoryMailRepository: outbound snapshot + failure recording", () => it("records a send failure as one event + needs_action + an audit row", async () => { const repo = new InMemoryMailRepository() const t = await repo.createThread({ subject: "Pothole", status: "sent" }) - const m = await repo.insertMessage({ threadId: t.id, direction: "out", toAddr: "clerk@city.gov", body: "packet" }) + const m = await repo.insertMessage({ + threadId: t.id, + direction: "out", + toAddr: "clerk@city.gov", + body: "packet", + }) await repo.recordSendFailure({ threadId: t.id, messageId: m?.id ?? "", meta: { to: "clerk@city.gov", error: "smtp down" }, - audit: { actorId: null, action: "mail.send_failed", target: "report:rep-1", meta: { reportId: "rep-1" } }, + audit: { + actorId: null, + action: "mail.send_failed", + target: "report:rep-1", + meta: { reportId: "rep-1" }, + }, }) expect(repo.events.map((e) => e.type)).toEqual(["failed"]) expect((await repo.getThreadRecord(t.id))?.status).toBe("needs_action") @@ -367,8 +376,18 @@ describe("InMemoryMailRepository: outbound snapshot + failure recording", () => it("re-reads the latest outbound message whole, and only outbound ones", async () => { const repo = new InMemoryMailRepository() const t = await repo.createThread({ subject: "Pothole" }) - await repo.insertMessage({ threadId: t.id, direction: "out", toAddr: "clerk@city.gov", body: "first" }) - const inbound = await repo.insertMessage({ threadId: t.id, direction: "in", fromAddr: "clerk@city.gov", body: "re" }) + await repo.insertMessage({ + threadId: t.id, + direction: "out", + toAddr: "clerk@city.gov", + body: "first", + }) + const inbound = await repo.insertMessage({ + threadId: t.id, + direction: "in", + fromAddr: "clerk@city.gov", + body: "re", + }) const last = await repo.insertMessage({ threadId: t.id, direction: "out", diff --git a/services/api/test/unit/admin-mail.test.ts b/services/api/test/unit/admin-mail.test.ts index 21b40f8c..978f9d4d 100644 --- a/services/api/test/unit/admin-mail.test.ts +++ b/services/api/test/unit/admin-mail.test.ts @@ -8,7 +8,6 @@ import { type MailService, } from "../../src/services/admin/mail-service.js" - const FROM_OUTREACH = "outreach@civfix.org" interface Harness { @@ -39,9 +38,39 @@ describe("mail-service recipient-resolution helpers", () => { it("resolveCorrespondent picks the latest non-civfix from address, else null", () => { expect(resolveCorrespondent([], FROM_OUTREACH)).toBeNull() const msgs = [ - { id: "a", who: "civfix", from: FROM_OUTREACH, to: "clerk@city.gov", dir: "out" as const, body: "hi", ts: "t", attachments: [], delivery: "sent" as const }, - { id: "b", who: "clerk", from: "clerk@city.gov", to: "", dir: "in" as const, body: "re", ts: "t", attachments: [], delivery: null }, - { id: "c", who: "civfix", from: FROM_OUTREACH, to: "clerk@city.gov", dir: "out" as const, body: "ok", ts: "t", attachments: [], delivery: "sent" as const }, + { + id: "a", + who: "civfix", + from: FROM_OUTREACH, + to: "clerk@city.gov", + dir: "out" as const, + body: "hi", + ts: "t", + attachments: [], + delivery: "sent" as const, + }, + { + id: "b", + who: "clerk", + from: "clerk@city.gov", + to: "", + dir: "in" as const, + body: "re", + ts: "t", + attachments: [], + delivery: null, + }, + { + id: "c", + who: "civfix", + from: FROM_OUTREACH, + to: "clerk@city.gov", + dir: "out" as const, + body: "ok", + ts: "t", + attachments: [], + delivery: "sent" as const, + }, ] expect(resolveCorrespondent(msgs, FROM_OUTREACH)).toBe("clerk@city.gov") expect(resolveCorrespondent(msgs, "OUTREACH@CIVFIX.ORG")).toBe("clerk@city.gov") @@ -49,9 +78,39 @@ describe("mail-service recipient-resolution helpers", () => { it("resolveCorrespondent treats every civfix reply address as ours, not as a correspondent", () => { const msgs = [ - { id: "a", who: "civfix", from: '"civfix Reports" ', to: "clerk@city.gov", dir: "out" as const, body: "packet", ts: "t", attachments: [], delivery: "sent" as const }, - { id: "b", who: "clerk", from: "clerk@city.gov", to: "", dir: "in" as const, body: "re", ts: "t", attachments: [], delivery: null }, - { id: "c", who: "civfix", from: "reply-abcd2345wxyz@civfix.org", to: "clerk@city.gov", dir: "out" as const, body: "ok", ts: "t", attachments: [], delivery: "sent" as const }, + { + id: "a", + who: "civfix", + from: '"civfix Reports" ', + to: "clerk@city.gov", + dir: "out" as const, + body: "packet", + ts: "t", + attachments: [], + delivery: "sent" as const, + }, + { + id: "b", + who: "clerk", + from: "clerk@city.gov", + to: "", + dir: "in" as const, + body: "re", + ts: "t", + attachments: [], + delivery: null, + }, + { + id: "c", + who: "civfix", + from: "reply-abcd2345wxyz@civfix.org", + to: "clerk@city.gov", + dir: "out" as const, + body: "ok", + ts: "t", + attachments: [], + delivery: "sent" as const, + }, ] expect(resolveCorrespondent(msgs, FROM_OUTREACH, "civfix.org")).toBe("clerk@city.gov") expect(resolveCorrespondent(msgs, FROM_OUTREACH)).toBe("clerk@city.gov") @@ -59,18 +118,36 @@ describe("mail-service recipient-resolution helpers", () => { it("resolveCorrespondent keeps a real municipal sender on a civfix-shaped local part", () => { const msgs = [ - { id: "a", who: "clerk", from: "report-desk@lacity.gov", to: "", dir: "in" as const, body: "re", ts: "t", attachments: [], delivery: null }, + { + id: "a", + who: "clerk", + from: "report-desk@lacity.gov", + to: "", + dir: "in" as const, + body: "re", + ts: "t", + attachments: [], + delivery: null, + }, ] expect(resolveCorrespondent(msgs, FROM_OUTREACH, "civfix.org")).toBe("report-desk@lacity.gov") }) - }) describe("mail-service: list + getThread", () => { it("lists threads newest-first and reads a thread + messages, 404 on unknown", async () => { const { repo, svc } = harness() - const t = await repo.createThread({ subject: "Pothole", org: "City of LA", jurisdictionGeoid: "0644000" }) - await repo.insertMessage({ threadId: t.id, direction: "out", fromAddr: FROM_OUTREACH, body: "please review" }) + const t = await repo.createThread({ + subject: "Pothole", + org: "City of LA", + jurisdictionGeoid: "0644000", + }) + await repo.insertMessage({ + threadId: t.id, + direction: "out", + fromAddr: FROM_OUTREACH, + body: "please review", + }) const page = await svc.list({}) expect(page.items.map((i) => i.id)).toEqual([t.id]) @@ -85,10 +162,30 @@ describe("mail-service: list + getThread", () => { it("reports per-message delivery: sent / failed / pending outbound, null inbound", async () => { const { repo, svc } = harness() const t = await repo.createThread({ subject: "Pothole", org: "City of LA" }) - const sent = await repo.insertMessage({ threadId: t.id, direction: "out", fromAddr: FROM_OUTREACH, body: "one" }) - const failed = await repo.insertMessage({ threadId: t.id, direction: "out", fromAddr: FROM_OUTREACH, body: "two" }) - await repo.insertMessage({ threadId: t.id, direction: "out", fromAddr: FROM_OUTREACH, body: "three" }) - await repo.insertMessage({ threadId: t.id, direction: "in", fromAddr: "clerk@city.gov", body: "four" }) + const sent = await repo.insertMessage({ + threadId: t.id, + direction: "out", + fromAddr: FROM_OUTREACH, + body: "one", + }) + const failed = await repo.insertMessage({ + threadId: t.id, + direction: "out", + fromAddr: FROM_OUTREACH, + body: "two", + }) + await repo.insertMessage({ + threadId: t.id, + direction: "out", + fromAddr: FROM_OUTREACH, + body: "three", + }) + await repo.insertMessage({ + threadId: t.id, + direction: "in", + fromAddr: "clerk@city.gov", + body: "four", + }) await repo.recordEvent({ threadId: t.id, messageId: sent?.id ?? "", type: "sent" }) await repo.recordEvent({ threadId: t.id, messageId: failed?.id ?? "", type: "failed" }) @@ -98,10 +195,28 @@ describe("mail-service: list + getThread", () => { it("passes through the dir / attn / geoid / q filters", async () => { const { repo, svc } = harness() - const la = await repo.createThread({ subject: "Trash", org: "City of LA", jurisdictionGeoid: "0644000" }) - await repo.insertMessage({ threadId: la.id, direction: "out", fromAddr: FROM_OUTREACH, body: "x" }) - const sf = await repo.createThread({ subject: "Graffiti", org: "City of SF", jurisdictionGeoid: "0667000" }) - await repo.insertMessage({ threadId: sf.id, direction: "in", fromAddr: "clerk@sf.gov", body: "y" }) + const la = await repo.createThread({ + subject: "Trash", + org: "City of LA", + jurisdictionGeoid: "0644000", + }) + await repo.insertMessage({ + threadId: la.id, + direction: "out", + fromAddr: FROM_OUTREACH, + body: "x", + }) + const sf = await repo.createThread({ + subject: "Graffiti", + org: "City of SF", + jurisdictionGeoid: "0667000", + }) + await repo.insertMessage({ + threadId: sf.id, + direction: "in", + fromAddr: "clerk@sf.gov", + body: "y", + }) expect((await svc.list({ dir: "in" })).items.map((i) => i.id)).toEqual([sf.id]) expect((await svc.list({ geoid: "0644000" })).items.map((i) => i.id)).toEqual([la.id]) @@ -145,7 +260,12 @@ describe("mail-service: reply", () => { toAddr: "clerk@city.gov", body: "Original packet.", }) - await repo.insertMessage({ threadId: t.id, direction: "in", fromAddr: "clerk@city.gov", body: "Q?" }) + await repo.insertMessage({ + threadId: t.id, + direction: "in", + fromAddr: "clerk@city.gov", + body: "Q?", + }) expect((await repo.getThreadRecord(t.id))?.unread).toBe(true) const updated = await svc.reply(t.id, { body: "Here is the answer." }, "op-1") @@ -156,7 +276,9 @@ describe("mail-service: reply", () => { expect(dto?.messages[2]?.dir).toBe("out") expect(dto?.messages[2]?.body).toBe("Here is the answer.") expect(mailer.sent.at(-1)?.to).toBe("clerk@city.gov") - expect(mailer.sent.at(-1)?.outbound?.from).toMatch(/^"civfix" $/) + expect(mailer.sent.at(-1)?.outbound?.from).toMatch( + /^"civfix" $/, + ) expect(repo.audits.at(-1)).toMatchObject({ action: "mail.replied", target: `mail:${t.id}` }) }) @@ -238,14 +360,24 @@ describe("mail-service: reply", () => { }) await expect(svc.resend(t.id, "op-1")).rejects.toMatchObject({ httpStatus: 409 }) - await repo.recordEvent({ threadId: t.id, messageId: out!.id, type: "sent", meta: { late: true } }) + await repo.recordEvent({ + threadId: t.id, + messageId: out!.id, + type: "sent", + meta: { late: true }, + }) await expect(svc.reply(t.id, { body: "any update?" }, "op-1")).resolves.toBeDefined() }) it("H5: a thread with ONLY an inbound message has no jurisdiction contact, so Reply is refused", async () => { const { repo, svc } = harness() const t = await repo.createThread({ subject: "Cold inbound" }) - await repo.insertMessage({ threadId: t.id, direction: "in", fromAddr: "clerk@city.gov", body: "Q?" }) + await repo.insertMessage({ + threadId: t.id, + direction: "in", + fromAddr: "clerk@city.gov", + body: "Q?", + }) await expect(svc.reply(t.id, { body: "b" }, "op-1")).rejects.toMatchObject({ httpStatus: 422 }) }) @@ -274,7 +406,12 @@ describe("mail-service: reply", () => { toAddr: "clerk@city.gov", body: "Original packet.", }) - await repo.insertMessage({ threadId: t.id, direction: "in", fromAddr: "clerk@city.gov", body: "Q?" }) + await repo.insertMessage({ + threadId: t.id, + direction: "in", + fromAddr: "clerk@city.gov", + body: "Q?", + }) const getThread = vi.spyOn(repo, "getThread") const lastOutbound = vi.spyOn(repo, "getLastOutboundRecipient") @@ -291,7 +428,12 @@ describe("mail-service: reply", () => { httpStatus: 404, }) const t = await repo.createThread({ subject: "S" }) - await repo.insertMessage({ threadId: t.id, direction: "out", fromAddr: FROM_OUTREACH, body: "hi" }) + await repo.insertMessage({ + threadId: t.id, + direction: "out", + fromAddr: FROM_OUTREACH, + body: "hi", + }) await expect(svc.reply(t.id, { body: "b" }, "op-1")).rejects.toMatchObject({ httpStatus: 422 }) }) }) @@ -325,7 +467,12 @@ describe("mail-service: resend", () => { it("re-delivers the latest outbound body as a fresh OUT message to the correspondent", async () => { const { repo, mailer, svc } = harness() const t = await repo.createThread({ subject: "Follow-up", org: "City of LA" }) - await repo.insertMessage({ threadId: t.id, direction: "in", fromAddr: "clerk@city.gov", body: "Q?" }) + await repo.insertMessage({ + threadId: t.id, + direction: "in", + fromAddr: "clerk@city.gov", + body: "Q?", + }) await repo.insertMessage({ threadId: t.id, direction: "out", @@ -390,14 +537,21 @@ describe("mail-service: resend", () => { const replayed = h.repo.messagesOf(t.id).at(-1) expect(replayed?.kind).toBe("resend") expect(replayed?.html).toBe("

packet

") - expect(replayed?.attachments).toEqual([{ key: "media/r2/photo.jpg", filename: "photo.jpg", size: 4 }]) + expect(replayed?.attachments).toEqual([ + { key: "media/r2/photo.jpg", filename: "photo.jpg", size: 4 }, + ]) }) it("404s an unknown thread and 422s a thread with no outbound message to resend", async () => { const { repo, svc } = harness() await expect(svc.resend("missing", "op-1")).rejects.toMatchObject({ httpStatus: 404 }) const t = await repo.createThread({ subject: "S" }) - await repo.insertMessage({ threadId: t.id, direction: "in", fromAddr: "clerk@city.gov", body: "Q?" }) + await repo.insertMessage({ + threadId: t.id, + direction: "in", + fromAddr: "clerk@city.gov", + body: "Q?", + }) await expect(svc.resend(t.id, "op-1")).rejects.toMatchObject({ httpStatus: 422 }) }) }) diff --git a/services/api/test/unit/admin-moderation.test.ts b/services/api/test/unit/admin-moderation.test.ts index 441bb2fe..666c08bf 100644 --- a/services/api/test/unit/admin-moderation.test.ts +++ b/services/api/test/unit/admin-moderation.test.ts @@ -6,12 +6,21 @@ import { } from "../../src/services/admin/moderation-service.js" import type { UserStatus } from "@civfix/shared" - const NOW = new Date("2026-06-06T00:00:00.000Z") class FakeReportChatEmitter { - readonly events: { reportId: string; status: string; kind?: string | null; note?: string | null }[] = [] - emit(event: { reportId: string; status: string; kind?: string | null; note?: string | null }): Promise { + readonly events: { + reportId: string + status: string + kind?: string | null + note?: string | null + }[] = [] + emit(event: { + reportId: string + status: string + kind?: string | null + note?: string | null + }): Promise { this.events.push(event) return Promise.resolve() } @@ -227,7 +236,9 @@ describe("moderation detail", () => { strikes: 0, device: "iOS - Los Angeles", }, - media: [{ id: "MA-1", kind: "image", r2Key: "reports/x.jpg", thumbKey: "reports/x-thumb.jpg" }], + media: [ + { id: "MA-1", kind: "image", r2Key: "reports/x.jpg", thumbKey: "reports/x-thumb.jpg" }, + ], }) const detail = await svc.getItem("MOD-1") @@ -270,7 +281,9 @@ describe("moderation detail", () => { repo.seedItem({ id: "MOD-P", kind: "image", - media: [{ id: "MA-2", kind: "image", r2Key: "reports/held.jpg", thumbKey: "reports/held-t.jpg" }], + media: [ + { id: "MA-2", kind: "image", r2Key: "reports/held.jpg", thumbKey: "reports/held-t.jpg" }, + ], }) const detail = await svc.getItem("MOD-P") expect(detail.media[0]).toEqual({ @@ -479,7 +492,13 @@ describe("moderation actions", () => { repo.seedItem({ id: "MOD-U", subjectType: "user", subjectId: "USER-9", status: "open" }) await svc.remove("MOD-U", { actorId: "op-1", reason: "abuse" }) - repo.seedItem({ id: "APP-U", kind: "appeal", subjectType: "user", subjectId: "USER-9", status: "open" }) + repo.seedItem({ + id: "APP-U", + kind: "appeal", + subjectType: "user", + subjectId: "USER-9", + status: "open", + }) await svc.appeal("APP-U", { decision: "overturn", actorId: "op-1", note: null }) @@ -502,14 +521,32 @@ describe("moderation actions", () => { }, }) - repo.seedItem({ id: "APP-KEEP", kind: "appeal", subjectType: "user", subjectId: "USER-1", status: "open" }) + repo.seedItem({ + id: "APP-KEEP", + kind: "appeal", + subjectType: "user", + subjectId: "USER-1", + status: "open", + }) await svc.appeal("APP-KEEP", { decision: "uphold", actorId: "op-1", note: null }) - repo.seedItem({ id: "APP-CHAT", kind: "appeal", subjectType: "chat", subjectId: "CHAT-1", status: "open" }) + repo.seedItem({ + id: "APP-CHAT", + kind: "appeal", + subjectType: "chat", + subjectId: "CHAT-1", + status: "open", + }) await svc.appeal("APP-CHAT", { decision: "overturn", actorId: "op-1", note: null }) repo.deletedUserIds.add("USER-GONE") - repo.seedItem({ id: "APP-GONE", kind: "appeal", subjectType: "user", subjectId: "USER-GONE", status: "open" }) + repo.seedItem({ + id: "APP-GONE", + kind: "appeal", + subjectType: "user", + subjectId: "USER-GONE", + status: "open", + }) await svc.appeal("APP-GONE", { decision: "overturn", actorId: "op-1", note: null }) expect(cleared).toEqual([]) diff --git a/services/api/test/unit/admin-outreach-claim.test.ts b/services/api/test/unit/admin-outreach-claim.test.ts index f9f56966..167d9a2e 100644 --- a/services/api/test/unit/admin-outreach-claim.test.ts +++ b/services/api/test/unit/admin-outreach-claim.test.ts @@ -11,7 +11,6 @@ import { import type { OutboundMailService } from "../../src/services/admin/outbound-mail-service.js" import type { OutreachStateRecord } from "../../src/services/admin/mail-repository.js" - const NOW = new Date("2026-06-06T00:00:00.000Z") const THROTTLE_DAYS = 7 const WINDOW_MS = THROTTLE_DAYS * 24 * 60 * 60 * 1000 @@ -66,7 +65,9 @@ function harness(): Harness { }) const claim = outreachRepo.claimOutreachWindow if (claim === undefined) { - throw new Error("harness must construct InMemoryOutreachRepository with the shared outreach_state map") + throw new Error( + "harness must construct InMemoryOutreachRepository with the shared outreach_state map", + ) } return { outreachRepo, mailRepo, mailer, state, svc, claim } } @@ -96,7 +97,11 @@ describe("InMemoryOutreachRepository.claimOutreachWindow mirrors the SQL upsert- it("WINS again once the previous send falls outside the window", async () => { const { claim, state } = harness() - state.set(GEOID, { geoid: GEOID, lastOutreachAt: daysAgo(THROTTLE_DAYS + 1), suppressed: false }) + state.set(GEOID, { + geoid: GEOID, + lastOutreachAt: daysAgo(THROTTLE_DAYS + 1), + suppressed: false, + }) const won = await claim(GEOID, { at: NOW, windowStart: new Date(NOW.getTime() - WINDOW_MS), diff --git a/services/api/test/unit/admin-report-chat.test.ts b/services/api/test/unit/admin-report-chat.test.ts index 272f2226..9cc2b3c7 100644 --- a/services/api/test/unit/admin-report-chat.test.ts +++ b/services/api/test/unit/admin-report-chat.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from "vitest" -import { AppError, type ChatHistoryPage, type ChatMessageDTO, type UserMentionDTO } from "@civfix/shared" +import { + AppError, + type ChatHistoryPage, + type ChatMessageDTO, + type UserMentionDTO, +} from "@civfix/shared" import type { PersistChatInput } from "@civfix/shared/interfaces" import { makeAdminReportChatService } from "../../src/services/admin/admin-report-chat-service.js" import { InMemoryAdminReportChatRepository } from "../../src/services/admin/admin-report-chat-repository.memory.js" -import { sendReportChatMessage, type ReportChatSendDeps } from "../../src/services/report-chat-send.js" +import { + sendReportChatMessage, + type ReportChatSendDeps, +} from "../../src/services/report-chat-send.js" import { roomKeyFor } from "../../src/ws/gateway.js" const REPORT_ID = "11111111-1111-1111-1111-111111111111" @@ -37,7 +45,10 @@ interface SendHarness { } function sendHarness( - opts: { mentions?: UserMentionDTO[]; persistBody?: (input: PersistChatInput) => ChatMessageDTO } = {}, + opts: { + mentions?: UserMentionDTO[] + persistBody?: (input: PersistChatInput) => ChatMessageDTO + } = {}, ): SendHarness { const persisted: PersistChatInput[] = [] const broadcasts: SendHarness["broadcasts"] = [] diff --git a/services/api/test/unit/admin-reports.test.ts b/services/api/test/unit/admin-reports.test.ts index b34845d0..518dae00 100644 --- a/services/api/test/unit/admin-reports.test.ts +++ b/services/api/test/unit/admin-reports.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect } from "vitest" -import { - AppError, - DEFAULT_FORWARD_SUBJECT_TEMPLATE, - templateUsesToken, -} from "@civfix/shared" +import { AppError, DEFAULT_FORWARD_SUBJECT_TEMPLATE, templateUsesToken } from "@civfix/shared" import { FakeMailer } from "@civfix/shared/fakes" import { runAutoForwardWith } from "../../src/services/admin/autoforward-jobs.js" import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" @@ -25,7 +21,6 @@ import { type OutboundMailService, } from "../../src/services/admin/outbound-mail-service.js" - const NOW = new Date("2026-06-06T00:00:00.000Z") const REPORTER = { @@ -48,9 +43,19 @@ const REPORT_STATUSES = [ ] as const class FakeReportChatEmitter { - readonly events: { reportId: string; status: string; kind?: string | null; note?: string | null }[] = [] + readonly events: { + reportId: string + status: string + kind?: string | null + note?: string | null + }[] = [] shouldThrow = false - emit(event: { reportId: string; status: string; kind?: string | null; note?: string | null }): Promise { + emit(event: { + reportId: string + status: string + kind?: string | null + note?: string | null + }): Promise { this.events.push(event) if (this.shouldThrow) return Promise.reject(new Error("emit boom")) return Promise.resolve() @@ -341,7 +346,11 @@ describe("admin reports list", () => { repo.seedReport({ id: "unverified-held", status: "held" }) repo.seedReport({ id: "unverified-published", status: "published" }) repo.seedReport({ id: "approved", status: "published", verificationVerdict: "approved" }) - repo.seedReport({ id: "rejected-verdict", status: "submitted", verificationVerdict: "rejected" }) + repo.seedReport({ + id: "rejected-verdict", + status: "submitted", + verificationVerdict: "rejected", + }) repo.seedReport({ id: "later-stage", status: "in_progress" }) const ids = (await svc.list({ filter: "needs_verification" })).items.map((i) => i.id).sort() @@ -645,9 +654,9 @@ describe("admin reports mutations", () => { const { repo, emitter, svc } = harness() repo.seedReport({ id: "rep-1", status: "acknowledged" }) emitter.shouldThrow = true - await expect( - svc.setStatus("rep-1", { status: "resolved", actorId: "op-1" }), - ).rejects.toThrow("emit boom") + await expect(svc.setStatus("rep-1", { status: "resolved", actorId: "op-1" })).rejects.toThrow( + "emit boom", + ) expect(repo.reports.get("rep-1")?.record.status).toBe("resolved") expect(repo.timeline.get("rep-1")?.at(-1)).toMatchObject({ status: "resolved" }) }) @@ -779,7 +788,11 @@ describe("admin reports mutations", () => { it("follow-up to city REFUSES while a send on that thread is still in flight (409, nothing mailed)", async () => { const { repo, mailRepo, mailer, svc } = harness() - const thread = mailRepo.seedThread({ reportId: "rep-1", subject: "Hazard report", status: "sent" }) + const thread = mailRepo.seedThread({ + reportId: "rep-1", + subject: "Hazard report", + status: "sent", + }) repo.seedReport({ id: "rep-1", routing: { @@ -920,7 +933,10 @@ describe("M5: routeToJurisdiction destination + audit", () => { }) describe("routeToJurisdiction template resolution", () => { - function seedWith(h: Harness, templates: { subject?: string | null; body?: string | null }): void { + function seedWith( + h: Harness, + templates: { subject?: string | null; body?: string | null }, + ): void { h.repo.seedReport({ id: "rep-1", status: "submitted", @@ -944,7 +960,10 @@ describe("routeToJurisdiction template resolution", () => { it("sends the JURISDICTION's own template, rendered, when it has one", async () => { const h = harness() - h.forwardTemplates.seed({ subjectTemplate: "Default {referenceCode}", bodyTemplate: "Default body." }) + h.forwardTemplates.seed({ + subjectTemplate: "Default {referenceCode}", + bodyTemplate: "Default body.", + }) seedWith(h, { subject: "City case {referenceCode}: {category}", body: "A {category} report at {address}, confirmed by {confirmations} neighbors.", @@ -994,7 +1013,10 @@ describe("routeToJurisdiction template resolution", () => { it("an EMPTY jurisdiction template does not shadow the stored default", async () => { const h = harness() - h.forwardTemplates.seed({ subjectTemplate: "Stored {referenceCode}", bodyTemplate: "Stored body." }) + h.forwardTemplates.seed({ + subjectTemplate: "Stored {referenceCode}", + bodyTemplate: "Stored body.", + }) seedWith(h, { subject: " ", body: "" }) await h.svc.routeToJurisdiction("rep-1", { note: null, actorId: "op-1" }) @@ -1195,7 +1217,10 @@ describe("routeToJurisdiction re-send gate", () => { it("clears a thread's 'bounced' status once a re-route actually delivers (no unbounded re-sends)", async () => { const h = harness() - const thread = await h.mailRepo.findOrCreateReportThread("rep-1", { subject: "S", status: "sent" }) + const thread = await h.mailRepo.findOrCreateReportThread("rep-1", { + subject: "S", + status: "sent", + }) await h.mailRepo.setThreadStatus(thread.id, "bounced") expect((await h.mailRepo.getThreadRecord(thread.id))?.status).toBe("bounced") seedRouted(h, { threadStatus: "bounced" }) @@ -1380,7 +1405,12 @@ describe("F009 routeToJurisdiction concurrent double-send guard", () => { expect(timeline[0]?.status).toBe("acknowledged") expect(timeline[0]?.who).toBe("op-1") expect(repo.audits.filter((a) => a.action === "report.status_changed")).toEqual([ - { actorId: "op-1", action: "report.status_changed", target: "report:rep-1", meta: { status: "acknowledged" } }, + { + actorId: "op-1", + action: "report.status_changed", + target: "report:rep-1", + meta: { status: "acknowledged" }, + }, ]) }) @@ -1402,7 +1432,7 @@ describe("F009 routeToJurisdiction concurrent double-send guard", () => { let lockDepth = 0 let maxLockDepthDuringSend = 0 const realLock = repo.withRouteLock.bind(repo) - repo.withRouteLock = async (id: string, fn: () => Promise): Promise => { + repo.withRouteLock = async (id: string, fn: () => Promise): Promise => { return realLock(id, async () => { lockDepth += 1 try { diff --git a/services/api/test/unit/admin-routes-http.test.ts b/services/api/test/unit/admin-routes-http.test.ts index 8f443ef3..64f242ec 100644 --- a/services/api/test/unit/admin-routes-http.test.ts +++ b/services/api/test/unit/admin-routes-http.test.ts @@ -26,7 +26,6 @@ import { InMemoryMailRepository } from "../../src/services/admin/mail-repository import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" import { makeOutboundMailService } from "../../src/services/admin/outbound-mail-service.js" - const OPERATOR = "ops@civfix.org" const FROM_OUTREACH = "outreach@civfix.org" @@ -645,7 +644,9 @@ describe("every admin list route rejects a malformed query with 422", () => { for (const tab of ["reports", "events", "messages"]) { const res = await get(`/v1/admin/users/${SUBJECT_USER}/${tab}?limit=abc`) expect(res.statusCode, tab).toBe(422) - expect((res.json() as { fields?: Record }).fields, tab).toHaveProperty("limit") + expect((res.json() as { fields?: Record }).fields, tab).toHaveProperty( + "limit", + ) } expect(h.reads).toEqual([]) }) diff --git a/services/api/test/unit/admin-system-health.test.ts b/services/api/test/unit/admin-system-health.test.ts index f519f59e..b3d63411 100644 --- a/services/api/test/unit/admin-system-health.test.ts +++ b/services/api/test/unit/admin-system-health.test.ts @@ -6,7 +6,6 @@ import { type SystemHealthProbes, } from "../../src/services/admin/system-health-service.js" - const FULL_ENV: SystemHealthEnv = { glitchTipConfigured: true, tileCdnConfigured: true, @@ -65,11 +64,29 @@ describe("system health assembly", () => { it("F125: probe failures map to a fixed vocabulary and never leak driver detail", async () => { const cases: { err: unknown; val: string }[] = [ - { err: Object.assign(new Error("connect ECONNREFUSED 10.0.0.7:5432"), { code: "ECONNREFUSED" }), val: "Unreachable" }, - { err: Object.assign(new Error("getaddrinfo ENOTFOUND compose-postgres-1"), { code: "ENOTFOUND" }), val: "Unreachable" }, - { err: Object.assign(new Error('password authentication failed for user "civfix"'), { code: "28P01" }), val: "Auth failed" }, + { + err: Object.assign(new Error("connect ECONNREFUSED 10.0.0.7:5432"), { + code: "ECONNREFUSED", + }), + val: "Unreachable", + }, + { + err: Object.assign(new Error("getaddrinfo ENOTFOUND compose-postgres-1"), { + code: "ENOTFOUND", + }), + val: "Unreachable", + }, + { + err: Object.assign(new Error('password authentication failed for user "civfix"'), { + code: "28P01", + }), + val: "Auth failed", + }, { err: new Error("probe timed out after 2000ms"), val: "Timed out" }, - { err: Object.assign(new Error('relation "pgboss.job" does not exist'), { code: "42P01" }), val: "Not provisioned" }, + { + err: Object.assign(new Error('relation "pgboss.job" does not exist'), { code: "42P01" }), + val: "Not provisioned", + }, ] for (const { err, val } of cases) { const svc = makeSystemHealthService({ @@ -154,7 +171,12 @@ describe("system health assembly", () => { it("GlitchTip / Basemap reflect configuration", async () => { const svc = makeSystemHealthService({ probes: {}, - env: { glitchTipConfigured: false, tileCdnConfigured: false, mailerIsFake: false, jobsIsFake: false }, + env: { + glitchTipConfigured: false, + tileCdnConfigured: false, + mailerIsFake: false, + jobsIsFake: false, + }, }) const { services } = await svc.health() expect(row(services, "GlitchTip")).toMatchObject({ status: "warn", val: "Not configured" }) diff --git a/services/api/test/unit/admin-users.test.ts b/services/api/test/unit/admin-users.test.ts index 5875ca17..a5b4262d 100644 --- a/services/api/test/unit/admin-users.test.ts +++ b/services/api/test/unit/admin-users.test.ts @@ -7,7 +7,6 @@ import { } from "../../src/services/admin/admin-user-service.js" import { avatarGradient, type UserStatus } from "@civfix/shared" - const NOW = new Date("2026-06-06T00:00:00.000Z") interface Harness { @@ -439,7 +438,6 @@ describe("admin users mutations", () => { expect(revoked).toHaveLength(0) }) - it("H3: REFUSES to grant `operator` (no console-minted operator backdoor)", async () => { const { repo, svc, revoked } = harness() repo.seedUser({ id: "u-1", role: "citizen" }) diff --git a/services/api/test/unit/affiliation.test.ts b/services/api/test/unit/affiliation.test.ts index 1fda3c66..3e982d4c 100644 --- a/services/api/test/unit/affiliation.test.ts +++ b/services/api/test/unit/affiliation.test.ts @@ -39,7 +39,15 @@ function fakeSql(rows: FakeRow[]): { sql: Sql; calls: Capture[]; fragments: Capt } function person(id: string): PersonDTO { - return { id, name: id, handle: null, avatar: null, followers: 0, following: 0, isFollowing: false } + return { + id, + name: id, + handle: null, + avatar: null, + followers: 0, + following: 0, + isFollowing: false, + } } function row(over: Partial & { user_id: string; id: string }): FakeRow { diff --git a/services/api/test/unit/anon-hold-release.test.ts b/services/api/test/unit/anon-hold-release.test.ts index 21930be7..78c632e2 100644 --- a/services/api/test/unit/anon-hold-release.test.ts +++ b/services/api/test/unit/anon-hold-release.test.ts @@ -3,7 +3,6 @@ import { FakeAbuseChecks } from "@civfix/shared/fakes" import { releaseAnonHoldIfReady } from "../../src/services/anon-hold-release.js" import { InMemoryAnonStore } from "../helpers/anon.js" - function harness() { const store = new InMemoryAnonStore() const abuse = new FakeAbuseChecks() @@ -15,7 +14,12 @@ function harness() { describe("releaseAnonHoldIfReady: publishes when ready + clean", () => { it("flips a held anon report to published once its single media is ready and clean", async () => { const { store, release } = harness() - const r = store.seedReport({ status: "held", reporterUserId: null, anonSessionId: "anontok-1", publishedAt: null }) + const r = store.seedReport({ + status: "held", + reporterUserId: null, + anonSessionId: "anontok-1", + publishedAt: null, + }) store.seedMedia({ reportId: r.id, status: "ready" }) const res = await release(r.id) @@ -29,7 +33,12 @@ describe("releaseAnonHoldIfReady: publishes when ready + clean", () => { it("publishes a media-less held report (nothing to validate)", async () => { const { store, release } = harness() - const r = store.seedReport({ status: "held", reporterUserId: null, anonSessionId: "anontok-1", publishedAt: null }) + const r = store.seedReport({ + status: "held", + reporterUserId: null, + anonSessionId: "anontok-1", + publishedAt: null, + }) const res = await release(r.id) expect(res.outcome).toBe("published") expect(store.reports.get(r.id)!.status).toBe("published") @@ -37,7 +46,12 @@ describe("releaseAnonHoldIfReady: publishes when ready + clean", () => { it("publishes when every one of several media is ready", async () => { const { store, release } = harness() - const r = store.seedReport({ status: "held", reporterUserId: null, anonSessionId: "anontok-1", publishedAt: null }) + const r = store.seedReport({ + status: "held", + reporterUserId: null, + anonSessionId: "anontok-1", + publishedAt: null, + }) store.seedMedia({ reportId: r.id, status: "ready" }) store.seedMedia({ reportId: r.id, status: "ready" }) const res = await release(r.id) @@ -48,7 +62,12 @@ describe("releaseAnonHoldIfReady: publishes when ready + clean", () => { describe("releaseAnonHoldIfReady: stays held", () => { it("stays held when a media is HELD (nsfw)", async () => { const { store, release } = harness() - const r = store.seedReport({ status: "held", reporterUserId: null, anonSessionId: "anontok-1", publishedAt: null }) + const r = store.seedReport({ + status: "held", + reporterUserId: null, + anonSessionId: "anontok-1", + publishedAt: null, + }) store.seedMedia({ reportId: r.id, status: "ready" }) store.seedMedia({ reportId: r.id, status: "held" }) const res = await release(r.id) @@ -58,7 +77,12 @@ describe("releaseAnonHoldIfReady: stays held", () => { it("stays held when a media is REJECTED", async () => { const { store, release } = harness() - const r = store.seedReport({ status: "held", reporterUserId: null, anonSessionId: "anontok-1", publishedAt: null }) + const r = store.seedReport({ + status: "held", + reporterUserId: null, + anonSessionId: "anontok-1", + publishedAt: null, + }) store.seedMedia({ reportId: r.id, status: "rejected" }) const res = await release(r.id) expect(res.outcome).toBe("media_blocked") @@ -67,7 +91,12 @@ describe("releaseAnonHoldIfReady: stays held", () => { it("stays held while a media is still validating (not done yet)", async () => { const { store, release } = harness() - const r = store.seedReport({ status: "held", reporterUserId: null, anonSessionId: "anontok-1", publishedAt: null }) + const r = store.seedReport({ + status: "held", + reporterUserId: null, + anonSessionId: "anontok-1", + publishedAt: null, + }) store.seedMedia({ reportId: r.id, status: "ready" }) store.seedMedia({ reportId: r.id, status: "validating" }) const res = await release(r.id) @@ -77,7 +106,12 @@ describe("releaseAnonHoldIfReady: stays held", () => { it("stays held when an OPEN abuse_flag exists for the report (dup)", async () => { const { store, release } = harness() - const r = store.seedReport({ status: "held", reporterUserId: null, anonSessionId: "anontok-1", publishedAt: null }) + const r = store.seedReport({ + status: "held", + reporterUserId: null, + anonSessionId: "anontok-1", + publishedAt: null, + }) store.seedMedia({ reportId: r.id, status: "ready" }) store.seedOpenFlag("report", r.id, "phash_dup") const res = await release(r.id) @@ -87,7 +121,12 @@ describe("releaseAnonHoldIfReady: stays held", () => { it("stays held when an OPEN abuse_flag exists for one of its media", async () => { const { store, release } = harness() - const r = store.seedReport({ status: "held", reporterUserId: null, anonSessionId: "anontok-1", publishedAt: null }) + const r = store.seedReport({ + status: "held", + reporterUserId: null, + anonSessionId: "anontok-1", + publishedAt: null, + }) const m = store.seedMedia({ reportId: r.id, status: "ready" }) store.seedOpenFlag("media", m.id, "nsfw") const res = await release(r.id) diff --git a/services/api/test/unit/anon-routes.test.ts b/services/api/test/unit/anon-routes.test.ts index 9a399153..27bd95a6 100644 --- a/services/api/test/unit/anon-routes.test.ts +++ b/services/api/test/unit/anon-routes.test.ts @@ -20,7 +20,6 @@ import { clientQuery } from "../helpers/query.js" import type { ReportServiceOverrides } from "../../src/routes/reports.routes.js" import type { ReportOwner } from "../../src/services/report-service.js" - const SIGNING_KEY = "test-anon-signing-key" const KEY_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" @@ -170,7 +169,11 @@ function mirrorHeldIntoReportRepo(h: Harness, reportId: string): void { describe("POST /anon/reports", () => { it("creates a HELD report (202) with a claim code and issues an anon token", async () => { const { app } = await makeHarness() - const res = await app.inject({ method: "POST", url: "/v1/anon/reports", payload: anonPayload() }) + const res = await app.inject({ + method: "POST", + url: "/v1/anon/reports", + payload: anonPayload(), + }) expect(res.statusCode).toBe(202) const body = res.json() expect(body.status).toBe("held") @@ -209,7 +212,11 @@ describe("POST /anon/reports", () => { it("replays the original response for a duplicate idempotency key (still 202, same report)", async () => { const { app, anonStore } = await makeHarness() - const first = await app.inject({ method: "POST", url: "/v1/anon/reports", payload: anonPayload() }) + const first = await app.inject({ + method: "POST", + url: "/v1/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). @@ -227,7 +234,11 @@ describe("POST /anon/reports", () => { it("F028: a DIFFERENT anon session reusing the key gets 409, never the first submitter's snapshot", async () => { const { app, anonStore } = await makeHarness() - const first = await app.inject({ method: "POST", url: "/v1/anon/reports", payload: anonPayload() }) + const first = await app.inject({ + method: "POST", + url: "/v1/anon/reports", + payload: anonPayload(), + }) const firstBody = first.json() const other = await app.inject({ @@ -352,7 +363,11 @@ describe("held anon report stays hidden", () => { it("GET /anon/reports/:id/status 404s a wrong claim code (no enumeration)", async () => { const h = await makeHarness() - const submit = await h.app.inject({ method: "POST", url: "/v1/anon/reports", payload: anonPayload() }) + const submit = await h.app.inject({ + method: "POST", + url: "/v1/anon/reports", + payload: anonPayload(), + }) const { reportId } = submit.json() const status = await h.app.inject({ method: "GET", @@ -363,7 +378,11 @@ describe("held anon report stays hidden", () => { it("GET /anon/reports/:id/status 422s a missing claim code", async () => { const h = await makeHarness() - const submit = await h.app.inject({ method: "POST", url: "/v1/anon/reports", payload: anonPayload() }) + const submit = await h.app.inject({ + method: "POST", + url: "/v1/anon/reports", + payload: anonPayload(), + }) const { reportId } = submit.json() const status = await h.app.inject({ method: "GET", url: `/v1/anon/reports/${reportId}/status` }) expect(status.statusCode).toBe(422) @@ -371,7 +390,11 @@ describe("held anon report stays hidden", () => { it("P2-7: the status endpoint has a dedicated tighter per-IP limit (429 past 30/min)", async () => { const h = await makeHarness() - const submit = await h.app.inject({ method: "POST", url: "/v1/anon/reports", payload: anonPayload() }) + const submit = await h.app.inject({ + method: "POST", + url: "/v1/anon/reports", + payload: anonPayload(), + }) const { reportId } = submit.json() let saw429 = false for (let i = 0; i < 40; i++) { @@ -392,7 +415,11 @@ describe("held anon report stays hidden", () => { describe("claim flow", () => { it("nudge -> sign-in -> claim links the report to the user (mine=true), single-use", async () => { const h = await makeHarness() - const submit = await h.app.inject({ method: "POST", url: "/v1/anon/reports", payload: anonPayload() }) + const submit = await h.app.inject({ + method: "POST", + url: "/v1/anon/reports", + payload: anonPayload(), + }) const { reportId } = submit.json() const anonToken = submit.headers["x-anon-token"] as string mirrorHeldIntoReportRepo(h, reportId) @@ -445,7 +472,11 @@ describe("claim flow", () => { it("POST /claim/nudge falls back to the civfix_anon cookie when the body omits anonToken", async () => { const h = await makeHarness() - const submit = await h.app.inject({ method: "POST", url: "/v1/anon/reports", payload: anonPayload() }) + const submit = await h.app.inject({ + method: "POST", + url: "/v1/anon/reports", + payload: anonPayload(), + }) const { reportId } = submit.json() const anonToken = submit.headers["x-anon-token"] as string mirrorHeldIntoReportRepo(h, reportId) diff --git a/services/api/test/unit/anon-service.test.ts b/services/api/test/unit/anon-service.test.ts index 54325103..0cb5a768 100644 --- a/services/api/test/unit/anon-service.test.ts +++ b/services/api/test/unit/anon-service.test.ts @@ -14,7 +14,6 @@ import { import { InMemoryAnonStore } from "../helpers/anon.js" import { sha256Hex } from "../../src/auth/crypto.js" - const SIGNING_KEY = "test-anon-signing-key" const KEY_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" const KEY_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" @@ -72,7 +71,6 @@ function req(over: Partial = {}): AnonReportRequest { const ctx = { ip: "203.0.113.10", cfGeo: {} as Record } - describe("submitAnonReport: held create", () => { it("creates a HELD report, issues an anon token, stamps a claim code, bumps report_count", async () => { const { store, service } = makeHarness() @@ -115,7 +113,6 @@ describe("submitAnonReport: held create", () => { }) }) - describe("submitAnonReport: Turnstile", () => { it("rejects a failed Turnstile FIRST with TURNSTILE_FAILED and creates nothing", async () => { const { store, service } = makeHarness() @@ -136,7 +133,9 @@ describe("submitAnonReport: honeypot", () => { service.submitAnonReport(req({ honeypot: "gotcha", anonToken: signed }), ctx), ).rejects.toMatchObject({ code: "VALIDATION" }) expect(store.reports.size).toBe(0) - expect(flags).toEqual([{ subjectType: "anon_token", subjectId: "anontok-1", reason: "honeypot" }]) + expect(flags).toEqual([ + { subjectType: "anon_token", subjectId: "anontok-1", reason: "honeypot" }, + ]) }) it("a whitespace-only honeypot is treated as empty (legitimate)", async () => { @@ -209,9 +208,9 @@ describe("submitAnonReport: per-token cap", () => { const { store, service } = makeHarness() const token = store.seedToken({ id: "anontok-1", reportCount: ANON_TOKEN_REPORT_CAP }) const signed = signAnonToken(token.id, SIGNING_KEY) - await expect( - service.submitAnonReport(req({ anonToken: signed }), ctx), - ).rejects.toMatchObject({ code: "RATE_LIMITED" }) + await expect(service.submitAnonReport(req({ anonToken: signed }), ctx)).rejects.toMatchObject({ + code: "RATE_LIMITED", + }) expect(store.reports.size).toBe(0) }) }) @@ -300,7 +299,6 @@ describe("submitAnonReport: GPS sanity", () => { }) }) - describe("submitAnonReport: idempotency replay", () => { it("returns the ORIGINAL AnonReportResponse for a duplicate key, with NO second report", async () => { const { store, service } = makeHarness() @@ -309,7 +307,11 @@ describe("submitAnonReport: idempotency replay", () => { const countAfterFirst = store.tokens.get("anontok-1")!.reportCount const second = await service.submitAnonReport( - req({ idempotencyKey: KEY_A, category: "hazard", anonToken: signAnonToken("anontok-1", SIGNING_KEY) }), + req({ + idempotencyKey: KEY_A, + category: "hazard", + anonToken: signAnonToken("anontok-1", SIGNING_KEY), + }), ctx, ) expect(second.response).toEqual(first.response) @@ -330,7 +332,10 @@ describe("submitAnonReport: idempotency replay", () => { it("bugs P1-1: an idempotent replay does NOT burn per-IP or per-H3-cell budget", async () => { const { store, service, counters } = makeHarness() - const first = await service.submitAnonReport(req({ idempotencyKey: KEY_A, lat: 34.1, lng: -118.35 }), ctx) + const first = await service.submitAnonReport( + req({ idempotencyKey: KEY_A, lat: 34.1, lng: -118.35 }), + ctx, + ) expect(store.reports.size).toBe(1) const ipKey = "abuse:ip:203.0.113.10" @@ -342,7 +347,12 @@ describe("submitAnonReport: idempotency replay", () => { for (let i = 0; i < 4; i++) { const replay = await service.submitAnonReport( - req({ idempotencyKey: KEY_A, lat: 34.1, lng: -118.35, anonToken: signAnonToken("anontok-1", SIGNING_KEY) }), + req({ + idempotencyKey: KEY_A, + lat: 34.1, + lng: -118.35, + anonToken: signAnonToken("anontok-1", SIGNING_KEY), + }), ctx, ) expect(replay.response).toEqual(first.response) @@ -353,7 +363,6 @@ describe("submitAnonReport: idempotency replay", () => { }) }) - describe("submitAnonReport: per-token cap is atomic under concurrency (bugs P0-1)", () => { it("fires N concurrent submits on a token with ONE slot left; at most one is created", async () => { const { store, service } = makeHarness() @@ -364,10 +373,10 @@ describe("submitAnonReport: per-token cap is atomic under concurrency (bugs P0-1 const N = 6 const results = await Promise.allSettled( Array.from({ length: N }, (_unused, i) => - service.submitAnonReport( - req({ idempotencyKey: uuid(i), anonToken: signed }), - { ip: `10.1.0.${i}`, cfGeo: {} }, - ), + service.submitAnonReport(req({ idempotencyKey: uuid(i), anonToken: signed }), { + ip: `10.1.0.${i}`, + cfGeo: {}, + }), ), ) @@ -396,12 +405,14 @@ describe("submitAnonReport: per-token cap is atomic under concurrency (bugs P0-1 }) }) - describe("anonReportStatus", () => { it("returns the status for the matching claim code", async () => { const { service } = makeHarness() const created = await service.submitAnonReport(req(), ctx) - const status = await service.anonReportStatus(created.response.reportId, created.response.claimCode) + const status = await service.anonReportStatus( + created.response.reportId, + created.response.claimCode, + ) expect(status.status).toBe("held") expect(status.publishedAt).toBeUndefined() }) @@ -427,7 +438,10 @@ describe("anonReportStatus", () => { const r = store.reports.get(created.response.reportId)! r.status = "published" r.publishedAt = new Date("2026-02-01T00:00:00Z") - const status = await service.anonReportStatus(created.response.reportId, created.response.claimCode) + const status = await service.anonReportStatus( + created.response.reportId, + created.response.claimCode, + ) expect(status.status).toBe("published") expect(status.publishedAt).toBe("2026-02-01T00:00:00.000Z") }) @@ -496,7 +510,6 @@ describe("claim codes are persisted as a digest only (F150)", () => { }) }) - describe("submitAnonReport: idempotency is scoped to the anon session (F028)", () => { it("does NOT replay one session's snapshot (or its claim code) to a DIFFERENT session", async () => { const { store, service } = makeHarness() @@ -521,8 +534,14 @@ describe("submitAnonReport: idempotency is scoped to the anon session (F028)", ( it("still replays the original response for the SAME anon session", async () => { const { store, service } = makeHarness() const signed = signAnonToken(store.seedToken({ id: "anontok-owner" }).id, SIGNING_KEY) - const first = await service.submitAnonReport(req({ idempotencyKey: KEY_A, anonToken: signed }), ctx) - const replay = await service.submitAnonReport(req({ idempotencyKey: KEY_A, anonToken: signed }), ctx) + const first = await service.submitAnonReport( + req({ idempotencyKey: KEY_A, anonToken: signed }), + ctx, + ) + const replay = await service.submitAnonReport( + req({ idempotencyKey: KEY_A, anonToken: signed }), + ctx, + ) expect(replay.response).toEqual(first.response) expect(store.reports.size).toBe(1) @@ -531,9 +550,14 @@ describe("submitAnonReport: idempotency is scoped to the anon session (F028)", ( it("a caller presenting NO anon token cannot replay a session-owned snapshot", async () => { const { store, service } = makeHarness() const signed = signAnonToken(store.seedToken({ id: "anontok-owner" }).id, SIGNING_KEY) - const first = await service.submitAnonReport(req({ idempotencyKey: KEY_A, anonToken: signed }), ctx) + const first = await service.submitAnonReport( + req({ idempotencyKey: KEY_A, anonToken: signed }), + ctx, + ) - await expect(service.submitAnonReport(req({ idempotencyKey: KEY_A }), ctx)).rejects.toMatchObject({ + await expect( + service.submitAnonReport(req({ idempotencyKey: KEY_A }), ctx), + ).rejects.toMatchObject({ code: "CONFLICT", }) expect(store.reports.size).toBe(1) diff --git a/services/api/test/unit/audit-read-actions.test.ts b/services/api/test/unit/audit-read-actions.test.ts index f6a4d9b7..f2d8406a 100644 --- a/services/api/test/unit/audit-read-actions.test.ts +++ b/services/api/test/unit/audit-read-actions.test.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url" import { describe, expect, it } from "vitest" import { AUDIT_READ_ACTIONS } from "../../src/services/admin/audit.js" - const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "src") function walk(dir: string, out: string[] = []): string[] { diff --git a/services/api/test/unit/auth-jwks.test.ts b/services/api/test/unit/auth-jwks.test.ts index 3cb0481c..03a910fb 100644 --- a/services/api/test/unit/auth-jwks.test.ts +++ b/services/api/test/unit/auth-jwks.test.ts @@ -10,8 +10,7 @@ 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") + const enc = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url") const signingInput = `${enc(header)}.${enc(claims)}` const signer = createSign("RSA-SHA256") signer.update(signingInput) @@ -135,7 +134,13 @@ describe("RemoteJwksVerifier", () => { it("accepts a token whose nonce claim is the SHA-256 hex of the nonce (Apple native)", async () => { const raw = "client-nonce-xyz" const hashed = createHash("sha256").update(raw).digest("hex") - const token = signJwt(privateKey, { iss: ISS, aud: AUD, sub: "s", exp: NOW + 600, nonce: hashed }) + const token = signJwt(privateKey, { + iss: ISS, + aud: AUD, + sub: "s", + exp: NOW + 600, + nonce: hashed, + }) const verifier = makeVerifier(publicKey, NOW) const result = await verifier.verify(token, { ...params(NOW), expectedNonce: raw }) expect(result.sub).toBe("s") @@ -150,13 +155,17 @@ describe("RemoteJwksVerifier", () => { nonce: "some-other-nonce", }) const verifier = makeVerifier(publicKey, NOW) - await expectUnauthorized(verifier.verify(token, { ...params(NOW), expectedNonce: "expected" })) + await expectUnauthorized( + verifier.verify(token, { ...params(NOW), expectedNonce: "expected" }), + ) }) it("REJECTS a token with NO nonce claim when one is expected", async () => { const token = signJwt(privateKey, { iss: ISS, aud: AUD, sub: "s", exp: NOW + 600 }) const verifier = makeVerifier(publicKey, NOW) - await expectUnauthorized(verifier.verify(token, { ...params(NOW), expectedNonce: "expected" })) + await expectUnauthorized( + verifier.verify(token, { ...params(NOW), expectedNonce: "expected" }), + ) }) it("a caller that passes NO expectedNonce gets no nonce checking — which is why sign-in always passes one", async () => { diff --git a/services/api/test/unit/auth-oauth.test.ts b/services/api/test/unit/auth-oauth.test.ts index 29277270..d3f2fb48 100644 --- a/services/api/test/unit/auth-oauth.test.ts +++ b/services/api/test/unit/auth-oauth.test.ts @@ -107,7 +107,9 @@ describe("OAuthService", () => { }) it("accepts a native Apple token whose aud is the bundle id via extraAudiences", async () => { - const verifier = new CapturingVerifier(claims("apple-native-sub", "native@example.com", "Native User")) + const verifier = new CapturingVerifier( + claims("apple-native-sub", "native@example.com", "Native User"), + ) const service = new OAuthService({ config: { apple: { @@ -130,9 +132,13 @@ describe("OAuthService", () => { }) it("threads an expectedNonce through Google native sign-in to the verifier, and omits it when absent", async () => { - const verifier = new CapturingVerifier(claims("google-nonce-sub", "nonce@example.com", "Nonce User")) + const verifier = new CapturingVerifier( + claims("google-nonce-sub", "nonce@example.com", "Nonce User"), + ) const service = new OAuthService({ - config: { google: { clientId: "gid", clientSecret: "gsecret", redirectUri: "http://localhost/cb" } }, + config: { + google: { clientId: "gid", clientSecret: "gsecret", redirectUri: "http://localhost/cb" }, + }, oauthStore: new InMemoryOAuthIdentityStore(), users: new InMemoryUserStore(), verifier, diff --git a/services/api/test/unit/auth-otp.test.ts b/services/api/test/unit/auth-otp.test.ts index f55b6d17..ba175aed 100644 --- a/services/api/test/unit/auth-otp.test.ts +++ b/services/api/test/unit/auth-otp.test.ts @@ -456,7 +456,10 @@ describe("OtpService reviewer-OTP bypass", () => { it("when the bypass is NOT configured, the reviewer email behaves like a normal email", async () => { const { service, users } = makeOtp() - await expectAppError(service.verifyOtp(REVIEWER_EMAIL, REVIEWER_CODE, IP), ErrorCode.UNAUTHORIZED) + await expectAppError( + service.verifyOtp(REVIEWER_EMAIL, REVIEWER_CODE, IP), + ErrorCode.UNAUTHORIZED, + ) expect(await users.findByEmail(REVIEWER_EMAIL)).toBeNull() }) @@ -543,10 +546,7 @@ describe("OTP per-IP counters normalize IPv6 to the /64 (F004)", () => { for (let i = 0; i < OTP_IP_MAX_PER_WINDOW; i++) { await service.issueOtp(`user${i}@example.com`, i % 2 === 0 ? IPV6_A : IPV6_B) } - await expectAppError( - service.issueOtp("overflow@example.com", IPV6_B), - ErrorCode.RATE_LIMITED, - ) + await expectAppError(service.issueOtp("overflow@example.com", IPV6_B), ErrorCode.RATE_LIMITED) }) it("two addresses in one /64 share the verify-failure throttle", async () => { diff --git a/services/api/test/unit/auth-route-mounting.test.ts b/services/api/test/unit/auth-route-mounting.test.ts index edacc57f..a95949a6 100644 --- a/services/api/test/unit/auth-route-mounting.test.ts +++ b/services/api/test/unit/auth-route-mounting.test.ts @@ -49,9 +49,9 @@ describe("auth/admin route mounting is gated on the auth bundle", () => { // failure is systemic (the whole auth surface is gone), not specific to 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, - ) + 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" }) diff --git a/services/api/test/unit/backfill-served-key.test.ts b/services/api/test/unit/backfill-served-key.test.ts index 37374751..511960bd 100644 --- a/services/api/test/unit/backfill-served-key.test.ts +++ b/services/api/test/unit/backfill-served-key.test.ts @@ -22,7 +22,10 @@ function scripted(pages: Row[][], updated: Row[][], remaining: number): FakeSqlC ]) } -function statementsMatching(fake: FakeSqlControl, re: RegExp): { sql: string; values: unknown[] }[] { +function statementsMatching( + fake: FakeSqlControl, + re: RegExp, +): { sql: string; values: unknown[] }[] { return fake.statements.filter((s) => re.test(s.sql)) } diff --git a/services/api/test/unit/boundaries-manifest.test.ts b/services/api/test/unit/boundaries-manifest.test.ts index 38128df0..9fc3ef01 100644 --- a/services/api/test/unit/boundaries-manifest.test.ts +++ b/services/api/test/unit/boundaries-manifest.test.ts @@ -157,6 +157,8 @@ 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). - expect(vintageTag(DEFAULT_TIGER_VINTAGE)).toBe(`tiger${DEFAULT_TIGER_VINTAGE}-padus${PADUS_VERSION}`) + expect(vintageTag(DEFAULT_TIGER_VINTAGE)).toBe( + `tiger${DEFAULT_TIGER_VINTAGE}-padus${PADUS_VERSION}`, + ) }) }) diff --git a/services/api/test/unit/certificate-model.test.ts b/services/api/test/unit/certificate-model.test.ts index 9284e758..d7ff3e0e 100644 --- a/services/api/test/unit/certificate-model.test.ts +++ b/services/api/test/unit/certificate-model.test.ts @@ -1,4 +1,3 @@ - import { MAX_CERTIFICATE_ENTRIES } from "@civfix/shared" import { describe, expect, it } from "vitest" import { diff --git a/services/api/test/unit/cf-access.test.ts b/services/api/test/unit/cf-access.test.ts index 23ec1f37..9c2c2ec8 100644 --- a/services/api/test/unit/cf-access.test.ts +++ b/services/api/test/unit/cf-access.test.ts @@ -23,7 +23,10 @@ const TEAM = "https://civfix.cloudflareaccess.com" const AUD = "access-app-aud-tag" const KID = "test-key-1" -async function setup(): Promise<{ privateKey: KeyLike; verify: ReturnType }> { +async function setup(): Promise<{ + privateKey: KeyLike + verify: ReturnType +}> { const { publicKey, privateKey } = await generateKeyPair("RS256") const jwk = await exportJWK(publicKey) jwk.kid = KID @@ -40,11 +43,7 @@ interface SignOpts { expEpoch?: number } -function sign( - key: KeyLike, - claims: Record, - opts: SignOpts = {}, -): Promise { +function sign(key: KeyLike, claims: Record, opts: SignOpts = {}): Promise { const nowSec = Math.floor(Date.now() / 1000) return new SignJWT(claims) .setProtectedHeader({ alg: opts.alg ?? "RS256", kid: KID }) @@ -81,7 +80,11 @@ describe("createAccessVerifier", () => { it("rejects a token with the wrong issuer", async () => { const { privateKey, verify } = await setup() - const token = await sign(privateKey, { email: "ops@civfix.org" }, { iss: "https://evil.example" }) + const token = await sign( + privateKey, + { email: "ops@civfix.org" }, + { iss: "https://evil.example" }, + ) await expect(verify(token)).rejects.toThrow() }) diff --git a/services/api/test/unit/chat-attachments-servable.test.ts b/services/api/test/unit/chat-attachments-servable.test.ts index a2f7ed84..4a35fa7f 100644 --- a/services/api/test/unit/chat-attachments-servable.test.ts +++ b/services/api/test/unit/chat-attachments-servable.test.ts @@ -24,7 +24,10 @@ const MESSAGE = "11111111-1111-4111-8111-111111111111" const OTHER_MESSAGE = "22222222-2222-4222-8222-222222222222" const PRESIGN = (r2Key: string, thumbKey: string | null) => - Promise.resolve({ url: `memory://${r2Key}`, ...(thumbKey !== null ? { thumbUrl: `memory://${thumbKey}` } : {}) }) + Promise.resolve({ + url: `memory://${r2Key}`, + ...(thumbKey !== null ? { thumbUrl: `memory://${thumbKey}` } : {}), + }) function row(over: Record = {}) { return { @@ -147,7 +150,11 @@ describe("loadServableAttachmentsFor", () => { match: /FROM media_assets/, rows: [ row(), - row({ id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", message_id: OTHER_MESSAGE, status: "ready" }), + row({ + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + message_id: OTHER_MESSAGE, + status: "ready", + }), ], }, ]) diff --git a/services/api/test/unit/chat-block-gates.test.ts b/services/api/test/unit/chat-block-gates.test.ts index ec29ec79..d019d5c6 100644 --- a/services/api/test/unit/chat-block-gates.test.ts +++ b/services/api/test/unit/chat-block-gates.test.ts @@ -24,7 +24,11 @@ function message(): ChatMessageDTO { return { id: "msg-1", cleanupId: ROOM, - from: { id: ACTOR, name: "Blocker McBlocked", avatar: { kind: "gradient", from: "#000", to: "#fff" } }, + from: { + id: ACTOR, + name: "Blocker McBlocked", + avatar: { kind: "gradient", from: "#000", to: "#fff" }, + }, body: "hello there", kind: "text", createdAt: new Date().toISOString(), @@ -33,7 +37,10 @@ function message(): ChatMessageDTO { } as unknown as ChatMessageDTO } -function notificationSpy(): { createNotifications: ReturnType; recipients: () => string[] } { +function notificationSpy(): { + createNotifications: ReturnType + recipients: () => string[] +} { const createNotifications = vi.fn(() => Promise.resolve()) return { createNotifications, @@ -180,7 +187,9 @@ describe("L10: the dm lane's pin power respects blocks", () => { }) it("a blocked participant holds NO powers in the thread", async () => { - const resolve = makeChatPowersResolver(deps({ isDmBlocked: () => Promise.resolve(true) }) as never) + const resolve = makeChatPowersResolver( + deps({ isDmBlocked: () => Promise.resolve(true) }) as never, + ) expect(await resolve({ roomKind: "dm", roomId: ROOM, userId: ACTOR })).toEqual({ canPin: false, canDeleteOthers: false, @@ -189,7 +198,9 @@ describe("L10: the dm lane's pin power respects blocks", () => { }) it("an unblocked participant keeps canPin (and never canDeleteOthers)", async () => { - const resolve = makeChatPowersResolver(deps({ isDmBlocked: () => Promise.resolve(false) }) as never) + const resolve = makeChatPowersResolver( + deps({ isDmBlocked: () => Promise.resolve(false) }) as never, + ) expect(await resolve({ roomKind: "dm", roomId: ROOM, userId: ACTOR })).toEqual({ canPin: true, canDeleteOthers: false, diff --git a/services/api/test/unit/chat-broadcast-viewer-fields.test.ts b/services/api/test/unit/chat-broadcast-viewer-fields.test.ts index 746e090e..b5ac295d 100644 --- a/services/api/test/unit/chat-broadcast-viewer-fields.test.ts +++ b/services/api/test/unit/chat-broadcast-viewer-fields.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest" -import { - handleClientFrame, - type GatewayDeps, - type GatewaySession, -} from "../../src/ws/gateway.js" +import { handleClientFrame, type GatewayDeps, type GatewaySession } from "../../src/ws/gateway.js" import { WsChatService } from "../../src/adapters/chat-service.ws.js" import { InMemoryChatPubSub } from "../../src/adapters/chat-pubsub.js" import { InMemoryChatPresence } from "../../src/adapters/chat-presence.js" @@ -41,7 +37,8 @@ function gatewayDeps(): GatewayDeps { return { chat, presence, - isMember: (_roomId: string, userId: string) => Promise.resolve(userId === ALICE || userId === BOB), + isMember: (_roomId: string, userId: string) => + Promise.resolve(userId === ALICE || userId === BOB), reportVisible: () => Promise.resolve(true), reportChat: { isMember: () => Promise.resolve(true), @@ -50,7 +47,8 @@ function gatewayDeps(): GatewayDeps { access: () => Promise.resolve({ isMember: true, canPost: true, visibility: "private" }), } as unknown as GatewayDeps["groupChat"], dm: { - peerOf: (threadId: string, userId: string) => Promise.resolve(dmRepo.peerOf(threadId, userId)), + peerOf: (threadId: string, userId: string) => + Promise.resolve(dmRepo.peerOf(threadId, userId)), persist: (input: Parameters[0]) => dmRepo.persist(input), markRead: () => Promise.resolve(), }, @@ -199,7 +197,8 @@ describe("F043 delete tombstones fan out without the deleter's viewer fields", ( senderPath: true, softDelete: () => Promise.resolve(tombstoneDto()), findMessageMeta: () => Promise.resolve(null), - resolveChatPowers: () => Promise.resolve({ canDeleteOthers: true, canPin: true, isModerator: true }), + resolveChatPowers: () => + Promise.resolve({ canDeleteOthers: true, canPin: true, isModerator: true }), chat: stub, legacyBroadcast: true, }) diff --git a/services/api/test/unit/chat-edit-service.test.ts b/services/api/test/unit/chat-edit-service.test.ts index 3b96a3f3..bf12757f 100644 --- a/services/api/test/unit/chat-edit-service.test.ts +++ b/services/api/test/unit/chat-edit-service.test.ts @@ -43,7 +43,10 @@ function dmHarness() { describe("chat-edit-service (offline branches)", () => { it("409s the LOST RACE: meta resolves live but the sender-gated UPDATE matches nothing", async () => { const chat = cleanupHarness() - const msg = await chat.insertMessage({ cleanupId: ROOM, userId: ALICE, body: "hi" }, randomUUID()) + const msg = await chat.insertMessage( + { cleanupId: ROOM, userId: ALICE, body: "hi" }, + randomUUID(), + ) // Simulate the row being tombstoned between findMessageMeta and the gated UPDATE: the meta still // reads live, but the edit write matches nothing. chat.editMessage = () => Promise.resolve(null) @@ -65,7 +68,10 @@ describe("chat-edit-service (offline branches)", () => { it("REPLACES the mention set: dropping every @mention records an EMPTY replace", async () => { const chat = cleanupHarness() - const msg = await chat.insertMessage({ cleanupId: ROOM, userId: ALICE, body: "hey @bob" }, randomUUID()) + const msg = await chat.insertMessage( + { cleanupId: ROOM, userId: ALICE, body: "hey @bob" }, + randomUUID(), + ) const resolveInputs: { handles: string[]; userIds: string[] }[] = [] const recorded: { messageId: string; ids: string[] }[] = [] @@ -100,7 +106,10 @@ describe("chat-edit-service (offline branches)", () => { it("passes parsed @handles from the edited body to the mention resolver", async () => { const chat = cleanupHarness() - const msg = await chat.insertMessage({ cleanupId: ROOM, userId: ALICE, body: "plain" }, randomUUID()) + const msg = await chat.insertMessage( + { cleanupId: ROOM, userId: ALICE, body: "plain" }, + randomUUID(), + ) const resolveInputs: { handles: string[] }[] = [] const service = makeChatEditService({ diff --git a/services/api/test/unit/chat-fanout-job.test.ts b/services/api/test/unit/chat-fanout-job.test.ts index 08bb6ca7..6606ecc0 100644 --- a/services/api/test/unit/chat-fanout-job.test.ts +++ b/services/api/test/unit/chat-fanout-job.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect } from "vitest" import { FakePushSender } from "@civfix/shared/fakes" import type { ChatMessageDTO, PersonDTO } from "@civfix/shared" @@ -17,7 +16,10 @@ import { type RoomFanoutNotifierDeps, } from "../../src/services/chat-room-fanout-notifier.js" import { makeNotificationService } from "../../src/services/notification-service.js" -import { InMemoryNotificationRepository, flushNotificationDispatch } from "../helpers/notifications.js" +import { + InMemoryNotificationRepository, + flushNotificationDispatch, +} from "../helpers/notifications.js" const ACTOR = "dddddddd-dddd-dddd-dddd-dddddddddddd" const A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" 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 bf820ebb..521391bc 100644 --- a/services/api/test/unit/chat-gateway-fanout-handoff.test.ts +++ b/services/api/test/unit/chat-gateway-fanout-handoff.test.ts @@ -104,10 +104,7 @@ describe("H19 over the WS send lane: a burst enqueues one job, never fans out in dispatchToJob: makeRoomFanoutDispatcher(p.jobs, "group"), }) const conn = new MockConnection("A") - const s = session( - { onGroupMessage: (groupId, message) => notify(groupId, message) }, - conn, - ) + const s = session({ onGroupMessage: (groupId, message) => notify(groupId, message) }, conn) await handleClientFrame( s, JSON.stringify({ type: "join", cleanupId: GROUP, roomKind: "group" }), @@ -186,7 +183,13 @@ describe("H19 over the WS send lane: a burst enqueues one job, never fans out in ) await handleClientFrame( s, - JSON.stringify({ type: "send", cleanupId: GROUP, roomKind: "group", body: "x", clientId: "c1" }), + JSON.stringify({ + type: "send", + cleanupId: GROUP, + roomKind: "group", + body: "x", + clientId: "c1", + }), ) await flush() @@ -210,7 +213,10 @@ describe("H19 over the WS send lane: a burst enqueues one job, never fans out in const conn = new MockConnection("A") const s = session( { - reportChat: { isMember: () => Promise.resolve(true), advanceReadWatermark: () => Promise.resolve() }, + reportChat: { + isMember: () => Promise.resolve(true), + advanceReadWatermark: () => Promise.resolve(), + }, onReportMessage: (reportId, message) => notify(reportId, message), }, conn, @@ -240,10 +246,12 @@ describe("H19 over the WS send lane: a burst enqueues one job, never fans out in }) it("USE_FAKE_JOBS never queues: FakeJobs registers no chat.room.fanout handler, so bells must stay inline", () => { - expect(roomFanoutMode({ useFakeChat: false, useFakeJobs: false, usesRealRedis: true })).toEqual({ - queued: true, - claimed: true, - }) + expect(roomFanoutMode({ useFakeChat: false, useFakeJobs: false, usesRealRedis: true })).toEqual( + { + queued: true, + claimed: true, + }, + ) expect(roomFanoutMode({ useFakeChat: false, useFakeJobs: true, usesRealRedis: true })).toEqual({ queued: false, claimed: true, @@ -252,7 +260,9 @@ describe("H19 over the WS send lane: a burst enqueues one job, never fans out in queued: false, claimed: false, }) - expect(roomFanoutMode({ useFakeChat: false, useFakeJobs: false, usesRealRedis: false })).toEqual({ + expect( + roomFanoutMode({ useFakeChat: false, useFakeJobs: false, usesRealRedis: false }), + ).toEqual({ queued: false, claimed: false, }) @@ -282,7 +292,13 @@ describe("H19 over the WS send lane: a burst enqueues one job, never fans out in ) await handleClientFrame( s, - JSON.stringify({ type: "send", cleanupId: GROUP, roomKind: "group", body: "x", clientId: "c1" }), + JSON.stringify({ + type: "send", + cleanupId: GROUP, + roomKind: "group", + body: "x", + clientId: "c1", + }), ) await flush() @@ -312,7 +328,13 @@ describe("H19 over the WS send lane: a burst enqueues one job, never fans out in ) await handleClientFrame( s, - JSON.stringify({ type: "send", cleanupId: GROUP, roomKind: "group", body: "x", clientId: "c1" }), + JSON.stringify({ + type: "send", + cleanupId: GROUP, + roomKind: "group", + body: "x", + clientId: "c1", + }), ) await flush() 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 09974507..17d7d4a3 100644 --- a/services/api/test/unit/chat-group-service-gates.test.ts +++ b/services/api/test/unit/chat-group-service-gates.test.ts @@ -3,7 +3,6 @@ import { AppError } from "@civfix/shared" import { makeChatGroupService } from "../../src/services/chat-group-service.js" import type { ChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" - const GROUP = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" const OWNER = "11111111-1111-1111-1111-111111111111" const MEMBER = "22222222-2222-2222-2222-222222222222" @@ -56,7 +55,8 @@ function fakeRepo(opts: FakeOpts = {}): FakeRepo { return { findById: () => Promise.resolve(exists ? view : null), - roleOf: (_g: string, userId: string) => Promise.resolve(exists ? (roles[userId] ?? null) : null), + roleOf: (_g: string, userId: string) => + Promise.resolve(exists ? (roles[userId] ?? null) : null), listMemberIds: () => Promise.resolve(members), invitableIdsOf: (actor: string, candidates: string[]) => Promise.resolve(candidates.filter((c) => !blocked.has(`${actor}|${c}`))), @@ -85,7 +85,10 @@ async function statusOf(run: () => Promise): Promise<{ status: number; return { status: 200 } } catch (err) { if (err instanceof AppError) { - return { status: err.httpStatus, ...(err.fields?.code ? { code: String(err.fields.code) } : {}) } + return { + status: err.httpStatus, + ...(err.fields?.code ? { code: String(err.fields.code) } : {}), + } } throw err } @@ -147,7 +150,9 @@ describe("addMembers reports the ACCEPTED invitees, independently of the members describe("L8: unknown group and no-access answer identically (no existence oracle)", () => { it("readable surface: unknown group and private non-member both 403 not_a_member", async () => { - const unknown = await statusOf(() => svcOver(fakeRepo({ exists: false })).getGroup(STRANGER, GROUP)) + const unknown = await statusOf(() => + svcOver(fakeRepo({ exists: false })).getGroup(STRANGER, GROUP), + ) const priv = await statusOf(() => svcOver(fakeRepo()).getGroup(STRANGER, GROUP)) expect(unknown).toEqual({ status: 403, code: "not_a_member" }) expect(priv).toEqual(unknown) @@ -166,7 +171,10 @@ describe("L8: unknown group and no-access answer identically (no existence oracl it("addMembers: unknown group and a powerless caller both 403 add_members_forbidden", async () => { const unknown = await statusOf(() => - svcOver(fakeRepo({ exists: false })).addMembers(STRANGER, { id: GROUP, memberIds: [INVITEE_OK] }), + svcOver(fakeRepo({ exists: false })).addMembers(STRANGER, { + id: GROUP, + memberIds: [INVITEE_OK], + }), ) const powerless = await statusOf(() => svcOver(fakeRepo()).addMembers(STRANGER, { id: GROUP, memberIds: [INVITEE_OK] }), @@ -179,7 +187,9 @@ describe("L8: unknown group and no-access answer identically (no existence oracl const unknown = await statusOf(() => svcOver(fakeRepo({ exists: false })).removeMember(STRANGER, GROUP, MEMBER), ) - const powerless = await statusOf(() => svcOver(fakeRepo()).removeMember(STRANGER, GROUP, MEMBER)) + const powerless = await statusOf(() => + svcOver(fakeRepo()).removeMember(STRANGER, GROUP, MEMBER), + ) expect(unknown).toEqual({ status: 403, code: "remove_forbidden" }) expect(powerless).toEqual(unknown) }) @@ -196,7 +206,9 @@ describe("L8: unknown group and no-access answer identically (no existence oracl }) it("joinGroup: unknown group and a private group both 403 not_public", async () => { - const unknown = await statusOf(() => svcOver(fakeRepo({ exists: false })).joinGroup(STRANGER, GROUP)) + const unknown = await statusOf(() => + svcOver(fakeRepo({ exists: false })).joinGroup(STRANGER, GROUP), + ) const priv = await statusOf(() => svcOver(fakeRepo()).joinGroup(STRANGER, GROUP)) expect(unknown).toEqual({ status: 403, code: "not_public" }) expect(priv).toEqual(unknown) diff --git a/services/api/test/unit/chat-mentions-gateway.test.ts b/services/api/test/unit/chat-mentions-gateway.test.ts index 631d72a6..57e6b71b 100644 --- a/services/api/test/unit/chat-mentions-gateway.test.ts +++ b/services/api/test/unit/chat-mentions-gateway.test.ts @@ -75,9 +75,19 @@ function spyMentions() { return { seam, recorded, notified } } -function sessionFor(userId: string, conn: MockConnection, mentions: GatewayChatMentions): GatewaySession { +function sessionFor( + userId: string, + conn: MockConnection, + mentions: GatewayChatMentions, +): GatewaySession { const deps: GatewayDeps = { chat, isMember: memberOf, chatMentions: mentions } - return { userId, conn, joined: new Set(), typingThrottle: new Map(), deps } + return { + userId, + conn, + joined: new Set(), + typingThrottle: new Map(), + deps, + } } beforeEach(() => { 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 6e6e61f8..aaca81a3 100644 --- a/services/api/test/unit/chat-poll-close-membership.test.ts +++ b/services/api/test/unit/chat-poll-close-membership.test.ts @@ -6,7 +6,6 @@ import { type ChatPollServiceDeps, } from "../../src/services/chat-poll-service.js" - const CLEANUP = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" const POLL_MSG = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" const AUTHOR = "cccccccc-cccc-cccc-cccc-cccccccccccc" @@ -60,7 +59,8 @@ async function statusOf(run: () => Promise): Promise<{ status: number; await run() return { status: 200 } } catch (err) { - if (err instanceof AppError) return { status: err.httpStatus, code: err.fields?.["code"] as string } + if (err instanceof AppError) + return { status: err.httpStatus, code: err.fields?.["code"] as string } throw err } } diff --git a/services/api/test/unit/chat-presence.test.ts b/services/api/test/unit/chat-presence.test.ts index 9a3770a8..7dc4d6eb 100644 --- a/services/api/test/unit/chat-presence.test.ts +++ b/services/api/test/unit/chat-presence.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest" -import { - handleClientFrame, - type GatewaySession, - type GatewayDeps, -} from "../../src/ws/gateway.js" +import { handleClientFrame, type GatewaySession, type GatewayDeps } from "../../src/ws/gateway.js" import { WsChatService } from "../../src/adapters/chat-service.ws.js" import { InMemoryChatPubSub } from "../../src/adapters/chat-pubsub.js" import { InMemoryChatPresence, PRESENCE_TTL_MS } from "../../src/adapters/chat-presence.js" @@ -28,7 +24,13 @@ let presence: InMemoryChatPresence function sessionFor(userId: string, conn: MockConnection): GatewaySession { const deps: GatewayDeps = { chat, isMember: allMembers, presence } - return { userId, conn, joined: new Set(), typingThrottle: new Map(), deps } + return { + userId, + conn, + joined: new Set(), + typingThrottle: new Map(), + deps, + } } beforeEach(() => { @@ -100,12 +102,17 @@ describe("gateway presence flow (snapshot to joiner, deltas to others)", () => { await handleClientFrame(bSession, JSON.stringify({ type: "join", cleanupId: ROOM })) // B's snapshot lists both; A gets a presence(join) delta for B; B gets no delta about itself. - expect((bConn.framesOfType("presence_snapshot")[0] as { userIds: string[] }).userIds.sort()).toEqual( - [ALICE, BOB].sort(), - ) + expect( + (bConn.framesOfType("presence_snapshot")[0] as { userIds: string[] }).userIds.sort(), + ).toEqual([ALICE, BOB].sort()) const aDeltas = aConn.framesOfType("presence") expect(aDeltas).toHaveLength(1) - expect(aDeltas[0]).toMatchObject({ type: "presence", userId: BOB, state: "join", cleanupId: ROOM }) + expect(aDeltas[0]).toMatchObject({ + type: "presence", + userId: BOB, + state: "join", + cleanupId: ROOM, + }) expect(bConn.framesOfType("presence")).toHaveLength(0) }) @@ -139,7 +146,9 @@ describe("gateway presence flow (snapshot to joiner, deltas to others)", () => { await handleClientFrame(a1Session, JSON.stringify({ type: "join", cleanupId: ROOM })) await handleClientFrame(a2Session, JSON.stringify({ type: "join", cleanupId: ROOM })) - const leaveDeltasBefore = bConn.framesOfType("presence").filter((f) => f.state === "leave").length + const leaveDeltasBefore = bConn + .framesOfType("presence") + .filter((f) => f.state === "leave").length // Alice's first device leaves: she still has a2, so NO leave delta. await handleClientFrame(a1Session, JSON.stringify({ type: "leave", cleanupId: ROOM })) diff --git a/services/api/test/unit/chat-pubsub-redis.test.ts b/services/api/test/unit/chat-pubsub-redis.test.ts index 8cf20133..cafd2065 100644 --- a/services/api/test/unit/chat-pubsub-redis.test.ts +++ b/services/api/test/unit/chat-pubsub-redis.test.ts @@ -42,7 +42,10 @@ describe("RedisChatPubSub over ioredis-mock", () => { const received: string[] = [] const unsubscribe = await pubsub.subscribe(chatChannel(ROOM), (p) => received.push(p)) - await pubsub.publish(chatChannel(ROOM), JSON.stringify({ type: "message", message: { body: "hi" } })) + await pubsub.publish( + chatChannel(ROOM), + JSON.stringify({ type: "message", message: { body: "hi" } }), + ) // Allow the mock's async message delivery to flush. await new Promise((r) => setTimeout(r, 20)) @@ -83,7 +86,12 @@ describe("RedisChatPubSub over ioredis-mock", () => { await new Promise((r) => setTimeout(r, 20)) // Alice sends from worker 1. - const msg = await worker1.persist({ cleanupId: ROOM, userId: ALICE, body: "cross-node", clientId: "c1" }) + const msg = await worker1.persist({ + cleanupId: ROOM, + userId: ALICE, + body: "cross-node", + clientId: "c1", + }) await worker1.broadcast(ROOM, msg) await new Promise((r) => setTimeout(r, 20)) diff --git a/services/api/test/unit/chat-pubsub.test.ts b/services/api/test/unit/chat-pubsub.test.ts index 70d30bb1..5eea958a 100644 --- a/services/api/test/unit/chat-pubsub.test.ts +++ b/services/api/test/unit/chat-pubsub.test.ts @@ -40,7 +40,12 @@ describe("Redis pub/sub fan-out across two workers (in-memory pub/sub)", () => { expect(pubsub.channelCount).toBe(1) // Alice sends from worker 1: persist there, then broadcast (PUBLISH) on the shared pub/sub. - const msg = await worker1.persist({ cleanupId: ROOM, userId: ALICE, body: "cross-worker hi", clientId: "c1" }) + const msg = await worker1.persist({ + cleanupId: ROOM, + userId: ALICE, + body: "cross-worker hi", + clientId: "c1", + }) await worker1.broadcast(ROOM, msg) // Bob (worker 2) received the broadcast even though Alice's socket lives on a different worker. @@ -83,7 +88,12 @@ describe("Redis pub/sub fan-out across two workers (in-memory pub/sub)", () => { // 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, userId: ALICE, body: "elsewhere", clientId: "c" }) + const msg = await worker.persist({ + cleanupId: other, + userId: ALICE, + body: "elsewhere", + clientId: "c", + }) await worker.broadcast(other, msg) expect(conn.framesOfType("message")).toHaveLength(0) }) diff --git a/services/api/test/unit/chat-reaction-service.test.ts b/services/api/test/unit/chat-reaction-service.test.ts index 0e097adf..9ce5ab56 100644 --- a/services/api/test/unit/chat-reaction-service.test.ts +++ b/services/api/test/unit/chat-reaction-service.test.ts @@ -101,7 +101,6 @@ describe("chat reaction service — cleanup group chat", () => { const h = makeHarness() await h.chat.insertMessage({ cleanupId: CLEANUP, userId: ALICE, body: "hi" }, MSG_ID) await expect( - h.service.toggleCleanupReaction(CLEANUP, MSG_ID, ALICE, "thumbsup" as any), ).rejects.toBeInstanceOf(AppError) }) @@ -134,9 +133,9 @@ describe("chat reaction service — direct messages", () => { const thread = await h.dm.openOrCreateThread(ALICE, BOB) const msg = await h.dm.persist({ threadId: thread.id, senderId: ALICE, body: "yo" }) await h.blocks.block(ALICE, BOB) - await expect( - h.service.toggleDmReaction(thread.id, msg.id, BOB, "like"), - ).rejects.toMatchObject({ httpStatus: 403 }) + await expect(h.service.toggleDmReaction(thread.id, msg.id, BOB, "like")).rejects.toMatchObject({ + httpStatus: 403, + }) }) it("404s a missing dm message", async () => { diff --git a/services/api/test/unit/chat-realtime.test.ts b/services/api/test/unit/chat-realtime.test.ts index d58586f6..bd6bf546 100644 --- a/services/api/test/unit/chat-realtime.test.ts +++ b/services/api/test/unit/chat-realtime.test.ts @@ -1,18 +1,10 @@ import { describe, it, expect, beforeEach, vi } from "vitest" -import { - handleClientFrame, - type GatewaySession, - type GatewayDeps, -} from "../../src/ws/gateway.js" +import { handleClientFrame, type GatewaySession, type GatewayDeps } from "../../src/ws/gateway.js" import { WsChatService } from "../../src/adapters/chat-service.ws.js" import { InMemoryChatPubSub, chatChannel } from "../../src/adapters/chat-pubsub.js" import { InMemoryChatPresence } from "../../src/adapters/chat-presence.js" import { InMemoryChatRepository, MockConnection } from "../helpers/chat.js" -import { - WsClientMessageSchema, - WsServerMessageSchema, - type ChatMessageDTO, -} from "@civfix/shared" +import { WsClientMessageSchema, WsServerMessageSchema, type ChatMessageDTO } from "@civfix/shared" /** * THE PHASE-1 DONE-CRITERION, proven locally: two devices chat in real time. @@ -48,7 +40,13 @@ 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 { userId, conn, joined: new Set(), typingThrottle: new Map(), deps } + return { + userId, + conn, + joined: new Set(), + typingThrottle: new Map(), + deps, + } } /** Assert a raw frame string parses as a valid client frame (outbound-from-client direction). */ @@ -142,9 +140,9 @@ describe("two-device real-time chat (A -> B with ack + persistence)", () => { // 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). 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", - ) + 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) @@ -335,7 +333,10 @@ describe("ack updates read state for the open room", () => { // After joining, an ack marks THAT room read. await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: ROOM })) - await handleClientFrame(session, JSON.stringify({ type: "ack", upToId: "55555555-5555-5555-5555-555555555555" })) + await handleClientFrame( + session, + JSON.stringify({ type: "ack", upToId: "55555555-5555-5555-5555-555555555555" }), + ) expect(marks).toHaveLength(1) expect(marks[0]).toMatchObject({ cleanupId: ROOM, userId: ALICE }) }) diff --git a/services/api/test/unit/chat-reply-gateway.test.ts b/services/api/test/unit/chat-reply-gateway.test.ts index 7fe08738..7012f5d7 100644 --- a/services/api/test/unit/chat-reply-gateway.test.ts +++ b/services/api/test/unit/chat-reply-gateway.test.ts @@ -43,7 +43,12 @@ let repo: InMemoryChatRepository function spySeams() { const recorded: Array<{ messageId: string; ids: string[] }> = [] const mentionBells: string[] = [] - const replyBells: Array<{ kind: string; roomId: string; actorUserId: string; targetUserId: string }> = [] + const replyBells: Array<{ + kind: string + roomId: string + actorUserId: string + targetUserId: string + }> = [] const dir: Record = { bob: { id: BOB, handle: "bob", displayName: "Bob" }, cara: { id: CARA, handle: "cara", displayName: "Cara" }, @@ -89,7 +94,13 @@ function sessionFor( chatMentions: seams.chatMentions, onChatReply: seams.onChatReply, } - return { userId, conn, joined: new Set(), typingThrottle: new Map(), deps } + return { + userId, + conn, + joined: new Set(), + typingThrottle: new Map(), + deps, + } } /** Flush the fire-and-forget bell microtasks queued by handleSend. */ @@ -171,7 +182,13 @@ describe("replies over the gateway send path (P2 2.5)", () => { const mineId = (aConn.framesOfType("ack").at(-1) as { message: ChatMessageDTO }).message.id await handleClientFrame( aSession, - JSON.stringify({ type: "send", cleanupId: ROOM, body: "self reply", clientId: "c2", replyToId: mineId }), + JSON.stringify({ + type: "send", + cleanupId: ROOM, + body: "self reply", + clientId: "c2", + replyToId: mineId, + }), ) await flush() diff --git a/services/api/test/unit/chat-room-fanout-coalescing.test.ts b/services/api/test/unit/chat-room-fanout-coalescing.test.ts index 6e0fd005..da7130c9 100644 --- a/services/api/test/unit/chat-room-fanout-coalescing.test.ts +++ b/services/api/test/unit/chat-room-fanout-coalescing.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect, beforeEach } from "vitest" import { FakePushSender } from "@civfix/shared/fakes" import type { ChatMessageDTO, PersonDTO, UserMentionDTO } from "@civfix/shared" @@ -12,7 +11,10 @@ import { makeNotificationService, type NotificationService, } from "../../src/services/notification-service.js" -import { InMemoryNotificationRepository, flushNotificationDispatch } from "../helpers/notifications.js" +import { + InMemoryNotificationRepository, + flushNotificationDispatch, +} from "../helpers/notifications.js" const ACTOR = "dddddddd-dddd-dddd-dddd-dddddddddddd" const A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" @@ -132,9 +134,11 @@ describe("room-activity coalescing (H19)", () => { await flushNotificationDispatch() expect(bells(A)).toHaveLength(2) - expect(bells(A).map((n) => n.link).sort()).toEqual( - [`/messages/group/${ROOM}`, `/messages/group/${other}`].sort(), - ) + expect( + bells(A) + .map((n) => n.link) + .sort(), + ).toEqual([`/messages/group/${ROOM}`, `/messages/group/${other}`].sort()) }) it("refreshes the coalesced bell to the LATEST sender + preview instead of inserting a second row", async () => { @@ -170,7 +174,9 @@ describe("room-activity coalescing (H19)", () => { it("leaves the mention bell alone: mentioned members are excluded and their own bells are per message", async () => { const notify = makeNotifier({ members: [ACTOR, A, MENTIONED] }) - const mention: UserMentionDTO[] = [{ id: MENTIONED, handle: "mentioned", displayName: "Mentioned" }] + const mention: UserMentionDTO[] = [ + { id: MENTIONED, handle: "mentioned", displayName: "Mentioned" }, + ] await notify(ROOM, message("hey @mentioned", mention)) clockMs += 1_000 @@ -198,7 +204,11 @@ describe("room-activity coalescing (H19)", () => { send: (): Promise => new Promise(() => {}), sendMany: (_userIds: string[], _payload: PushPayload): Promise => new Promise(() => {}), } - const service = makeNotificationService({ repo, pushSender: hanging, now: () => new Date(clockMs) }) + const service = makeNotificationService({ + repo, + pushSender: hanging, + now: () => new Date(clockMs), + }) const notify = makeRoomFanoutNotifier( { kind: "group", titleFallbackKey: "notification.group_chat.title_fallback" }, { diff --git a/services/api/test/unit/chat-room-roles.test.ts b/services/api/test/unit/chat-room-roles.test.ts index 5f7415aa..e79eacc3 100644 --- a/services/api/test/unit/chat-room-roles.test.ts +++ b/services/api/test/unit/chat-room-roles.test.ts @@ -89,18 +89,24 @@ describe("resolveChatPowers — cleanup rooms", () => { it("plain member: nothing", async () => { const resolve = makeChatPowersResolver(deps({ cleanupRoleOf: async () => "member" })) - await expect(resolve({ roomKind: "cleanup", roomId: ROOM, userId: USER })).resolves.toEqual(NONE) + await expect(resolve({ roomKind: "cleanup", roomId: ROOM, userId: USER })).resolves.toEqual( + NONE, + ) }) it("non-member: nothing", async () => { const resolve = makeChatPowersResolver(deps({ cleanupRoleOf: async () => null })) - await expect(resolve({ roomKind: "cleanup", roomId: ROOM, userId: USER })).resolves.toEqual(NONE) + await expect(resolve({ roomKind: "cleanup", roomId: ROOM, userId: USER })).resolves.toEqual( + NONE, + ) }) 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(NONE) + await expect(resolve({ roomKind: "cleanup", roomId: ROOM, userId: USER })).resolves.toEqual( + NONE, + ) }) }) diff --git a/services/api/test/unit/chat-routes.test.ts b/services/api/test/unit/chat-routes.test.ts index 61723a6d..1b879f49 100644 --- a/services/api/test/unit/chat-routes.test.ts +++ b/services/api/test/unit/chat-routes.test.ts @@ -16,7 +16,6 @@ import { SESSION_COOKIE } from "../../src/auth/transport.js" import { sha256Hex } from "../../src/auth/crypto.js" import type { WsTicketPayload } from "../../src/auth/ws-ticket.js" - let current: FastifyInstance | undefined afterEach(async () => { @@ -121,7 +120,11 @@ describe("GET /threads", () => { const { token, userId } = await signIn(app, mailer, "member@example.com") const cleanupId = threadsRepo.seedCleanup("Cleanup with chatter") threadsRepo.addMember(cleanupId, userId, new Date("2026-06-01T10:00:00.000Z")) - threadsRepo.addMember(cleanupId, "99999999-9999-9999-9999-999999999999", new Date("2026-06-01T09:00:00.000Z")) + threadsRepo.addMember( + cleanupId, + "99999999-9999-9999-9999-999999999999", + new Date("2026-06-01T09:00:00.000Z"), + ) threadsRepo.addMessage(cleanupId, { senderId: "99999999-9999-9999-9999-999999999999", body: "anyone bringing bags?", @@ -147,7 +150,11 @@ describe("GET /threads", () => { }) describe("resolveWsUser (dual handshake auth)", () => { - async function withSession(): Promise<{ sessions: SessionService; token: string; userId: string }> { + async function withSession(): Promise<{ + sessions: SessionService + token: string + userId: string + }> { const stores = makeInMemoryStores() const cache = new InMemoryCacheClient(() => Date.now()) const sessions = new SessionService({ store: stores.sessions, cache, now: () => Date.now() }) diff --git a/services/api/test/unit/chat-send-resilience.test.ts b/services/api/test/unit/chat-send-resilience.test.ts index 58d8e9b3..a8377201 100644 --- a/services/api/test/unit/chat-send-resilience.test.ts +++ b/services/api/test/unit/chat-send-resilience.test.ts @@ -65,14 +65,17 @@ interface Harness { failures: BroadcastFailure[] } -function harness(opts: { pubsub?: ChatPubSub; dedupe?: SendDedupeStore | undefined } = {}): Harness { +function harness( + opts: { pubsub?: ChatPubSub; dedupe?: SendDedupeStore | undefined } = {}, +): Harness { const chat = new WsChatService({ repo, pubsub: opts.pubsub ?? new InMemoryChatPubSub() }) const failures: BroadcastFailure[] = [] const sendResilience = makeSendResilience({ dedupe: opts.dedupe, findRoomMessage: (_kind, roomId, messageId, viewerUserId) => repo.findMessage(roomId, messageId, viewerUserId), - deliverLocally: (roomKey, frame, excludeConnId) => chat.deliverLocal(roomKey, frame, excludeConnId), + deliverLocally: (roomKey, frame, excludeConnId) => + chat.deliverLocal(roomKey, frame, excludeConnId), onBroadcastFailure: (info) => failures.push(info), sleep: () => Promise.resolve(), jitter: () => 0, @@ -81,7 +84,8 @@ function harness(opts: { pubsub?: ChatPubSub; dedupe?: SendDedupeStore | undefin joinRoom: (room, conn, userId) => chat.joinRoom(room, conn, userId), leaveRoom: (room, conn) => chat.leaveRoom(room, conn), persist: (input) => chat.persist(input), - history: (room, before, limit, viewer, around) => chat.history(room, before, limit, viewer, around), + history: (room, before, limit, viewer, around) => + chat.history(room, before, limit, viewer, around), broadcast: (room, msg, o) => chat.broadcast(room, msg, o), broadcastEvent: (room, frame, o) => chat.broadcastEvent(room, frame, o), sendResilience, @@ -91,7 +95,13 @@ function harness(opts: { pubsub?: ChatPubSub; dedupe?: SendDedupeStore | undefin function sessionFor(userId: string, conn: MockConnection, h: Harness): GatewaySession { const deps: GatewayDeps = { chat: h.gatewayChat, isMember: memberOf } - return { userId, conn, joined: new Set(), typingThrottle: new Map(), deps } + return { + userId, + conn, + joined: new Set(), + typingThrottle: new Map(), + deps, + } } beforeEach(() => { @@ -197,7 +207,10 @@ describe("H17: a failing Redis publish never fails the sender", () => { const h = harness() const conn = new MockConnection("A") const session = sessionFor(ALICE, conn, h) - session.deps.chat = { ...h.gatewayChat, broadcast: () => Promise.reject(new Error("redis down")) } + session.deps.chat = { + ...h.gatewayChat, + broadcast: () => Promise.reject(new Error("redis down")), + } await expect(handleClientFrame(session, sendFrame("c1"))).resolves.toBeUndefined() expect(conn.framesOfType("ack")).toHaveLength(1) @@ -360,8 +373,12 @@ describe("H17: clientId send idempotency", () => { }) it("keys the reservation by user + room + clientId", () => { - expect(sendDedupeKey(ALICE, roomKeyFor("dm", ROOM), "c1")).toBe(`chat:send:${ALICE}:dm:${ROOM}:c1`) - expect(sendDedupeKey(ALICE, roomKeyFor("cleanup", ROOM), "c1")).toBe(`chat:send:${ALICE}:${ROOM}:c1`) + expect(sendDedupeKey(ALICE, roomKeyFor("dm", ROOM), "c1")).toBe( + `chat:send:${ALICE}:dm:${ROOM}:c1`, + ) + expect(sendDedupeKey(ALICE, roomKeyFor("cleanup", ROOM), "c1")).toBe( + `chat:send:${ALICE}:${ROOM}:c1`, + ) }) it("rejects an over-long clientId before anything is persisted", async () => { @@ -386,7 +403,7 @@ describe("H17: a hung Redis does not stall the per-socket frame chain", () => { const COLD_START_SLACK_MS = 500 it("reserve is bounded, commit is fire-and-forget, so a send settles on the broadcast budget", async () => { - const hang = (): Promise => new Promise(() => undefined) + const hang = (): Promise => new Promise(() => undefined) const hungDedupe: SendDedupeStore = { reserve: () => hang(), commit: () => hang(), diff --git a/services/api/test/unit/chat-tombstone.test.ts b/services/api/test/unit/chat-tombstone.test.ts index 2d9352ad..bf217abd 100644 --- a/services/api/test/unit/chat-tombstone.test.ts +++ b/services/api/test/unit/chat-tombstone.test.ts @@ -41,7 +41,12 @@ function loadedMessage(): ChatMessageDTO { editedAt: "2026-06-01T11:30:00.000Z", pinnedAt: "2026-06-01T11:40:00.000Z", replyToId: "66666666-6666-4666-8666-666666666666", - replyTo: { id: "66666666-6666-4666-8666-666666666666", from: null, excerpt: "hi", kind: "text" }, + replyTo: { + id: "66666666-6666-4666-8666-666666666666", + from: null, + excerpt: "hi", + kind: "text", + }, forwardedToCity: true, cityMention: { handle: "losangeles", geoid: "0644000", name: "Los Angeles", forwarded: true }, poll: { diff --git a/services/api/test/unit/chat-typing.test.ts b/services/api/test/unit/chat-typing.test.ts index 18128864..3f68c22a 100644 --- a/services/api/test/unit/chat-typing.test.ts +++ b/services/api/test/unit/chat-typing.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest" -import { - handleClientFrame, - type GatewaySession, - type GatewayDeps, -} from "../../src/ws/gateway.js" +import { handleClientFrame, type GatewaySession, type GatewayDeps } from "../../src/ws/gateway.js" import { WsChatService } from "../../src/adapters/chat-service.ws.js" import { InMemoryChatPubSub } from "../../src/adapters/chat-pubsub.js" import { InMemoryChatPresence } from "../../src/adapters/chat-presence.js" @@ -30,7 +26,13 @@ let presence: InMemoryChatPresence function sessionFor(userId: string, conn: MockConnection): GatewaySession { const deps: GatewayDeps = { chat, isMember: memberOf, presence } - return { userId, conn, joined: new Set(), typingThrottle: new Map(), deps } + return { + userId, + conn, + joined: new Set(), + typingThrottle: new Map(), + deps, + } } beforeEach(() => { diff --git a/services/api/test/unit/city-forward-wiring.test.ts b/services/api/test/unit/city-forward-wiring.test.ts index 765b2f7c..7ccf8fe3 100644 --- a/services/api/test/unit/city-forward-wiring.test.ts +++ b/services/api/test/unit/city-forward-wiring.test.ts @@ -62,7 +62,8 @@ vi.mock("../../src/services/discussion-repository.drizzle.js", () => ({ })) vi.mock("../../src/services/admin/outbound-mail-service.js", async (importOriginal) => { - const actual = await importOriginal() + const actual = + await importOriginal() return { ...actual, makeOutboundMailService: () => ({ diff --git a/services/api/test/unit/claim-service.test.ts b/services/api/test/unit/claim-service.test.ts index 0c6f98d9..285cb351 100644 --- a/services/api/test/unit/claim-service.test.ts +++ b/services/api/test/unit/claim-service.test.ts @@ -110,9 +110,9 @@ describe("claimNudge", () => { it("404s a valid token with no pending report", async () => { const { store, service } = harness() store.seedToken({ id: "tok-empty" }) - await expect( - service.claimNudge(signAnonToken("tok-empty", SIGNING_KEY)), - ).rejects.toMatchObject({ code: "NOT_FOUND" }) + await expect(service.claimNudge(signAnonToken("tok-empty", SIGNING_KEY))).rejects.toMatchObject( + { code: "NOT_FOUND" }, + ) }) }) @@ -187,7 +187,11 @@ describe("claimReport", () => { 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" }) - await expect(service.claimReport("code-b", "user-3")).rejects.toMatchObject({ code: "NOT_FOUND" }) + await expect(service.claimReport("code-a", "user-3")).rejects.toMatchObject({ + code: "NOT_FOUND", + }) + await expect(service.claimReport("code-b", "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 0026a5b3..5937c43f 100644 --- a/services/api/test/unit/cleanup-complete.test.ts +++ b/services/api/test/unit/cleanup-complete.test.ts @@ -26,7 +26,9 @@ let service: CleanupService * neither the stored status, `completed_at` nor the timeline moves. */ -function seedEvent(over: { scheduledAt?: Date; endsAt?: Date; status?: "upcoming" | "cancelled" } = {}): string { +function seedEvent( + over: { scheduledAt?: Date; endsAt?: Date; status?: "upcoming" | "cancelled" } = {}, +): string { repo.seedCleanup({ id: CLEANUP_ID, organizerUserId: ORG, @@ -82,7 +84,10 @@ describe("completeCleanup — the deprecated no-op contract", () => { }) 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) }) + const id = seedEvent({ + scheduledAt: FUTURE, + endsAt: new Date(FUTURE.getTime() + 4 * 3_600_000), + }) const dto = await service.completeCleanup(id, null, ORG) @@ -189,7 +194,10 @@ describe("completeCleanup — B19: completion rings nobody", () => { describe("a past event's roster is frozen in BOTH directions", () => { it("still lets an attendee leave (and a host remove) while the event has not been cancelled", async () => { - const id = seedEvent({ scheduledAt: FUTURE, endsAt: new Date(FUTURE.getTime() + 4 * 3_600_000) }) + const id = seedEvent({ + scheduledAt: FUTURE, + endsAt: new Date(FUTURE.getTime() + 4 * 3_600_000), + }) await expect(service.leaveCleanup(id, MEMBER)).resolves.toMatchObject({ joined: false }) await expect(service.removeMember(id, ORG, COHOST)).resolves.toMatchObject({ ok: true }) diff --git a/services/api/test/unit/cleanup-linking.test.ts b/services/api/test/unit/cleanup-linking.test.ts index f991e04f..acafcba0 100644 --- a/services/api/test/unit/cleanup-linking.test.ts +++ b/services/api/test/unit/cleanup-linking.test.ts @@ -12,7 +12,6 @@ import { } from "../../src/services/cleanup-repository.drizzle.js" import { AppError, type CreateCleanupRequest } from "@civfix/shared" - const ORG = "11111111-1111-1111-1111-111111111111" const STRANGER = "22222222-2222-2222-2222-222222222222" const R1 = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1" @@ -63,20 +62,25 @@ describe("createCleanup linking", () => { expect(r1.thumbUrl).toBe("thumb/r1.jpg") expect(r1.title).toBe("Bin 1") expect(repo.links.filter((l) => l.cleanupId === dto.id)).toHaveLength(2) - expect(repo.timeline.filter((t) => t.cleanupId === dto.id && t.kind === "report_linked")).toHaveLength(2) + expect( + repo.timeline.filter((t) => t.cleanupId === dto.id && t.kind === "report_linked"), + ).toHaveLength(2) }) it("rejects an invisible (held) report at create time with a 422", async () => { - await expect(service.createCleanup(baseInput({ linkedReportIds: [R1, R3] }), ORG)).rejects.toMatchObject( - { code: "VALIDATION" }, - ) + await expect( + service.createCleanup(baseInput({ linkedReportIds: [R1, R3] }), ORG), + ).rejects.toMatchObject({ code: "VALIDATION" }) expect(repo.cleanups.size).toBe(0) expect(repo.links).toHaveLength(0) }) it("rejects linking on a non-cleanup eventKind (other_volunteer)", async () => { await expect( - service.createCleanup(baseInput({ eventKind: "other_volunteer", linkedReportIds: [R1] }), ORG), + service.createCleanup( + baseInput({ eventKind: "other_volunteer", linkedReportIds: [R1] }), + ORG, + ), ).rejects.toMatchObject({ code: "VALIDATION" }) }) @@ -90,7 +94,9 @@ describe("createCleanup linking", () => { describe("updateCleanup (host-gated PATCH)", () => { it("403s a non-organizer", async () => { const created = await service.createCleanup(baseInput(), ORG) - await expect(service.updateCleanup(created.id, { title: "Hijack" }, STRANGER)).rejects.toMatchObject({ + await expect( + service.updateCleanup(created.id, { title: "Hijack" }, STRANGER), + ).rejects.toMatchObject({ code: "FORBIDDEN", }) expect(repo.cleanups.get(created.id)?.title).toBe("Beach cleanup") @@ -119,7 +125,9 @@ describe("updateCleanup (host-gated PATCH)", () => { const created = await service.createCleanup(baseInput({ linkedReportIds: [R1] }), ORG) const updated = await service.updateCleanup(created.id, { linkedReportIds: [R2] }, ORG) expect(updated.linkedReports.map((r) => r.id)).toEqual([R2]) - expect(repo.links.filter((l) => l.cleanupId === created.id).map((l) => l.reportId)).toEqual([R2]) + expect(repo.links.filter((l) => l.cleanupId === created.id).map((l) => l.reportId)).toEqual([ + R2, + ]) const kinds = repo.timeline.filter((t) => t.cleanupId === created.id).map((t) => t.kind) expect(kinds).toContain("report_unlinked") }) @@ -155,9 +163,12 @@ describe("reconcileLinkedReports only unlinks what the host can see", () => { const updated = await service.updateCleanup(created.id, { linkedReportIds: [R1] }, ORG) expect(updated.linkedReports.map((r) => r.id)).toEqual([R1]) - expect(repo.links.filter((l) => l.cleanupId === created.id).map((l) => l.reportId).sort()).toEqual( - [R1, R3].sort(), - ) + expect( + repo.links + .filter((l) => l.cleanupId === created.id) + .map((l) => l.reportId) + .sort(), + ).toEqual([R1, R3].sort()) expect( repo.timeline.filter((t) => t.cleanupId === created.id && t.kind === "report_unlinked"), ).toHaveLength(0) @@ -169,7 +180,9 @@ describe("reconcileLinkedReports only unlinks what the host can see", () => { const updated = await service.updateCleanup(created.id, { linkedReportIds: [R1] }, ORG) expect(updated.linkedReports.map((r) => r.id)).toEqual([R1]) - expect(repo.links.filter((l) => l.cleanupId === created.id).map((l) => l.reportId)).toEqual([R1]) + expect(repo.links.filter((l) => l.cleanupId === created.id).map((l) => l.reportId)).toEqual([ + R1, + ]) const unlinked = repo.timeline.filter( (t) => t.cleanupId === created.id && t.kind === "report_unlinked", ) @@ -183,14 +196,20 @@ describe("reconcileLinkedReports only unlinks what the host can see", () => { const updated = await service.updateCleanup(created.id, { linkedReportIds: [R1, R4] }, ORG) expect(updated.linkedReports.map((r) => r.id).sort()).toEqual([R1, R4].sort()) - expect(repo.links.filter((l) => l.cleanupId === created.id).map((l) => l.reportId).sort()).toEqual( - [R1, R3, R4].sort(), - ) + expect( + repo.links + .filter((l) => l.cleanupId === created.id) + .map((l) => l.reportId) + .sort(), + ).toEqual([R1, R3, R4].sort()) const kinds = repo.timeline.filter((t) => t.cleanupId === created.id) expect(kinds.filter((t) => t.kind === "report_unlinked").map((t) => t.reportId)).toEqual([R2]) - expect(kinds.filter((t) => t.kind === "report_linked").map((t) => t.reportId).sort()).toEqual( - [R1, R2, R4].sort(), - ) + expect( + kinds + .filter((t) => t.kind === "report_linked") + .map((t) => t.reportId) + .sort(), + ).toEqual([R1, R2, R4].sort()) }) }) @@ -296,7 +315,8 @@ describe("listCleanups linkedReports hydration (#70 map blend)", () => { { length: 10 }, (_, i) => `bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbb${String(i).padStart(4, "0")}`, ) - for (const [i, id] of extra.entries()) repo.seedReport({ id, title: `Bulk ${i}`, category: "trash" }) + for (const [i, id] of extra.entries()) + repo.seedReport({ id, title: `Bulk ${i}`, category: "trash" }) const linked = await service.createCleanup( baseInput({ title: "Big gallery", linkedReportIds: extra }), ORG, diff --git a/services/api/test/unit/cleanup-service.test.ts b/services/api/test/unit/cleanup-service.test.ts index 2fca3602..84166834 100644 --- a/services/api/test/unit/cleanup-service.test.ts +++ b/services/api/test/unit/cleanup-service.test.ts @@ -18,7 +18,6 @@ import { InMemoryCleanupRepository } from "../helpers/cleanups.js" import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" import type { CreateCleanupRequest } from "@civfix/shared" - const ORG = "11111111-1111-1111-1111-111111111111" const ALICE = "22222222-2222-2222-2222-222222222222" const BOB = "33333333-3333-3333-3333-333333333333" @@ -74,7 +73,9 @@ describe("createCleanup", () => { expect(repo.cleanups.has(dto.id)).toBe(true) expect(await repo.isMember(dto.id, ORG)).toBe(true) - const organizerMembers = repo.members.filter((m) => m.cleanupId === dto.id && m.role === "organizer") + const organizerMembers = repo.members.filter( + (m) => m.cleanupId === dto.id && m.role === "organizer", + ) expect(organizerMembers).toHaveLength(1) }) @@ -118,7 +119,9 @@ describe("joinCleanup / leaveCleanup", () => { const again = await service.joinCleanup(created.id, ALICE) expect(again).toEqual({ joined: true, going: 2 }) - expect(repo.members.filter((m) => m.cleanupId === created.id && m.userId === ALICE)).toHaveLength(1) + expect( + repo.members.filter((m) => m.cleanupId === created.id && m.userId === ALICE), + ).toHaveLength(1) }) it("a member can leave; going drops and membership is removed", async () => { @@ -243,7 +246,9 @@ describe("cancelCleanup", () => { const dto = await service.cancelCleanup(created.id, null, ORG) expect(dto.status).toBe("cancelled") expect(repo.cleanups.get(created.id)?.status).toBe("cancelled") - const cancelRows = repo.timeline.filter((t) => t.cleanupId === created.id && t.kind === "cancel") + const cancelRows = repo.timeline.filter( + (t) => t.cleanupId === created.id && t.kind === "cancel", + ) expect(cancelRows).toHaveLength(1) expect(cancelRows[0]!.actorId).toBe(ORG) }) @@ -276,7 +281,9 @@ describe("cancelCleanup", () => { message: "This event has already ended and can't be cancelled.", }) expect(repo.cleanups.get(created.id)?.status).toBe("upcoming") - expect(repo.timeline.filter((t) => t.cleanupId === created.id && t.kind === "cancel")).toEqual([]) + expect(repo.timeline.filter((t) => t.cleanupId === created.id && t.kind === "cancel")).toEqual( + [], + ) }) it("F067: an ended event's date/title/location/type are frozen; cosmetic edits still apply", async () => { @@ -582,11 +589,17 @@ describe("listCleanups filters", () => { ) await service.joinCleanup(rsvped.id, ALICE) await service.createCleanup( - baseInput({ title: "Hosted", scheduledAt: new Date("2026-06-08T00:00:00.000Z").toISOString() }), + baseInput({ + title: "Hosted", + scheduledAt: new Date("2026-06-08T00:00:00.000Z").toISOString(), + }), ALICE, ) await service.createCleanup( - baseInput({ title: "Other", scheduledAt: new Date("2026-06-03T00:00:00.000Z").toISOString() }), + baseInput({ + title: "Other", + scheduledAt: new Date("2026-06-03T00:00:00.000Z").toISOString(), + }), BOB, ) @@ -645,10 +658,7 @@ describe("listCleanups filters", () => { scheduledAt: new Date("2026-06-10T00:00:00.000Z"), }) - const res = await service.listCleanups( - { near: { lat: 34.0, lng: -118.49 } }, - { userId: null }, - ) + const res = await service.listCleanups({ near: { lat: 34.0, lng: -118.49 } }, { userId: null }) expect(res.items.map((c) => c.title)).toEqual(["Near", "Far"]) expect(res.items[0]!.dist!).toBeLessThan(res.items[1]!.dist!) }) @@ -840,11 +850,21 @@ describe("requestResources (D19 event resource request)", () => { } it("happy path: org-hosted event -> event thread send + resource_request timeline row", async () => { - const { repo: r, svc, sends, creator, organizationId } = harness({ + const { + repo: r, + svc, + sends, + creator, + organizationId, + } = harness({ contact: { geoid: "0644000", email: "events@lacity.gov" }, }) const id = await seedEvent(r, creator, "0644000", organizationId) - const res = await svc.requestResources({ cleanupId: id, message: "Need 20 trash bags.", actorId: ORG }) + const res = await svc.requestResources({ + cleanupId: id, + message: "Need 20 trash bags.", + actorId: ORG, + }) expect(res).toEqual({ ok: true }) expect(sends).toHaveLength(1) @@ -859,7 +879,13 @@ describe("requestResources (D19 event resource request)", () => { }) it("F068: 422s a slur in the message and neither sends nor writes a timeline row", async () => { - const { repo: r, svc, sends, creator, organizationId } = harness({ + const { + repo: r, + svc, + sends, + creator, + organizationId, + } = harness({ contact: { geoid: "0644000", email: "events@lacity.gov" }, }) const id = await seedEvent(r, creator, "0644000", organizationId) @@ -867,12 +893,19 @@ describe("requestResources (D19 event resource request)", () => { svc.requestResources({ cleanupId: id, message: "please send bags nigger", actorId: ORG }), ).rejects.toMatchObject({ code: "VALIDATION" }) expect(sends).toHaveLength(0) - expect(r.timeline.find((t) => t.cleanupId === id && t.kind === "resource_request")).toBeUndefined() + expect( + r.timeline.find((t) => t.cleanupId === id && t.kind === "resource_request"), + ).toBeUndefined() }) - it("M20: a FRESH event does not reset the host's budget (the old cleanupId-keyed bypass)", async () => { - const { repo: r, svc, sends, creator, organizationId } = harness({ + const { + repo: r, + svc, + sends, + creator, + organizationId, + } = harness({ contact: { geoid: "0644000", email: "events@lacity.gov" }, }) for (let i = 0; i < RESOURCE_REQUEST_PER_HOST_PER_DAY; i += 1) { @@ -889,7 +922,12 @@ describe("requestResources (D19 event resource request)", () => { }) it("M20: caps a single jurisdiction per hour across DIFFERENT hosts (colluding accounts)", async () => { - const { repo: r, svc, sends, organizationId } = harness({ + const { + repo: r, + svc, + sends, + organizationId, + } = harness({ contact: { geoid: "0644000", email: "events@lacity.gov" }, }) let host = 0 @@ -924,7 +962,13 @@ describe("requestResources (D19 event resource request)", () => { }) it("M20: a rejected request (403) costs the host nothing", async () => { - const { repo: r, svc, sends, creator, organizationId } = harness({ + const { + repo: r, + svc, + sends, + creator, + organizationId, + } = harness({ contact: { geoid: "0644000", email: "events@lacity.gov" }, }) const id = await seedEvent(r, creator, "0644000", organizationId) @@ -938,7 +982,13 @@ describe("requestResources (D19 event resource request)", () => { }) it("403s a non-host (and sends nothing)", async () => { - const { repo: r, svc, sends, creator, organizationId } = harness({ + const { + repo: r, + svc, + sends, + creator, + organizationId, + } = harness({ contact: { geoid: "0644000", email: "events@lacity.gov" }, }) const id = await seedEvent(r, creator, "0644000", organizationId) @@ -949,7 +999,12 @@ describe("requestResources (D19 event resource request)", () => { }) it("403s a personal (org-less) event: city resources are an organization's ask", async () => { - const { repo: r, svc, sends, creator } = harness({ + const { + repo: r, + svc, + sends, + creator, + } = harness({ contact: { geoid: "0644000", email: "events@lacity.gov" }, }) const id = await seedEvent(r, creator, "0644000") @@ -960,7 +1015,13 @@ describe("requestResources (D19 event resource request)", () => { }) it("403s an org member who only has a plain seat on the event (no manage_event)", async () => { - const { repo: r, svc, sends, creator, organizationId } = harness({ + const { + repo: r, + svc, + sends, + creator, + organizationId, + } = harness({ contact: { geoid: "0644000", email: "events@lacity.gov" }, }) const id = await seedEvent(r, creator, "0644000", organizationId) @@ -1222,7 +1283,6 @@ describe("WS4 co-hosts: setMemberRole / removeMember / role-aware reads", () => }) }) - describe("M17: attendee removal is enforceable (cleanup_bans)", () => { let svc: CleanupService @@ -1383,7 +1443,12 @@ describe("M19: the slur filter reaches event fields", () => { it("still accepts ordinary event text", async () => { const dto = await service.createCleanup( - baseInput({ title: "Ballona Creek sweep", description: "Meet by the bridge", address: "North gate", bring: ["gloves"] }), + baseInput({ + title: "Ballona Creek sweep", + description: "Meet by the bridge", + address: "North gate", + bring: ["gloves"], + }), ORG, ) expect(dto.title).toBe("Ballona Creek sweep") diff --git a/services/api/test/unit/cleanup-slots-service.test.ts b/services/api/test/unit/cleanup-slots-service.test.ts index eb5293d9..ccd836c0 100644 --- a/services/api/test/unit/cleanup-slots-service.test.ts +++ b/services/api/test/unit/cleanup-slots-service.test.ts @@ -280,10 +280,12 @@ describe("updateCleanup — the reconcile diff (B23)", () => { [false, "Sign-in", undefined], ]) // "Cleanup crew" is absent from the desired set, so it is gone. - expect(repo.slots.filter((s) => s.cleanupId === id).map((s) => s.title).sort()).toEqual([ - "Grill duty", - "Sign-in", - ]) + expect( + repo.slots + .filter((s) => s.cleanupId === id) + .map((s) => s.title) + .sort(), + ).toEqual(["Grill duty", "Sign-in"]) }) it("sending [] is REFUSED; OMITTING the key still leaves the board untouched", async () => { @@ -382,8 +384,22 @@ describe("updateCleanup — the reconcile diff (B23)", () => { repo.reconcileSlots( id, [ - { title: "Grill", description: null, capacity: null, startsAt: null, endsAt: null, sortOrder: 0 }, - { title: "GRILL", description: null, capacity: null, startsAt: null, endsAt: null, sortOrder: 1 }, + { + title: "Grill", + description: null, + capacity: null, + startsAt: null, + endsAt: null, + sortOrder: 0, + }, + { + title: "GRILL", + description: null, + capacity: null, + startsAt: null, + endsAt: null, + sortOrder: 1, + }, ], ORG, ))(), @@ -738,9 +754,7 @@ describe("slot windows (0167)", () => { { slots: [{ title: "Sweep", startsAt: at(0), endsAt: at(2) }] }, ORG, ) - expect(dto.slots.map((s) => [s.title, s.startsAt, s.endsAt])).toEqual([ - ["Sweep", at(0), at(2)], - ]) + expect(dto.slots.map((s) => [s.title, s.startsAt, s.endsAt])).toEqual([["Sweep", at(0), at(2)]]) }) it("leaves an untimed slot's window keys OFF the DTO entirely", async () => { @@ -788,9 +802,14 @@ describe("slot windows (0167)", () => { it("422s a window that starts before the event does, naming the slot", async () => { const id = seedTimedEvent() await expect( - service.updateCleanup(id, { slots: [{ title: "Sweep", startsAt: at(-1), endsAt: at(1) }] }, ORG), + service.updateCleanup( + id, + { slots: [{ title: "Sweep", startsAt: at(-1), endsAt: at(1) }] }, + ORG, + ), ).rejects.toSatisfy( - (err: unknown) => fieldsOf(err).slots === `slot "Sweep" falls outside the event's start and end`, + (err: unknown) => + fieldsOf(err).slots === `slot "Sweep" falls outside the event's start and end`, ) expect(repo.slots).toEqual([]) }) @@ -798,9 +817,14 @@ describe("slot windows (0167)", () => { it("422s a window that runs past the event's end", async () => { const id = seedTimedEvent() await expect( - service.updateCleanup(id, { slots: [{ title: "Sweep", startsAt: at(3), endsAt: at(5) }] }, ORG), + service.updateCleanup( + id, + { slots: [{ title: "Sweep", startsAt: at(3), endsAt: at(5) }] }, + ORG, + ), ).rejects.toSatisfy( - (err: unknown) => fieldsOf(err).slots === `slot "Sweep" falls outside the event's start and end`, + (err: unknown) => + fieldsOf(err).slots === `slot "Sweep" falls outside the event's start and end`, ) }) @@ -919,9 +943,7 @@ describe("slot windows (0167)", () => { endsAt: new Date(at(4)), }) - await expect( - service.updateCleanup(id, { endsAt: at(2) }, ORG), - ).rejects.toSatisfy( + await expect(service.updateCleanup(id, { endsAt: at(2) }, ORG)).rejects.toSatisfy( (err: unknown) => fieldsOf(err).scheduledAt === "timed slots would fall outside the new start and end; update the slots in the same save", @@ -1035,7 +1057,11 @@ describe("slot windows (0167)", () => { await notified.updateCleanup( id, - { slots: [{ id: slot.id, title: "Morning sweep", startsAt: at(0), endsAt: at(2), capacity: 9 }] }, + { + slots: [ + { id: slot.id, title: "Morning sweep", startsAt: at(0), endsAt: at(2), capacity: 9 }, + ], + }, ORG, ) expect(bells).toEqual([]) diff --git a/services/api/test/unit/cleanups-routes.test.ts b/services/api/test/unit/cleanups-routes.test.ts index de3fb9fb..27aebe5f 100644 --- a/services/api/test/unit/cleanups-routes.test.ts +++ b/services/api/test/unit/cleanups-routes.test.ts @@ -16,7 +16,6 @@ import { type CleanupServiceOverrides, } from "../../src/routes/cleanups.routes.js" - interface Harness { app: FastifyInstance repo: InMemoryCleanupRepository @@ -555,8 +554,16 @@ describe("POST /cleanups/:id/complete (the deprecated no-op)", () => { const member = await signIn(app, mailer, "attendee@example.com") repo.seedUser({ id: cohost.userId, displayName: "Cory" }) repo.seedUser({ id: member.userId, displayName: "Mel" }) - await app.inject({ method: "POST", url: `/v1/cleanups/${id}/join`, headers: auth(cohost.token) }) - await app.inject({ method: "POST", url: `/v1/cleanups/${id}/join`, headers: auth(member.token) }) + await app.inject({ + method: "POST", + url: `/v1/cleanups/${id}/join`, + headers: auth(cohost.token), + }) + await app.inject({ + method: "POST", + url: `/v1/cleanups/${id}/join`, + headers: auth(member.token), + }) await app.inject({ method: "PATCH", url: `/v1/cleanups/${id}/members/${cohost.userId}`, @@ -627,7 +634,11 @@ describe("POST /cleanups/:id/complete (the deprecated no-op)", () => { it("401s an anonymous completion", async () => { const { app, token } = await makeHarness() const id = await createCleanup(app, token, PAST) - const res = await app.inject({ method: "POST", url: `/v1/cleanups/${id}/complete`, payload: {} }) + const res = await app.inject({ + method: "POST", + url: `/v1/cleanups/${id}/complete`, + payload: {}, + }) expect(res.statusCode).toBe(401) }) @@ -697,7 +708,11 @@ describe("GET /cleanups/:id/messages (member-gated history)", () => { const joiner = await signIn(app, mailer, "joiner@example.com") repo.seedUser({ id: joiner.userId, displayName: "Jordan" }) - await app.inject({ method: "POST", url: `/v1/cleanups/${id}/join`, headers: auth(joiner.token) }) + await app.inject({ + method: "POST", + url: `/v1/cleanups/${id}/join`, + headers: auth(joiner.token), + }) const anon = await app.inject({ method: "GET", url: `/v1/cleanups/${id}/attendees` }) expect(anon.statusCode).toBe(200) @@ -812,7 +827,11 @@ describe("WS4 member management: PATCH + DELETE /cleanups/:id/members/:userId", const joiner = await signIn(app, mailer, "joiner@example.com") repo.seedUser({ id: joiner.userId, displayName: "Jordan" }) - await app.inject({ method: "POST", url: `/v1/cleanups/${id}/join`, headers: auth(joiner.token) }) + await app.inject({ + method: "POST", + url: `/v1/cleanups/${id}/join`, + headers: auth(joiner.token), + }) const promote = await app.inject({ method: "PATCH", @@ -855,7 +874,11 @@ describe("WS4 member management: PATCH + DELETE /cleanups/:id/members/:userId", const id = await createCleanup(app, token) const joiner = await signIn(app, mailer, "joiner@example.com") repo.seedUser({ id: joiner.userId, displayName: "Jordan" }) - await app.inject({ method: "POST", url: `/v1/cleanups/${id}/join`, headers: auth(joiner.token) }) + await app.inject({ + method: "POST", + url: `/v1/cleanups/${id}/join`, + headers: auth(joiner.token), + }) const asMember = await app.inject({ method: "PATCH", @@ -878,7 +901,11 @@ describe("WS4 member management: PATCH + DELETE /cleanups/:id/members/:userId", const id = await createCleanup(app, token) const joiner = await signIn(app, mailer, "joiner@example.com") repo.seedUser({ id: joiner.userId, displayName: "Jordan" }) - await app.inject({ method: "POST", url: `/v1/cleanups/${id}/join`, headers: auth(joiner.token) }) + await app.inject({ + method: "POST", + url: `/v1/cleanups/${id}/join`, + headers: auth(joiner.token), + }) const badRole = await app.inject({ method: "PATCH", @@ -903,7 +930,11 @@ describe("WS4 member management: PATCH + DELETE /cleanups/:id/members/:userId", const id = await createCleanup(app, token) const joiner = await signIn(app, mailer, "joiner@example.com") repo.seedUser({ id: joiner.userId, displayName: "Jordan" }) - await app.inject({ method: "POST", url: `/v1/cleanups/${id}/join`, headers: auth(joiner.token) }) + await app.inject({ + method: "POST", + url: `/v1/cleanups/${id}/join`, + headers: auth(joiner.token), + }) const before = await app.inject({ method: "GET", @@ -935,8 +966,16 @@ describe("WS4 member management: PATCH + DELETE /cleanups/:id/members/:userId", const member = await signIn(app, mailer, "member@example.com") repo.seedUser({ id: cohost.userId, displayName: "Cory" }) repo.seedUser({ id: member.userId, displayName: "Mel" }) - await app.inject({ method: "POST", url: `/v1/cleanups/${id}/join`, headers: auth(cohost.token) }) - await app.inject({ method: "POST", url: `/v1/cleanups/${id}/join`, headers: auth(member.token) }) + await app.inject({ + method: "POST", + url: `/v1/cleanups/${id}/join`, + headers: auth(cohost.token), + }) + await app.inject({ + method: "POST", + url: `/v1/cleanups/${id}/join`, + headers: auth(member.token), + }) await app.inject({ method: "PATCH", url: `/v1/cleanups/${id}/members/${cohost.userId}`, @@ -974,8 +1013,16 @@ describe("WS4 member management: PATCH + DELETE /cleanups/:id/members/:userId", const member = await signIn(app, mailer, "member@example.com") repo.seedUser({ id: cohost.userId, displayName: "Cory" }) repo.seedUser({ id: member.userId, displayName: "Mel" }) - await app.inject({ method: "POST", url: `/v1/cleanups/${id}/join`, headers: auth(cohost.token) }) - await app.inject({ method: "POST", url: `/v1/cleanups/${id}/join`, headers: auth(member.token) }) + await app.inject({ + method: "POST", + url: `/v1/cleanups/${id}/join`, + headers: auth(cohost.token), + }) + await app.inject({ + method: "POST", + url: `/v1/cleanups/${id}/join`, + headers: auth(member.token), + }) await app.inject({ method: "PATCH", url: `/v1/cleanups/${id}/members/${cohost.userId}`, diff --git a/services/api/test/unit/cursor-helpers.test.ts b/services/api/test/unit/cursor-helpers.test.ts index 4e6c5df9..10a28a8e 100644 --- a/services/api/test/unit/cursor-helpers.test.ts +++ b/services/api/test/unit/cursor-helpers.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect } from "vitest" import { CURSOR_UUID_RE, @@ -93,7 +92,9 @@ describe("parseTimeCursor", () => { it("splits on the FIRST '|' (unlike parseNameCursor, which splits on the last)", () => { expect(parseTimeCursor(`${ISO}|${UUID}|extra`)).toBeNull() - expect(parseTimeCursor(`${ISO}|${UUID}|extra`, { requireUuid: false })!.id).toBe(`${UUID}|extra`) + expect(parseTimeCursor(`${ISO}|${UUID}|extra`, { requireUuid: false })!.id).toBe( + `${UUID}|extra`, + ) }) it("rejects a forged out-of-range timestamp that JS parses but Postgres would 22008 on", () => { @@ -236,7 +237,10 @@ describe("paginate", () => { }) it("falls back to `createdAt` when the anchor has no `at`", () => { - const rows = [{ id: UUID, createdAt: new Date(ISO) }, { id: UUID_2, createdAt: new Date(ISO) }] + const rows = [ + { id: UUID, createdAt: new Date(ISO) }, + { id: UUID_2, createdAt: new Date(ISO) }, + ] const page = paginate(rows, 1, (r) => ({ createdAt: r.createdAt, id: r.id })) expect(page.nextCursor).toBe(`${ISO}|${UUID}`) }) diff --git a/services/api/test/unit/data-export.test.ts b/services/api/test/unit/data-export.test.ts index 173b5222..f0c14975 100644 --- a/services/api/test/unit/data-export.test.ts +++ b/services/api/test/unit/data-export.test.ts @@ -45,7 +45,13 @@ function harness( users.seed(user.email, user) const mailer = new FakeMailer() const sql = makeFakeSql(handlers).sql as unknown as Sql - const service = makeDataExportService({ sql, mailer, users, fromNoReply: FROM, supportEmail: SUPPORT }) + const service = makeDataExportService({ + sql, + mailer, + users, + fromNoReply: FROM, + supportEmail: SUPPORT, + }) return { mailer, service } } diff --git a/services/api/test/unit/db-client.test.ts b/services/api/test/unit/db-client.test.ts index fd49ea75..e6a7e110 100644 --- a/services/api/test/unit/db-client.test.ts +++ b/services/api/test/unit/db-client.test.ts @@ -33,7 +33,8 @@ describe("makeDb: the raw sql client keeps postgres.js Date serializers (drizzle (oid) => { const serialize = serializers[oid] // Throw (not just expect) so TS narrows away `undefined` and the failure names the missing OID. - if (typeof serialize !== "function") throw new Error(`no serializer registered for OID ${oid}`) + if (typeof serialize !== "function") + throw new Error(`no serializer registered for OID ${oid}`) // A clean postgres.js client returns the ISO string; a drizzle-clobbered client returns the Date. const out = serialize(sample) expect(typeof out).toBe("string") diff --git a/services/api/test/unit/delete-account-route.test.ts b/services/api/test/unit/delete-account-route.test.ts index 10157e97..dcb9560d 100644 --- a/services/api/test/unit/delete-account-route.test.ts +++ b/services/api/test/unit/delete-account-route.test.ts @@ -10,7 +10,6 @@ import { buildAuthServices, type AuthServices } from "../../src/auth/auth-servic import { StubJwksVerifier } from "../helpers/auth.js" import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" - let current: FastifyInstance | undefined afterEach(async () => { if (current) { @@ -61,9 +60,7 @@ async function cleanupHarness(): Promise<{ unlinkCalls += 1 return Promise.reject(new Error("oauth store unavailable")) } - const fake = makeFakeSql([ - { match: /INSERT INTO audit_log/i, rows: [{ id: "audit-1" }] }, - ]) + const fake = makeFakeSql([{ match: /INSERT INTO audit_log/i, rows: [{ id: "audit-1" }] }]) const container = { ...buildContainer(env), getDb: () => ({ sql: fake.sql }), @@ -114,7 +111,11 @@ describe("DELETE /me email-OTP gate", () => { it("rejects deletion with 401 when a freshly-issued code does not match", async () => { const { app, mailer } = await harness() const token = await signIn(app, mailer, "jane@example.com") - await app.inject({ method: "POST", url: "/v1/auth/otp/request", payload: { email: "jane@example.com" } }) + await app.inject({ + method: "POST", + url: "/v1/auth/otp/request", + payload: { email: "jane@example.com" }, + }) const real = mailer.lastOtpFor("jane@example.com")! const wrong = real === "123456" ? "654321" : "123456" const res = await del(app, token, wrong) @@ -136,7 +137,11 @@ describe("DELETE /me email-OTP gate", () => { const { app, mailer } = await harness() const email = "jane@example.com" await signIn(app, mailer, email) - const res = await app.inject({ method: "POST", url: "/v1/auth/otp/request", payload: { email } }) + const res = await app.inject({ + method: "POST", + url: "/v1/auth/otp/request", + payload: { email }, + }) expect(res.statusCode).toBe(200) expect(res.json()).toMatchObject({ sent: true }) const codes = mailer.sent.filter((m) => m.to === email && m.code !== undefined) @@ -162,7 +167,10 @@ describe("DELETE /me post-revocation cleanup isolation", () => { expect(pushDelete?.values).toContain(userId) const audit = fake.statements.find((s) => /INSERT INTO audit_log/i.test(s.sql)) - expect(audit, "the account.deleted audit row must not be skipped by the unlink failure").toBeDefined() + expect( + audit, + "the account.deleted audit row must not be skipped by the unlink failure", + ).toBeDefined() expect(audit?.values.slice(0, 3)).toEqual([userId, "account.deleted", `user:${userId}`]) const notifDelete = fake.statements.find((s) => /DELETE FROM notifications/i.test(s.sql)) diff --git a/services/api/test/unit/discovery-jobs.test.ts b/services/api/test/unit/discovery-jobs.test.ts index 4c091e4e..de1144cf 100644 --- a/services/api/test/unit/discovery-jobs.test.ts +++ b/services/api/test/unit/discovery-jobs.test.ts @@ -78,9 +78,9 @@ describe("registerDiscoveryJobs", () => { // 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))).toBe( - false, - ) + expect( + db.statements.some((s) => /INSERT\s+INTO\s+jurisdiction_discovery_tasks/i.test(s.sql)), + ).toBe(false) }) it("is a no-op for a malformed payload with no geoid (no DB touched)", async () => { diff --git a/services/api/test/unit/discussion-mentions.test.ts b/services/api/test/unit/discussion-mentions.test.ts index da5e53ff..e14ef140 100644 --- a/services/api/test/unit/discussion-mentions.test.ts +++ b/services/api/test/unit/discussion-mentions.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from "vitest" import { jurisdictionHandle, @@ -116,7 +115,9 @@ describe("parseUserMentions", () => { }) it("F050: caps distinct handles at MAX_MENTIONS_PER_MESSAGE (mention-bomb bound)", () => { - const body = Array.from({ length: MAX_MENTIONS_PER_MESSAGE + 40 }, (_, i) => `@user${i}`).join(" ") + const body = Array.from({ length: MAX_MENTIONS_PER_MESSAGE + 40 }, (_, i) => `@user${i}`).join( + " ", + ) const out = parseUserMentions(body) expect(out).toHaveLength(MAX_MENTIONS_PER_MESSAGE) expect(out[0]).toBe("user0") diff --git a/services/api/test/unit/dm-blocks-threads.test.ts b/services/api/test/unit/dm-blocks-threads.test.ts index 8a4cf213..416de5ec 100644 --- a/services/api/test/unit/dm-blocks-threads.test.ts +++ b/services/api/test/unit/dm-blocks-threads.test.ts @@ -11,7 +11,6 @@ import { } from "../../src/services/threads-service.js" import { InMemoryThreadsRepository } from "../helpers/chat.js" - const ALICE = "11111111-1111-1111-1111-111111111111" const BOB = "22222222-2222-2222-2222-222222222222" const CAROL = "33333333-3333-3333-3333-333333333333" diff --git a/services/api/test/unit/dm-gateway.test.ts b/services/api/test/unit/dm-gateway.test.ts index b663715a..e6bce10a 100644 --- a/services/api/test/unit/dm-gateway.test.ts +++ b/services/api/test/unit/dm-gateway.test.ts @@ -13,11 +13,7 @@ import { InMemoryBlocksRepository, InMemoryDmRepository, } from "../../src/services/dm-repository.memory.js" -import { - WsServerMessageSchema, - type ChatMessageDTO, -} from "@civfix/shared" - +import { WsServerMessageSchema, type ChatMessageDTO } from "@civfix/shared" const ALICE = "11111111-1111-1111-1111-111111111111" const BOB = "22222222-2222-2222-2222-222222222222" @@ -152,12 +148,24 @@ describe("DM gateway routing (join/send/ack/block)", () => { const bConn = new MockConnection("B") const aSession = sessionFor(ALICE, aConn) const bSession = sessionFor(BOB, bConn) - await handleClientFrame(aSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) - await handleClientFrame(bSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) + await handleClientFrame( + aSession, + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) + await handleClientFrame( + bSession, + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) await handleClientFrame( aSession, - JSON.stringify({ type: "send", cleanupId: THREAD, roomKind: "dm", clientId: "c1", body: "hi bob" }), + JSON.stringify({ + type: "send", + cleanupId: THREAD, + roomKind: "dm", + clientId: "c1", + body: "hi bob", + }), ) const bMsgs = bConn.framesOfType("message") @@ -186,12 +194,24 @@ describe("DM gateway routing (join/send/ack/block)", () => { const bConn = new MockConnection("B") const aSession = sessionFor(ALICE, aConn) const bSession = sessionFor(BOB, bConn) - await handleClientFrame(aSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) - await handleClientFrame(bSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) + await handleClientFrame( + aSession, + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) + await handleClientFrame( + bSession, + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) await handleClientFrame( aSession, - JSON.stringify({ type: "send", cleanupId: THREAD, roomKind: "dm", clientId: "c1", body: "you retard" }), + JSON.stringify({ + type: "send", + cleanupId: THREAD, + roomKind: "dm", + clientId: "c1", + body: "you retard", + }), ) const errs = aConn.framesOfType("error") @@ -210,7 +230,13 @@ describe("DM gateway routing (join/send/ack/block)", () => { const aSession = sessionFor(ALICE, aConn) await handleClientFrame( aSession, - JSON.stringify({ type: "send", cleanupId: THREAD, roomKind: "cleanup", clientId: "c1", body: "you retard" }), + JSON.stringify({ + type: "send", + cleanupId: THREAD, + roomKind: "cleanup", + clientId: "c1", + body: "you retard", + }), ) const errs = aConn.framesOfType("error") expect(errs).toHaveLength(1) @@ -223,10 +249,19 @@ describe("DM gateway routing (join/send/ack/block)", () => { it("a clean send is unaffected by the slur gate (general profanity passes)", async () => { const aConn = new MockConnection("A") const aSession = sessionFor(ALICE, aConn) - await handleClientFrame(aSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) await handleClientFrame( aSession, - JSON.stringify({ type: "send", cleanupId: THREAD, roomKind: "dm", clientId: "c1", body: "this is damn slow" }), + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) + await handleClientFrame( + aSession, + JSON.stringify({ + type: "send", + cleanupId: THREAD, + roomKind: "dm", + clientId: "c1", + body: "this is damn slow", + }), ) expect(aConn.framesOfType("error")).toHaveLength(0) expect(aConn.framesOfType("ack")).toHaveLength(1) @@ -239,7 +274,10 @@ describe("DM gateway routing (join/send/ack/block)", () => { const aConn = new MockConnection("A") const aSession = sessionFor(ALICE, aConn) - await handleClientFrame(aSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) + await handleClientFrame( + aSession, + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) const joinErr = aConn.framesOfType("error") expect(joinErr).toHaveLength(1) expect((joinErr[0] as { code: string }).code).toBe("FORBIDDEN") @@ -247,7 +285,13 @@ describe("DM gateway routing (join/send/ack/block)", () => { await handleClientFrame( aSession, - JSON.stringify({ type: "send", cleanupId: THREAD, roomKind: "dm", clientId: "c", body: "let me in" }), + JSON.stringify({ + type: "send", + cleanupId: THREAD, + roomKind: "dm", + clientId: "c", + body: "let me in", + }), ) expect(aConn.framesOfType("error")).toHaveLength(2) expect(aConn.framesOfType("ack")).toHaveLength(0) @@ -257,7 +301,10 @@ describe("DM gateway routing (join/send/ack/block)", () => { it("ack with roomKind:dm routes to the dm read-state seam (not the cleanup one)", async () => { const aConn = new MockConnection("A") const aSession = sessionFor(ALICE, aConn) - await handleClientFrame(aSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) + await handleClientFrame( + aSession, + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) await handleClientFrame( aSession, JSON.stringify({ @@ -275,7 +322,10 @@ describe("DM gateway routing (join/send/ack/block)", () => { it("ack with NO room fields falls back to the socket's first joined room (dm)", async () => { const aConn = new MockConnection("A") const aSession = sessionFor(ALICE, aConn) - await handleClientFrame(aSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) + await handleClientFrame( + aSession, + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) await handleClientFrame( aSession, JSON.stringify({ type: "ack", upToId: "44444444-4444-4444-4444-444444444444" }), @@ -304,7 +354,10 @@ describe("DM gateway routing (join/send/ack/block)", () => { it("opening (join) a dm marks the room read on open and self-signals the reader's threads (#42)", async () => { const aConn = new MockConnection("A") const aSession = sessionFor(ALICE, aConn) - await handleClientFrame(aSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) + await handleClientFrame( + aSession, + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) expect(openMarks).toEqual([{ kind: "dm", id: THREAD, userId: ALICE }]) await new Promise((r) => setTimeout(r, 0)) expect(signals).toContainEqual({ userId: ALICE, topic: "threads", id: THREAD }) @@ -313,7 +366,10 @@ describe("DM gateway routing (join/send/ack/block)", () => { it("a dm ack self-signals the reader's threads so the badge refetches after the watermark (#42)", async () => { const aConn = new MockConnection("A") const aSession = sessionFor(ALICE, aConn) - await handleClientFrame(aSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) + await handleClientFrame( + aSession, + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) signals.length = 0 await handleClientFrame( aSession, diff --git a/services/api/test/unit/dm-notifications.test.ts b/services/api/test/unit/dm-notifications.test.ts index da94325b..c8ad7ea3 100644 --- a/services/api/test/unit/dm-notifications.test.ts +++ b/services/api/test/unit/dm-notifications.test.ts @@ -22,7 +22,6 @@ import { } from "../../src/services/notification-service.js" import { clearConversationBellFor } from "../../src/services/conversation-bell.js" - const ALICE = "11111111-1111-1111-1111-111111111111" const BOB = "22222222-2222-2222-2222-222222222222" const CLEANUP = "55555555-5555-5555-5555-555555555555" @@ -143,10 +142,19 @@ describe("DM bell notifications (#42)", () => { it("a dm send creates a `dm` notification for the PEER (not the sender) and pushes", async () => { const aConn = new MockConnection("A") const aSession = sessionFor(ALICE, aConn) - await handleClientFrame(aSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) await handleClientFrame( aSession, - JSON.stringify({ type: "send", cleanupId: THREAD, roomKind: "dm", clientId: "c1", body: "hi bob" }), + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) + await handleClientFrame( + aSession, + JSON.stringify({ + type: "send", + cleanupId: THREAD, + roomKind: "dm", + clientId: "c1", + body: "hi bob", + }), ) await new Promise((r) => setTimeout(r, 0)) @@ -166,12 +174,24 @@ describe("DM bell notifications (#42)", () => { const bConn = new MockConnection("B") const aSession = sessionFor(ALICE, aConn) const bSession = sessionFor(BOB, bConn) - await handleClientFrame(bSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) - await handleClientFrame(aSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) + await handleClientFrame( + bSession, + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) + await handleClientFrame( + aSession, + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) await handleClientFrame( aSession, - JSON.stringify({ type: "send", cleanupId: THREAD, roomKind: "dm", clientId: "c1", body: "you there?" }), + JSON.stringify({ + type: "send", + cleanupId: THREAD, + roomKind: "dm", + clientId: "c1", + body: "you there?", + }), ) await new Promise((r) => setTimeout(r, 0)) @@ -182,17 +202,29 @@ describe("DM bell notifications (#42)", () => { it("reading the dm conversation clears the recipient's `dm` notification", async () => { const aConn = new MockConnection("A") const aSession = sessionFor(ALICE, aConn) - await handleClientFrame(aSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) await handleClientFrame( aSession, - JSON.stringify({ type: "send", cleanupId: THREAD, roomKind: "dm", clientId: "c1", body: "ping" }), + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) + await handleClientFrame( + aSession, + JSON.stringify({ + type: "send", + cleanupId: THREAD, + roomKind: "dm", + clientId: "c1", + body: "ping", + }), ) await new Promise((r) => setTimeout(r, 0)) expect(unreadDmNotifs(BOB)).toHaveLength(1) const bConn = new MockConnection("B") const bSession = sessionFor(BOB, bConn) - await handleClientFrame(bSession, JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" })) + await handleClientFrame( + bSession, + JSON.stringify({ type: "join", cleanupId: THREAD, roomKind: "dm" }), + ) await handleClientFrame( bSession, JSON.stringify({ diff --git a/services/api/test/unit/dm-service.test.ts b/services/api/test/unit/dm-service.test.ts index 97c69c04..0c64f713 100644 --- a/services/api/test/unit/dm-service.test.ts +++ b/services/api/test/unit/dm-service.test.ts @@ -11,7 +11,6 @@ import { } from "../../src/services/dm-repository.memory.js" import type { AppError } from "@civfix/shared" - const ALICE = "11111111-1111-1111-1111-111111111111" const BOB = "22222222-2222-2222-2222-222222222222" const CAROL = "33333333-3333-3333-3333-333333333333" @@ -38,7 +37,10 @@ beforeEach(() => { users = new Map() users.set(ALICE, user({ id: ALICE, displayName: "Alice", handle: "alice" })) users.set(BOB, user({ id: BOB, displayName: "Bob", handle: "bob" })) - users.set(CAROL, user({ id: CAROL, displayName: "Carol", handle: "carol", allowDirectMessages: false })) + users.set( + CAROL, + user({ id: CAROL, displayName: "Carol", handle: "carol", allowDirectMessages: false }), + ) service = makeDmService({ dm, blocks, @@ -108,9 +110,15 @@ describe("DmService.openDm", () => { }) it("opens an EXISTING thread even when the target later disables DMs", async () => { - users.set(CAROL, user({ id: CAROL, displayName: "Carol", handle: "carol", allowDirectMessages: true })) + users.set( + CAROL, + user({ id: CAROL, displayName: "Carol", handle: "carol", allowDirectMessages: true }), + ) const first = await service.openDm(ALICE, CAROL) - users.set(CAROL, user({ id: CAROL, displayName: "Carol", handle: "carol", allowDirectMessages: false })) + users.set( + CAROL, + user({ id: CAROL, displayName: "Carol", handle: "carol", allowDirectMessages: false }), + ) const again = await service.openDm(ALICE, CAROL) expect(again.id).toBe(first.id) }) 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 f3d8a5b6..e504bfa9 100644 --- a/services/api/test/unit/drizzle-client-sql-guard.test.ts +++ b/services/api/test/unit/drizzle-client-sql-guard.test.ts @@ -9,7 +9,9 @@ describe("check:sql — raw SQL on drizzle's own client", () => { it("flags a direct tagged template and a client handed to a helper", () => { expect(findDrizzleClientUses("await db.$client`DELETE FROM media_assets`")).toHaveLength(1) - expect(findDrizzleClientUses("resolveAvatarMediaOrThrow(this.db.$client, uploadId)")).toHaveLength(1) + expect( + findDrizzleClientUses("resolveAvatarMediaOrThrow(this.db.$client, uploadId)"), + ).toHaveLength(1) }) it("leaves the fully-serializing raw tag alone", () => { diff --git a/services/api/test/unit/email-format.test.ts b/services/api/test/unit/email-format.test.ts index f7cb3999..e5e1ff93 100644 --- a/services/api/test/unit/email-format.test.ts +++ b/services/api/test/unit/email-format.test.ts @@ -92,7 +92,7 @@ describe("email layout", () => { ]) expect(block.text).toContain("Title:") expect(block.text).toContain("Category:") - expect(block.html).toContain("role=\"presentation\"") + expect(block.html).toContain('role="presentation"') }) it("renders photo links as labels, never raw URLs as text", () => { @@ -185,13 +185,25 @@ describe("buildReportPacket", () => { }) it("strips CRLF from the subject to block header injection", () => { - const packet = buildReportPacket(reportRecord({ title: "Hi\r\nBcc: evil@x" }), null, [], null, NO_TEMPLATES) + const packet = buildReportPacket( + reportRecord({ title: "Hi\r\nBcc: evil@x" }), + null, + [], + null, + NO_TEMPLATES, + ) expect(packet.subject).not.toContain("\n") expect(packet.subject).not.toContain("\r") }) it("escapes HTML-significant characters in user content", () => { - const packet = buildReportPacket(reportRecord({ desc: "" }), null, [], null, NO_TEMPLATES) + const packet = buildReportPacket( + reportRecord({ desc: "" }), + null, + [], + null, + NO_TEMPLATES, + ) expect(packet.html).not.toContain("") expect(packet.html).toContain("<script>") }) @@ -329,7 +341,9 @@ describe("buildReportPacket", () => { subject: null, body: "Map: {mapLink}", }) - expect(packet.html).toMatch(/plain", @@ -347,8 +361,12 @@ describe("buildReportPacket", () => { null, NO_TEMPLATES, ) - expect(packet.html).toContain("Location: 100 Main St
Coordinates: 39.5, -98.35
View the exact location") - expect(packet.html).toContain('a.jpg

Coordinates: 39.5, -98.35
View the exact location", + ) + expect(packet.html).toContain( + 'a.jpg

{ { ...input, displayName: "Dana Neighbor" }, "Still not cleared.", ) - expect(packet.text).toContain("Dana Neighbor commented on a trash report in Oakville via civfix") + expect(packet.text).toContain( + "Dana Neighbor commented on a trash report in Oakville via civfix", + ) expect(packet.text).not.toContain("A neighbor commented") expect(packet.text).toContain("> Still not cleared.") }) @@ -393,7 +413,9 @@ describe("buildDiscussionForwardPacket", () => { expect(packet.text).toContain("A neighbor commented on a trash report in Oakville via civfix") } const deleted = buildDiscussionForwardPacket({ ...input, displayName: "Deleted User" }, "hi") - expect(deleted.text).toContain("Deleted User commented on a trash report in Oakville via civfix") + expect(deleted.text).toContain( + "Deleted User commented on a trash report in Oakville via civfix", + ) }) it("escapes a display name that carries HTML-significant characters", () => { @@ -438,11 +460,16 @@ describe("footer rendering", () => { expect(html).toContain('href="https://civfix.org/unsubscribe?t=abc"') expect(html).toContain('href="https://civfix.org/e/beach"') expect(html).toContain("never gave them your email address.
") - expect(text).toContain("Stop receiving messages about this event: https://civfix.org/unsubscribe?t=abc") + expect(text).toContain( + "Stop receiving messages about this event: https://civfix.org/unsubscribe?t=abc", + ) }) it("escapes HTML in the footer before linkifying", () => { - const { html } = renderEmailBody({ blocks: [paragraph("x")], footer: "bold https://civfix.org" }) + const { html } = renderEmailBody({ + blocks: [paragraph("x")], + footer: "bold https://civfix.org", + }) expect(html).not.toContain("bold") expect(html).toContain("<b>bold</b>") expect(html).toContain('href="https://civfix.org"') @@ -462,7 +489,11 @@ describe("renderOtp", () => { describe("renderTemplate", () => { it("guest_otp: shows the code in the big code block, not buried in a sentence", () => { - const out = renderTemplate("guest_otp", { title: "Beach Cleanup", code: "738201", minutes: "5" }) + const out = renderTemplate("guest_otp", { + title: "Beach Cleanup", + code: "738201", + minutes: "5", + }) expect(out.subject).toBe("Your code to RSVP for Beach Cleanup") expect(out.html).toContain("letter-spacing:6px") expect(out.html).toContain(">738201<") diff --git a/services/api/test/unit/enums.test.ts b/services/api/test/unit/enums.test.ts index cf5736d3..ade8f25b 100644 --- a/services/api/test/unit/enums.test.ts +++ b/services/api/test/unit/enums.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from "vitest" import { AbuseReasonSchema, @@ -157,16 +156,16 @@ describe("schema enum tuples mirror @civfix/shared", () => { ["UserStatus", USER_ACCOUNT_STATUS_VALUES, UserStatusSchema.options], ["Risk", USER_RISK_VALUES, RiskSchema.options], ["ModerationKind", MODERATION_KIND_VALUES, ModerationKindSchema.options], - [ - "ModerationSubjectType", - MODERATION_SUBJECT_TYPE_VALUES, - ModerationSubjectTypeSchema.options, - ], + ["ModerationSubjectType", MODERATION_SUBJECT_TYPE_VALUES, ModerationSubjectTypeSchema.options], ["Priority", MODERATION_PRIORITY_VALUES, PrioritySchema.options], ["MailStatus", MAIL_THREAD_STATUS_VALUES, MailStatusSchema.options], ["MailDirection", MAIL_DIRECTION_VALUES, MailDirectionSchema.options], ["EventVisibility", EVENT_VISIBILITY_VALUES, EventVisibilitySchema.options], - ["OrganizationMemberRole", ORGANIZATION_MEMBER_ROLE_VALUES, OrganizationMemberRoleSchema.options], + [ + "OrganizationMemberRole", + ORGANIZATION_MEMBER_ROLE_VALUES, + OrganizationMemberRoleSchema.options, + ], ["OrgVerificationStatus", ORG_VERIFICATION_STATUS_VALUES, OrgVerificationStatusSchema.options], ["OrgVerificationKind", ORG_VERIFICATION_KIND_VALUES, OrgVerificationKindSchema.options], ["EventTeamRole", EVENT_TEAM_ROLE_VALUES, EventTeamRoleSchema.options], diff --git a/services/api/test/unit/env.test.ts b/services/api/test/unit/env.test.ts index ea05e102..2b3553c1 100644 --- a/services/api/test/unit/env.test.ts +++ b/services/api/test/unit/env.test.ts @@ -33,7 +33,6 @@ function validProdEnv(): NodeJS.ProcessEnv { } } - describe("loadEnv: outbound SMS", () => { it("defaults to the fake sender outside production and the real one in production", () => { expect(loadEnv({ NODE_ENV: "test" }).USE_FAKE_SMS).toBe(true) @@ -160,7 +159,9 @@ describe("loadEnv", () => { it("H11: the guard covers every USE_FAKE_* key the loader exposes on Env (no flag left behind)", () => { const listed = new Set(FAKE_SEAM_FLAGS.map((f) => f.flag)) - const exposed = Object.keys(loadEnv({ NODE_ENV: "test" })).filter((k) => k.startsWith("USE_FAKE_")) + const exposed = Object.keys(loadEnv({ NODE_ENV: "test" })).filter((k) => + k.startsWith("USE_FAKE_"), + ) expect(exposed.length).toBeGreaterThan(0) for (const key of exposed) expect(listed.has(key), key).toBe(true) }) @@ -205,7 +206,6 @@ describe("loadEnv", () => { expect(env.PORT).toBe(8080) }) - it("defaults REVIEWER_OTP_BYPASS to false in every environment", () => { expect(loadEnv(validProdEnv()).REVIEWER_OTP_BYPASS).toBe(false) expect(loadEnv({ NODE_ENV: "development" }).REVIEWER_OTP_BYPASS).toBe(false) @@ -248,7 +248,6 @@ describe("loadEnv", () => { expect(loadEnv(validProdEnv()).REVIEWER_OTP_CODE).toBeUndefined() }) - it("requires R2_INBOUND_BUCKET once R2_PUBLIC_BASE is set", () => { const source = validProdEnv() source.R2_PUBLIC_BASE = "https://cdn.civfix.org" @@ -280,7 +279,6 @@ describe("loadEnv", () => { expect(env.R2_PUBLIC_BASE).toBe("https://cdn.civfix.org") }) - it.each(["require", "verify-ca", "verify-full"])( "accepts sslmode=%s on DATABASE_URL in production", (mode) => { @@ -302,7 +300,6 @@ describe("loadEnv", () => { }, ) - it.each([ ["compose service alias", "postgres://user:pass@postgres:5432/civfix"], ["single-label with hyphen", "postgres://user:pass@civfix-postgres:5432/civfix"], @@ -336,7 +333,6 @@ describe("loadEnv", () => { ).not.toThrow() }) - it("rejects TRUST_PROXY=true in production", () => { const source = validProdEnv() source.TRUST_PROXY = "true" @@ -349,7 +345,9 @@ describe("loadEnv", () => { it("parses CF_TURNSTILE_HOSTNAMES as a lowercased, de-duplicated list and defaults to empty", () => { expect(loadEnv({ NODE_ENV: "test" }).CF_TURNSTILE_HOSTNAMES).toEqual([]) - expect(loadEnv({ NODE_ENV: "test", CF_TURNSTILE_HOSTNAMES: "" }).CF_TURNSTILE_HOSTNAMES).toEqual([]) + expect( + loadEnv({ NODE_ENV: "test", CF_TURNSTILE_HOSTNAMES: "" }).CF_TURNSTILE_HOSTNAMES, + ).toEqual([]) const env = loadEnv({ NODE_ENV: "test", CF_TURNSTILE_HOSTNAMES: " CivFix.org , www.civfix.org ,civfix.org", @@ -396,9 +394,9 @@ describe("loadEnv: outbound send policy", () => { }) it("REFUSES a throughput floor below the minimum", () => { - expect(() => - loadEnv({ NODE_ENV: "test", OUTBOUND_SEND_MIN_THROUGHPUT_BPS: "48" }), - ).toThrow(/OUTBOUND_SEND_MIN_THROUGHPUT_BPS/) + expect(() => loadEnv({ NODE_ENV: "test", OUTBOUND_SEND_MIN_THROUGHPUT_BPS: "48" })).toThrow( + /OUTBOUND_SEND_MIN_THROUGHPUT_BPS/, + ) }) it("REFUSES an SMTP timeout above the ceiling", () => { diff --git a/services/api/test/unit/errors-http-mapper.test.ts b/services/api/test/unit/errors-http-mapper.test.ts index 64190c72..82bd9f9c 100644 --- a/services/api/test/unit/errors-http-mapper.test.ts +++ b/services/api/test/unit/errors-http-mapper.test.ts @@ -50,7 +50,11 @@ async function buildProbe(thrown: () => unknown): Promise { app.setNotFoundHandler(makeNotFoundHandler()) app.post( "/boom", - { schema: { body: { type: "object", required: ["email"], properties: { email: { type: "string" } } } } }, + { + schema: { + body: { type: "object", required: ["email"], properties: { email: { type: "string" } } }, + }, + }, async () => ({ ok: true }), ) app.get("/throw", async () => { diff --git a/services/api/test/unit/event-address-verification.test.ts b/services/api/test/unit/event-address-verification.test.ts index dc2b910d..8e3e0e6e 100644 --- a/services/api/test/unit/event-address-verification.test.ts +++ b/services/api/test/unit/event-address-verification.test.ts @@ -137,7 +137,10 @@ describe("createCleanup: a NEW client's confirmed address", () => { describe("createCleanup: the OLD-client compat shim", () => { it("treats an old client's text as the host's own, so it is 'manual'", async () => { const resolve = resolver(STREET) - const dto = await serviceWith(resolve).createCleanup(baseInput({ address: "Boathouse dock" }), HOST) + const dto = await serviceWith(resolve).createCleanup( + baseInput({ address: "Boathouse dock" }), + HOST, + ) expect(dto.address).toBe("Boathouse dock") expect(dto.addressSource).toBe("manual") diff --git a/services/api/test/unit/federal-lands.test.ts b/services/api/test/unit/federal-lands.test.ts index 25712545..2b54cbb2 100644 --- a/services/api/test/unit/federal-lands.test.ts +++ b/services/api/test/unit/federal-lands.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from "vitest" import { FEDERAL_LANDS, @@ -96,10 +95,12 @@ describe("federal lands dataset", () => { it("the Angeles-over-city probe lies inside BOTH the forest and the seeded LA city box", () => { const angeles = FEDERAL_LANDS.find((l) => l.geoid === "USFS-ANGELES")! expect(PROBE_ANGELES_OVER_CITY.expectGeoid).toBe("USFS-ANGELES") - expect(pointInRing(PROBE_ANGELES_OVER_CITY.lng, PROBE_ANGELES_OVER_CITY.lat, ringOf(angeles))).toBe( + expect( + pointInRing(PROBE_ANGELES_OVER_CITY.lng, PROBE_ANGELES_OVER_CITY.lat, ringOf(angeles)), + ).toBe(true) + expect(inBbox(PROBE_ANGELES_OVER_CITY.lng, PROBE_ANGELES_OVER_CITY.lat, LA_CITY.bbox)).toBe( true, ) - expect(inBbox(PROBE_ANGELES_OVER_CITY.lng, PROBE_ANGELES_OVER_CITY.lat, LA_CITY.bbox)).toBe(true) }) it("carries ONLY example.* placeholder contacts so no real agency address can ship as a routing target", () => { diff --git a/services/api/test/unit/feed-counts-service.test.ts b/services/api/test/unit/feed-counts-service.test.ts index d46ff8d6..bfcf64b3 100644 --- a/services/api/test/unit/feed-counts-service.test.ts +++ b/services/api/test/unit/feed-counts-service.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from "vitest" import type { Sql } from "../../src/db/client.js" import { makePostService } from "../../src/services/post-service.js" -import type { - FeedCountsRow, - PostRepository, -} from "../../src/services/post-repository.drizzle.js" +import type { FeedCountsRow, PostRepository } from "../../src/services/post-repository.drizzle.js" const VIEWER = "11111111-1111-1111-1111-111111111111" const PUBLIC_POST = "22222222-2222-2222-2222-222222222222" diff --git a/services/api/test/unit/feed-ranked-service.test.ts b/services/api/test/unit/feed-ranked-service.test.ts index 2201597f..68b13a4b 100644 --- a/services/api/test/unit/feed-ranked-service.test.ts +++ b/services/api/test/unit/feed-ranked-service.test.ts @@ -119,7 +119,10 @@ function repoOver(over: Partial): PostRepository { } as PostRepository } -function recordingChannel(): { channel: UserChannel; sent: Array<{ users: string[]; signal: UserSignal }> } { +function recordingChannel(): { + channel: UserChannel + sent: Array<{ users: string[]; signal: UserSignal }> +} { const sent: Array<{ users: string[]; signal: UserSignal }> = [] const channel = { subscribeUser: () => Promise.resolve(() => Promise.resolve()), @@ -524,11 +527,13 @@ describe("ranked feed: a signed-out reader keeps scrolling", () => { let cursor: string | undefined = first.nextCursor ?? undefined seen.push(...first.items.map((item) => item.id)) while (cursor !== undefined) { - const page: { items: PostDTO[]; nextCursor: string | null } = await guestService().publicFeed({ - filter: "all", - limit: 20, - cursor, - }) + const page: { items: PostDTO[]; nextCursor: string | null } = await guestService().publicFeed( + { + filter: "all", + limit: 20, + cursor, + }, + ) seen.push(...page.items.map((item) => item.id)) cursor = page.nextCursor ?? undefined } @@ -996,7 +1001,13 @@ describe("ranked feed: realtime fanout never blocks the request path", () => { }) await svc.createPost( - { kind: "post", body: "reply", replyToId: REPOST, mediaUploadIds: [], mentionedUserIds: [] } as never, + { + kind: "post", + body: "reply", + replyToId: REPOST, + mediaUploadIds: [], + mentionedUserIds: [], + } as never, VIEWER, ) diff --git a/services/api/test/unit/feed-ranking.test.ts b/services/api/test/unit/feed-ranking.test.ts index 7527929e..c5b87d76 100644 --- a/services/api/test/unit/feed-ranking.test.ts +++ b/services/api/test/unit/feed-ranking.test.ts @@ -313,7 +313,12 @@ describe("feed ranking: ordering", () => { it("never emits a negative score", () => { const hostile: FeedRankingConfig = { ...CFG, baseWeight: 0 } - const score = scoreCandidate(candidate({ createdAtMs: NOW - 24 * 365 * HOUR }), hostile, NOW, SEED) + const score = scoreCandidate( + candidate({ createdAtMs: NOW - 24 * 365 * HOUR }), + hostile, + NOW, + SEED, + ) expect(score).toBeGreaterThanOrEqual(0) }) }) @@ -400,7 +405,10 @@ describe("feed ranking: the global half is viewer-independent by construction", expect(viewerScore(candidate(), CFG)).toBe(0) expect(viewerScore(candidate({ authorFollowed: true }), CFG)).toBeCloseTo(CFG.followWeight, 10) expect(viewerScore(candidate({ authorIsViewer: true }), CFG)).toBeCloseTo(CFG.selfWeight, 10) - expect(viewerScore(candidate({ viewerMentioned: true }), CFG)).toBeCloseTo(CFG.mentionWeight, 10) + expect(viewerScore(candidate({ viewerMentioned: true }), CFG)).toBeCloseTo( + CFG.mentionWeight, + 10, + ) expect(viewerScore(candidate({ distanceKm: 0 }), CFG)).toBeCloseTo(CFG.nearbyWeight, 10) }) diff --git a/services/api/test/unit/forward-template-service.test.ts b/services/api/test/unit/forward-template-service.test.ts index d758014f..5b198acc 100644 --- a/services/api/test/unit/forward-template-service.test.ts +++ b/services/api/test/unit/forward-template-service.test.ts @@ -123,7 +123,10 @@ describe("forward-template service: preview", () => { it("appends the operator note WITHOUT a heading when the body does not render {operatorNote}", async () => { const { svc } = harness() - const preview = await svc.preview({ subjectTemplate: null, bodyTemplate: "A {category} report." }) + const preview = await svc.preview({ + subjectTemplate: null, + bodyTemplate: "A {category} report.", + }) expect(preview.text).toContain(`> ${sample("operatorNote")}`) expect(preview.text).not.toContain("Note from the civfix team") expect(preview.html).not.toContain("Note from the civfix team") diff --git a/services/api/test/unit/guest-rsvp-routes.test.ts b/services/api/test/unit/guest-rsvp-routes.test.ts index 20da11b5..1f4dafff 100644 --- a/services/api/test/unit/guest-rsvp-routes.test.ts +++ b/services/api/test/unit/guest-rsvp-routes.test.ts @@ -31,7 +31,9 @@ beforeAll(async () => { }, }) }) -afterAll(async () => { await app.close() }) +afterAll(async () => { + await app.close() +}) describe("guest rsvp over HTTP", () => { it("accepts a real request body and merges the path id", async () => { diff --git a/services/api/test/unit/guest-rsvp-service.test.ts b/services/api/test/unit/guest-rsvp-service.test.ts index e93ebd8f..1e9232e8 100644 --- a/services/api/test/unit/guest-rsvp-service.test.ts +++ b/services/api/test/unit/guest-rsvp-service.test.ts @@ -75,7 +75,9 @@ function build( const counters = new InMemoryCounterStore(now) const roles = new Map([[HOST_ID, "organizer"]]) const reviewer = - opts.reviewer === null ? undefined : (opts.reviewer ?? { email: REVIEWER_EMAIL, code: REVIEWER_CODE }) + opts.reviewer === null + ? undefined + : (opts.reviewer ?? { email: REVIEWER_EMAIL, code: REVIEWER_CODE }) const service = makeGuestRsvpService({ repo, @@ -329,10 +331,7 @@ describe("guest rsvp: requesting a code", () => { h.advance(61_000) const known = await h.service.requestCode(emailRequest(), ctx) - const unknown = await h.service.requestCode( - emailRequest({ email: "nobody@example.org" }), - ctx, - ) + const unknown = await h.service.requestCode(emailRequest({ email: "nobody@example.org" }), ctx) const honeypot = await h.service.requestCode( emailRequest({ email: "bot@example.org", website: "spam" }), ctx, @@ -608,7 +607,6 @@ describe("guest rsvp: verifying a code", () => { expect(h.repo.guests).toHaveLength(0) }) - it("refuses to join when the single-use consume is lost to a concurrent verify", async () => { h.repo.markOtpConsumed = () => Promise.resolve(false) @@ -963,7 +961,10 @@ describe("guest rsvp: the host roster", () => { it("returns scrubbed contacts as null once retention has run", async () => { const h = build() await seedGuest(h, "ada@example.org") - h.repo.seedEvent({ id: EVENT_ID, scheduledAt: new Date(Date.parse("2026-01-01T00:00:00.000Z")) }) + h.repo.seedEvent({ + id: EVENT_ID, + scheduledAt: new Date(Date.parse("2026-01-01T00:00:00.000Z")), + }) const result = await h.service.runRetentionSweep() expect(result.scrubbedGuests).toBe(1) @@ -1001,7 +1002,10 @@ describe("guest rsvp: retention", () => { await h.service.requestCode(emailRequest(), ctx) await h.service.verifyCode(emailVerify(), ctx) - h.repo.seedEvent({ id: EVENT_ID, scheduledAt: new Date(Date.parse("2026-01-01T00:00:00.000Z")) }) + h.repo.seedEvent({ + id: EVENT_ID, + scheduledAt: new Date(Date.parse("2026-01-01T00:00:00.000Z")), + }) h.advance(25 * 60 * 60 * 1000) const result = await h.service.runRetentionSweep() @@ -1065,7 +1069,10 @@ describe("guest rsvp: retention", () => { const h = build() await h.service.requestCode(emailRequest(), ctx) await h.service.verifyCode(emailVerify(), ctx) - h.repo.seedEvent({ id: EVENT_ID, scheduledAt: new Date(Date.parse("2026-01-01T00:00:00.000Z")) }) + h.repo.seedEvent({ + id: EVENT_ID, + scheduledAt: new Date(Date.parse("2026-01-01T00:00:00.000Z")), + }) await h.service.runRetentionSweep() @@ -1123,7 +1130,6 @@ describe("guest rsvp: SMS title truncation", () => { expect(body).toContain("Beach cleanup") expect(body).not.toContain("...") }) - }) describe("going: members plus verified, non-cancelled guests", () => { @@ -1250,7 +1256,10 @@ describe("guest rsvp: one global SMS budget covers every outbound text", () => { describe("guest rsvp: retention drains rather than shaving one batch", () => { it("keeps paging until the backlog is gone", async () => { const h = build() - h.repo.seedEvent({ id: EVENT_ID, scheduledAt: new Date(Date.parse("2026-01-01T00:00:00.000Z")) }) + h.repo.seedEvent({ + id: EVENT_ID, + scheduledAt: new Date(Date.parse("2026-01-01T00:00:00.000Z")), + }) for (let i = 0; i < 1200; i++) { h.repo.guests.push({ id: randomUUID(), @@ -1347,10 +1356,7 @@ describe("guest rsvp: the SMS half of the cancel/update notice", () => { expect(await h.service.notifyGuestsBySms(EVENT_ID, "updated")).toBe(1) const body = h.sms.sent[0]?.body ?? "" expect(body).toContain( - formatEventWhen( - new Date(Date.parse("2026-09-05T17:00:00.000Z")), - "America/Los_Angeles", - ), + formatEventWhen(new Date(Date.parse("2026-09-05T17:00:00.000Z")), "America/Los_Angeles"), ) expect(body).toContain("10:00 AM PDT") expect(body).not.toContain("2026-09-05T17:00:00.000Z") diff --git a/services/api/test/unit/handle-policy.test.ts b/services/api/test/unit/handle-policy.test.ts index 8c54231a..d6240a6b 100644 --- a/services/api/test/unit/handle-policy.test.ts +++ b/services/api/test/unit/handle-policy.test.ts @@ -51,8 +51,9 @@ describe("handleChanged (the unchanged-handle no-op predicate)", () => { describe("decideHandleWrite: initial set (first-run completion)", () => { it("sets the handle and leaves the cooldown clock NULL, so the first real rename is free", () => { - expect(decideHandleWrite(input({ current: null, submitted: "jane_doe", profileComplete: false }))) - .toEqual({ handle: "jane_doe", handleChangedAt: null }) + expect( + decideHandleWrite(input({ current: null, submitted: "jane_doe", profileComplete: false })), + ).toEqual({ handle: "jane_doe", handleChangedAt: null }) }) it("does not stamp the clock even when the user already had a handle (placeholder -> chosen)", () => { @@ -201,7 +202,10 @@ describe("InMemoryUserStore.updateProfile DELEGATES to the policy (no divergent it("initial set leaves handle_changed_at null; the next rename stamps it; a second rename 429s", async () => { const { clock, store, user } = await seed() - const initial = await store.updateProfile(user.id, { handle: "coach_alex", displayName: "Coach" }) + const initial = await store.updateProfile(user.id, { + handle: "coach_alex", + displayName: "Coach", + }) expect(initial.handle).toBe("coach_alex") expect(initial.handleChangedAt).toBeNull() expect(initial.profileComplete).toBe(true) diff --git a/services/api/test/unit/home-turf-routes.test.ts b/services/api/test/unit/home-turf-routes.test.ts index e3e5d8f6..b2068d19 100644 --- a/services/api/test/unit/home-turf-routes.test.ts +++ b/services/api/test/unit/home-turf-routes.test.ts @@ -14,7 +14,6 @@ import { HOME_TURF_RATE_LIMIT, } from "../../src/routes/forms.routes.js" - interface Harness { app: FastifyInstance mailer: FakeMailer @@ -68,7 +67,11 @@ function outbounds(mailer: FakeMailer): OutboundEmail[] { describe("POST /forms/home-turf", () => { it("accepts a valid submit (200 {ok:true}) and sends notification + confirmation", async () => { const { app, mailer } = await makeHarness() - const res = await app.inject({ method: "POST", url: "/forms/home-turf", payload: formPayload() }) + const res = await app.inject({ + method: "POST", + url: "/forms/home-turf", + payload: formPayload(), + }) expect(res.statusCode).toBe(200) expect(res.json()).toEqual({ ok: true }) @@ -182,7 +185,11 @@ describe("POST /forms/home-turf", () => { it("is DISABLED when HOME_TURF_NOTIFY_TO is unset: 409, no mail, and NOT a captured 5xx", async () => { const { app, mailer } = await makeHarness({ HOME_TURF_NOTIFY_TO: "" }) - const res = await app.inject({ method: "POST", url: "/forms/home-turf", payload: formPayload() }) + const res = await app.inject({ + method: "POST", + url: "/forms/home-turf", + payload: formPayload(), + }) expect(res.statusCode).toBe(409) expect(res.statusCode).toBeLessThan(500) expect(res.json().code).toBe("CONFLICT") @@ -196,7 +203,11 @@ describe("POST /forms/home-turf", () => { calls += 1 return Promise.reject(new Error("smtp down")) } - const res = await app.inject({ method: "POST", url: "/forms/home-turf", payload: formPayload() }) + const res = await app.inject({ + method: "POST", + url: "/forms/home-turf", + payload: formPayload(), + }) expect(res.statusCode).toBe(500) expect(calls).toBe(1) }) @@ -210,7 +221,11 @@ describe("POST /forms/home-turf", () => { if (calls === 2) return Promise.reject(new Error("smtp down")) return original(email) } - const res = await app.inject({ method: "POST", url: "/forms/home-turf", payload: formPayload() }) + const res = await app.inject({ + method: "POST", + url: "/forms/home-turf", + payload: formPayload(), + }) expect(res.statusCode).toBe(200) expect(res.json()).toEqual({ ok: true }) expect(outbounds(mailer)).toHaveLength(1) @@ -251,20 +266,24 @@ describe("POST /forms/home-turf", () => { it(`still charges the budget on SUCCESS: a same-address resubmit 429s (limit ${HOME_TURF_EMAIL_LIMIT_PER_DAY}/day)`, async () => { const { app, mailer } = await makeHarness() - const first = await app.inject({ method: "POST", url: "/forms/home-turf", payload: formPayload() }) + const first = await app.inject({ + method: "POST", + url: "/forms/home-turf", + payload: formPayload(), + }) expect(first.statusCode).toBe(200) - const second = await app.inject({ method: "POST", url: "/forms/home-turf", payload: formPayload() }) + const second = await app.inject({ + method: "POST", + url: "/forms/home-turf", + payload: formPayload(), + }) expect(second.statusCode).toBe(429) expect(second.json().code).toBe("RATE_LIMITED") const sent = outbounds(mailer) expect(sent).toHaveLength(3) - expect(sent.map((e) => e.to)).toEqual([ - NOTIFY_TO, - "coach@example.org", - NOTIFY_TO, - ]) + expect(sent.map((e) => e.to)).toEqual([NOTIFY_TO, "coach@example.org", NOTIFY_TO]) const other = await app.inject({ method: "POST", @@ -309,7 +328,11 @@ describe("enforceHomeTurfIpCap", () => { describe("Turnstile action (F128)", () => { it("verifies the token with the 'home-turf' widget action", async () => { const { app, container } = await makeHarness() - const res = await app.inject({ method: "POST", url: "/forms/home-turf", payload: formPayload() }) + const res = await app.inject({ + method: "POST", + url: "/forms/home-turf", + payload: formPayload(), + }) expect(res.statusCode).toBe(200) expect((container.abuseChecks as FakeAbuseChecks).lastVerifyExpect?.action).toBe("home-turf") }) @@ -319,18 +342,26 @@ describe("enforceHomeTurfRecipientCap (M8)", () => { it(`allows ${HOME_TURF_EMAIL_LIMIT_PER_DAY} confirmation per address per day and 429s the next`, async () => { const counters = new InMemoryCounterStore(() => 0) for (let i = 0; i < HOME_TURF_EMAIL_LIMIT_PER_DAY; i++) { - await expect(enforceHomeTurfRecipientCap("victim@example.org", counters)).resolves.toBeUndefined() + await expect( + enforceHomeTurfRecipientCap("victim@example.org", counters), + ).resolves.toBeUndefined() } - await expect(enforceHomeTurfRecipientCap("victim@example.org", counters)).rejects.toMatchObject({ - code: "RATE_LIMITED", - }) - await expect(enforceHomeTurfRecipientCap("someone-else@example.org", counters)).resolves.toBeUndefined() + await expect(enforceHomeTurfRecipientCap("victim@example.org", counters)).rejects.toMatchObject( + { + code: "RATE_LIMITED", + }, + ) + await expect( + enforceHomeTurfRecipientCap("someone-else@example.org", counters), + ).resolves.toBeUndefined() }) it("normalizes case and surrounding whitespace so the bucket cannot be trivially varied", async () => { const counters = new InMemoryCounterStore(() => 0) await enforceHomeTurfRecipientCap("Victim@Example.org", counters) - await expect(enforceHomeTurfRecipientCap(" victim@example.ORG ", counters)).rejects.toMatchObject({ + await expect( + enforceHomeTurfRecipientCap(" victim@example.ORG ", counters), + ).rejects.toMatchObject({ code: "RATE_LIMITED", }) }) @@ -338,7 +369,9 @@ describe("enforceHomeTurfRecipientCap (M8)", () => { it("folds gmail +tags, dots and googlemail into one recipient bucket (F138)", async () => { const counters = new InMemoryCounterStore(() => 0) await enforceHomeTurfRecipientCap("victim@gmail.com", counters) - await expect(enforceHomeTurfRecipientCap("victim+abc@gmail.com", counters)).rejects.toMatchObject({ + await expect( + enforceHomeTurfRecipientCap("victim+abc@gmail.com", counters), + ).rejects.toMatchObject({ code: "RATE_LIMITED", }) await expect( @@ -349,10 +382,14 @@ describe("enforceHomeTurfRecipientCap (M8)", () => { it("strips +tags for non-gmail providers, but keeps dots significant (F138)", async () => { const counters = new InMemoryCounterStore(() => 0) await enforceHomeTurfRecipientCap("victim@example.org", counters) - await expect(enforceHomeTurfRecipientCap("victim+1@example.org", counters)).rejects.toMatchObject({ + await expect( + enforceHomeTurfRecipientCap("victim+1@example.org", counters), + ).rejects.toMatchObject({ code: "RATE_LIMITED", }) - await expect(enforceHomeTurfRecipientCap("v.ictim@example.org", counters)).resolves.toBeUndefined() + await expect( + enforceHomeTurfRecipientCap("v.ictim@example.org", counters), + ).resolves.toBeUndefined() }) }) @@ -363,7 +400,11 @@ describe("home-turf abuse caps FAIL CLOSED (M8)", () => { const app = await buildServer({ env, container }) current = { app, mailer: container.mailer as FakeMailer, container } - const res = await app.inject({ method: "POST", url: "/forms/home-turf", payload: formPayload() }) + const res = await app.inject({ + method: "POST", + url: "/forms/home-turf", + payload: formPayload(), + }) expect(res.statusCode).toBeGreaterThanOrEqual(500) expect(outbounds(container.mailer as FakeMailer)).toHaveLength(0) @@ -385,7 +426,11 @@ describe("home-turf abuse caps FAIL CLOSED (M8)", () => { const app = await buildServer({ env, container }) current = { app, mailer: container.mailer as FakeMailer, container } - const res = await app.inject({ method: "POST", url: "/forms/home-turf", payload: formPayload() }) + const res = await app.inject({ + method: "POST", + url: "/forms/home-turf", + payload: formPayload(), + }) expect(res.statusCode).toBe(200) expect(counted.some((k) => k.startsWith("abuse:home-turf:ip:"))).toBe(true) expect(counted.some((k) => k.startsWith("abuse:home-turf:email:"))).toBe(true) diff --git a/services/api/test/unit/host-analytics.test.ts b/services/api/test/unit/host-analytics.test.ts index 0ddf7003..8ba2a99b 100644 --- a/services/api/test/unit/host-analytics.test.ts +++ b/services/api/test/unit/host-analytics.test.ts @@ -82,8 +82,7 @@ function analyticsRepo(overrides: Partial = {}): AnalyticsR reportsResolved: 1, postsCreated: 7, }), - heldEventTotals: () => - Promise.resolve({ events: 2, registered: 20, checkedIn: 12, noShow: 1 }), + heldEventTotals: () => Promise.resolve({ events: 2, registered: 20, checkedIn: 12, noShow: 1 }), signupsByDayAcross: () => Promise.resolve({ daily: [ @@ -101,7 +100,9 @@ function analyticsRepo(overrides: Partial = {}): AnalyticsR } } -function metricsRepo(rows: Parameters[0] = []): MetricsRepository { +function metricsRepo( + rows: Parameters[0] = [], +): MetricsRepository { return { resolveSlug: () => Promise.resolve(null), eventTimezone: () => Promise.resolve("UTC"), @@ -110,13 +111,20 @@ function metricsRepo(rows: Parameters[0] = []) upsertExact: () => Promise.resolve(), upsertGreatest: () => Promise.resolve(), read: () => - Promise.resolve(rows.map((r) => ({ day: r.day, metric: r.metric, bucket: r.bucket, value: r.value }))), + Promise.resolve( + rows.map((r) => ({ day: r.day, metric: r.metric, bucket: r.bucket, value: r.value })), + ), readMany: () => - Promise.resolve(rows.map((r) => ({ day: r.day, metric: r.metric, bucket: r.bucket, value: r.value }))), + Promise.resolve( + rows.map((r) => ({ day: r.day, metric: r.metric, bucket: r.bucket, value: r.value })), + ), } } -function build(overrides: Partial = {}, metricRows: Parameters[0] = []) { +function build( + overrides: Partial = {}, + metricRows: Parameters[0] = [], +) { const cache = new InMemoryCacheClient() const service = makeAnalyticsService({ analytics: analyticsRepo(overrides), @@ -854,8 +862,7 @@ describe("hosted-events analytics summary", () => { it("keeps a sub-k held-event rate exact rather than hiding it", async () => { const { service } = build({ - heldEventTotals: () => - Promise.resolve({ events: 1, registered: 3, checkedIn: 2, noShow: 1 }), + heldEventTotals: () => Promise.resolve({ events: 1, registered: 3, checkedIn: 2, noShow: 1 }), }) const payload = await service.summary(OWNER, null, "30d", "self") expect(payload.eventsHeld.checkInRate.value).toBeCloseTo(2 / 3, 4) @@ -864,8 +871,7 @@ describe("hosted-events analytics summary", () => { it("leaves the rate null when nobody registered", async () => { const { service } = build({ - heldEventTotals: () => - Promise.resolve({ events: 1, registered: 0, checkedIn: 0, noShow: 0 }), + heldEventTotals: () => Promise.resolve({ events: 1, registered: 0, checkedIn: 0, noShow: 0 }), }) const payload = await service.summary(OWNER, null, "30d", "self") expect(payload.eventsHeld.checkInRate.value).toBeNull() diff --git a/services/api/test/unit/host-broadcast-audience.test.ts b/services/api/test/unit/host-broadcast-audience.test.ts index 3da859d7..ed756293 100644 --- a/services/api/test/unit/host-broadcast-audience.test.ts +++ b/services/api/test/unit/host-broadcast-audience.test.ts @@ -109,7 +109,9 @@ describe("broadcast audience", () => { subjectId: u(1), }) expect((await resolve(repo, { kind: "all_registered" })).members).not.toContain(u(1)) - expect((await resolve(repo, { kind: "all_registered" }, "event_cancelled")).members).toContain(u(1)) + expect((await resolve(repo, { kind: "all_registered" }, "event_cancelled")).members).toContain( + u(1), + ) }) it("honours a global unsubscribe for bulk", async () => { @@ -127,7 +129,9 @@ describe("broadcast audience", () => { const repo = seed() await repo.setEventMute(EVENT, u(2), true) expect((await resolve(repo, { kind: "all_registered" })).members).not.toContain(u(2)) - expect((await resolve(repo, { kind: "all_registered" }, "event_cancelled")).members).toContain(u(2)) + expect((await resolve(repo, { kind: "all_registered" }, "event_cancelled")).members).toContain( + u(2), + ) }) it("honours a guest unsubscribe for bulk", async () => { diff --git a/services/api/test/unit/host-broadcast-caps.test.ts b/services/api/test/unit/host-broadcast-caps.test.ts index d86688b4..00a40b6b 100644 --- a/services/api/test/unit/host-broadcast-caps.test.ts +++ b/services/api/test/unit/host-broadcast-caps.test.ts @@ -247,10 +247,7 @@ describe("broadcast content gates", () => { it("counts the CTA url toward the link cap", async () => { const { service } = build() - const fiveLinks = Array.from( - { length: 5 }, - (_, i) => `https://civfix.org/${i}`, - ).join(" and ") + const fiveLinks = Array.from({ length: 5 }, (_, i) => `https://civfix.org/${i}`).join(" and ") await expect( service.create(EVENT, HOST, { id: EVENT, diff --git a/services/api/test/unit/host-broadcast-pipeline.test.ts b/services/api/test/unit/host-broadcast-pipeline.test.ts index c0992b09..8014dce8 100644 --- a/services/api/test/unit/host-broadcast-pipeline.test.ts +++ b/services/api/test/unit/host-broadcast-pipeline.test.ts @@ -420,9 +420,7 @@ describe("broadcast chunk", () => { const h = harness({ members: 1, mailer: badAuth as unknown as FakeMailer }) const id = await draftSending(h) await h.pipeline.plan(id) - await expect( - h.pipeline.runChunk(id, 0, AUTH_ABORT_BACKOFF_SEC.length), - ).rejects.toBeDefined() + await expect(h.pipeline.runChunk(id, 0, AUTH_ABORT_BACKOFF_SEC.length)).rejects.toBeDefined() }) it("suppresses the remainder when the kill switch trips mid-send", async () => { diff --git a/services/api/test/unit/host-broadcast-render.test.ts b/services/api/test/unit/host-broadcast-render.test.ts index 0ef42d37..7bf016b1 100644 --- a/services/api/test/unit/host-broadcast-render.test.ts +++ b/services/api/test/unit/host-broadcast-render.test.ts @@ -59,10 +59,7 @@ describe("broadcast rendering", () => { }) it("degrades an unsafe markdown link to plain text", () => { - const out = renderBroadcast( - { subject: "x", bodyMd: "[click](javascript:alert(1))" }, - ctx, - ) + const out = renderBroadcast({ subject: "x", bodyMd: "[click](javascript:alert(1))" }, ctx) expect(out.html).not.toContain("javascript:") expect(out.html).not.toContain(">click
") }) diff --git a/services/api/test/unit/host-comms-env.test.ts b/services/api/test/unit/host-comms-env.test.ts index ffb0928f..790e8b70 100644 --- a/services/api/test/unit/host-comms-env.test.ts +++ b/services/api/test/unit/host-comms-env.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest" import { loadCommsEnv } from "../../src/env/comms-env.js" -function load(source: NodeJS.ProcessEnv): { env: ReturnType; errors: string[] } { +function load(source: NodeJS.ProcessEnv): { + env: ReturnType + errors: string[] +} { const errors: string[] = [] return { env: loadCommsEnv(source, errors), errors } } @@ -84,8 +87,10 @@ describe("loadCommsEnv defaults", () => { }) it("parses the link allowlist as a lowercased comma list", () => { - expect(load({ BROADCAST_LINK_ALLOWED_HOSTS: "Civfix.org, example.ORG" }).env - .BROADCAST_LINK_ALLOWED_HOSTS).toEqual(["civfix.org", "example.org"]) + expect( + load({ BROADCAST_LINK_ALLOWED_HOSTS: "Civfix.org, example.ORG" }).env + .BROADCAST_LINK_ALLOWED_HOSTS, + ).toEqual(["civfix.org", "example.org"]) }) }) diff --git a/services/api/test/unit/host-event-metrics.test.ts b/services/api/test/unit/host-event-metrics.test.ts index 6f002199..921b0671 100644 --- a/services/api/test/unit/host-event-metrics.test.ts +++ b/services/api/test/unit/host-event-metrics.test.ts @@ -22,9 +22,7 @@ function repoStub(): MetricsRepository & { greatest: MetricUpsert[]; exact: Metr exact, resolveSlug: (slug) => Promise.resolve( - slug === "beach-cleanup" - ? { cleanupId: EVENT, timezone: "America/Los_Angeles" } - : null, + slug === "beach-cleanup" ? { cleanupId: EVENT, timezone: "America/Los_Angeles" } : null, ), eventTimezone: () => Promise.resolve("America/Los_Angeles"), listRollupEvents: () => Promise.resolve([EVENT]), diff --git a/services/api/test/unit/host-export-csv.test.ts b/services/api/test/unit/host-export-csv.test.ts index e6089c2c..dba8cc48 100644 --- a/services/api/test/unit/host-export-csv.test.ts +++ b/services/api/test/unit/host-export-csv.test.ts @@ -2,10 +2,7 @@ import { readFileSync } from "node:fs" import { fileURLToPath } from "node:url" import { describe, expect, it } from "vitest" import { csvCell, csvProvenanceRow, csvRow } from "../../src/services/host/export-csv.js" -import { - makeHostExportService, - toHostExportDTO, -} from "../../src/services/host/export-service.js" +import { makeHostExportService, toHostExportDTO } from "../../src/services/host/export-service.js" import { hostExportBuilder, registerHostExportBuilder, @@ -25,7 +22,7 @@ describe("csv cells", () => { }) it("prefixes formula-injection cells", () => { - expect(csvCell("=HYPERLINK(\"https://evil.example\")")).toContain("'=") + expect(csvCell('=HYPERLINK("https://evil.example")')).toContain("'=") expect(csvCell("+1")).toBe("'+1") expect(csvCell("-1")).toBe("'-1") expect(csvCell("@x")).toBe("'@x") @@ -143,9 +140,16 @@ function harness( ...(authorize !== undefined ? { authorize } : {}), now: () => new Date("2026-02-05T12:00:00Z"), }) - return { service, puts, deletes, claims, current: () => current, setCurrent: (patch: Partial) => { - current = { ...current, ...patch } - } } + return { + service, + puts, + deletes, + claims, + current: () => current, + setCurrent: (patch: Partial) => { + current = { ...current, ...patch } + }, + } } describe("host export build", () => { @@ -158,9 +162,9 @@ describe("host export build", () => { const put = h.puts[0]! expect(put.key).toBe("exports/host/2026/02/00000000-0000-0000-0000-0000000000e1.csv") expect(put.meta).toMatchObject({ contentType: "text/csv; charset=utf-8" }) - expect(String(put.meta && (put.meta as { contentDisposition: string }).contentDisposition)).toContain( - "attachment;", - ) + expect( + String(put.meta && (put.meta as { contentDisposition: string }).contentDisposition), + ).toContain("attachment;") const text = put.body.toString("utf8") expect(text).toContain("# member email is never included") expect(text).toContain("# k=5 note") diff --git a/services/api/test/unit/host-insights.test.ts b/services/api/test/unit/host-insights.test.ts index 4a0685f0..571d6127 100644 --- a/services/api/test/unit/host-insights.test.ts +++ b/services/api/test/unit/host-insights.test.ts @@ -170,7 +170,9 @@ describe("event insights", () => { }) it("calls a cancelled event cancelled, and ignores a legacy 'done' column entirely", async () => { - const cancelled = build({ eventClock: () => Promise.resolve(clockRecord({ status: "cancelled" })) }) + const cancelled = build({ + eventClock: () => Promise.resolve(clockRecord({ status: "cancelled" })), + }) 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 diff --git a/services/api/test/unit/host-mail-failure.test.ts b/services/api/test/unit/host-mail-failure.test.ts index 6a8983f4..5cd74749 100644 --- a/services/api/test/unit/host-mail-failure.test.ts +++ b/services/api/test/unit/host-mail-failure.test.ts @@ -2,7 +2,11 @@ import Fastify from "fastify" import { describe, expect, it } from "vitest" import { AppError, ErrorCode, MailSendError } from "@civfix/shared" import { LOG_REDACT_PATHS } from "../../src/server.js" -import { isRetryableMailFailure, mailFailure, mailFailureKind } from "../../src/adapters/mail-failure.js" +import { + isRetryableMailFailure, + mailFailure, + mailFailureKind, +} from "../../src/adapters/mail-failure.js" describe("mailFailure", () => { it("classifies an auth rejection", () => { @@ -39,7 +43,11 @@ describe("mailFailure", () => { }), ).toBe("auth") expect( - mailFailureKind({ responseCode: 550, command: "RCPT TO", response: "550 relay not permitted" }), + mailFailureKind({ + responseCode: 550, + command: "RCPT TO", + response: "550 relay not permitted", + }), ).toBe("auth") expect( mailFailure({ @@ -64,23 +72,26 @@ describe("mailFailure", () => { }) it("classifies a SENDER rejection as auth, never as a recipient hard bounce", () => { - expect(mailFailureKind({ responseCode: 550, response: "550 5.7.1 Sender address rejected" })).toBe( - "auth", - ) - expect(mailFailureKind({ responseCode: 553, response: "553 sorry, that domain isn't allowed" })).toBe( + expect( + mailFailureKind({ responseCode: 550, response: "550 5.7.1 Sender address rejected" }), + ).toBe("auth") + expect( + mailFailureKind({ responseCode: 553, response: "553 sorry, that domain isn't allowed" }), + ).toBe("auth") + expect(mailFailureKind({ responseCode: 530, response: "530 Authentication required" })).toBe( "auth", ) - expect(mailFailureKind({ responseCode: 530, response: "530 Authentication required" })).toBe("auth") expect(mailFailureKind({ responseCode: 535, response: "535 auth failed" })).toBe("auth") expect(mailFailureKind({ responseCode: 554, response: "554 5.7.1 Relay access denied" })).toBe( "auth", ) - expect( - mailFailureKind({ responseCode: 550, command: "MAIL FROM", response: "550 no" }), - ).toBe("auth") - expect(mailFailure({ responseCode: 550, response: "550 5.7.1 Sender address rejected" }).senderRejected).toBe( - true, + expect(mailFailureKind({ responseCode: 550, command: "MAIL FROM", response: "550 no" })).toBe( + "auth", ) + expect( + mailFailure({ responseCode: 550, response: "550 5.7.1 Sender address rejected" }) + .senderRejected, + ).toBe(true) }) it("still classifies a RECIPIENT rejection as permanent (the only thing that suppresses)", () => { @@ -88,7 +99,11 @@ describe("mailFailure", () => { "permanent", ) expect( - mailFailureKind({ responseCode: 550, command: "RCPT TO", response: "550 mailbox unavailable" }), + mailFailureKind({ + responseCode: 550, + command: "RCPT TO", + response: "550 mailbox unavailable", + }), ).toBe("permanent") expect( mailFailure({ responseCode: 550, response: "550 5.1.1 unknown user" }).senderRejected, diff --git a/services/api/test/unit/host-unsubscribe-route.test.ts b/services/api/test/unit/host-unsubscribe-route.test.ts index 7e02387f..80d3bdf8 100644 --- a/services/api/test/unit/host-unsubscribe-route.test.ts +++ b/services/api/test/unit/host-unsubscribe-route.test.ts @@ -212,7 +212,10 @@ describe("GET /v1/broadcasts/unsubscribe", () => { }) expect(wrongKey.headers.location).toBe("https://civfix.org/unsubscribe") - const tooShort = await instance.inject({ method: "GET", url: "/v1/broadcasts/unsubscribe?t=nope" }) + const tooShort = await instance.inject({ + method: "GET", + url: "/v1/broadcasts/unsubscribe?t=nope", + }) expect(tooShort.statusCode).toBe(302) expect(tooShort.headers.location).toBe("https://civfix.org/unsubscribe") diff --git a/services/api/test/unit/host/capabilities-authz.test.ts b/services/api/test/unit/host/capabilities-authz.test.ts index 753291bc..e352aa51 100644 --- a/services/api/test/unit/host/capabilities-authz.test.ts +++ b/services/api/test/unit/host/capabilities-authz.test.ts @@ -30,9 +30,7 @@ describe("host capability matrix (backend realm)", () => { it("organizer holds every event-lane capability", () => { const caps = hostCapabilities({ eventRole: "organizer", orgRole: null }) - const orgLaneOnly: HostCapability[] = [ - "manage_org_members", - ] + const orgLaneOnly: HostCapability[] = ["manage_org_members"] for (const capability of HOST_CAPABILITY_VALUES) { expect(caps.has(capability), capability).toBe(!orgLaneOnly.includes(capability)) } diff --git a/services/api/test/unit/host/checkin-service.test.ts b/services/api/test/unit/host/checkin-service.test.ts index f3986fc9..d574e310 100644 --- a/services/api/test/unit/host/checkin-service.test.ts +++ b/services/api/test/unit/host/checkin-service.test.ts @@ -274,9 +274,7 @@ describe("check-in service", () => { }) it("refuses a guest ticket read behind an unknown manage token", async () => { - await expect( - h.service.guestTicket({ token: "x".repeat(32) }), - ).rejects.toBeInstanceOf(AppError) + await expect(h.service.guestTicket({ token: "x".repeat(32) })).rejects.toBeInstanceOf(AppError) }) it("names a guest on a scanned seat that carries no attendee name of its own", async () => { diff --git a/services/api/test/unit/host/cleanup-host-fields.test.ts b/services/api/test/unit/host/cleanup-host-fields.test.ts index cacf03ff..ffe56145 100644 --- a/services/api/test/unit/host/cleanup-host-fields.test.ts +++ b/services/api/test/unit/host/cleanup-host-fields.test.ts @@ -197,11 +197,15 @@ describe("createCleanup with host fields", () => { }) it("carries both donation links onto a read event: the host's own and the organization's", async () => { - repo.seedUser({ id: ORG, displayName: "Olive Organizer", handle: "olive", donationUrl: "https://give.example.org/olive" }) - const solo = await service.getCleanup( - (await service.createCleanup(base(), ORG)).id, - { userId: ORG }, - ) + repo.seedUser({ + id: ORG, + displayName: "Olive Organizer", + handle: "olive", + donationUrl: "https://give.example.org/olive", + }) + const solo = await service.getCleanup((await service.createCleanup(base(), ORG)).id, { + userId: ORG, + }) expect(solo.organizer.donationUrl).toBe("https://give.example.org/olive") expect(solo.organization).toBeNull() @@ -281,9 +285,9 @@ describe("updateCleanup authorization", () => { it("staff may not edit the event at all", async () => { const created = await service.createCleanup(base(), ORG) repo.seedMember(created.id, STAFF, "staff") - await expect( - service.updateCleanup(created.id, { title: "Nope" }, STAFF), - ).rejects.toMatchObject({ code: "FORBIDDEN" }) + await expect(service.updateCleanup(created.id, { title: "Nope" }, STAFF)).rejects.toMatchObject( + { code: "FORBIDDEN" }, + ) }) it("an org owner inherits the organizer's powers on the org's events", async () => { @@ -324,12 +328,12 @@ describe("updateCleanup authorization", () => { it("409s a page slug already taken by another event", async () => { await service.createCleanup(base({ pageSlug: "taken" }), ORG) const other = await service.createCleanup(base({ title: "Other" }), ORG) - await expect( - service.updateCleanup(other.id, { pageSlug: "taken" }, ORG), - ).rejects.toMatchObject({ code: "CONFLICT" }) - await expect( - service.updateCleanup(other.id, { pageSlug: "free" }, ORG), - ).resolves.toMatchObject({ pageSlug: "free" }) + await expect(service.updateCleanup(other.id, { pageSlug: "taken" }, ORG)).rejects.toMatchObject( + { code: "CONFLICT" }, + ) + await expect(service.updateCleanup(other.id, { pageSlug: "free" }, ORG)).resolves.toMatchObject( + { pageSlug: "free" }, + ) }) it("lets an event keep its own page slug on a re-save", async () => { @@ -359,9 +363,9 @@ describe("updateCleanup authorization", () => { await expect(service.getCleanup(created.id, { userId: OUTSIDER })).rejects.toMatchObject({ code: "NOT_FOUND", }) - await expect( - service.listAttendees(created.id, { userId: OUTSIDER }), - ).rejects.toMatchObject({ code: "NOT_FOUND" }) + await expect(service.listAttendees(created.id, { userId: OUTSIDER })).rejects.toMatchObject({ + code: "NOT_FOUND", + }) }) it("an invited member of a private event may join, read and see the roster", async () => { @@ -419,12 +423,17 @@ describe("cover and gallery URLs", () => { it("422s more gallery images than the cap and a repeated image", async () => { const many = Array.from({ length: 13 }, (_, i) => `aaaaaaaa-1111-4111-8111-00000000000${i}`) - await expect( - service.createCleanup(base({ galleryMediaIds: many }), ORG), - ).rejects.toMatchObject({ code: "VALIDATION" }) + await expect(service.createCleanup(base({ galleryMediaIds: many }), ORG)).rejects.toMatchObject( + { code: "VALIDATION" }, + ) await expect( service.createCleanup( - base({ galleryMediaIds: ["aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa", "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa"] }), + base({ + galleryMediaIds: [ + "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa", + "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa", + ], + }), ORG, ), ).rejects.toMatchObject({ code: "VALIDATION" }) diff --git a/services/api/test/unit/host/event-analytics-service.test.ts b/services/api/test/unit/host/event-analytics-service.test.ts index a406d630..57ed1552 100644 --- a/services/api/test/unit/host/event-analytics-service.test.ts +++ b/services/api/test/unit/host/event-analytics-service.test.ts @@ -168,7 +168,12 @@ describe("analyticsPhaseOf", () => { it("reads an unfinished event as upcoming", () => { expect( analyticsPhaseOf( - clock({ status: "upcoming", completedAt: null, endsAt: new Date("2026-03-01T00:00:00Z"), scheduledAt: new Date("2026-03-01T00:00:00Z") }), + clock({ + status: "upcoming", + completedAt: null, + endsAt: new Date("2026-03-01T00:00:00Z"), + scheduledAt: new Date("2026-03-01T00:00:00Z"), + }), NOW, ), ).toBe("upcoming") diff --git a/services/api/test/unit/host/host-portfolio-service.test.ts b/services/api/test/unit/host/host-portfolio-service.test.ts index 738ad966..bcae19a5 100644 --- a/services/api/test/unit/host/host-portfolio-service.test.ts +++ b/services/api/test/unit/host/host-portfolio-service.test.ts @@ -131,9 +131,8 @@ function scopedRepo(records: readonly HostedEventRecord[]): HostPortfolioReposit const rows = inScope(args.organizationId) return Promise.resolve({ eventsHosted: rows.length, - upcomingEvents: rows.filter( - (row) => row.startsAt >= args.now && row.status !== "cancelled", - ).length, + upcomingEvents: rows.filter((row) => row.startsAt >= args.now && row.status !== "cancelled") + .length, }) }, } @@ -142,10 +141,15 @@ function scopedRepo(records: readonly HostedEventRecord[]): HostPortfolioReposit function portfolioService(counts: Map) { return makeHostPortfolioService({ repo: scopedRepo(PORTFOLIO), - counts: (ids) => Promise.resolve(new Map(ids.flatMap((id) => { - const row = counts.get(id) - return row === undefined ? [] : [[id, row] as const] - }))), + counts: (ids) => + Promise.resolve( + new Map( + ids.flatMap((id) => { + const row = counts.get(id) + return row === undefined ? [] : [[id, row] as const] + }), + ), + ), now: () => NOW, }) } diff --git a/services/api/test/unit/host/host-routes.test.ts b/services/api/test/unit/host/host-routes.test.ts index ea6fedf0..84226ca5 100644 --- a/services/api/test/unit/host/host-routes.test.ts +++ b/services/api/test/unit/host/host-routes.test.ts @@ -371,12 +371,11 @@ describe("host registration routes", () => { it("configures the scanner at 300 requests a minute and the roster at 60", async () => { const { app } = await makeHarness() - expect( - app.hasRoute({ method: "POST", url: versionedPath(endpoints.scanEventTicket) }), - ).toBe(true) - const { SCAN_RATE_LIMIT, ROSTER_READ_RATE_LIMIT } = await import( - "../../../src/routes/host/_host-routes.js" + expect(app.hasRoute({ method: "POST", url: versionedPath(endpoints.scanEventTicket) })).toBe( + true, ) + const { SCAN_RATE_LIMIT, ROSTER_READ_RATE_LIMIT } = + await import("../../../src/routes/host/_host-routes.js") expect(SCAN_RATE_LIMIT.max).toBe(300) expect(ROSTER_READ_RATE_LIMIT.max).toBe(60) }) 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 f9fe12e5..5509b9b7 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 @@ -16,7 +16,9 @@ describe("check:sql — uncast parameter in a NULL test", () => { it("accepts an explicitly cast parameter", () => { expect( - findParamNullTests("sql`WHERE (${claimantUserId}::uuid IS NULL OR u.id <> ${claimantUserId}::uuid)`"), + findParamNullTests( + "sql`WHERE (${claimantUserId}::uuid IS NULL OR u.id <> ${claimantUserId}::uuid)`", + ), ).toEqual([]) expect(findParamNullTests("sql`WHERE ${ids}::uuid[] IS NOT NULL`")).toEqual([]) }) diff --git a/services/api/test/unit/host/host-team-service.test.ts b/services/api/test/unit/host/host-team-service.test.ts index 800c0d76..a637aa7e 100644 --- a/services/api/test/unit/host/host-team-service.test.ts +++ b/services/api/test/unit/host/host-team-service.test.ts @@ -675,11 +675,7 @@ describe("the coordinator tier", () => { identifier: "ida", role: "coordinator", }) - const result = await service.acceptInvite( - EVENT, - INVITEE, - "token-1-aaaaaaaaaaaaaaaaaaaaaaaa", - ) + const result = await service.acceptInvite(EVENT, INVITEE, "token-1-aaaaaaaaaaaaaaaaaaaaaaaa") expect(result).toEqual({ ok: true, role: "coordinator" }) }) @@ -690,11 +686,7 @@ describe("the coordinator tier", () => { role: "coordinator", }) repo.seedMember(EVENT, INVITEE, "cohost") - const result = await service.acceptInvite( - EVENT, - INVITEE, - "token-1-aaaaaaaaaaaaaaaaaaaaaaaa", - ) + const result = await service.acceptInvite(EVENT, INVITEE, "token-1-aaaaaaaaaaaaaaaaaaaaaaaa") expect(result.role).toBe("cohost") }) @@ -705,11 +697,7 @@ describe("the coordinator tier", () => { role: "coordinator", }) repo.seedMember(EVENT, INVITEE, "staff") - const result = await service.acceptInvite( - EVENT, - INVITEE, - "token-1-aaaaaaaaaaaaaaaaaaaaaaaa", - ) + const result = await service.acceptInvite(EVENT, INVITEE, "token-1-aaaaaaaaaaaaaaaaaaaaaaaa") expect(result.role).toBe("coordinator") }) diff --git a/services/api/test/unit/host/insights-invalidation-wiring.test.ts b/services/api/test/unit/host/insights-invalidation-wiring.test.ts index 69789b73..dbc3c97e 100644 --- a/services/api/test/unit/host/insights-invalidation-wiring.test.ts +++ b/services/api/test/unit/host/insights-invalidation-wiring.test.ts @@ -15,10 +15,7 @@ import { makeInsightsGeneration, type InsightsGeneration, } from "../../../src/services/host/host-analytics-cache.js" -import { - makeCleanupService, - type CleanupService, -} from "../../../src/services/cleanup-service.js" +import { makeCleanupService, type CleanupService } from "../../../src/services/cleanup-service.js" import { InMemoryCleanupRepository } from "../../helpers/cleanups.js" import { makeInsightsService, diff --git a/services/api/test/unit/host/org-invite-inbox.test.ts b/services/api/test/unit/host/org-invite-inbox.test.ts index 3cf36439..602d0a92 100644 --- a/services/api/test/unit/host/org-invite-inbox.test.ts +++ b/services/api/test/unit/host/org-invite-inbox.test.ts @@ -119,8 +119,9 @@ describe("acceptMyOrgInvite", () => { const res = await service.acceptMyInvite(INVITEE, inviteId) expect(res).toMatchObject({ ok: true, role: "member" }) expect(res.organization.id).toBe(organizationId) - expect(repo.members.some((m) => m.organizationId === organizationId && m.userId === INVITEE)) - .toBe(true) + expect( + repo.members.some((m) => m.organizationId === organizationId && m.userId === INVITEE), + ).toBe(true) expect(repo.invites.find((i) => i.id === inviteId)?.status).toBe("accepted") }) @@ -169,8 +170,9 @@ describe("declineMyOrgInvite", () => { const { organizationId, inviteId } = await invited() expect(await service.declineMyInvite(INVITEE, inviteId)).toEqual({ ok: true }) expect(repo.invites.find((i) => i.id === inviteId)?.status).toBe("declined") - expect(repo.members.some((m) => m.organizationId === organizationId && m.userId === INVITEE)) - .toBe(false) + expect( + repo.members.some((m) => m.organizationId === organizationId && m.userId === INVITEE), + ).toBe(false) expect(repo.audits.some((a) => a.action === "org.invite_declined")).toBe(true) }) diff --git a/services/api/test/unit/host/org-team-routes.test.ts b/services/api/test/unit/host/org-team-routes.test.ts index b8686167..82a7e555 100644 --- a/services/api/test/unit/host/org-team-routes.test.ts +++ b/services/api/test/unit/host/org-team-routes.test.ts @@ -122,7 +122,11 @@ async function makeHarness(): Promise { const csrf = (web.json() as { csrfToken?: string }).csrfToken ?? "" async function signIn(otherEmail: string): Promise { - await app.inject({ method: "POST", url: "/v1/auth/otp/request", payload: { email: otherEmail } }) + await app.inject({ + method: "POST", + url: "/v1/auth/otp/request", + payload: { email: otherEmail }, + }) const otherCode = mailer.lastOtpFor(otherEmail) as string const res = await app.inject({ method: "POST", @@ -209,7 +213,10 @@ describe("organization routes", () => { url: "/v1/orgs/by-slug/ballona-creek-trust", }) expect(bySlug.statusCode).toBe(200) - expect(bySlug.json()).toMatchObject({ slug: "ballona-creek-trust", verifiedStatus: "unverified" }) + expect(bySlug.json()).toMatchObject({ + slug: "ballona-creek-trust", + verifiedStatus: "unverified", + }) const mine = await app.inject({ method: "GET", @@ -376,7 +383,12 @@ describe("portfolio route", () => { describe("organization invites + suspension routes (0.41.0)", () => { async function ownedOrg(h: Harness): Promise { - h.orgs.seedUser({ id: h.userId, displayName: "Host", handle: "host", email: "host@example.com" }) + h.orgs.seedUser({ + id: h.userId, + displayName: "Host", + handle: "host", + email: "host@example.com", + }) const created = await h.app.inject({ method: "POST", url: "/v1/orgs", @@ -396,10 +408,18 @@ describe("organization invites + suspension routes (0.41.0)", () => { payload: { identifierKind: "email", identifier: "newcomer@example.org", role: "member" }, }) expect(invited.statusCode).toBe(200) - const body = invited.json() as { member: null; invited: boolean; invite: { id: string; status: string } } + const body = invited.json() as { + member: null + invited: boolean + invite: { id: string; status: string } + } expect(body.member).toBeNull() expect(body.invited).toBe(true) - expect(body.invite).toMatchObject({ status: "pending", role: "member", email: "newcomer@example.org" }) + expect(body.invite).toMatchObject({ + status: "pending", + role: "member", + email: "newcomer@example.org", + }) const mail = h.mailer.sent.find((m) => m.to === "newcomer@example.org") expect(mail).toBeDefined() expect(JSON.stringify(mail)).toContain(`/manage/org-invites/accept#token=${INVITE_TOKEN}`) @@ -411,7 +431,9 @@ describe("organization invites + suspension routes (0.41.0)", () => { headers: auth(h.token), }) expect(listed.statusCode).toBe(200) - expect((listed.json() as { items: { id: string }[] }).items.map((i) => i.id)).toEqual([body.invite.id]) + expect((listed.json() as { items: { id: string }[] }).items.map((i) => i.id)).toEqual([ + body.invite.id, + ]) const revoked = await h.app.inject({ method: "DELETE", @@ -419,11 +441,15 @@ describe("organization invites + suspension routes (0.41.0)", () => { headers: auth(h.token), }) expect(revoked.statusCode).toBe(200) - expect((await h.app.inject({ - method: "GET", - url: `/v1/orgs/${id}/invites`, - headers: auth(h.token), - })).json().items[0].status).toBe("revoked") + expect( + ( + await h.app.inject({ + method: "GET", + url: `/v1/orgs/${id}/invites`, + headers: auth(h.token), + }) + ).json().items[0].status, + ).toBe("revoked") const again = await h.app.inject({ method: "DELETE", url: `/v1/orgs/${id}/invites/${body.invite.id}`, @@ -583,11 +609,19 @@ describe("organization invites + suspension routes (0.41.0)", () => { ["DELETE", `/v1/orgs/${id}/invites/${randomUUID()}`], ["POST", "/v1/org-invites/accept"], ] as const) { - const res = await h.app.inject({ method, url, ...(method === "GET" ? {} : { payload: { token: INVITE_TOKEN } }) }) + const res = await h.app.inject({ + method, + url, + ...(method === "GET" ? {} : { payload: { token: INVITE_TOKEN } }), + }) expect(res.statusCode, `${method} ${url}`).toBe(401) } const stranger = await h.signIn("stranger@example.org") - const res = await h.app.inject({ method: "GET", url: `/v1/orgs/${id}/invites`, headers: auth(stranger) }) + const res = await h.app.inject({ + method: "GET", + url: `/v1/orgs/${id}/invites`, + headers: auth(stranger), + }) expect(res.statusCode).toBe(404) }) @@ -637,7 +671,11 @@ describe("organization invites + suspension routes (0.41.0)", () => { payload: { kind: "community", documents: [] }, }) expect(apply.statusCode).toBe(403) - const mine = await h.app.inject({ method: "GET", url: "/v1/me/organizations", headers: auth(h.token) }) + const mine = await h.app.inject({ + method: "GET", + url: "/v1/me/organizations", + headers: auth(h.token), + }) expect(mine.json().items[0]).toMatchObject({ suspended: true }) }) }) diff --git a/services/api/test/unit/host/organization-events.test.ts b/services/api/test/unit/host/organization-events.test.ts index cb08f299..94512131 100644 --- a/services/api/test/unit/host/organization-events.test.ts +++ b/services/api/test/unit/host/organization-events.test.ts @@ -29,7 +29,11 @@ beforeEach(() => { presigned = [] repo.seedUser({ id: MEMBER, displayName: "Olive Organizer", handle: "olive" }) repo.seedUser({ id: STRANGER, displayName: "Sam Stranger", handle: "sam" }) - const org = repo.seedOrganization({ slug: "bct", name: "Ballona Creek Trust", logoKey: "logos/bct" }) + const org = repo.seedOrganization({ + slug: "bct", + name: "Ballona Creek Trust", + logoKey: "logos/bct", + }) organizationId = org.id repo.seedOrgMember(org.id, MEMBER, "owner") service = makeCleanupService({ diff --git a/services/api/test/unit/host/organization-service.test.ts b/services/api/test/unit/host/organization-service.test.ts index 3bc8bf09..72c69311 100644 --- a/services/api/test/unit/host/organization-service.test.ts +++ b/services/api/test/unit/host/organization-service.test.ts @@ -106,7 +106,9 @@ describe("createOrganization", () => { }) it("applies the reserved-word check to the NORMALIZED slug", async () => { - await expect(service.createOrganization(base({ slug: " ADMIN " }), OWNER)).rejects.toMatchObject({ + await expect( + service.createOrganization(base({ slug: " ADMIN " }), OWNER), + ).rejects.toMatchObject({ code: "VALIDATION", }) expect(repo.organizations.size).toBe(0) @@ -298,9 +300,9 @@ describe("membership", () => { it("403s a plain member trying to change a role", async () => { const id = await seeded() - await expect( - service.setMemberRole(id, MEMBER, ADMIN, "member"), - ).rejects.toMatchObject({ code: "FORBIDDEN" }) + await expect(service.setMemberRole(id, MEMBER, ADMIN, "member")).rejects.toMatchObject({ + code: "FORBIDDEN", + }) }) it("refuses to change or remove the owner", async () => { @@ -427,9 +429,9 @@ describe("last-admin guard", () => { it("the owner seat alone already makes the invariant unreachable through the normal paths", async () => { const id = await seeded() await service.setMemberRole(id, OWNER, ADMIN, "member") - expect( - repo.members.filter((m) => m.organizationId === id && m.role !== "member"), - ).toHaveLength(1) + expect(repo.members.filter((m) => m.organizationId === id && m.role !== "member")).toHaveLength( + 1, + ) await expect(service.removeMember(id, OWNER, OWNER)).rejects.toMatchObject({ code: "FORBIDDEN", }) @@ -449,7 +451,12 @@ describe("last-admin guard", () => { it("allows the removal once a second admin seat exists", async () => { const id = await seeded() dropOwnerSeat(id) - await repo.setMemberRoleTx({ organizationId: id, userId: MEMBER, role: "admin", actorId: ADMIN }) + await repo.setMemberRoleTx({ + organizationId: id, + userId: MEMBER, + role: "admin", + actorId: ADMIN, + }) await expect(service.removeMember(id, ADMIN, ADMIN)).resolves.toEqual({ ok: true }) }) @@ -469,7 +476,12 @@ describe("last-admin guard", () => { it("lets an admin be demoted while another owner-or-admin seat remains", async () => { const id = await seeded() dropOwnerSeat(id) - await repo.setMemberRoleTx({ organizationId: id, userId: MEMBER, role: "admin", actorId: ADMIN }) + await repo.setMemberRoleTx({ + organizationId: id, + userId: MEMBER, + role: "admin", + actorId: ADMIN, + }) await expect( repo.setMemberRoleTx({ organizationId: id, userId: ADMIN, role: "member", actorId: ADMIN }), @@ -490,7 +502,9 @@ describe("last-admin guard", () => { } function countIndex(statements: { sql: string }[]): number { - return statements.findIndex((s) => /count\(\*\)::int AS n\s+FROM organization_members/.test(s.sql)) + return statements.findIndex((s) => + /count\(\*\)::int AS n\s+FROM organization_members/.test(s.sql), + ) } it("serializes a demotion on the organizations row before it counts the seats", async () => { @@ -643,9 +657,12 @@ describe("verification", () => { input: { type: string; title: string; body?: string; link?: string } } - function notifying( - over: { mailer?: boolean; notifier?: boolean; failing?: boolean } = {}, - ): { svc: OrganizationService; mails: Sent[]; notes: Notified[]; warnings: unknown[] } { + function notifying(over: { mailer?: boolean; notifier?: boolean; failing?: boolean } = {}): { + svc: OrganizationService + mails: Sent[] + notes: Notified[] + warnings: unknown[] + } { const mails: Sent[] = [] const notes: Notified[] = [] const warnings: unknown[] = [] @@ -698,7 +715,11 @@ describe("verification", () => { expect(notes).toHaveLength(1) expect(notes[0]).toMatchObject({ userId: OWNER, - input: { type: "system", title: "Ballona Creek Trust is now verified", link: "/orgs/ballona-creek-trust" }, + input: { + type: "system", + title: "Ballona Creek Trust is now verified", + link: "/orgs/ballona-creek-trust", + }, }) expect(notes[0]?.userId).not.toBe(ADMIN) }) @@ -846,7 +867,9 @@ describe("org invites (0.41.0)", () => { expect(stored?.expiresAt.getTime()).toBe(clock.getTime() + 14 * 24 * 60 * 60 * 1000) expect(mails).toHaveLength(1) expect(mails[0]?.to).toBe("newcomer@example.com") - expect(String(mails[0]?.vars.subject)).toContain("Olive Owner invited you to join Ballona Creek Trust") + expect(String(mails[0]?.vars.subject)).toContain( + "Olive Owner invited you to join Ballona Creek Trust", + ) // The token rides the URL FRAGMENT, never the query string (DECISIONS §32). expect(mails[0]?.vars.ctaUrl).toBe( `https://civfix.test/manage/org-invites/accept#token=${OPERATOR_TOKEN}`, @@ -866,7 +889,12 @@ describe("org invites (0.41.0)", () => { role: "member", }) expect(again).toMatchObject({ ok: true, member: null, invited: true }) - expect(again.invite).toMatchObject({ id: inviteId, role: "admin", status: "pending", user: null }) + expect(again.invite).toMatchObject({ + id: inviteId, + role: "admin", + status: "pending", + user: null, + }) expect(repo.invites).toHaveLength(1) expect(mails).toHaveLength(1) }) @@ -890,18 +918,30 @@ describe("org invites (0.41.0)", () => { // Both got the email; the account additionally got a best-effort in-app note. expect(mails.map((m) => m.to).sort()).toEqual(["mel@x.org", "nobody@example.com"]) expect(notes).toEqual([ - { userId: MEMBER, type: "org_invite", title: "You've been invited to join Ballona Creek Trust" }, + { + userId: MEMBER, + type: "org_invite", + title: "You've been invited to join Ballona Creek Trust", + }, ]) // Re-inviting either is the same silent success. for (const identifier of ["mel@x.org", "nobody@example.com"]) { - const again = await service.inviteMember(dto.id, OWNER, { identifierKind: "email", identifier, role: "member" }) + const again = await service.inviteMember(dto.id, OWNER, { + identifierKind: "email", + identifier, + role: "member", + }) expect(again.invite?.status).toBe("pending") } // Once suspended, both are refused with the same code. await service.adminSetSuspended(dto.id, OPERATOR, { suspended: true, reason: "spam" }) for (const identifier of ["olive@x.org", "other@example.com"]) { await expect( - service.inviteMember(dto.id, OWNER, { identifierKind: "email", identifier, role: "member" }), + service.inviteMember(dto.id, OWNER, { + identifierKind: "email", + identifier, + role: "member", + }), ).rejects.toMatchObject({ code: "FORBIDDEN" }) } }) @@ -927,7 +967,11 @@ describe("org invites (0.41.0)", () => { it("an existing member who accepts keeps their seated role (no upgrade)", async () => { const dto = await service.createOrganization(base(), OWNER) - await service.inviteMember(dto.id, OWNER, { identifierKind: "handle", identifier: "mel", role: "member" }) + await service.inviteMember(dto.id, OWNER, { + identifierKind: "handle", + identifier: "mel", + role: "member", + }) await service.inviteMember(dto.id, OWNER, { identifierKind: "email", identifier: "mel@x.org", @@ -989,7 +1033,9 @@ describe("org invites (0.41.0)", () => { expect(repo.invites[0]?.status).toBe("pending") expect(repo.members.some((m) => m.userId === newcomer.id)).toBe(false) await service.adminSetSuspended(orgId, OPERATOR, { suspended: false, reason: "resolved" }) - await expect(service.acceptInvite(newcomer.id, OPERATOR_TOKEN)).resolves.toMatchObject({ role: "admin" }) + await expect(service.acceptInvite(newcomer.id, OPERATOR_TOKEN)).resolves.toMatchObject({ + role: "admin", + }) }) it("still sends the invite with a generic inviter name when the inviter lookup fails", async () => { @@ -1068,7 +1114,11 @@ describe("org invites (0.41.0)", () => { it("listing invites is owner|admin; a plain member is refused", async () => { const { orgId } = await invited() - await service.inviteMember(orgId, OWNER, { identifierKind: "handle", identifier: "mel", role: "member" }) + await service.inviteMember(orgId, OWNER, { + identifierKind: "handle", + identifier: "mel", + role: "member", + }) await expect(service.listInvites(orgId, MEMBER)).rejects.toMatchObject({ code: "FORBIDDEN" }) }) @@ -1101,7 +1151,11 @@ describe("org invites (0.41.0)", () => { it("notifies a member added by handle in-app", async () => { const dto = await service.createOrganization(base(), OWNER) - await service.inviteMember(dto.id, OWNER, { identifierKind: "handle", identifier: "adam", role: "admin" }) + await service.inviteMember(dto.id, OWNER, { + identifierKind: "handle", + identifier: "adam", + role: "admin", + }) expect(notes).toEqual([ { userId: ADMIN, type: "org_invite", title: "You've been added to Ballona Creek Trust" }, ]) @@ -1111,7 +1165,11 @@ describe("org invites (0.41.0)", () => { describe("suspension (0.41.0)", () => { async function suspended(): Promise { const dto = await service.createOrganization(base(), OWNER) - await service.inviteMember(dto.id, OWNER, { identifierKind: "handle", identifier: "mel", role: "member" }) + await service.inviteMember(dto.id, OWNER, { + identifierKind: "handle", + identifier: "mel", + role: "member", + }) await service.adminSetSuspended(dto.id, OPERATOR, { suspended: true, reason: "impersonation" }) return dto.id } @@ -1121,7 +1179,9 @@ describe("suspension (0.41.0)", () => { await expect(service.getOrganizationBySlug("ballona-creek-trust", null)).rejects.toMatchObject({ code: "NOT_FOUND", }) - await expect(service.getOrganizationBySlug("ballona-creek-trust", STRANGER)).rejects.toMatchObject({ + await expect( + service.getOrganizationBySlug("ballona-creek-trust", STRANGER), + ).rejects.toMatchObject({ code: "NOT_FOUND", }) const asMember = await service.getOrganizationBySlug("ballona-creek-trust", MEMBER) @@ -1132,11 +1192,17 @@ describe("suspension (0.41.0)", () => { it("refuses self-service writes while suspended, and lifts cleanly", async () => { const id = await suspended() - await expect(service.updateOrganization(id, { name: "New name" }, OWNER)).rejects.toMatchObject({ - code: "FORBIDDEN", - }) + await expect(service.updateOrganization(id, { name: "New name" }, OWNER)).rejects.toMatchObject( + { + code: "FORBIDDEN", + }, + ) await expect( - service.inviteMember(id, OWNER, { identifierKind: "handle", identifier: "adam", role: "admin" }), + service.inviteMember(id, OWNER, { + identifierKind: "handle", + identifier: "adam", + role: "admin", + }), ).rejects.toMatchObject({ code: "FORBIDDEN" }) await expect( service.applyVerification(id, OWNER, { kind: "nonprofit", documents: [] }), @@ -1144,10 +1210,15 @@ describe("suspension (0.41.0)", () => { // A member can still leave. await expect(service.removeMember(id, MEMBER, MEMBER)).resolves.toEqual({ ok: true }) - const lifted = await service.adminSetSuspended(id, OPERATOR, { suspended: false, reason: "resolved" }) + const lifted = await service.adminSetSuspended(id, OPERATOR, { + suspended: false, + reason: "resolved", + }) expect(lifted.suspendedAt).toBeNull() expect(lifted.verifiedStatus).toBe("unverified") - await expect(service.updateOrganization(id, { name: "New name" }, OWNER)).resolves.toMatchObject({ + await expect( + service.updateOrganization(id, { name: "New name" }, OWNER), + ).resolves.toMatchObject({ name: "New name", suspended: false, }) @@ -1237,14 +1308,26 @@ describe("admin org management (0.41.0)", () => { expect(all.items).toHaveLength(3) expect(all.counts).toEqual({ all: 3, verified: 1, pending: 1, suspended: 1 }) - const verified = await service.adminListOrganizations({ verified: "verified", cursor: null, limit: 25 }) + const verified = await service.adminListOrganizations({ + verified: "verified", + cursor: null, + limit: 25, + }) expect(verified.items.map((o) => o.slug)).toEqual(["city-of-playa"]) - const kind = await service.adminListOrganizations({ kind: "nonprofit", cursor: null, limit: 25 }) + const kind = await service.adminListOrganizations({ + kind: "nonprofit", + cursor: null, + limit: 25, + }) expect(kind.items.map((o) => o.slug)).toEqual(["city-of-playa"]) const q = await service.adminListOrganizations({ q: "third", cursor: null, limit: 25 }) expect(q.items.map((o) => o.slug)).toEqual(["third"]) expect(q.counts).toEqual({ all: 1, verified: 0, pending: 0, suspended: 1 }) - const notSuspended = await service.adminListOrganizations({ suspended: false, cursor: null, limit: 25 }) + const notSuspended = await service.adminListOrganizations({ + suspended: false, + cursor: null, + limit: 25, + }) expect(notSuspended.items).toHaveLength(2) const paged = await service.adminListOrganizations({ cursor: null, limit: 2 }) @@ -1281,7 +1364,11 @@ describe("admin org management (0.41.0)", () => { it("lists members with operator actor refs", async () => { const dto = await created() - await service.adminAddMember(dto.id, OPERATOR, { userId: ADMIN, role: "admin", reason: "staff" }) + await service.adminAddMember(dto.id, OPERATOR, { + userId: ADMIN, + role: "admin", + reason: "staff", + }) const page = await service.adminListMembers(dto.id, { cursor: null, limit: 25 }) expect(page.items.map((m) => [m.user.handle, m.role])).toEqual([ ["olive", "owner"], @@ -1303,7 +1390,11 @@ describe("admin org management (0.41.0)", () => { service.adminAddMember(dto.id, OPERATOR, { userId: MEMBER, role: "admin", reason: "again" }), ).rejects.toMatchObject({ code: "CONFLICT" }) await expect( - service.adminAddMember(dto.id, OPERATOR, { userId: randomUUID(), role: "member", reason: "x" }), + service.adminAddMember(dto.id, OPERATOR, { + userId: randomUUID(), + role: "member", + reason: "x", + }), ).rejects.toMatchObject({ code: "VALIDATION" }) }) @@ -1315,7 +1406,9 @@ describe("admin org management (0.41.0)", () => { await expect( service.adminAddMember(dto.id, OPERATOR, { userId: ADMIN, role: "owner", reason: "repair" }), ).resolves.toEqual({ ok: true }) - const roles = repo.members.filter((m) => m.organizationId === dto.id).map((m) => [m.userId, m.role]) + const roles = repo.members + .filter((m) => m.organizationId === dto.id) + .map((m) => [m.userId, m.role]) expect(roles).toEqual([[ADMIN, "owner"]]) expect(repo.audits.filter((a) => a.action === "org.ownership_transferred")[0]?.meta).toEqual({ from: null, @@ -1340,7 +1433,11 @@ describe("admin org management (0.41.0)", () => { it("adding as owner transfers ownership: the previous owner becomes admin", async () => { const dto = await created() - await service.adminAddMember(dto.id, OPERATOR, { userId: ADMIN, role: "owner", reason: "handover" }) + await service.adminAddMember(dto.id, OPERATOR, { + userId: ADMIN, + role: "owner", + reason: "handover", + }) const roles = repo.members .filter((m) => m.organizationId === dto.id) .map((m) => [m.userId, m.role]) @@ -1362,7 +1459,11 @@ describe("admin org management (0.41.0)", () => { service.adminSetMemberRole(dto.id, OPERATOR, { userId: OWNER, role: "admin", reason: "x" }), ).rejects.toMatchObject({ code: "CONFLICT" }) await expect( - service.adminSetMemberRole(dto.id, OPERATOR, { userId: ADMIN, role: "owner", reason: "handover" }), + service.adminSetMemberRole(dto.id, OPERATOR, { + userId: ADMIN, + role: "owner", + reason: "handover", + }), ).resolves.toEqual({ ok: true }) expect(repo.members.find((m) => m.userId === OWNER)?.role).toBe("admin") expect(repo.members.find((m) => m.userId === ADMIN)?.role).toBe("owner") @@ -1371,7 +1472,11 @@ describe("admin org management (0.41.0)", () => { service.adminSetMemberRole(dto.id, OPERATOR, { userId: OWNER, role: "member", reason: "x" }), ).resolves.toEqual({ ok: true }) await expect( - service.adminSetMemberRole(dto.id, OPERATOR, { userId: STRANGER, role: "member", reason: "x" }), + service.adminSetMemberRole(dto.id, OPERATOR, { + userId: STRANGER, + role: "member", + reason: "x", + }), ).rejects.toMatchObject({ code: "NOT_FOUND" }) }) diff --git a/services/api/test/unit/host/page-service.test.ts b/services/api/test/unit/host/page-service.test.ts index 192a6d09..194039f4 100644 --- a/services/api/test/unit/host/page-service.test.ts +++ b/services/api/test/unit/host/page-service.test.ts @@ -62,16 +62,16 @@ describe("event page service", () => { AppError, ) await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [] }) - await expect( - h.service.publish({ id: EVENT, published: true }, HOST), - ).rejects.toMatchObject({ fields: { blocks: "add at least one block before publishing" } }) + await expect(h.service.publish({ id: EVENT, published: true }, HOST)).rejects.toMatchObject({ + fields: { blocks: "add at least one block before publishing" }, + }) }) it("refuses a reserved slug and a slug another event already holds", async () => { const reserved = [...RESERVED_SLUGS][0] as string - await expect( - h.service.save({ id: EVENT, slug: reserved, blocks: [] }), - ).rejects.toMatchObject({ fields: { slug: "that address is reserved" } }) + await expect(h.service.save({ id: EVENT, slug: reserved, blocks: [] })).rejects.toMatchObject({ + fields: { slug: "that address is reserved" }, + }) h.repo.seedEvent({ cleanupId: OTHER_EVENT, pageSlug: "taken-slug" }) await expect( @@ -237,7 +237,11 @@ describe("event page service", () => { it("accepts the markdown subset the contract allows", () => { expect(() => validatePageBlocks([ - { id: "b1", kind: "about", body: "Bring **gloves** and see [the map](https://civfix.org)." }, + { + id: "b1", + kind: "about", + body: "Bring **gloves** and see [the map](https://civfix.org).", + }, ]), ).not.toThrow() }) @@ -247,9 +251,7 @@ describe("event page service", () => { await expect( h.service.getPublicEventPage({ slug: "beach-sweep" }, STRANGER), ).rejects.toBeInstanceOf(AppError) - await expect( - h.service.getPublicEventPage({ slug: "beach-sweep" }, HOST), - ).resolves.toBeDefined() + await expect(h.service.getPublicEventPage({ slug: "beach-sweep" }, HOST)).resolves.toBeDefined() }) it("404s a draft and a flagged page for a plain member standing", async () => { @@ -299,9 +301,9 @@ describe("event page service", () => { await expect( h.service.getPublicEventPage({ slug: "beach-sweep" }, STRANGER), ).rejects.toBeInstanceOf(AppError) - await expect(h.service.getPublicEventPage({ slug: "beach-sweep" }, null)).rejects.toBeInstanceOf( - AppError, - ) + await expect( + h.service.getPublicEventPage({ slug: "beach-sweep" }, null), + ).rejects.toBeInstanceOf(AppError) }) it("serves an unlisted event's page with noindex", async () => { diff --git a/services/api/test/unit/host/registration-retention.test.ts b/services/api/test/unit/host/registration-retention.test.ts index b9c52c1e..71d43532 100644 --- a/services/api/test/unit/host/registration-retention.test.ts +++ b/services/api/test/unit/host/registration-retention.test.ts @@ -57,8 +57,9 @@ describe("registration retention lanes", () => { }) it("keeps draining a lane while it returns a full batch, up to the page ceiling", async () => { - const full = Array.from({ length: REGISTRATION_RETENTION_MAX_PAGES + 5 }, () => - REGISTRATION_RETENTION_BATCH, + const full = Array.from( + { length: REGISTRATION_RETENTION_MAX_PAGES + 5 }, + () => REGISTRATION_RETENTION_BATCH, ) const { sql } = stubSql(full) const warnings: unknown[] = [] diff --git a/services/api/test/unit/host/registration-service.test.ts b/services/api/test/unit/host/registration-service.test.ts index e9be733b..07270d33 100644 --- a/services/api/test/unit/host/registration-service.test.ts +++ b/services/api/test/unit/host/registration-service.test.ts @@ -112,13 +112,13 @@ describe("registration service", () => { }) h.repo.seedTicketType({ cleanupId: EVENT, capacity: 10, maxPartySize: 4 }) - await expect(h.service.register(request(), { kind: "user", userId: USER })).rejects.toMatchObject( - { - code: "CONFLICT", - message: "This event has already ended.", - fields: { event: "ended" }, - }, - ) + await expect( + h.service.register(request(), { kind: "user", userId: USER }), + ).rejects.toMatchObject({ + code: "CONFLICT", + message: "This event has already ended.", + fields: { event: "ended" }, + }) expect(h.repo.registrations.size).toBe(0) const walkup = await h.service.walkup( @@ -136,9 +136,9 @@ describe("registration service", () => { }) h.repo.seedTicketType({ cleanupId: EVENT, capacity: 10 }) - await expect(h.service.register(request(), { kind: "user", userId: USER })).rejects.toMatchObject( - { code: "CONFLICT", message: "This event has already ended." }, - ) + await expect( + h.service.register(request(), { kind: "user", userId: USER }), + ).rejects.toMatchObject({ code: "CONFLICT", message: "This event has already ended." }) }) it("still registers someone for an event that started an hour ago with no endsAt", async () => { @@ -247,18 +247,18 @@ describe("registration service", () => { ).toBe("access_code_required") expect( ( - await h.service.register( - request({ ticketTypeId: type.id, accessCode: "wrong-code" }), - { kind: "user", userId: USER }, - ) + await h.service.register(request({ ticketTypeId: type.id, accessCode: "wrong-code" }), { + kind: "user", + userId: USER, + }) ).outcome, ).toBe("access_code_invalid") expect( ( - await h.service.register( - request({ ticketTypeId: type.id, accessCode: "open-sesame" }), - { kind: "user", userId: USER }, - ) + await h.service.register(request({ ticketTypeId: type.id, accessCode: "open-sesame" }), { + kind: "user", + userId: USER, + }) ).outcome, ).toBe("registered") }) @@ -433,7 +433,10 @@ describe("registration service", () => { it("leaves no guest row behind when a walk-up is refused", async () => { h.repo.seedTicketType({ cleanupId: EVENT, capacity: 0 }) - const refused = await h.service.walkup({ id: EVENT, name: "Ada", partySize: 1, checkInNow: false }, OTHER) + const refused = await h.service.walkup( + { id: EVENT, name: "Ada", partySize: 1, checkInNow: false }, + OTHER, + ) expect(refused.outcome).toBe("full") expect(refused.registration).toBeNull() @@ -443,7 +446,10 @@ describe("registration service", () => { it("leaves no guest row behind when a walk-up replays the same minute", async () => { h.repo.seedTicketType({ cleanupId: EVENT, capacity: 10, maxPartySize: 4 }) await h.service.walkup({ id: EVENT, name: "Ada", partySize: 1, checkInNow: false }, OTHER) - const replay = await h.service.walkup({ id: EVENT, name: "Ada", partySize: 1, checkInNow: false }, OTHER) + const replay = await h.service.walkup( + { id: EVENT, name: "Ada", partySize: 1, checkInNow: false }, + OTHER, + ) expect(replay.outcome).toBe("replayed") expect(h.repo.guests.size).toBe(1) @@ -462,10 +468,10 @@ describe("registration service", () => { it("gates an event with no ticket types on the event capacity", async () => { h.repo.seedEvent({ cleanupId: EVENT, capacity: 2 }) - const first = await h.service.register( - request({ partySize: 2 }), - { kind: "user", userId: USER }, - ) + const first = await h.service.register(request({ partySize: 2 }), { + kind: "user", + userId: USER, + }) expect(first.outcome).toBe("registered") const second = await h.service.register(request(), { kind: "user", userId: OTHER }) @@ -654,5 +660,4 @@ describe("registration questions: which questions a registration must answer", ( expect(res.outcome).toBe("answers_invalid") expect(res.fields?.[TIER_Q]).toBe("unknown question") }) - }) diff --git a/services/api/test/unit/host/signup-seats.test.ts b/services/api/test/unit/host/signup-seats.test.ts index 25f5381c..ef045929 100644 --- a/services/api/test/unit/host/signup-seats.test.ts +++ b/services/api/test/unit/host/signup-seats.test.ts @@ -19,10 +19,7 @@ import { TEST_TICKET_SIGNER } from "../../helpers/ticket-signer.js" import { randomUUID } from "node:crypto" import { beforeEach, describe, expect, it } from "vitest" import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" -import { - makeCleanupService, - type CleanupService, -} from "../../../src/services/cleanup-service.js" +import { makeCleanupService, type CleanupService } from "../../../src/services/cleanup-service.js" import { InMemoryCleanupRepository } from "../../helpers/cleanups.js" import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" import type { TicketTypeRecord } from "../../../src/services/host/registration-repository.types.js" diff --git a/services/api/test/unit/host/ticket-token.test.ts b/services/api/test/unit/host/ticket-token.test.ts index 692df1e0..ff42cbee 100644 --- a/services/api/test/unit/host/ticket-token.test.ts +++ b/services/api/test/unit/host/ticket-token.test.ts @@ -20,9 +20,7 @@ describe("ticket tokens", () => { it("is deterministic for one seat and different for another", () => { expect(signer.tokenFor(SEAT)).toBe(signer.tokenFor(SEAT)) - expect(signer.tokenFor(SEAT)).not.toBe( - signer.tokenFor("22222222-2222-2222-2222-222222222222"), - ) + expect(signer.tokenFor(SEAT)).not.toBe(signer.tokenFor("22222222-2222-2222-2222-222222222222")) }) it("changes completely when the secret rotates", () => { @@ -39,9 +37,7 @@ describe("ticket tokens", () => { it("verifies a token round trip and rejects a forgery", () => { expect(signer.verify(SEAT, signer.tokenFor(SEAT))).toBe(true) expect(signer.verify(SEAT, "AAAAAAAAAAAAAAAAAAAAAAAAAA")).toBe(false) - expect(signer.verify("22222222-2222-2222-2222-222222222222", signer.tokenFor(SEAT))).toBe( - false, - ) + expect(signer.verify("22222222-2222-2222-2222-222222222222", signer.tokenFor(SEAT))).toBe(false) }) it("normalizes the scanner's spacing and case before hashing", () => { diff --git a/services/api/test/unit/host/ticket-type-service.test.ts b/services/api/test/unit/host/ticket-type-service.test.ts index 178524eb..39cb22df 100644 --- a/services/api/test/unit/host/ticket-type-service.test.ts +++ b/services/api/test/unit/host/ticket-type-service.test.ts @@ -105,9 +105,9 @@ describe("ticket type service", () => { waitlistId: null, now: NOW, }) - await expect( - h.service.remove({ id: EVENT, ticketTypeId: created.id }), - ).rejects.toBeInstanceOf(AppError) + await expect(h.service.remove({ id: EVENT, ticketTypeId: created.id })).rejects.toBeInstanceOf( + AppError, + ) }) it("deletes an unused type", async () => { @@ -121,9 +121,9 @@ describe("ticket type service", () => { const a = await h.service.create({ ...base, name: "A" }, HOST) const b = await h.service.create({ ...base, name: "B" }, HOST) - await expect( - h.service.reorder({ id: EVENT, ticketTypeIds: [b.id] }), - ).rejects.toBeInstanceOf(AppError) + await expect(h.service.reorder({ id: EVENT, ticketTypeIds: [b.id] })).rejects.toBeInstanceOf( + AppError, + ) const reordered = await h.service.reorder({ id: EVENT, ticketTypeIds: [b.id, a.id] }) expect(reordered.items.map((item) => item.id)).toEqual([b.id, a.id]) @@ -196,7 +196,9 @@ describe("ticket type service", () => { it("refuses an unlimited ticket type on an event that has a capacity", async () => { h.repo.seedEvent({ cleanupId: EVENT, capacity: 30 }) - await expect(h.service.create({ ...base, name: "General" }, HOST)).rejects.toMatchObject({ code: "VALIDATION" }) + await expect(h.service.create({ ...base, name: "General" }, HOST)).rejects.toMatchObject({ + code: "VALIDATION", + }) }) it("refuses a sales window that closes before it opens", async () => { diff --git a/services/api/test/unit/host/waitlist-service.test.ts b/services/api/test/unit/host/waitlist-service.test.ts index 10b65fe2..69202111 100644 --- a/services/api/test/unit/host/waitlist-service.test.ts +++ b/services/api/test/unit/host/waitlist-service.test.ts @@ -117,7 +117,10 @@ describe("waitlist service", () => { it("refuses to join a ticket type with no waitlist", async () => { const type = h.repo.seedTicketType({ cleanupId: EVENT, capacity: 1 }) await expect( - h.service.join({ id: EVENT, ticketTypeId: type.id, partySize: 1 }, { kind: "user", userId: ALICE }), + h.service.join( + { id: EVENT, ticketTypeId: type.id, partySize: 1 }, + { kind: "user", userId: ALICE }, + ), ).rejects.toBeInstanceOf(AppError) }) @@ -143,8 +146,14 @@ describe("waitlist service", () => { it("promotes strictly FIFO, holds the seat and notifies the offered member", async () => { const type = h.repo.seedTicketType({ cleanupId: EVENT, capacity: 1, waitlistEnabled: true }) - await h.service.join({ id: EVENT, ticketTypeId: type.id, partySize: 1 }, { kind: "user", userId: ALICE }) - await h.service.join({ id: EVENT, ticketTypeId: type.id, partySize: 1 }, { kind: "user", userId: BOB }) + await h.service.join( + { id: EVENT, ticketTypeId: type.id, partySize: 1 }, + { kind: "user", userId: ALICE }, + ) + await h.service.join( + { id: EVENT, ticketTypeId: type.id, partySize: 1 }, + { kind: "user", userId: BOB }, + ) const offered = await h.service.runPromote({ ticketTypeId: type.id }) expect(offered).toBe(1) @@ -161,8 +170,14 @@ describe("waitlist service", () => { maxPartySize: 4, waitlistEnabled: true, }) - await h.service.join({ id: EVENT, ticketTypeId: type.id, partySize: 4 }, { kind: "user", userId: ALICE }) - await h.service.join({ id: EVENT, ticketTypeId: type.id, partySize: 1 }, { kind: "user", userId: BOB }) + await h.service.join( + { id: EVENT, ticketTypeId: type.id, partySize: 4 }, + { kind: "user", userId: ALICE }, + ) + await h.service.join( + { id: EVENT, ticketTypeId: type.id, partySize: 1 }, + { kind: "user", userId: BOB }, + ) expect(await h.service.runPromote({ ticketTypeId: type.id })).toBe(0) expect(h.repo.ticketTypes.get(type.id)?.reservedSeats).toBe(0) @@ -234,7 +249,10 @@ describe("waitlist service", () => { it("releases the held seats when an offered member leaves the queue", async () => { const type = h.repo.seedTicketType({ cleanupId: EVENT, capacity: 1, waitlistEnabled: true }) - await h.service.join({ id: EVENT, ticketTypeId: type.id, partySize: 1 }, { kind: "user", userId: ALICE }) + await h.service.join( + { id: EVENT, ticketTypeId: type.id, partySize: 1 }, + { kind: "user", userId: ALICE }, + ) await h.service.runPromote({ ticketTypeId: type.id }) await h.service.leave({ id: EVENT, ticketTypeId: type.id }, { kind: "user", userId: ALICE }) @@ -339,7 +357,10 @@ describe("waitlist service", () => { now: h.now(), }) await expect( - h.service.join({ id: EVENT, ticketTypeId: type.id, partySize: 1 }, { kind: "user", userId: ALICE }), + h.service.join( + { id: EVENT, ticketTypeId: type.id, partySize: 1 }, + { kind: "user", userId: ALICE }, + ), ).rejects.toBeInstanceOf(AppError) }) @@ -413,7 +434,11 @@ describe("waitlist service", () => { it("keeps the promotion when the guest email fails, and says so in the log", async () => { const broken = build({ guestNotifyFails: true }) - const type = broken.repo.seedTicketType({ cleanupId: EVENT, capacity: 1, waitlistEnabled: true }) + const type = broken.repo.seedTicketType({ + cleanupId: EVENT, + capacity: 1, + waitlistEnabled: true, + }) const joined = await broken.service.join( { id: EVENT, ticketTypeId: type.id, partySize: 1 }, { kind: "guest", guestId: GUEST }, diff --git a/services/api/test/unit/inbound-html-sanitizer-bypass.test.ts b/services/api/test/unit/inbound-html-sanitizer-bypass.test.ts index 063c3120..360c3fb2 100644 --- a/services/api/test/unit/inbound-html-sanitizer-bypass.test.ts +++ b/services/api/test/unit/inbound-html-sanitizer-bypass.test.ts @@ -106,12 +106,18 @@ describe("sanitizer is linear at the size cap (H14)", () => { } const payloads: [string, () => string][] = [ - ["allowed tag + attribute name-run", () => ``], + [ + "allowed tag + attribute name-run", + () => ``, + ], [ "allowed tag + unclosed quoted value", () => ` `${repeatChar("a",`], + [ + "allowed tag + unquoted value-run", + () => `${repeatChar("a",`, + ], [ "allowed tag + name-run then href", () => ``, @@ -182,9 +188,9 @@ describe("sanitizer is linear at the size cap (H14)", () => { }) it("keeps the FIRST occurrence of a duplicated attribute and drops later ones", () => { - expect(sanitizeInboundHtml('x')).toBe( - 'x', - ) + expect( + sanitizeInboundHtml('x'), + ).toBe('x') expect(sanitizeInboundHtml('x')).toBe( 'x', ) diff --git a/services/api/test/unit/inbound-mail-authentication.test.ts b/services/api/test/unit/inbound-mail-authentication.test.ts index ac12539d..5ff09d86 100644 --- a/services/api/test/unit/inbound-mail-authentication.test.ts +++ b/services/api/test/unit/inbound-mail-authentication.test.ts @@ -33,8 +33,14 @@ function mail(over: Partial = {}): ParsedMail { describe("readMailAuthVerdict (M7)", () => { const cf = (...resinfos: string[]) => ["mx.cloudflare.net", ...resinfos].join("; ") const verdict = (header: string, from = "clerk@lacity.gov") => - readMailAuthVerdict(mail({ from: { address: from }, headers: { "authentication-results": header } })) - const stamp = (helo: string, mailFrom: string, dmarc = "dmarc=none header.from=lacity.gov policy.dmarc=none") => + readMailAuthVerdict( + mail({ from: { address: from }, headers: { "authentication-results": header } }), + ) + const stamp = ( + helo: string, + mailFrom: string, + dmarc = "dmarc=none header.from=lacity.gov policy.dmarc=none", + ) => cf( "dkim=none", dmarc, @@ -45,20 +51,24 @@ describe("readMailAuthVerdict (M7)", () => { it("returns 'unknown' when the MTA stamped no Authentication-Results header (FAIL CLOSED)", () => { expect(readMailAuthVerdict(mail())).toBe("unknown") - expect(readMailAuthVerdict(mail({ headers: { "authentication-results": " " } }))).toBe("unknown") + expect(readMailAuthVerdict(mail({ headers: { "authentication-results": " " } }))).toBe( + "unknown", + ) expect(verdict(cf("none"))).toBe("unknown") }) it("returns 'unknown' when the header was not stamped by Cloudflare's MX", () => { expect(CLOUDFLARE_AUTHSERV_ID).toBe("mx.cloudflare.net") expect(verdict("mx.civfix.org; dmarc=pass header.from=lacity.gov")).toBe("unknown") - expect(verdict("attacker.example; spf=pass; dkim=pass header.d=lacity.gov; dmarc=pass")).toBe("unknown") + expect(verdict("attacker.example; spf=pass; dkim=pass header.d=lacity.gov; dmarc=pass")).toBe( + "unknown", + ) }) it("passes a DMARC pass evaluated on the parsed From domain", () => { - expect(verdict(cf("spf=pass", "dkim=pass header.d=lacity.gov", "dmarc=pass header.from=lacity.gov"))).toBe( - "pass", - ) + expect( + verdict(cf("spf=pass", "dkim=pass header.d=lacity.gov", "dmarc=pass header.from=lacity.gov")), + ).toBe("pass") }) it("fails a DMARC pass that was evaluated on a different From domain", () => { @@ -66,14 +76,16 @@ describe("readMailAuthVerdict (M7)", () => { }) it("fails a DMARC fail even when SPF passes (SPF authenticates the envelope, not the From header)", () => { - expect(verdict(cf("dmarc=fail header.from=lacity.gov", "spf=pass smtp.mailfrom=clerk@lacity.gov"))).toBe( - "fail", - ) + expect( + verdict(cf("dmarc=fail header.from=lacity.gov", "spf=pass smtp.mailfrom=clerk@lacity.gov")), + ).toBe("fail") }) it("keeps an enforced DMARC failure a failure even with an aligned DKIM pass", () => { for (const result of ["fail", "quarantine", "reject"]) { - expect(verdict(cf("dkim=pass header.d=lacity.gov", `dmarc=${result} header.from=lacity.gov`))).toBe("fail") + expect( + verdict(cf("dkim=pass header.d=lacity.gov", `dmarc=${result} header.from=lacity.gov`)), + ).toBe("fail") } }) @@ -98,16 +110,26 @@ describe("readMailAuthVerdict (M7)", () => { }) it("falls back to an aligned SPF pass when there is no DMARC policy", () => { - for (const dmarc of ["dmarc=none header.from=lacity.gov", "dmarc=temperror", "dmarc=permerror"]) { + for (const dmarc of [ + "dmarc=none header.from=lacity.gov", + "dmarc=temperror", + "dmarc=permerror", + ]) { expect(verdict(cf(dmarc, "spf=pass smtp.mailfrom=clerk@lacity.gov"))).toBe("pass") } expect(verdict(cf("spf=pass smtp.mailfrom=clerk@lacity.gov"))).toBe("pass") - expect(verdict(cf("spf=pass smtp.mailfrom=clerk@lacity.gov", "arc=none smtp.remote-ip=192.0.2.1"))).toBe("pass") + expect( + verdict(cf("spf=pass smtp.mailfrom=clerk@lacity.gov", "arc=none smtp.remote-ip=192.0.2.1")), + ).toBe("pass") }) it("counts SPF only for a lone address-shaped smtp.mailfrom followed by nothing but Cloudflare's arc result", () => { expect(verdict(cf("spf=pass smtp.mailfrom=lacity.gov"))).toBe("fail") - expect(verdict(cf("spf=pass smtp.mailfrom=clerk@lacity.gov", "arc=none smtp.remote-ip=x@lacity.gov"))).toBe("fail") + expect( + verdict( + cf("spf=pass smtp.mailfrom=clerk@lacity.gov", "arc=none smtp.remote-ip=x@lacity.gov"), + ), + ).toBe("fail") expect(verdict(cf("spf=pass smtp.mailfrom=clerk@lacity.gov", "dkim=none"))).toBe("fail") }) @@ -136,18 +158,27 @@ describe("readMailAuthVerdict (M7)", () => { }) it("reads only each result's leading method=result, never comments, quoted text or properties", () => { - expect(verdict(cf("spf=fail (dmarc=pass header.from=lacity.gov)", "dmarc=none header.from=lacity.gov"))).toBe( - "fail", - ) - expect(verdict(cf('dmarc=none reason="dmarc=pass; dkim=pass header.d=lacity.gov" policy.dmarc=pass'))).toBe( - "fail", - ) + expect( + verdict( + cf("spf=fail (dmarc=pass header.from=lacity.gov)", "dmarc=none header.from=lacity.gov"), + ), + ).toBe("fail") + expect( + verdict( + cf('dmarc=none reason="dmarc=pass; dkim=pass header.d=lacity.gov" policy.dmarc=pass'), + ), + ).toBe("fail") }) it("does not let a later DMARC result override the first one", () => { - expect(verdict(cf("dmarc=fail header.from=lacity.gov, attacker.example", "dmarc=pass header.from=lacity.gov"))).toBe( - "fail", - ) + expect( + verdict( + cf( + "dmarc=fail header.from=lacity.gov, attacker.example", + "dmarc=pass header.from=lacity.gov", + ), + ), + ).toBe("fail") }) it("passes only an SPF pass whose envelope domain is aligned", () => { @@ -163,10 +194,16 @@ describe("readMailAuthVerdict (M7)", () => { }) it("ignores results that a ';' in the echoed helo appends, with or without a DMARC result", () => { - const forged = ["dkim=pass header.d=lacity.gov", "spf=pass smtp.mailfrom=lacity.gov", "dmarc=pass header.from=lacity.gov"] + const forged = [ + "dkim=pass header.d=lacity.gov", + "spf=pass smtp.mailfrom=lacity.gov", + "dmarc=pass header.from=lacity.gov", + ] for (const result of forged) { expect(verdict(stamp(`relay.example;${result}`, "a@attacker.example"))).toBe("fail") - expect(verdict(stamp(`relay.example;${result}`, "a@attacker.example", "dkim=none"))).toBe("fail") + expect(verdict(stamp(`relay.example;${result}`, "a@attacker.example", "dkim=none"))).toBe( + "fail", + ) } }) @@ -175,12 +212,16 @@ describe("readMailAuthVerdict (M7)", () => { const dkim = `dkim=pass header.d=lacity.gov ${tail}` expect(verdict(cf(dkim, "dmarc=none header.from=lacity.gov"))).toBe("fail") } - expect(verdict(cf("dmarc=none header.from=lacity.gov", "dkim=pass header.d=lacity.gov"))).toBe("fail") + expect(verdict(cf("dmarc=none header.from=lacity.gov", "dkim=pass header.d=lacity.gov"))).toBe( + "fail", + ) }) it("fails a Cloudflare-stamped message with no single From address", () => { const header = cf("dmarc=pass header.from=lacity.gov") - expect(readMailAuthVerdict(mail({ from: null, headers: { "authentication-results": header } }))).toBe("fail") + expect( + readMailAuthVerdict(mail({ from: null, headers: { "authentication-results": header } })), + ).toBe("fail") }) it("aligns on the organizational domain, never on a public suffix or a lookalike", () => { @@ -197,8 +238,12 @@ describe("readMailAuthVerdict (M7)", () => { it("fails a public-suffix From domain that the sender's own domain would suffix-match", () => { const noPolicy = "dmarc=none header.from=org policy.dmarc=none" expect(verdict(cf("dkim=pass header.d=evil.org", noPolicy), "x@org")).toBe("fail") - expect(verdict(cf("dkim=none", noPolicy, "spf=pass smtp.mailfrom=a@evil.org"), "x@org")).toBe("fail") - expect(verdict(cf("dkim=pass header.d=evil.co.uk", "dmarc=none header.from=co.uk"), "x@co.uk")).toBe("fail") + expect(verdict(cf("dkim=none", noPolicy, "spf=pass smtp.mailfrom=a@evil.org"), "x@org")).toBe( + "fail", + ) + expect( + verdict(cf("dkim=pass header.d=evil.co.uk", "dmarc=none header.from=co.uk"), "x@co.uk"), + ).toBe("fail") }) }) @@ -206,8 +251,13 @@ describe("isJurisdictionSender", () => { it("matches the contact on file by organizational domain, never by a bare public suffix", async () => { const mailRepo = new InMemoryMailRepository() const thread = mailRepo.seedThread({ threadToken: "abcdefgh1234" }) - mailRepo.seedMessage({ threadId: thread.id, direction: "out", toAddr: "publicworks@lacity.org" }) - const sentBy = (address: string) => isJurisdictionSender(mailRepo, thread.id, mail({ from: { address } })) + mailRepo.seedMessage({ + threadId: thread.id, + direction: "out", + toAddr: "publicworks@lacity.org", + }) + const sentBy = (address: string) => + isJurisdictionSender(mailRepo, thread.id, mail({ from: { address } })) expect(await sentBy("x@org")).toBe(false) expect(await sentBy("clerk@evil.org")).toBe(false) expect(await sentBy("clerk@lacity.org")).toBe(true) @@ -218,7 +268,9 @@ describe("isJurisdictionSender", () => { describe("CfInboundMail.parse: the headers the verdict trusts", () => { const adapter = new CfInboundMail({ replyDomain: "civfix.org" }) const eml = (...headers: string[]) => - new TextEncoder().encode([...headers, "To: report-abcdefgh1234@civfix.org", "", "Crew dispatched."].join("\r\n")) + new TextEncoder().encode( + [...headers, "To: report-abcdefgh1234@civfix.org", "", "Crew dispatched."].join("\r\n"), + ) it("trusts only the top-most Authentication-Results, ignoring a forged copy below it", async () => { const parsed = await adapter.parse( @@ -233,21 +285,32 @@ describe("CfInboundMail.parse: the headers the verdict trusts", () => { }) it("fails a stamp that a leading continuation line in the sender's headers extends", async () => { - const stamp = "mx.cloudflare.net; dkim=none; dmarc=none header.from=lacity.gov; spf=pass smtp.mailfrom=a@x.example" - for (const injected of ["dkim=pass header.d=lacity.gov", "spf=pass smtp.mailfrom=clerk@lacity.gov"]) { - const parsed = await adapter.parse(eml(`Authentication-Results: ${stamp}`, ` ; ${injected}`, "From: clerk@lacity.gov")) + const stamp = + "mx.cloudflare.net; dkim=none; dmarc=none header.from=lacity.gov; spf=pass smtp.mailfrom=a@x.example" + for (const injected of [ + "dkim=pass header.d=lacity.gov", + "spf=pass smtp.mailfrom=clerk@lacity.gov", + ]) { + const parsed = await adapter.parse( + eml(`Authentication-Results: ${stamp}`, ` ; ${injected}`, "From: clerk@lacity.gov"), + ) expect(parsed.headers["authentication-results"]).toContain(injected) expect(readMailAuthVerdict(parsed)).toBe("fail") } }) it("returns no From for a message with two From headers or two From addresses", async () => { - const auth = "Authentication-Results: mx.cloudflare.net; dmarc=pass header.from=attacker.example" - const twoHeaders = await adapter.parse(eml(auth, "From: x@attacker.example", "From: clerk@lacity.gov")) + const auth = + "Authentication-Results: mx.cloudflare.net; dmarc=pass header.from=attacker.example" + const twoHeaders = await adapter.parse( + eml(auth, "From: x@attacker.example", "From: clerk@lacity.gov"), + ) expect(twoHeaders.from).toBeNull() expect(readMailAuthVerdict(twoHeaders)).toBe("fail") - const twoAddresses = await adapter.parse(eml(auth, "From: clerk@lacity.gov, x@attacker.example")) + const twoAddresses = await adapter.parse( + eml(auth, "From: clerk@lacity.gov, x@attacker.example"), + ) expect(twoAddresses.from).toBeNull() }) }) @@ -308,7 +371,9 @@ describe("sanitizeInboundHtml (M6)", () => { expect(sanitizeInboundHtml('x')).toBe("x") expect(sanitizeInboundHtml('x')).toBe("x") expect(sanitizeInboundHtml('x')).toBe("x") - expect(sanitizeInboundHtml('x')).toBe("x") + expect(sanitizeInboundHtml('x')).toBe( + "x", + ) }) it("keeps a safe http/mailto href", () => { @@ -337,7 +402,7 @@ describe("sanitizeInboundHtml (M6)", () => { }) it("removes svg/iframe/object wholesale", () => { - expect(sanitizeInboundHtml('

ok

')).toBe("

ok

") + expect(sanitizeInboundHtml("

ok

")).toBe("

ok

") expect(sanitizeInboundHtml('

ok

')).toBe("

ok

") }) diff --git a/services/api/test/unit/inbound-mail-webhook.test.ts b/services/api/test/unit/inbound-mail-webhook.test.ts index ea087848..49fe725d 100644 --- a/services/api/test/unit/inbound-mail-webhook.test.ts +++ b/services/api/test/unit/inbound-mail-webhook.test.ts @@ -89,13 +89,19 @@ interface Harness { secret: string | undefined } -async function harness(opts?: { secret?: string | undefined; inboundMail?: InboundMail }): Promise { +async function harness(opts?: { + secret?: string | undefined + inboundMail?: InboundMail +}): Promise { const mailRepo = new InMemoryMailRepository() const inboundRepo = new InMemoryInboundRepository() const storage = new FakeStorage() const inboundMail = opts?.inboundMail ?? new FakeInboundMail() const secret = opts && "secret" in opts ? opts.secret : SECRET - const container = { env: { CF_EMAIL_WEBHOOK_SECRET: secret }, inboundMail } as unknown as Container + const container = { + env: { CF_EMAIL_WEBHOOK_SECRET: secret }, + inboundMail, + } as unknown as Container const app = Fastify() app.setErrorHandler(makeErrorHandler()) @@ -126,7 +132,10 @@ async function ingest( describe("inbound-mail webhook: authentication", () => { it("rejects a missing signature with 401 and writes nothing", async () => { const h = await harness() - await h.storage.put(`${INBOUND_PENDING_PREFIX}x.eml`, rfc822({ from: "a@b.gov", to: "reply+t@civfix.org" })) + await h.storage.put( + `${INBOUND_PENDING_PREFIX}x.eml`, + rfc822({ from: "a@b.gov", to: "reply+t@civfix.org" }), + ) const res = await h.app.inject({ method: "POST", url: "/webhooks/inbound-mail", @@ -160,7 +169,10 @@ describe("inbound-mail webhook: authentication", () => { describe("inbound-mail webhook: reply threading (token present)", () => { it("threads onto an existing thread, marks unread, records an event, and deletes the pending object", async () => { const h = await harness() - const thread = h.mailRepo.seedThread({ threadToken: "0a0a0a0a0a0a0a0a0a0a0a0a", org: "City of LA" }) + const thread = h.mailRepo.seedThread({ + threadToken: "0a0a0a0a0a0a0a0a0a0a0a0a", + org: "City of LA", + }) const { res, key } = await ingest(h, { eml: rfc822({ from: "clerk@lacity.gov", @@ -188,7 +200,11 @@ describe("inbound-mail webhook: reply threading (token present)", () => { 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({ from: "x@city.gov", to: "reply+0b0b0b0b0b0b0b0b0b0b0b0b@civfix.org", body: "hi" }), + eml: rfc822({ + from: "x@city.gov", + to: "reply+0b0b0b0b0b0b0b0b0b0b0b0b@civfix.org", + body: "hi", + }), }) expect(res.statusCode).toBe(202) expect(res.json()).toMatchObject({ accepted: true, outcome: "inbox" }) @@ -218,12 +234,22 @@ describe("inbound-mail webhook: reply threading (token present)", () => { it("streams an attachment to R2 and stores it on the message", async () => { const bytes = encoder.encode("PDF-BYTES-HERE") const h = await harness({ - inboundMail: new InboundMailWithAttachments([{ filename: "notice.pdf", content: bytes, size: bytes.byteLength }]), + inboundMail: new InboundMailWithAttachments([ + { filename: "notice.pdf", content: bytes, size: bytes.byteLength }, + ]), }) h.mailRepo.seedThread({ threadToken: "0d0d0d0d0d0d0d0d0d0d0d0d" }) - const { res } = await ingest(h, { eml: rfc822({ from: "c@city.gov", to: "reply+0d0d0d0d0d0d0d0d0d0d0d0d@civfix.org", body: "see attached" }) }) + const { res } = await ingest(h, { + eml: rfc822({ + from: "c@city.gov", + to: "reply+0d0d0d0d0d0d0d0d0d0d0d0d@civfix.org", + body: "see attached", + }), + }) expect(res.statusCode).toBe(202) - const att = (await h.mailRepo.getThread([...h.mailRepo.threads.values()][0]!.id))?.messages[0]?.attachments ?? [] + const att = + (await h.mailRepo.getThread([...h.mailRepo.threads.values()][0]!.id))?.messages[0] + ?.attachments ?? [] expect(att).toHaveLength(1) expect(att[0]?.filename).toBe("notice.pdf") expect(h.storage.get(att[0]!.key)).not.toBeNull() @@ -232,10 +258,18 @@ describe("inbound-mail webhook: reply threading (token present)", () => { it("preserves an OVER-SIZE attachment by reference (flagged, not stored)", async () => { const h = await harness({ - inboundMail: new InboundMailWithAttachments([{ filename: "huge.zip", size: INBOUND_ATTACHMENT_MAX_BYTES + 1 }]), + inboundMail: new InboundMailWithAttachments([ + { filename: "huge.zip", size: INBOUND_ATTACHMENT_MAX_BYTES + 1 }, + ]), }) h.mailRepo.seedThread({ threadToken: "0e0e0e0e0e0e0e0e0e0e0e0e" }) - const { res } = await ingest(h, { eml: rfc822({ from: "c@city.gov", to: "reply+0e0e0e0e0e0e0e0e0e0e0e0e@civfix.org", body: "big" }) }) + const { res } = await ingest(h, { + eml: rfc822({ + from: "c@city.gov", + to: "reply+0e0e0e0e0e0e0e0e0e0e0e0e@civfix.org", + body: "big", + }), + }) expect(res.statusCode).toBe(202) const dto = await h.mailRepo.getThread([...h.mailRepo.threads.values()][0]!.id) expect(dto?.messages[0]?.attachments).toHaveLength(0) @@ -248,7 +282,12 @@ describe("inbound-mail webhook: catch-all (no token) -> inbox", () => { it("inserts a no-token message into inbound_emails and deletes the pending object", async () => { const h = await harness() const { res, key } = await ingest(h, { - eml: rfc822({ from: "resident@example.com", to: "support@civfix.org", subject: "Help", body: "question" }), + eml: rfc822({ + from: "resident@example.com", + to: "support@civfix.org", + subject: "Help", + body: "question", + }), }) expect(res.statusCode).toBe(202) expect(res.json()).toMatchObject({ accepted: true, outcome: "inbox" }) @@ -261,7 +300,12 @@ describe("inbound-mail webhook: catch-all (no token) -> inbox", () => { it("is idempotent: a re-delivered catch-all message inserts one inbox row", async () => { const h = await harness() - const eml = rfc822({ from: "r@example.com", to: "hello@civfix.org", body: "x", messageId: "" }) + const eml = rfc822({ + from: "r@example.com", + to: "hello@civfix.org", + body: "x", + messageId: "", + }) await ingest(h, { eml, key: `${INBOUND_PENDING_PREFIX}ib.eml` }) const second = await ingest(h, { eml, key: `${INBOUND_PENDING_PREFIX}ib.eml` }) expect(second.res.json()).toMatchObject({ outcome: "replay" }) @@ -277,7 +321,10 @@ describe("inbound-mail webhook: malformed / safe handling", () => { const res = await h.app.inject({ method: "POST", url: "/webhooks/inbound-mail", - headers: { "content-type": "application/json", [CF_WEBHOOK_SIGNATURE_HEADER]: sign(body, SECRET) }, + headers: { + "content-type": "application/json", + [CF_WEBHOOK_SIGNATURE_HEADER]: sign(body, SECRET), + }, payload: body, }) expect(res.statusCode).toBe(202) diff --git a/services/api/test/unit/inbound-processor-hardening.test.ts b/services/api/test/unit/inbound-processor-hardening.test.ts index a8794c44..d1c42b85 100644 --- a/services/api/test/unit/inbound-processor-hardening.test.ts +++ b/services/api/test/unit/inbound-processor-hardening.test.ts @@ -24,7 +24,9 @@ function domainOf(address: string): string { function rfc822(opts: { from: string; to: string; body?: string; messageId?: string }): Buffer { const lines = [`From: ${opts.from}`, `To: ${opts.to}`] if (opts.messageId !== undefined) lines.push(`Message-ID: ${opts.messageId}`) - lines.push(`Authentication-Results: mx.cloudflare.net; dmarc=pass header.from=${domainOf(opts.from)}`) + lines.push( + `Authentication-Results: mx.cloudflare.net; dmarc=pass header.from=${domainOf(opts.from)}`, + ) lines.push("", opts.body ?? "") return Buffer.from(lines.join("\n"), "utf8") } @@ -53,7 +55,10 @@ interface Ctx { db: FakeSqlControl } -function ctx(inboundMail: InboundMail = new FakeInboundMail(), sqlHandlers: SqlHandler[] = []): Ctx { +function ctx( + inboundMail: InboundMail = new FakeInboundMail(), + sqlHandlers: SqlHandler[] = [], +): Ctx { const storage = new FakeStorage() const mailRepo = new InMemoryMailRepository() const inboundRepo = new InMemoryInboundRepository() diff --git a/services/api/test/unit/inbound-processor.test.ts b/services/api/test/unit/inbound-processor.test.ts index 5cb8af3a..4b7fbb8e 100644 --- a/services/api/test/unit/inbound-processor.test.ts +++ b/services/api/test/unit/inbound-processor.test.ts @@ -27,7 +27,6 @@ import { MESSAGE_BODY_MAX } from "@civfix/shared" import type { ReportTimelineEvent } from "../../src/services/report-timeline-event.js" import { CfInboundMail } from "../../src/adapters/inbound-mail.cf.js" - const TOKEN = "0123456789abcdef01234567" function rfc822(opts: { @@ -44,7 +43,9 @@ function rfc822(opts: { if (opts.inReplyTo !== undefined) lines.push(`In-Reply-To: ${opts.inReplyTo}`) const headers = opts.headers ?? {} if (opts.authenticated !== false && headers["Authentication-Results"] === undefined) { - lines.push(`Authentication-Results: mx.cloudflare.net; dmarc=pass header.from=${domainOf(opts.from)}`) + lines.push( + `Authentication-Results: mx.cloudflare.net; dmarc=pass header.from=${domainOf(opts.from)}`, + ) } for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${v}`) lines.push("", opts.body ?? "") @@ -84,7 +85,10 @@ interface Ctx { chatEvents: ReportTimelineEvent[] } -function ctx(inboundMail: InboundMail = new FakeInboundMail(), sqlHandlers: SqlHandler[] = []): Ctx { +function ctx( + inboundMail: InboundMail = new FakeInboundMail(), + sqlHandlers: SqlHandler[] = [], +): Ctx { const storage = new FakeStorage() const mailRepo = new InMemoryMailRepository() const inboundRepo = new InMemoryInboundRepository() @@ -182,7 +186,12 @@ describe("processInboundObject: idempotency", () => { it("inbox path dedups on the UNIQUE message_id", async () => { const c = ctx() - const eml = rfc822({ from: "r@example.com", to: "hi@civfix.org", body: "x", messageId: "" }) + const eml = rfc822({ + from: "r@example.com", + to: "hi@civfix.org", + body: "x", + messageId: "", + }) const key = `${INBOUND_PENDING_PREFIX}d.eml` await put(c, key, eml) expect((await processInboundObject(c.container, key, c.deps)).outcome).toBe("inbox") @@ -271,7 +280,11 @@ describe("processInboundObject: jurisdiction reply -> report side-effects (#40)" seedContact(c, thread2.id, "publicworks@lacity.gov") const key = `${INBOUND_PENDING_PREFIX}reply2.eml` - await put(c, key, rfc822({ from: "clerk@lacity.gov", to: `reply+${TOKEN}@civfix.org`, body: "Done." })) + await put( + c, + key, + rfc822({ from: "clerk@lacity.gov", to: `reply+${TOKEN}@civfix.org`, body: "Done." }), + ) expect((await processInboundObject(c.container, key, c.deps)).outcome).toBe("threaded") expect(c.adminReportRepo.reports.get(reportId)?.record.status).toBe("resolved") @@ -286,10 +299,18 @@ describe("processInboundObject: jurisdiction reply -> report side-effects (#40)" it("a side-effect failure (report repo throws) never breaks routing / the delete", async () => { const c = ctx() c.adminReportRepo.getReport = () => Promise.reject(new Error("db down")) - const thread3 = c.mailRepo.seedThread({ threadToken: TOKEN, reportId: "report-x", status: "sent" }) + const thread3 = c.mailRepo.seedThread({ + threadToken: TOKEN, + reportId: "report-x", + status: "sent", + }) seedContact(c, thread3.id, "publicworks@lacity.gov") const key = `${INBOUND_PENDING_PREFIX}reply3.eml` - await put(c, key, rfc822({ from: "clerk@lacity.gov", to: `reply+${TOKEN}@civfix.org`, body: "hi" })) + await put( + c, + key, + rfc822({ from: "clerk@lacity.gov", to: `reply+${TOKEN}@civfix.org`, body: "hi" }), + ) const r = await processInboundObject(c.container, key, c.deps) expect(r.outcome).toBe("threaded") expect(c.storage.get(key)).toBeNull() @@ -321,7 +342,11 @@ describe("processInboundObject: jurisdiction reply -> report side-effects (#40)" } const reply = "A crew is scheduled for Tuesday." const key = `${INBOUND_PENDING_PREFIX}reply-full.eml` - await put(c, key, rfc822({ from: "clerk@lacity.gov", to: `reply+${TOKEN}@civfix.org`, body: reply })) + await put( + c, + key, + rfc822({ from: "clerk@lacity.gov", to: `reply+${TOKEN}@civfix.org`, body: reply }), + ) expect((await processInboundObject(c.container, key, c.deps)).outcome).toBe("threaded") expect(calls).toHaveLength(1) @@ -494,9 +519,13 @@ describe("cityReplyChatBody (what a city reply publishes into the report chat)", it("drops an unquoted Outlook header block (From: followed by Sent:/Date:/To:)", () => { for (const follow of ["Sent: Monday", "Date: Mon, 21 Sep 2026", "To: ops@lacity.gov"]) { - const body = ["Crew assigned.", "", "From: civfix ", follow, "old body"].join( - "\n", - ) + const body = [ + "Crew assigned.", + "", + "From: civfix ", + follow, + "old body", + ].join("\n") expect(cityReplyChatBody(body)).toBe("Crew assigned.") } }) @@ -527,9 +556,13 @@ describe("cityReplyChatBody (what a city reply publishes into the report chat)", }) it("still cuts a From: header block that real reply text precedes", () => { - const body = ["Crew 12 assigned.", "", "From: civfix ", "Sent: Monday", "old"].join( - "\n", - ) + const body = [ + "Crew 12 assigned.", + "", + "From: civfix ", + "Sent: Monday", + "old", + ].join("\n") expect(cityReplyChatBody(body)).toBe("Crew 12 assigned.") }) @@ -640,15 +673,22 @@ describe("processInboundObject: EVENT reply -> cleanup_timeline (D13/D19)", () = const c = ctx() const thread = c.mailRepo.seedThread({ threadToken: TOKEN, cleanupId, status: "sent" }) seedContact(c, thread.id, "events@lacity.gov") - const fullBody = "Yes, we can supply 20 bags and gloves; pick them up at the depot Friday morning." + const fullBody = + "Yes, we can supply 20 bags and gloves; pick them up at the depot Friday morning." const key = `${INBOUND_PENDING_PREFIX}evt-reply.eml` - await put(c, key, rfc822({ from: "events@lacity.gov", to: `reply+${TOKEN}@civfix.org`, body: fullBody })) + await put( + c, + key, + rfc822({ from: "events@lacity.gov", to: `reply+${TOKEN}@civfix.org`, body: fullBody }), + ) const r = await processInboundObject(c.container, key, c.deps) expect(r.outcome).toBe("threaded") expect((await c.mailRepo.getThreadRecord(thread.id))?.status).toBe("replied") - const row = c.cleanupRepo.timeline.find((t) => t.cleanupId === cleanupId && t.kind === "city_reply") + const row = c.cleanupRepo.timeline.find( + (t) => t.cleanupId === cleanupId && t.kind === "city_reply", + ) expect(row).toBeDefined() expect(row?.actorId).toBeNull() expect(row?.note).toBe(fullBody) @@ -711,7 +751,9 @@ describe("processInboundObject: EVENT reply -> cleanup_timeline (D13/D19)", () = ) expect((await processInboundObject(c.container, key, c.deps)).outcome).toBe("threaded") - const row = c.cleanupRepo.timeline.find((t) => t.cleanupId === cleanupId && t.kind === "city_reply") + const row = c.cleanupRepo.timeline.find( + (t) => t.cleanupId === cleanupId && t.kind === "city_reply", + ) expect(row?.note).toBe(fullBody) expect(row?.actorId).toBeNull() expect((await c.mailRepo.getThreadRecord(thread.id))?.status).toBe("replied") @@ -725,7 +767,11 @@ describe("processInboundObject: self-originated mail (loop guard)", () => { env: { MAIL_FROM_OUTREACH: "outreach@civfix.org", MAIL_REPLY_DOMAIN: "civfix.org" }, }) const thread = c.mailRepo.seedThread({ threadToken: TOKEN, reportId: "loop", status: "sent" }) - c.mailRepo.seedMessage({ threadId: thread.id, direction: "out", messageId: "" }) + c.mailRepo.seedMessage({ + threadId: thread.id, + direction: "out", + messageId: "", + }) const copies = [ rfc822({ from: "outreach@civfix.org", @@ -804,7 +850,11 @@ describe("processInboundObject: DSN/bounce handling (#40)", () => { { match: /FROM\s+mail_messages/i, rows: [{ ok: true }] }, { match: /SELECT\s+geoid\s+FROM\s+jurisdiction_contacts/i, rows: [{ geoid }] }, ]) - const thread = c.mailRepo.seedThread({ threadToken: TOKEN, jurisdictionGeoid: geoid, status: "sent" }) + const thread = c.mailRepo.seedThread({ + threadToken: TOKEN, + jurisdictionGeoid: geoid, + status: "sent", + }) c.mailRepo.seedMessage({ threadId: thread.id, direction: "out", @@ -817,7 +867,10 @@ describe("processInboundObject: DSN/bounce handling (#40)", () => { to: "outreach@civfix.org", messageId: "", headers: { "X-Failed-Recipients": failed }, - body: ["Your message could not be delivered.", `Original-Message-ID: `].join("\n"), + body: [ + "Your message could not be delivered.", + `Original-Message-ID: `, + ].join("\n"), }) await put(c, key, dsn) @@ -829,7 +882,9 @@ describe("processInboundObject: DSN/bounce handling (#40)", () => { expect(c.mailRepo.events.some((e) => e.type === "bounced")).toBe(true) expect((await c.mailRepo.getThreadRecord(thread.id))?.status).toBe("bounced") - const flag = c.db.statements.find((s) => /UPDATE\s+jurisdiction_contacts\s+SET\s+bounced_at/i.test(s.sql)) + const flag = c.db.statements.find((s) => + /UPDATE\s+jurisdiction_contacts\s+SET\s+bounced_at/i.test(s.sql), + ) expect(flag).toBeDefined() expect(flag?.values).toContain(failed) @@ -843,7 +898,9 @@ describe("processInboundObject: DSN/bounce handling (#40)", () => { expect(r2.outcome).toBe("replay") expect(c.mailRepo.events.filter((e) => e.type === "bounced")).toHaveLength(1) expect( - c.db.statements.filter((s) => /UPDATE\s+jurisdiction_contacts\s+SET\s+bounced_at/i.test(s.sql)), + c.db.statements.filter((s) => + /UPDATE\s+jurisdiction_contacts\s+SET\s+bounced_at/i.test(s.sql), + ), ).toHaveLength(1) expect(c.jobs.jobsFor("jurisdiction.discovery")).toHaveLength(1) }) @@ -894,7 +951,9 @@ describe("detectBounce (pure)", () => { it("is NOT a bounce for an ordinary inbound message", async () => { const parser = new FakeInboundMail() - const mail = await parser.parse(rfc822({ from: "clerk@lacity.gov", to: "outreach@civfix.org", body: "hi" })) + const mail = await parser.parse( + rfc822({ from: "clerk@lacity.gov", to: "outreach@civfix.org", body: "hi" }), + ) expect(detectBounce(mail).isBounce).toBe(false) }) }) @@ -932,7 +991,11 @@ describe("parseMessageIdList", () => { describe("processInboundObject: message authentication gate (M7)", () => { it("files an UNAUTHENTICATED token-addressed reply on its thread as UNAFFILIATED, with no public effects", async () => { const c = ctx() - const thread = c.mailRepo.seedThread({ threadToken: TOKEN, reportId: "report-auth", status: "sent" }) + const thread = c.mailRepo.seedThread({ + threadToken: TOKEN, + reportId: "report-auth", + status: "sent", + }) seedContact(c, thread.id, "publicworks@lacity.gov") c.adminReportRepo.seedReport({ id: "report-auth", status: "published", reporter: null }) @@ -952,7 +1015,10 @@ describe("processInboundObject: message authentication gate (M7)", () => { const stored = c.mailRepo.messagesOf(thread.id).filter((m) => m.direction === "in") expect(stored).toHaveLength(1) expect(stored[0]?.unaffiliated).toBe(true) - expect(c.mailRepo.events.at(-1)?.meta).toMatchObject({ authVerdict: "unknown", unaffiliated: true }) + expect(c.mailRepo.events.at(-1)?.meta).toMatchObject({ + authVerdict: "unknown", + unaffiliated: true, + }) expect(c.inboundRepo.rows).toHaveLength(0) expect(c.adminReportRepo.reports.get("report-auth")?.record.status).toBe("published") expect(c.chatEvents).toHaveLength(0) @@ -961,7 +1027,11 @@ describe("processInboundObject: message authentication gate (M7)", () => { it("files a DMARC-FAIL token-addressed reply as unaffiliated even when From matches the contact", async () => { const c = ctx() - const thread = c.mailRepo.seedThread({ threadToken: TOKEN, reportId: "report-fail", status: "sent" }) + const thread = c.mailRepo.seedThread({ + threadToken: TOKEN, + reportId: "report-fail", + status: "sent", + }) seedContact(c, thread.id, "publicworks@lacity.gov") c.adminReportRepo.seedReport({ id: "report-fail", status: "published", reporter: null }) const key = `${INBOUND_PENDING_PREFIX}dmarcfail.eml` @@ -972,12 +1042,17 @@ describe("processInboundObject: message authentication gate (M7)", () => { from: "clerk@lacity.gov", to: `reply+${TOKEN}@civfix.org`, body: "spoofed", - headers: { "Authentication-Results": "mx.cloudflare.net; spf=pass; dmarc=fail header.from=lacity.gov" }, + headers: { + "Authentication-Results": + "mx.cloudflare.net; spf=pass; dmarc=fail header.from=lacity.gov", + }, }), ) expect((await processInboundObject(c.container, key, c.deps)).outcome).toBe("threaded") - expect(c.mailRepo.messagesOf(thread.id).find((m) => m.direction === "in")?.unaffiliated).toBe(true) + expect(c.mailRepo.messagesOf(thread.id).find((m) => m.direction === "in")?.unaffiliated).toBe( + true, + ) expect(c.mailRepo.events.at(-1)?.meta).toMatchObject({ authVerdict: "fail" }) expect(c.adminReportRepo.reports.get("report-fail")?.record.status).toBe("published") expect(c.chatEvents).toHaveLength(0) @@ -985,8 +1060,16 @@ describe("processInboundObject: message authentication gate (M7)", () => { it("keeps an unauthenticated reply that only echoes an In-Reply-To in the Inbox", async () => { const c = ctx() - const thread = c.mailRepo.seedThread({ threadToken: TOKEN, reportId: "report-ref", status: "sent" }) - c.mailRepo.seedMessage({ threadId: thread.id, direction: "out", messageId: "" }) + const thread = c.mailRepo.seedThread({ + threadToken: TOKEN, + reportId: "report-ref", + status: "sent", + }) + c.mailRepo.seedMessage({ + threadId: thread.id, + direction: "out", + messageId: "", + }) const key = `${INBOUND_PENDING_PREFIX}refonly.eml` await put( c, @@ -1026,14 +1109,20 @@ describe("processInboundObject: message authentication gate (M7)", () => { ) expect((await processInboundObject(c.container, key, c.deps)).outcome).toBe("threaded") - expect(c.mailRepo.messagesOf(thread.id).find((m) => m.direction === "in")?.unaffiliated).toBe(false) + expect(c.mailRepo.messagesOf(thread.id).find((m) => m.direction === "in")?.unaffiliated).toBe( + false, + ) expect(c.adminReportRepo.reports.get(reportId)?.record.status).toBe("in_progress") expect(c.chatEvents.map((e) => e.body)).toEqual(["Crew scheduled for Tuesday."]) }) it("sends a token-addressed message with two From headers to the Inbox", async () => { const c = ctx(new CfInboundMail({ replyDomain: "civfix.org" })) - const thread = c.mailRepo.seedThread({ threadToken: TOKEN, reportId: "report-twofrom", status: "sent" }) + const thread = c.mailRepo.seedThread({ + threadToken: TOKEN, + reportId: "report-twofrom", + status: "sent", + }) seedContact(c, thread.id, "publicworks@lacity.gov") const key = `${INBOUND_PENDING_PREFIX}twofrom.eml` await put( diff --git a/services/api/test/unit/inbound-retention-repository.test.ts b/services/api/test/unit/inbound-retention-repository.test.ts index 41d5467f..bc0ac510 100644 --- a/services/api/test/unit/inbound-retention-repository.test.ts +++ b/services/api/test/unit/inbound-retention-repository.test.ts @@ -89,7 +89,9 @@ describe("deleteByIds", () => { expect(deleted).toBe(2) const stmt = fake.statements[0]! - expect(stmt.sql).toMatch(/DELETE FROM inbound_emails WHERE id = ANY\(\?::uuid\[\]\) RETURNING id/) + expect(stmt.sql).toMatch( + /DELETE FROM inbound_emails WHERE id = ANY\(\?::uuid\[\]\) RETURNING id/, + ) expect(stmt.values).toEqual([["a", "b", "c"]]) }) }) diff --git a/services/api/test/unit/inbound-sweep.test.ts b/services/api/test/unit/inbound-sweep.test.ts index 441daa6b..fea4b0d6 100644 --- a/services/api/test/unit/inbound-sweep.test.ts +++ b/services/api/test/unit/inbound-sweep.test.ts @@ -3,13 +3,15 @@ import { FakeInboundMail, FakeStorage } from "@civfix/shared/fakes" import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" import { runInboundSweep } from "../../src/services/admin/inbound-sweep.js" -import { INBOUND_PENDING_PREFIX, type InboundProcessorDeps } from "../../src/services/admin/inbound-processor.js" +import { + INBOUND_PENDING_PREFIX, + type InboundProcessorDeps, +} from "../../src/services/admin/inbound-processor.js" import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" import { RecordingNotifier } from "../helpers/notifications.js" import { JURISDICTION_REPLY_NOTE } from "../../src/services/admin/inbound-thread-correlation.js" import type { Container } from "../../src/di.js" - function rfc822(to: string, body = "x", messageId?: string): Buffer { const lines = [`From: c@city.gov`, `To: ${to}`] if (messageId) lines.push(`Message-ID: ${messageId}`) @@ -21,7 +23,12 @@ function harness() { const storage = new FakeStorage() const mailRepo = new InMemoryMailRepository() const inboundRepo = new InMemoryInboundRepository() - const deps: InboundProcessorDeps = { storage, inboundMail: new FakeInboundMail(), mailRepo, inboundRepo } + const deps: InboundProcessorDeps = { + storage, + inboundMail: new FakeInboundMail(), + mailRepo, + inboundRepo, + } const container = { env: {}, storage } as unknown as Container return { storage, mailRepo, inboundRepo, deps, container } } @@ -30,7 +37,10 @@ describe("runInboundSweep", () => { it("drains all pending catch-all objects and deletes each", async () => { const h = harness() for (let i = 0; i < 4; i++) { - await h.storage.put(`${INBOUND_PENDING_PREFIX}m${i}.eml`, rfc822("support@civfix.org", `q${i}`, ``)) + await h.storage.put( + `${INBOUND_PENDING_PREFIX}m${i}.eml`, + rfc822("support@civfix.org", `q${i}`, ``), + ) } const result = await runInboundSweep(h.container, { deps: h.deps }) expect(result.scanned).toBe(4) @@ -42,7 +52,10 @@ describe("runInboundSweep", () => { it("honors a bounded batch so a backlog drains across runs", async () => { const h = harness() for (let i = 0; i < 5; i++) { - await h.storage.put(`${INBOUND_PENDING_PREFIX}m${i}.eml`, rfc822("hi@civfix.org", `q${i}`, ``)) + await h.storage.put( + `${INBOUND_PENDING_PREFIX}m${i}.eml`, + rfc822("hi@civfix.org", `q${i}`, ``), + ) } const result = await runInboundSweep(h.container, { batch: 2, deps: h.deps }) expect(result.scanned).toBe(2) @@ -73,8 +86,14 @@ describe("runInboundSweep", () => { it("isolates a poison object: it is parked under failed/ while the rest process", async () => { const h = harness() - await h.storage.put(`${INBOUND_PENDING_PREFIX}good.eml`, rfc822("support@civfix.org", "ok", "")) - await h.storage.put(`${INBOUND_PENDING_PREFIX}good2.eml`, rfc822("hello@civfix.org", "ok2", "")) + await h.storage.put( + `${INBOUND_PENDING_PREFIX}good.eml`, + rfc822("support@civfix.org", "ok", ""), + ) + await h.storage.put( + `${INBOUND_PENDING_PREFIX}good2.eml`, + rfc822("hello@civfix.org", "ok2", ""), + ) const result = await runInboundSweep(h.container, { deps: h.deps }) expect(result.scanned).toBe(2) expect(result.errors).toBe(0) diff --git a/services/api/test/unit/ingest-geojsonseq.test.ts b/services/api/test/unit/ingest-geojsonseq.test.ts index c4695560..68197d12 100644 --- a/services/api/test/unit/ingest-geojsonseq.test.ts +++ b/services/api/test/unit/ingest-geojsonseq.test.ts @@ -6,7 +6,15 @@ import { ingestGeoJsonSeqFile } from "../../src/db/ingest-jurisdictions-core.js" const validPolygon = { type: "Polygon", - coordinates: [[[-118, 34], [-117, 34], [-117, 35], [-118, 35], [-118, 34]]], + coordinates: [ + [ + [-118, 34], + [-117, 34], + [-117, 35], + [-118, 35], + [-118, 34], + ], + ], } const feature = (props: Record, geometry: unknown = validPolygon): string => JSON.stringify({ type: "Feature", properties: props, geometry }) @@ -52,7 +60,9 @@ describe("ingestGeoJsonSeqFile (streaming GeoJSONSeq loader)", () => { const dir = mkdtempSync(join(tmpdir(), "geojsonseq-")) const file = join(dir, "big.geojsonl") const total = 2500 - const body = Array.from({ length: total }, (_, i) => feature({ OBJECTID: String(i), name: `NF ${i}` })).join("\n") + const body = Array.from({ length: total }, (_, i) => + feature({ OBJECTID: String(i), name: `NF ${i}` }), + ).join("\n") writeFileSync(file, body + "\n") const { sql, batches } = fakeSql() diff --git a/services/api/test/unit/ingest-jurisdictions.test.ts b/services/api/test/unit/ingest-jurisdictions.test.ts index 1bbbeb5c..782efe43 100644 --- a/services/api/test/unit/ingest-jurisdictions.test.ts +++ b/services/api/test/unit/ingest-jurisdictions.test.ts @@ -99,7 +99,13 @@ describe("normalizeFeatures geoid prefix", () => { // 0644000 (LA city) keeps a leading "06" FIPS prefix so the geocoder's uspsFromGeoid shortcut stays // valid. Prefixing it would (wrongly) make uspsFromGeoid return null. const { rows } = normalizeFeatures( - fc([{ type: "Feature", properties: { geoid: "0644000", name: "Los Angeles" }, geometry: validPolygon }]), + fc([ + { + type: "Feature", + properties: { geoid: "0644000", name: "Los Angeles" }, + geometry: validPolygon, + }, + ]), "place", ) expect(rows[0]?.geoid).toBe("0644000") @@ -110,7 +116,9 @@ describe("normalizeFeatures geoid prefix", () => { // PAD-US carries the id under OBJECTID; the alpha prefix makes uspsFromGeoid() return null so the // geocoder uses the spatial state query rather than misreading OBJECTID's first 2 digits as a FIPS. const { rows } = normalizeFeatures( - fc([{ type: "Feature", properties: { OBJECTID: "12345", name: "Fed" }, geometry: validPolygon }]), + fc([ + { type: "Feature", properties: { OBJECTID: "12345", name: "Fed" }, geometry: validPolygon }, + ]), "federal", "PADUS-", ) diff --git a/services/api/test/unit/jobs-pgboss.test.ts b/services/api/test/unit/jobs-pgboss.test.ts index ad905b74..c4240cf4 100644 --- a/services/api/test/unit/jobs-pgboss.test.ts +++ b/services/api/test/unit/jobs-pgboss.test.ts @@ -80,8 +80,14 @@ describe("PgBossJobs (API enqueue adapter)", () => { // 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). for (const name of API_QUEUE_NAMES) { - expect(lastBoss.createQueue).toHaveBeenCalledWith(name, expect.objectContaining({ policy: "short" })) - expect(lastBoss.updateQueue).toHaveBeenCalledWith(name, expect.objectContaining({ policy: "short" })) + expect(lastBoss.createQueue).toHaveBeenCalledWith( + name, + expect.objectContaining({ policy: "short" }), + ) + expect(lastBoss.updateQueue).toHaveBeenCalledWith( + name, + expect.objectContaining({ policy: "short" }), + ) } }) diff --git a/services/api/test/unit/jurisdiction-lookup-census.test.ts b/services/api/test/unit/jurisdiction-lookup-census.test.ts index 424ab4d7..2c1e65c2 100644 --- a/services/api/test/unit/jurisdiction-lookup-census.test.ts +++ b/services/api/test/unit/jurisdiction-lookup-census.test.ts @@ -10,7 +10,6 @@ import { type JurisdictionLookupResult, } from "../../src/adapters/jurisdiction-lookup.census.js" - const BASE_URL = "https://example.test/geocoder/geographies/coordinates" function body(geographies: Record): unknown { @@ -62,9 +61,7 @@ describe("parseCensusGeographies (pure)", () => { it("returns null when all three collections are present but empty", () => { expect( - parseCensusGeographies( - body({ "Incorporated Places": [], Counties: [], States: [] }), - ), + parseCensusGeographies(body({ "Incorporated Places": [], Counties: [], States: [] })), ).toBeNull() }) @@ -78,9 +75,7 @@ describe("parseCensusGeographies (pure)", () => { ), ).toBeNull() expect( - parseCensusGeographies( - body({ "Incorporated Places": [{ GEOID: "0644000", NAME: " " }] }), - ), + parseCensusGeographies(body({ "Incorporated Places": [{ GEOID: "0644000", NAME: " " }] })), ).toBeNull() }) diff --git a/services/api/test/unit/jurisdiction-service.test.ts b/services/api/test/unit/jurisdiction-service.test.ts index 073e11f8..06f00717 100644 --- a/services/api/test/unit/jurisdiction-service.test.ts +++ b/services/api/test/unit/jurisdiction-service.test.ts @@ -15,7 +15,6 @@ import { type JurisdictionLookup, } from "../../src/adapters/jurisdiction-lookup.census.js" - const NOW = new Date("2026-05-31T00:00:00.000Z") interface ResolvedRow { @@ -62,9 +61,7 @@ describe("needsDiscovery (pure)", () => { }) it("returns true when contact_emails is empty or all-blank", () => { - expect( - needsDiscovery({ geoid: "x", contactEmails: [], contactUpdatedAt: NOW }, NOW), - ).toBe(true) + expect(needsDiscovery({ geoid: "x", contactEmails: [], contactUpdatedAt: NOW }, NOW)).toBe(true) expect( needsDiscovery({ geoid: "x", contactEmails: ["", " "], contactUpdatedAt: NOW }, NOW), ).toBe(true) @@ -136,9 +133,9 @@ describe("needsDiscovery (per-category routing precedence)", () => { }) it("BACKWARD COMPATIBLE: with hasRoutingContact absent/false it reduces to the legacy check", () => { - expect( - needsDiscovery({ geoid: "x", contactEmails: null, contactUpdatedAt: NOW }, NOW), - ).toBe(true) + expect(needsDiscovery({ geoid: "x", contactEmails: null, contactUpdatedAt: NOW }, NOW)).toBe( + true, + ) expect( needsDiscovery( { geoid: "x", contactEmails: null, contactUpdatedAt: NOW, hasRoutingContact: false }, diff --git a/services/api/test/unit/legal-seed.test.ts b/services/api/test/unit/legal-seed.test.ts index 0c6bb630..34b5e211 100644 --- a/services/api/test/unit/legal-seed.test.ts +++ b/services/api/test/unit/legal-seed.test.ts @@ -99,7 +99,10 @@ describe("legal_documents seed", () => { for (const row of seededRows()) { const key = `${row.type}@${row.version}` const seen = byKey.get(key) - expect(seen === undefined || seen === row.sha256, `${key} seeded twice with differing sha256`).toBe(true) + expect( + seen === undefined || seen === row.sha256, + `${key} seeded twice with differing sha256`, + ).toBe(true) byKey.set(key, row.sha256) } }) diff --git a/services/api/test/unit/mail-preview.test.ts b/services/api/test/unit/mail-preview.test.ts index f572d2b4..63230341 100644 --- a/services/api/test/unit/mail-preview.test.ts +++ b/services/api/test/unit/mail-preview.test.ts @@ -26,7 +26,9 @@ describe("htmlToText", () => { it("toPreview prefers text, falls back to html, and bounds the length", () => { expect(toPreview("plain text wins", "

html

")).toBe("plain text wins") - expect(toPreview(null, "

Crew dispatched to 42 Elm St

")).toBe("Crew dispatched to 42 Elm St") + expect(toPreview(null, "

Crew dispatched to 42 Elm St

")).toBe( + "Crew dispatched to 42 Elm St", + ) expect(toPreview("x".repeat(PREVIEW_LEN + 50))).toHaveLength(PREVIEW_LEN) expect(toPreview(null, null)).toBe("") }) diff --git a/services/api/test/unit/map-routes.test.ts b/services/api/test/unit/map-routes.test.ts index 4343fc1f..8d275884 100644 --- a/services/api/test/unit/map-routes.test.ts +++ b/services/api/test/unit/map-routes.test.ts @@ -4,10 +4,7 @@ import { buildServer } from "../../src/server.js" import { buildContainer } from "../../src/di.js" import { loadEnv } from "../../src/env.js" import type { Container } from "../../src/di.js" -import type { - ReverseGeocode, - ReverseResult, -} from "../../src/adapters/reverse-geocode.chain.js" +import type { ReverseGeocode, ReverseResult } from "../../src/adapters/reverse-geocode.chain.js" /** * Route-level tests for the map plugin that need NO database: tileinfo (pure env read) and @@ -273,7 +270,7 @@ describe("POST /map/suggest", () => { expect(new URL(urls[0]!).searchParams.get("q")).toBe("Marienplatz") }) - it("keeps the provider's \"en\" default when the caller sends no language", async () => { + it('keeps the provider\'s "en" default when the caller sends no language', async () => { const urls: string[] = [] captureFetch(urls) app = await buildServer({ env: loadEnv() }) diff --git a/services/api/test/unit/media-authorization.test.ts b/services/api/test/unit/media-authorization.test.ts index 481364fe..cdf0582a 100644 --- a/services/api/test/unit/media-authorization.test.ts +++ b/services/api/test/unit/media-authorization.test.ts @@ -19,7 +19,6 @@ import { import { InMemoryByteMeter } from "../../src/services/media-byte-quota.js" import { InMemoryMediaRepository } from "../helpers/media.js" - const SHA = "c".repeat(64) function imageReq(over: Partial = {}): CreateMediaUploadRequest { @@ -28,7 +27,11 @@ function imageReq(over: Partial = {}): CreateMediaUplo class RecordingStorage extends FakeStorage { readonly presignCalls: { key: string; ttlSec: number; forceSigned: boolean }[] = [] - override presignGet(key: string, ttlSec: number, opts?: { forceSigned?: boolean }): Promise { + override presignGet( + key: string, + ttlSec: number, + opts?: { forceSigned?: boolean }, + ): Promise { this.presignCalls.push({ key, ttlSec, forceSigned: opts?.forceSigned === true }) return super.presignGet(key, ttlSec) } @@ -162,7 +165,10 @@ describe("makeUnboundOnlyMediaViewAuthorizer (the fail-closed default)", () => { }) describe("presigned-byte quota (M10)", () => { - function quotaService(meter: { add: (s: string, b: number) => Promise }, limitBytes: number): MediaIntakeService { + function quotaService( + meter: { add: (s: string, b: number) => Promise }, + limitBytes: number, + ): MediaIntakeService { return makeMediaIntakeService({ repo: new InMemoryMediaRepository(), storage: new FakeStorage(), @@ -262,12 +268,7 @@ describe("presigned-byte quota (M10)", () => { ipKey: "203.0.113.9", }), ).rejects.toMatchObject({ code: "RATE_LIMITED" }) - expect(charged).toEqual([ - "a:a1", - "ip:203.0.113.9", - "a:a1", - "ip:203.0.113.9", - ]) + expect(charged).toEqual(["a:a1", "ip:203.0.113.9", "a:a1", "ip:203.0.113.9"]) }) it("meters a signed-in user on the account bucket ONLY (no IP bucket to share with strangers)", async () => { 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 600f80bf..ae62a7e9 100644 --- a/services/api/test/unit/media-binding-drop-guard.test.ts +++ b/services/api/test/unit/media-binding-drop-guard.test.ts @@ -1,4 +1,3 @@ - import { readdirSync, readFileSync } from "node:fs" import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" diff --git a/services/api/test/unit/media-intake-service.test.ts b/services/api/test/unit/media-intake-service.test.ts index 8dfd8250..85b6a4d4 100644 --- a/services/api/test/unit/media-intake-service.test.ts +++ b/services/api/test/unit/media-intake-service.test.ts @@ -11,14 +11,19 @@ import { } from "../../src/services/media-intake-service.js" import { InMemoryMediaRepository } from "../helpers/media.js" - const SHA = "a".repeat(64) function imageReq(over: Partial = {}): CreateMediaUploadRequest { return { kind: "image", contentType: "image/jpeg", byteSize: 32 * 1024, sha256: SHA, ...over } } function videoReq(over: Partial = {}): CreateMediaUploadRequest { - return { kind: "video", contentType: "video/mp4", byteSize: 4 * 1024 * 1024, sha256: SHA, ...over } + return { + kind: "video", + contentType: "video/mp4", + byteSize: 4 * 1024 * 1024, + sha256: SHA, + ...over, + } } function makeHarness(over: { logger?: { warn(obj: unknown, msg?: string): void } } = {}) { @@ -89,7 +94,9 @@ describe("createUpload", () => { const row = [...repo.byId.values()][0]! expect(row.status).toBe("validating") expect(row.uploadId).toBe(res.uploadId) - expect(row.r2Key).toBe(`uploads/${row.r2Key.split("/")[1]}/${row.r2Key.split("/")[2]}/${res.uploadId}`) + expect(row.r2Key).toBe( + `uploads/${row.r2Key.split("/")[1]}/${row.r2Key.split("/")[2]}/${res.uploadId}`, + ) expect(row.r2Key.endsWith(res.uploadId)).toBe(true) expect(row.byteSize).toBe(32 * 1024) @@ -195,9 +202,9 @@ describe("finalize", () => { it("rejects when the uploaded object is missing in storage", async () => { const { jobs, service } = makeHarness() const created = await service.createUpload(imageReq(), {}) - await expect( - service.finalize({ uploadId: created.uploadId }, {}), - ).rejects.toMatchObject({ code: "MEDIA_REJECTED" }) + await expect(service.finalize({ uploadId: created.uploadId }, {})).rejects.toMatchObject({ + code: "MEDIA_REJECTED", + }) expect(jobs.jobsFor(MEDIA_CHECKS_JOB)).toHaveLength(0) }) @@ -206,9 +213,9 @@ describe("finalize", () => { const created = await service.createUpload(imageReq(), {}) const asset = await row(created.uploadId) await storage.put(asset.r2Key, new Uint8Array(1), { contentType: "image/jpeg" }) - await expect( - service.finalize({ uploadId: created.uploadId }, {}), - ).rejects.toMatchObject({ code: "MEDIA_REJECTED" }) + await expect(service.finalize({ uploadId: created.uploadId }, {})).rejects.toMatchObject({ + code: "MEDIA_REJECTED", + }) }) }) diff --git a/services/api/test/unit/media-routes.test.ts b/services/api/test/unit/media-routes.test.ts index 952090c0..b510e593 100644 --- a/services/api/test/unit/media-routes.test.ts +++ b/services/api/test/unit/media-routes.test.ts @@ -8,7 +8,6 @@ import { InMemoryMediaRepository } from "../helpers/media.js" import { MEDIA_CHECKS_JOB } from "../../src/services/media-intake-service.js" import type { FakeStorage, FakeJobs } from "@civfix/shared/fakes" - const SHA = "b".repeat(64) interface Harness { @@ -64,7 +63,12 @@ describe("POST /media/upload", () => { const res = await app.inject({ method: "POST", url: "/v1/media/upload", - payload: { kind: "image", contentType: "image/jpeg", byteSize: MAX_IMAGE_BYTES + 1, sha256: SHA }, + payload: { + kind: "image", + contentType: "image/jpeg", + byteSize: MAX_IMAGE_BYTES + 1, + sha256: SHA, + }, }) expect(res.statusCode).toBe(422) expect(res.json().code).toBe("VALIDATION") @@ -150,7 +154,10 @@ describe("media byte quota wiring", () => { jobs: container.jobs as unknown as FakeJobs, } - for (const anon of ["11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222"]) { + for (const anon of [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + ]) { const res = await app.inject({ method: "POST", url: "/v1/media/upload", diff --git a/services/api/test/unit/media-servable-quarantine.test.ts b/services/api/test/unit/media-servable-quarantine.test.ts index e1b25d0e..374dbca5 100644 --- a/services/api/test/unit/media-servable-quarantine.test.ts +++ b/services/api/test/unit/media-servable-quarantine.test.ts @@ -25,12 +25,42 @@ function asset(status: string, servedKey: string | null): PredicateRow { } const CASES: { label: string; row: PredicateRow; servable: boolean; moderatable: boolean }[] = [ - { label: "validating, not yet processed", row: asset("validating", null), servable: true, moderatable: true }, - { label: "validating, processed early", row: asset("validating", SERVED), servable: true, moderatable: true }, - { label: "ready with a served copy", row: asset("ready", SERVED), servable: true, moderatable: true }, - { label: "ready without a served copy", row: asset("ready", null), servable: false, moderatable: false }, - { label: "held with a served copy", row: asset("held", SERVED), servable: false, moderatable: true }, - { label: "held without a served copy", row: asset("held", null), servable: false, moderatable: true }, + { + label: "validating, not yet processed", + row: asset("validating", null), + servable: true, + moderatable: true, + }, + { + label: "validating, processed early", + row: asset("validating", SERVED), + servable: true, + moderatable: true, + }, + { + label: "ready with a served copy", + row: asset("ready", SERVED), + servable: true, + moderatable: true, + }, + { + label: "ready without a served copy", + row: asset("ready", null), + servable: false, + moderatable: false, + }, + { + label: "held with a served copy", + row: asset("held", SERVED), + servable: false, + moderatable: true, + }, + { + label: "held without a served copy", + row: asset("held", null), + servable: false, + moderatable: true, + }, { label: "rejected", row: asset("rejected", null), servable: false, moderatable: true }, ] diff --git a/services/api/test/unit/migrations-transaction-control.test.ts b/services/api/test/unit/migrations-transaction-control.test.ts index e208eb00..b010d66d 100644 --- a/services/api/test/unit/migrations-transaction-control.test.ts +++ b/services/api/test/unit/migrations-transaction-control.test.ts @@ -54,7 +54,9 @@ describe("migrations contain no transaction-control statements", () => { }) it.each(files)("%s drives no BEGIN / COMMIT / ROLLBACK of its own", (name) => { - const body = stripDollarQuoted(stripSqlComments(readFileSync(join(DRIZZLE_DIR, name), "utf8"))).toLowerCase() + const body = stripDollarQuoted( + stripSqlComments(readFileSync(join(DRIZZLE_DIR, name), "utf8")), + ).toLowerCase() for (const keyword of TXN_KEYWORDS) { // Statement-initial only: `;`/newline/start-of-file, then the keyword as a whole word. This keeps // identifiers that merely contain the word (a `commit_at` column, `rollback_reason`) legal. diff --git a/services/api/test/unit/moderation-operator-guard.test.ts b/services/api/test/unit/moderation-operator-guard.test.ts index e3b456c7..1bbb796e 100644 --- a/services/api/test/unit/moderation-operator-guard.test.ts +++ b/services/api/test/unit/moderation-operator-guard.test.ts @@ -41,9 +41,11 @@ describe("B1: moderation 'remove' can never suspend an operator account", () => repo.userRoles.set("OP-1", "operator") repo.seedItem({ id: "MOD-OP2", subjectType: "profile", subjectId: "OP-1", status: "open" }) - await expect(svc.remove("MOD-OP2", { actorId: "op-2", reason: "abuse" })).rejects.toMatchObject({ - httpStatus: 403, - }) + await expect(svc.remove("MOD-OP2", { actorId: "op-2", reason: "abuse" })).rejects.toMatchObject( + { + httpStatus: 403, + }, + ) expect(repo.accountStatus.get("OP-1")).toBeUndefined() expect(applied).toEqual([]) }) diff --git a/services/api/test/unit/notification-routes.test.ts b/services/api/test/unit/notification-routes.test.ts index 27588e43..ed15c2d9 100644 --- a/services/api/test/unit/notification-routes.test.ts +++ b/services/api/test/unit/notification-routes.test.ts @@ -16,7 +16,6 @@ import { } from "../../src/routes/notifications.routes.js" import { randomUUID } from "node:crypto" - const DEVICE_TOKEN = "a1".repeat(32) const DEVICE_TOKEN_WILD = "b2".repeat(32) const DEVICE_TOKEN_GOOD = "c3".repeat(32) @@ -33,7 +32,9 @@ interface Harness { let current: Harness | undefined -async function makeHarness(seed?: (repo: InMemoryNotificationRepository) => void): Promise { +async function makeHarness( + seed?: (repo: InMemoryNotificationRepository) => void, +): Promise { const env = loadEnv({ NODE_ENV: "test" }) const stores = makeInMemoryStores() @@ -97,8 +98,20 @@ describe("GET /notifications", () => { it("lists the caller's notifications newest-first (auth)", async () => { const { app, token, userId } = await makeHarness() const { repo } = current! - await repo.insertNotification({ userId, type: "system", title: "older", body: null, link: null }) - await repo.insertNotification({ userId, type: "system", title: "newer", body: null, link: null }) + await repo.insertNotification({ + userId, + type: "system", + title: "older", + body: null, + link: null, + }) + await repo.insertNotification({ + userId, + type: "system", + title: "newer", + body: null, + link: null, + }) const res = await app.inject({ method: "GET", url: "/v1/notifications", headers: auth(token) }) expect(res.statusCode).toBe(200) @@ -192,7 +205,9 @@ describe("POST /notifications/read", () => { payload: { ids }, }) expect(res.statusCode).toBe(200) - expect(repo.notifications.filter((n) => n.userId === userId && n.readAt === null)).toHaveLength(0) + expect(repo.notifications.filter((n) => n.userId === userId && n.readAt === null)).toHaveLength( + 0, + ) }) }) @@ -260,7 +275,9 @@ describe("GET + PUT /notifications/prefs", () => { it("401s anonymously", async () => { const { app } = await makeHarness() - expect((await app.inject({ method: "GET", url: "/v1/notifications/prefs" })).statusCode).toBe(401) + expect((await app.inject({ method: "GET", url: "/v1/notifications/prefs" })).statusCode).toBe( + 401, + ) }) }) @@ -277,7 +294,11 @@ describe("POST /push/register", () => { expect(res.json()).toEqual({ ok: true }) expect(current!.repo.pushTokens).toHaveLength(1) - expect(current!.repo.pushTokens[0]).toMatchObject({ userId, platform: "ios", token: DEVICE_TOKEN }) + expect(current!.repo.pushTokens[0]).toMatchObject({ + userId, + platform: "ios", + token: DEVICE_TOKEN, + }) expect(push.tokens.some((t) => t.token === DEVICE_TOKEN)).toBe(true) }) @@ -328,7 +349,11 @@ describe("POST /push/register", () => { method: "POST", url: "/v1/push/register", headers: auth(token), - payload: { platform: "android", token: `${"ab".repeat(30)}${String(i).padStart(4, "0")}`, deviceId }, + payload: { + platform: "android", + token: `${"ab".repeat(30)}${String(i).padStart(4, "0")}`, + deviceId, + }, }) expect(res.statusCode, deviceId.slice(0, 24)).toBe(200) expect(current!.repo.pushTokens[0]?.deviceId, deviceId.slice(0, 24)).toBeNull() @@ -398,7 +423,11 @@ describe("POST /push/unregister (F083)", () => { headers: auth(token), payload: { platform: "ios", token: DEVICE_TOKEN }, }) - expect(current!.repo.pushTokens[0]).toMatchObject({ userId, token: DEVICE_TOKEN, revokedAt: null }) + expect(current!.repo.pushTokens[0]).toMatchObject({ + userId, + token: DEVICE_TOKEN, + revokedAt: null, + }) const res = await app.inject({ method: "POST", diff --git a/services/api/test/unit/notification-service.test.ts b/services/api/test/unit/notification-service.test.ts index 83670d69..797801a4 100644 --- a/services/api/test/unit/notification-service.test.ts +++ b/services/api/test/unit/notification-service.test.ts @@ -13,10 +13,12 @@ import { type NotificationPrefsRecord, } from "../../src/services/notification-service.js" import { MAX_ACTIVE_PUSH_TOKENS_PER_USER } from "../../src/services/notification-repository.drizzle.js" -import { InMemoryNotificationRepository, flushNotificationDispatch } from "../helpers/notifications.js" +import { + InMemoryNotificationRepository, + flushNotificationDispatch, +} from "../helpers/notifications.js" import { normalizeDeviceId } from "../../src/routes/notifications.routes.js" - const TOK_1 = "d1".repeat(32) const TOK_SHARED = "d5".repeat(32) const capToken = (i: number): string => `${"ef".repeat(30)}${String(i).padStart(4, "0")}` @@ -43,7 +45,6 @@ function makeHarness(nowFn?: () => Date): { return { repo, push, service } } - describe("parseTimeOfDayMinutes", () => { it("parses HH:MM and HH:MM:SS", () => { expect(parseTimeOfDayMinutes("00:00")).toBe(0) @@ -60,7 +61,6 @@ describe("parseTimeOfDayMinutes", () => { }) }) - function at(hh: number, mm = 0): Date { return new Date(Date.UTC(2025, 0, 1, hh, mm, 0, 0)) } @@ -107,7 +107,6 @@ describe("isWithinQuietHours", () => { }) }) - describe("typeAllowedByPrefs", () => { const base: NotificationPrefsRecord = { ...DEFAULT_PREFS } @@ -140,7 +139,6 @@ describe("toPrefsDTO", () => { }) }) - describe("listNotifications + markRead", () => { it("lists newest-first and reflects read state", async () => { const { service } = makeHarness() @@ -202,8 +200,17 @@ describe("listNotifications + markRead", () => { describe("feed excludes conversation-message notifications", () => { it("hides dm + cleanup_chat + report_chat from the feed read while still recording the row and pushing", async () => { const { repo, push, service } = makeHarness() - await service.createNotification(U, { type: "report_update", title: "status changed", link: "/pin/x" }) - await service.createNotification(U, { type: "dm", title: "Alice", body: "hi", link: "/messages/dm/t1" }) + await service.createNotification(U, { + type: "report_update", + title: "status changed", + link: "/pin/x", + }) + await service.createNotification(U, { + type: "dm", + title: "Alice", + body: "hi", + link: "/messages/dm/t1", + }) await service.createNotification(U, { type: "cleanup_chat", title: "Bob mentioned you", @@ -296,7 +303,6 @@ describe("getPrefs + updatePrefs", () => { }) }) - describe("registerPushToken", () => { it("upserts a token and delegates to the PushSender", async () => { const { repo, push, service } = makeHarness() @@ -345,12 +351,24 @@ describe("registerPushToken", () => { it("F153: a matching device_id can NO LONGER take over another account's (platform,token) row", async () => { const { repo, service } = makeHarness() - await service.registerPushToken(U, { platform: "android", token: TOK_X, deviceId: "shared-device" }) + await service.registerPushToken(U, { + platform: "android", + token: TOK_X, + deviceId: "shared-device", + }) await expect( - service.registerPushToken(V, { platform: "android", token: TOK_X, deviceId: "shared-device" }), + service.registerPushToken(V, { + platform: "android", + token: TOK_X, + deviceId: "shared-device", + }), ).rejects.toMatchObject({ code: "CONFLICT" }) expect(repo.pushTokens).toHaveLength(1) - expect(repo.pushTokens[0]).toMatchObject({ userId: U, deviceId: "shared-device", revokedAt: null }) + expect(repo.pushTokens[0]).toMatchObject({ + userId: U, + deviceId: "shared-device", + revokedAt: null, + }) }) it("a DIFFERENT user CAN claim a (platform,token) row that is already revoked (device handoff)", async () => { @@ -393,7 +411,6 @@ describe("registerPushToken", () => { }) }) - describe("createNotification (inline-send gating)", () => { it("records the row and sends a push when prefs allow + not in quiet hours", async () => { const { repo, push, service } = makeHarness(() => at(12, 0)) @@ -417,7 +434,10 @@ describe("createNotification (inline-send gating)", () => { await flushNotificationDispatch() expect(push.sent[0]!.payload.link).toBe("/people/x") await flushNotificationDispatch() - expect(push.sent[0]!.payload.data).toMatchObject({ type: "new_follower", notificationId: dto.id }) + expect(push.sent[0]!.payload.data).toMatchObject({ + type: "new_follower", + notificationId: dto.id, + }) }) it("does NOT push when the master push switch is off (but still records the row)", async () => { @@ -484,13 +504,24 @@ describe("createNotification (inline-send gating)", () => { }) }) - describe("onNewFollower", () => { it("records a new_follower notification for the followee with a friendly body + link", async () => { const { repo, push, service } = makeHarness(() => at(12, 0)) await service.onNewFollower({ followeeId: U, - follower: { id: V, displayName: "Alice", handle: "alice", bio: null, followers: 0, following: 0, avatarR2Key: null, avatarUrl: null, socialLinks: null, donationUrl: null, showVolunteerHours: null }, + follower: { + id: V, + displayName: "Alice", + handle: "alice", + bio: null, + followers: 0, + following: 0, + avatarR2Key: null, + avatarUrl: null, + socialLinks: null, + donationUrl: null, + showVolunteerHours: null, + }, }) expect(repo.notifications).toHaveLength(1) const n = repo.notifications[0]! @@ -503,7 +534,6 @@ describe("onNewFollower", () => { }) }) - describe("createNotification (per-user signal)", () => { function makeSignalHarness(): { repo: InMemoryNotificationRepository @@ -535,9 +565,9 @@ describe("createNotification (per-user signal)", () => { await service.createNotification(U, { type: "cleanup_chat", title: "y" }) await service.createNotification(U, { type: "system", title: "z" }) expect(channel.published).toHaveLength(3) - expect(channel.published.every((p) => p.userId === U && p.signal.topic === "notifications")).toBe( - true, - ) + expect( + channel.published.every((p) => p.userId === U && p.signal.topic === "notifications"), + ).toBe(true) }) it("signals even when the PUSH is suppressed (prefs/quiet hours gate push, not the in-app badge)", async () => { @@ -713,9 +743,21 @@ describe("toggle-bell de-duplication (F015)", () => { describe("account-erasure notification purge (F088)", () => { it("hard-deletes every notification row for the erased user, keeping others", async () => { const repo = new InMemoryNotificationRepository() - await repo.insertNotification({ userId: U, type: "dm", title: "Alice", body: "secret", link: "/messages/dm/t1" }) + await repo.insertNotification({ + userId: U, + type: "dm", + title: "Alice", + body: "secret", + link: "/messages/dm/t1", + }) await repo.insertNotification({ userId: U, type: "system", title: "x", body: null, link: null }) - await repo.insertNotification({ userId: V, type: "system", title: "keep", body: null, link: null }) + await repo.insertNotification({ + userId: V, + type: "system", + title: "keep", + body: null, + link: null, + }) await repo.deleteAllNotificationsForUser(U) expect(repo.notifications.filter((n) => n.userId === U)).toHaveLength(0) diff --git a/services/api/test/unit/outbound-mail-service.test.ts b/services/api/test/unit/outbound-mail-service.test.ts index 6c17af64..e5ba9367 100644 --- a/services/api/test/unit/outbound-mail-service.test.ts +++ b/services/api/test/unit/outbound-mail-service.test.ts @@ -20,7 +20,6 @@ import { type OutboundMailService, } from "../../src/services/admin/outbound-mail-service.js" - const ENV: OutboundMailEnv = { MAIL_FROM_OUTREACH: "outreach@civfix.org", MAIL_REPLY_DOMAIN: "civfix.org", @@ -43,7 +42,11 @@ function harness(): { describe("OutboundMailService.sendReportToJurisdiction", () => { function png(): OutboundAttachment { - return { filename: "photo.png", contentType: "image/png", content: new Uint8Array([1, 2, 3, 4]) } + return { + filename: "photo.png", + contentType: "image/png", + content: new Uint8Array([1, 2, 3, 4]), + } } it("sends a per-report packet via sendOutbound From the report- reply address, no Reply-To, + attachments", async () => { @@ -303,7 +306,11 @@ describe("OutboundMailService.sendToCity (digest path: minted token)", () => { describe("OutboundMailService.compose / appendOutbound", () => { it("compose creates a new outbound thread + first message + sends From outreach via sendOutbound", async () => { const { repo, mailer, svc } = harness() - const thread = await svc.compose({ to: "mayor@city.gov", subject: "Intro", body: "Hello there." }) + const thread = await svc.compose({ + to: "mayor@city.gov", + subject: "Intro", + body: "Hello there.", + }) expect(repo.threads.size).toBe(1) expect(thread.lastMessageAt).not.toBeNull() const dto = await repo.getThread(thread.id) @@ -319,7 +326,12 @@ describe("OutboundMailService.compose / appendOutbound", () => { it("appendOutbound appends an OUT reply to an existing thread, defaulting the subject to Re:", async () => { const { repo, mailer, svc } = harness() const t = await repo.createThread({ subject: "Question", org: "City of LA" }) - await repo.insertMessage({ threadId: t.id, direction: "in", fromAddr: "clerk@city.gov", body: "Q?" }) + await repo.insertMessage({ + threadId: t.id, + direction: "in", + fromAddr: "clerk@city.gov", + body: "Q?", + }) expect((await repo.getThreadRecord(t.id))?.unread).toBe(true) const updated = await svc.appendOutbound(t.id, { @@ -417,7 +429,12 @@ describe("OutboundMailService: the outbound row is a true snapshot of what was s text: "A pothole on Main St.", html: "

A pothole on Main St.

", attachments: [ - { key: "media/r2/photo.jpg", filename: "photo.jpg", contentType: "image/jpeg", content: new Uint8Array([1, 2, 3]) }, + { + key: "media/r2/photo.jpg", + filename: "photo.jpg", + contentType: "image/jpeg", + content: new Uint8Array([1, 2, 3]), + }, { filename: "keyless.jpg", contentType: "image/jpeg", content: new Uint8Array([4]) }, ], }) @@ -426,15 +443,26 @@ describe("OutboundMailService: the outbound row is a true snapshot of what was s expect(stored?.fromAddr).toBe(`"civfix Reports" `) expect(stored?.html).toBe("

A pothole on Main St.

") expect(stored?.kind).toBe("packet") - expect(stored?.attachments).toEqual([{ key: "media/r2/photo.jpg", filename: "photo.jpg", size: 3 }]) + expect(stored?.attachments).toEqual([ + { key: "media/r2/photo.jpg", filename: "photo.jpg", size: 3 }, + ]) }) it("stamps the kind each entry point owns", async () => { const { repo, svc } = harness() - const digest = await svc.sendToCity({ geoid: "0644000", toAddr: "clerk@lacity.gov", subject: "Digest", body: "d" }) + const digest = await svc.sendToCity({ + geoid: "0644000", + toAddr: "clerk@lacity.gov", + subject: "Digest", + body: "d", + }) const composed = await svc.compose({ to: "mayor@city.gov", subject: "Intro", body: "hi" }) await svc.appendOutbound(composed.id, { toAddr: "mayor@city.gov", body: "again" }) - await svc.appendOutbound(composed.id, { toAddr: "mayor@city.gov", body: "again", kind: "resend" }) + await svc.appendOutbound(composed.id, { + toAddr: "mayor@city.gov", + body: "again", + kind: "resend", + }) const event = await svc.sendEventToJurisdiction({ cleanupId: "cleanup-1", geoid: "0644000", @@ -464,7 +492,9 @@ describe("OutboundMailService: the outbound row is a true snapshot of what was s subject: "civfix report: Pothole [ref-2]", text: "two", }) - expect((await repo.getThreadRecord(first.thread.id))?.subject).toBe("civfix report: Pothole [ref-2]") + expect((await repo.getThreadRecord(first.thread.id))?.subject).toBe( + "civfix report: Pothole [ref-2]", + ) }) }) @@ -547,7 +577,9 @@ describe("OutboundMailService: a failed send is recorded as a failure", () => { mailer.sendOutbound = () => Promise.reject(new Error("smtp down")) const svc = makeOutboundMailService({ repo, mailer, env: ENV }) const t = await repo.createThread({ subject: "S" }) - await expect(svc.appendOutbound(t.id, { toAddr: "x@y.com", body: "b" })).rejects.toThrow(/smtp down/) + await expect(svc.appendOutbound(t.id, { toAddr: "x@y.com", body: "b" })).rejects.toThrow( + /smtp down/, + ) expect(repo.threads.get(t.id)?.status).toBe("needs_action") expect(repo.audits).toHaveLength(0) }) diff --git a/services/api/test/unit/outreach-pipeline.test.ts b/services/api/test/unit/outreach-pipeline.test.ts index fa2b3582..1db2bd68 100644 --- a/services/api/test/unit/outreach-pipeline.test.ts +++ b/services/api/test/unit/outreach-pipeline.test.ts @@ -12,7 +12,6 @@ import { type OutreachRepository, } from "../../src/services/admin/outreach-service.js" - const NOW = new Date("2026-06-06T00:00:00.000Z") const THROTTLE_DAYS = 7 const FROM_OUTREACH = "outreach@civfix.org" @@ -75,7 +74,11 @@ describe("outreach pure helpers", () => { describe("outreach digest: aggregation + send", () => { it("aggregates a due jurisdiction's waiting reports into one digest, sends it, stamps outreach_state", async () => { const { outreachRepo, mailRepo, mailer, svc } = harness() - outreachRepo.seedJurisdiction({ geoid: "0644000", org: "City of LA", defaultEmail: "clerk@lacity.gov" }) + outreachRepo.seedJurisdiction({ + geoid: "0644000", + org: "City of LA", + defaultEmail: "clerk@lacity.gov", + }) outreachRepo.seedReport({ geoid: "0644000", category: "trash" }) outreachRepo.seedReport({ geoid: "0644000", category: "trash" }) outreachRepo.seedReport({ geoid: "0644000", category: "hazard" }) @@ -149,7 +152,9 @@ describe("outreach digest: throttle (defense in depth)", () => { const result = await svc.runForGeoid("0644000") expect(result.sent).toBe(true) expect(mailer.sent).toHaveLength(1) - expect((await mailRepo.getOutreachState("0644000"))?.lastOutreachAt?.getTime()).toBe(NOW.getTime()) + expect((await mailRepo.getOutreachState("0644000"))?.lastOutreachAt?.getTime()).toBe( + NOW.getTime(), + ) }) it("does NOT send a suppressed jurisdiction", async () => { diff --git a/services/api/test/unit/packet-media-links.test.ts b/services/api/test/unit/packet-media-links.test.ts index dd820f71..d4a9a8e9 100644 --- a/services/api/test/unit/packet-media-links.test.ts +++ b/services/api/test/unit/packet-media-links.test.ts @@ -38,8 +38,16 @@ async function routeWith(status: AdminReportStatus, visibility: ReportVisibility id: "rep-1", status, visibility, - routing: { geoid: "0644000", dept: "Public Works", place: "Los Angeles", contact: "311@lacity.gov", routed: false }, - media: [{ id: "m1", kind: "image", r2Key: "processed/uploads/a", thumbKey: "processed/thumbs/a" }], + routing: { + geoid: "0644000", + dept: "Public Works", + place: "Los Angeles", + contact: "311@lacity.gov", + routed: false, + }, + media: [ + { id: "m1", kind: "image", r2Key: "processed/uploads/a", thumbKey: "processed/thumbs/a" }, + ], }) await svc.routeToJurisdiction("rep-1", { note: null, actorId: "op-1" }) const outbound = mailer.sent.find((m) => m.outbound !== undefined)?.outbound @@ -74,11 +82,14 @@ describe("packet photo links", () => { ["submitted", "public"], ["held", "public"], ["published", "hidden"], - ] as const)("keeps signed links for a %s %s report, which the public cannot see", async (status, visibility) => { - const { calls, text } = await routeWith(status, visibility) - expect(calls).toEqual([ - { key: "processed/uploads/a", ttlSec: PACKET_MEDIA_URL_TTL_SEC, forceSigned: true }, - ]) - expect(text).not.toContain("https://cdn.test/") - }) + ] as const)( + "keeps signed links for a %s %s report, which the public cannot see", + async (status, visibility) => { + const { calls, text } = await routeWith(status, visibility) + expect(calls).toEqual([ + { key: "processed/uploads/a", ttlSec: PACKET_MEDIA_URL_TTL_SEC, forceSigned: true }, + ]) + expect(text).not.toContain("https://cdn.test/") + }, + ) }) diff --git a/services/api/test/unit/parse-acs.test.ts b/services/api/test/unit/parse-acs.test.ts index a548b955..131397cd 100644 --- a/services/api/test/unit/parse-acs.test.ts +++ b/services/api/test/unit/parse-acs.test.ts @@ -129,15 +129,21 @@ describe("parseAcs geoid assembly", () => { describe("parseAcs population + row filtering", () => { it("rounds a fractional population", () => { - expect(parseAcs([[ACS_POP_VAR, "state"], ["123.6", "06"]])).toEqual([ - { geoid: "06", population: 124 }, - ]) + expect( + parseAcs([ + [ACS_POP_VAR, "state"], + ["123.6", "06"], + ]), + ).toEqual([{ geoid: "06", population: 124 }]) }) it("keeps a zero population (a real ACS value, not a missing one)", () => { - expect(parseAcs([[ACS_POP_VAR, "state"], ["0", "06"]])).toEqual([ - { geoid: "06", population: 0 }, - ]) + expect( + parseAcs([ + [ACS_POP_VAR, "state"], + ["0", "06"], + ]), + ).toEqual([{ geoid: "06", population: 0 }]) }) it("skips rows with a non-numeric or NEGATIVE population, keeping the rest", () => { @@ -159,8 +165,18 @@ describe("parseAcs population + row filtering", () => { // over whatever was stored. Only "null"/"N"-style TEXT is rejected (NaN). Pinned here so that if the // parser is ever tightened to reject empty cells, this expectation flips deliberately rather than the // change slipping in unnoticed. - expect(parseAcs([[ACS_POP_VAR, "state"], ["", "36"]])).toEqual([{ geoid: "36", population: 0 }]) - expect(parseAcs([[ACS_POP_VAR, "state"], [null, "36"]])).toEqual([{ geoid: "36", population: 0 }]) + expect( + parseAcs([ + [ACS_POP_VAR, "state"], + ["", "36"], + ]), + ).toEqual([{ geoid: "36", population: 0 }]) + expect( + parseAcs([ + [ACS_POP_VAR, "state"], + [null, "36"], + ]), + ).toEqual([{ geoid: "36", population: 0 }]) }) it("skips a non-array row without dropping the rows around it", () => { diff --git a/services/api/test/unit/post-service.test.ts b/services/api/test/unit/post-service.test.ts index 80711e29..3b7fd97e 100644 --- a/services/api/test/unit/post-service.test.ts +++ b/services/api/test/unit/post-service.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from "vitest" import { AppError, DEFAULT_FEED_RANKING } from "@civfix/shared" import type { PostComposeInput, PostDTO } from "@civfix/shared" @@ -31,7 +30,9 @@ interface FakeConfig { likeCreated?: boolean } -function fakeRepo(cfg: FakeConfig = {}): PostRepository & { created: CreatePostArgs[]; deleted: string[] } { +function fakeRepo( + cfg: FakeConfig = {}, +): PostRepository & { created: CreatePostArgs[]; deleted: string[] } { const created: CreatePostArgs[] = [] const deleted: string[] = [] const dto = (id: string): PostDTO => ({ @@ -69,7 +70,8 @@ function fakeRepo(cfg: FakeConfig = {}): PostRepository & { created: CreatePostA Promise.resolve(cfg.orgMembers?.has(`${organizationId}:${userId}`) ?? false), isEventMember: (eventId, userId) => Promise.resolve(cfg.members?.has(`${eventId}:${userId}`) ?? false), - isReportAttachable: (reportId) => Promise.resolve(cfg.attachableReports?.has(reportId) ?? false), + isReportAttachable: (reportId) => + Promise.resolve(cfg.attachableReports?.has(reportId) ?? false), createPost: (args) => { created.push(args) return Promise.resolve("new-post-id") @@ -170,7 +172,11 @@ describe("PostService validation + authorization", () => { ranked.push(args.viewerId) return Promise.resolve([]) }, - publicFeed: (args: { filter: "all" | "events" | "fixes"; cursor: string | null; limit: number }) => { + publicFeed: (args: { + filter: "all" | "events" | "fixes" + cursor: string | null + limit: number + }) => { chronological.push({ cursor: args.cursor, limit: args.limit }) return Promise.resolve({ items: [], nextCursor: null }) }, @@ -318,7 +324,15 @@ describe("PostService validation + authorization", () => { const repo = { ...fakeRepo({ briefs: { - p1: { id: "p1", authorId: "self", kind: "post", replyToId: null, repostOfId: null, deletedAt: null, visibility: "public" }, + p1: { + id: "p1", + authorId: "self", + kind: "post", + replyToId: null, + repostOfId: null, + deletedAt: null, + visibility: "public", + }, }, }), repost: (postId: string, userId: string) => { @@ -338,8 +352,24 @@ describe("PostService validation + authorization", () => { const repo = { ...fakeRepo({ briefs: { - shell: { id: "shell", authorId: "booster", kind: "repost", replyToId: null, repostOfId: "orig", deletedAt: null, visibility: "public" }, - orig: { id: "orig", authorId: "self", kind: "post", replyToId: null, repostOfId: null, deletedAt: null, visibility: "public" }, + shell: { + id: "shell", + authorId: "booster", + kind: "repost", + replyToId: null, + repostOfId: "orig", + deletedAt: null, + visibility: "public", + }, + orig: { + id: "orig", + authorId: "self", + kind: "post", + replyToId: null, + repostOfId: null, + deletedAt: null, + visibility: "public", + }, }, }), repost: (postId: string, userId: string) => { @@ -384,7 +414,10 @@ describe("PostService listReplies names the focal author whose answers get inlin const svc = makePostService({ repo, sql: throwingSql }) await svc.listReplies("p1", "viewer", { limit: 5, cursor: "c1" }) expect(calls).toEqual([ - { postId: "p1", args: { viewerId: "viewer", focalAuthorId: "origAuthor", cursor: "c1", limit: 5 } }, + { + postId: "p1", + args: { viewerId: "viewer", focalAuthorId: "origAuthor", cursor: "c1", limit: 5 }, + }, ]) }) @@ -445,7 +478,8 @@ describe("PostRepository listReplies inlines the focal author's latest answers", } const LIST = /WHERE p\.reply_to_id = \? AND p\.deleted_at IS NULL/ - const ANSWERS = /SELECT DISTINCT ON \(p\.reply_to_id\)[\s\S]*WHERE p\.reply_to_id = ANY\(\?::uuid\[\]\)\s+AND p\.author_id = \?/ + const ANSWERS = + /SELECT DISTINCT ON \(p\.reply_to_id\)[\s\S]*WHERE p\.reply_to_id = ANY\(\?::uuid\[\]\)\s+AND p\.author_id = \?/ const AUTHORS = /LEFT JOIN media_assets am ON am\.id = u\.avatar_media_id/ function repoOver(listRows: unknown[], answerRows: unknown[]) { @@ -565,7 +599,13 @@ describe("PostService notification fan-out", () => { const spy = spyNotifier() const svc = makePostService({ repo, sql: throwingSql, notifier: spy.notifier }) await svc.createPost( - { kind: "reply", replyToId: "parent", body: "nice", mediaUploadIds: [], mentionedUserIds: [] }, + { + kind: "reply", + replyToId: "parent", + body: "nice", + mediaUploadIds: [], + mentionedUserIds: [], + }, "replier", ) expect(spy.replies).toEqual([ @@ -578,12 +618,17 @@ describe("PostService notification fan-out", () => { const orig = brief({ id: "orig", authorId: "origAuthor", kind: "post" }) const base = fakeRepo({ briefs: { shell, orig }, likeCreated: true }) const likeCalls: string[] = [] - const repo: typeof base = { ...base, like: (id: string) => (likeCalls.push(id), Promise.resolve(true)) } + const repo: typeof base = { + ...base, + like: (id: string) => (likeCalls.push(id), Promise.resolve(true)), + } const spy = spyNotifier() const svc = makePostService({ repo, sql: throwingSql, notifier: spy.notifier }) await svc.likePost("shell", "liker") expect(likeCalls).toEqual(["orig"]) - expect(spy.likes).toEqual([{ recipientId: "origAuthor", actorName: "Actor Zed", postId: "orig" }]) + expect(spy.likes).toEqual([ + { recipientId: "origAuthor", actorName: "Actor Zed", postId: "orig" }, + ]) }) }) @@ -841,7 +886,9 @@ describe("PostRepository.loadRefs: a hidden original is an unavailable embed", ( it("selects posts.visibility on the ref query", async () => { const { repo, fake } = repoOver("hidden") await repo.getPostDTO(QUOTE, VIEWER) - const refStmt = fake.statements.find((s) => /LEFT JOIN users u ON u\.id = p\.author_id/.test(s.sql)) + const refStmt = fake.statements.find((s) => + /LEFT JOIN users u ON u\.id = p\.author_id/.test(s.sql), + ) expect(refStmt?.sql).toContain("p.visibility") }) @@ -858,7 +905,9 @@ describe("PostRepository.loadRefs: a hidden original is an unavailable embed", ( event: null, report: null, }) - const mediaStmts = fake.statements.filter((s) => /FROM media_assets\s+WHERE post_id/.test(s.sql)) + const mediaStmts = fake.statements.filter((s) => + /FROM media_assets\s+WHERE post_id/.test(s.sql), + ) expect(mediaStmts.some((s) => JSON.stringify(s.values).includes(ORIG))).toBe(false) const eventStmts = fake.statements.filter((s) => /FROM cleanups c/.test(s.sql)) expect(eventStmts).toHaveLength(0) @@ -869,7 +918,9 @@ describe("PostRepository.loadRefs: a hidden original is an unavailable embed", ( const dto = await repo.getPostDTO(QUOTE, VIEWER) expect(dto?.repostOf).toMatchObject({ id: ORIG, body: "the original body" }) expect(dto?.repostOf?.deleted).toBeUndefined() - const mediaStmts = fake.statements.filter((s) => /FROM media_assets\s+WHERE post_id/.test(s.sql)) + const mediaStmts = fake.statements.filter((s) => + /FROM media_assets\s+WHERE post_id/.test(s.sql), + ) expect(mediaStmts.some((s) => JSON.stringify(s.values).includes(ORIG))).toBe(true) }) }) @@ -878,7 +929,9 @@ describe("PostRepository read paths all exclude non-public posts", () => { const VIEWER = "11111111-1111-1111-1111-111111111111" const SUBJECT = "22222222-2222-2222-2222-222222222222" - async function emitted(run: (repo: ReturnType) => Promise) { + async function emitted( + run: (repo: ReturnType) => Promise, + ) { const fake = makeFakeSql() const repo = makeDrizzlePostRepository(fake.sql as unknown as Sql, { presignMedia: () => Promise.resolve({ url: "u" }), @@ -891,7 +944,9 @@ describe("PostRepository read paths all exclude non-public posts", () => { const args = { viewerId: VIEWER, cursor: null, limit: 10 } it("filters every post-listing query on visibility, not just the public feed", async () => { - const cases: Array<[string, (r: ReturnType) => Promise]> = [ + const cases: Array< + [string, (r: ReturnType) => Promise] + > = [ ["getPostDTO", (r) => r.getPostDTO(SUBJECT, VIEWER)], ["homeFeedChronological", (r) => r.homeFeedChronological({ ...args, filter: "all" })], ["publicFeed", (r) => r.publicFeed({ filter: "all", cursor: null, limit: 10 })], @@ -986,7 +1041,9 @@ describe("PostRepository organization hydration", () => { it("selects organization_id on every post-listing query, so hydration never batches undefined", async () => { const args = { viewerId: VIEWER, cursor: null, limit: 10 } - const cases: Array<[string, (r: ReturnType) => Promise]> = [ + const cases: Array< + [string, (r: ReturnType) => Promise] + > = [ ["getPostDTO", (r) => r.getPostDTO(POST, VIEWER)], ["homeFeedChronological", (r) => r.homeFeedChronological({ ...args, filter: "all" })], ["publicFeed", (r) => r.publicFeed({ filter: "all", cursor: null, limit: 10 })], diff --git a/services/api/test/unit/posts-routes.test.ts b/services/api/test/unit/posts-routes.test.ts index 11832d5a..404fd86a 100644 --- a/services/api/test/unit/posts-routes.test.ts +++ b/services/api/test/unit/posts-routes.test.ts @@ -164,7 +164,11 @@ class InMemoryPostRepository implements PostRepository { } /** Newest-first keyset page over the canonical cursor helpers (the real repo's contract). */ - private page(rows: StoredPost[], viewerId: string, args: { cursor: string | null; limit: number }): FeedPage { + private page( + rows: StoredPost[], + viewerId: string, + args: { cursor: string | null; limit: number }, + ): FeedPage { const sorted = [...rows].sort( (a, b) => b.createdAt.getTime() - a.createdAt.getTime() || (a.id < b.id ? 1 : -1), ) @@ -270,7 +274,10 @@ class InMemoryPostRepository implements PostRepository { unrepost(postId: string, userId: string): Promise<{ targetId: string; removed: boolean }> { const targetId = this.targetOf(postId) - return Promise.resolve({ targetId, removed: this.live(targetId)?.reposts.delete(userId) ?? false }) + return Promise.resolve({ + targetId, + removed: this.live(targetId)?.reposts.delete(userId) ?? false, + }) } getPostDTO(id: string, viewerId: string): Promise { @@ -296,7 +303,12 @@ class InMemoryPostRepository implements PostRepository { }) } - homeFeedChronological(args: { viewerId: string; filter: "all" | "events" | "fixes"; cursor: string | null; limit: number }): Promise { + homeFeedChronological(args: { + viewerId: string + filter: "all" | "events" | "fixes" + cursor: string | null + limit: number + }): Promise { return Promise.resolve(this.page(this.byFilter(args.filter), args.viewerId, args)) } @@ -353,13 +365,19 @@ class InMemoryPostRepository implements PostRepository { return Promise.resolve(out) } - publicFeed(args: { filter: "all" | "events" | "fixes"; cursor: string | null; limit: number }): Promise { + publicFeed(args: { + filter: "all" | "events" | "fixes" + cursor: string | null + limit: number + }): Promise { // NIL viewer: every viewer flag must come back false for a signed-out reader. return Promise.resolve(this.page(this.byFilter(args.filter), "", args)) } listReplies(postId: string, args: ReplyListArgs): Promise { - const rows = [...this.posts.values()].filter((p) => p.replyToId === postId && p.deletedAt === null) + const rows = [...this.posts.values()].filter( + (p) => p.replyToId === postId && p.deletedAt === null, + ) const page = this.page(rows, args.viewerId, args) const authorReplies = page.items.flatMap((reply) => { const answer = this.latestAnswerBy(args.focalAuthorId, reply.id) @@ -453,7 +471,8 @@ async function makeHarness(): Promise { const service = makePostService({ repo, sql: throwingSql, - isBlockedEitherWay: (a, b) => Promise.resolve(blocked.has(`${a}:${b}`) || blocked.has(`${b}:${a}`)), + isBlockedEitherWay: (a, b) => + Promise.resolve(blocked.has(`${a}:${b}`) || blocked.has(`${b}:${a}`)), }) const container = buildContainer(env) @@ -511,7 +530,9 @@ function bearer(s: Session): Record { const UNKNOWN_ID = "11111111-2222-4333-8444-555555555555" /** Every route that mutates state, with a body where one is required. */ -const MUTATIONS = (id: string): ReadonlyArray<{ +const MUTATIONS = ( + id: string, +): ReadonlyArray<{ method: "POST" | "DELETE" url: string payload?: Record @@ -669,7 +690,11 @@ describe("posts routes: CSRF (cookie transport)", () => { const me = await h.signInWeb("read@example.com", "Read") const id = h.repo.seed({ authorId: me.userId }) for (const r of READS(id, me.userId)) { - const res = await h.app.inject({ method: r.method, url: r.url, headers: { cookie: me.cookie } }) + const res = await h.app.inject({ + method: r.method, + url: r.url, + headers: { cookie: me.cookie }, + }) expect(res.statusCode, r.url).toBe(200) } }) @@ -898,7 +923,11 @@ describe("GET /posts/:id (getPost) + DELETE /posts/:id (deletePost)", () => { expect(ok.statusCode).toBe(200) expect(ok.json()).toMatchObject({ id, body: "readable" }) - const bad = await h.app.inject({ method: "GET", url: "/v1/posts/not-a-uuid", headers: bearer(me) }) + const bad = await h.app.inject({ + method: "GET", + url: "/v1/posts/not-a-uuid", + headers: bearer(me), + }) expect(bad.statusCode).toBe(422) expect(bad.json().fields.id).toBeDefined() @@ -917,11 +946,16 @@ describe("GET /posts/:id (getPost) + DELETE /posts/:id (deletePost)", () => { const id = h.repo.seed({ authorId: author.userId }) h.blocked.add(`${author.userId}:${viewer.userId}`) - const res = await h.app.inject({ method: "GET", url: `/v1/posts/${id}`, headers: bearer(viewer) }) + const res = await h.app.inject({ + method: "GET", + url: `/v1/posts/${id}`, + headers: bearer(viewer), + }) expect(res.statusCode).toBe(404) // The author still sees their own post. expect( - (await h.app.inject({ method: "GET", url: `/v1/posts/${id}`, headers: bearer(author) })).statusCode, + (await h.app.inject({ method: "GET", url: `/v1/posts/${id}`, headers: bearer(author) })) + .statusCode, ).toBe(200) }) @@ -1087,8 +1121,9 @@ describe("interaction toggles (like / save / repost)", () => { const me = await h.signIn("saver@example.com", "Saver") const id = h.repo.seed({ authorId: me.userId }) - expect((await h.app.inject({ method: "GET", url: "/v1/me/saves", headers: bearer(me) })).json()) - .toEqual({ items: [], nextCursor: null }) + expect( + (await h.app.inject({ method: "GET", url: "/v1/me/saves", headers: bearer(me) })).json(), + ).toEqual({ items: [], nextCursor: null }) const saved = await h.app.inject({ method: "POST", @@ -1108,14 +1143,16 @@ describe("interaction toggles (like / save / repost)", () => { }) expect(unsaved.json()).toMatchObject({ counts: { saves: 0 }, viewer: { saved: false } }) expect( - (await h.app.inject({ method: "GET", url: "/v1/me/saves", headers: bearer(me) })).json().items, + (await h.app.inject({ method: "GET", url: "/v1/me/saves", headers: bearer(me) })).json() + .items, ).toEqual([]) // Another account's saves are not visible in mine. const other = await h.signIn("other-saver@example.com", "Other") await h.app.inject({ method: "POST", url: `/v1/posts/${id}/save`, headers: bearer(other) }) expect( - (await h.app.inject({ method: "GET", url: "/v1/me/saves", headers: bearer(me) })).json().items, + (await h.app.inject({ method: "GET", url: "/v1/me/saves", headers: bearer(me) })).json() + .items, ).toEqual([]) }) @@ -1125,7 +1162,12 @@ describe("interaction toggles (like / save / repost)", () => { const sharer = await h.signIn("sharer@example.com", "Sharer") const original = h.repo.seed({ authorId: author.userId, body: "original" }) // A pure repost row pointing at the original: reposting THAT must resolve to the original. - const bounce = h.repo.seed({ authorId: sharer.userId, kind: "repost", repostOfId: original, body: null }) + const bounce = h.repo.seed({ + authorId: sharer.userId, + kind: "repost", + repostOfId: original, + body: null, + }) const res = await h.app.inject({ method: "POST", @@ -1146,7 +1188,11 @@ describe("interaction toggles (like / save / repost)", () => { headers: bearer(sharer), }) expect(undone.statusCode).toBe(200) - expect(undone.json()).toMatchObject({ id: original, counts: { reposts: 0 }, viewer: { reposted: false } }) + expect(undone.json()).toMatchObject({ + id: original, + counts: { reposts: 0 }, + viewer: { reposted: false }, + }) }) it("404s every toggle against an unknown post and 422s a non-uuid id", async () => { @@ -1182,8 +1228,18 @@ describe("list endpoints: replies / user posts / saves / home feed", () => { const me = await h.signIn("threads@example.com", "Th") const parent = h.repo.seed({ authorId: me.userId, body: "parent" }) const otherParent = h.repo.seed({ authorId: me.userId, body: "unrelated" }) - const first = h.repo.seed({ authorId: me.userId, kind: "reply", replyToId: parent, body: "one" }) - const second = h.repo.seed({ authorId: me.userId, kind: "reply", replyToId: parent, body: "two" }) + const first = h.repo.seed({ + authorId: me.userId, + kind: "reply", + replyToId: parent, + body: "one", + }) + const second = h.repo.seed({ + authorId: me.userId, + kind: "reply", + replyToId: parent, + body: "two", + }) h.repo.seed({ authorId: me.userId, kind: "reply", replyToId: otherParent, body: "elsewhere" }) const res = await h.app.inject({ @@ -1228,10 +1284,25 @@ describe("list endpoints: replies / user posts / saves / home feed", () => { const author = await h.signIn("thread-author@example.com", "ThreadAuthor") const other = await h.signIn("thread-other@example.com", "ThreadOther") const parent = h.repo.seed({ authorId: author.userId, body: "parent" }) - const first = h.repo.seed({ authorId: other.userId, kind: "reply", replyToId: parent, body: "first" }) - const second = h.repo.seed({ authorId: other.userId, kind: "reply", replyToId: parent, body: "second" }) + const first = h.repo.seed({ + authorId: other.userId, + kind: "reply", + replyToId: parent, + body: "first", + }) + const second = h.repo.seed({ + authorId: other.userId, + kind: "reply", + replyToId: parent, + body: "second", + }) h.repo.seed({ authorId: author.userId, kind: "reply", replyToId: first, body: "older answer" }) - const latest = h.repo.seed({ authorId: author.userId, kind: "reply", replyToId: first, body: "latest answer" }) + const latest = h.repo.seed({ + authorId: author.userId, + kind: "reply", + replyToId: first, + body: "latest answer", + }) h.repo.seed({ authorId: author.userId, kind: "reply", @@ -1239,7 +1310,12 @@ describe("list endpoints: replies / user posts / saves / home feed", () => { body: "deleted answer", deletedAt: new Date(), }) - h.repo.seed({ authorId: other.userId, kind: "reply", replyToId: second, body: "not the author" }) + h.repo.seed({ + authorId: other.userId, + kind: "reply", + replyToId: second, + body: "not the author", + }) h.repo.seed({ authorId: author.userId, kind: "reply", replyToId: latest, body: "deeper" }) const res = await h.app.inject({ @@ -1259,10 +1335,30 @@ describe("list endpoints: replies / user posts / saves / home feed", () => { const author = await h.signIn("page-author@example.com", "PageAuthor") const other = await h.signIn("page-other@example.com", "PageOther") const parent = h.repo.seed({ authorId: author.userId, body: "parent" }) - const first = h.repo.seed({ authorId: other.userId, kind: "reply", replyToId: parent, body: "first" }) - const second = h.repo.seed({ authorId: other.userId, kind: "reply", replyToId: parent, body: "second" }) - const answer = h.repo.seed({ authorId: author.userId, kind: "reply", replyToId: first, body: "answer" }) - const otherAnswer = h.repo.seed({ authorId: other.userId, kind: "reply", replyToId: answer, body: "back at you" }) + const first = h.repo.seed({ + authorId: other.userId, + kind: "reply", + replyToId: parent, + body: "first", + }) + const second = h.repo.seed({ + authorId: other.userId, + kind: "reply", + replyToId: parent, + body: "second", + }) + const answer = h.repo.seed({ + authorId: author.userId, + kind: "reply", + replyToId: first, + body: "answer", + }) + const otherAnswer = h.repo.seed({ + authorId: other.userId, + kind: "reply", + replyToId: answer, + body: "back at you", + }) const newest = await h.app.inject({ method: "GET", @@ -1358,9 +1454,12 @@ describe("list endpoints: replies / user posts / saves / home feed", () => { const withReport = h.repo.seed({ authorId: me.userId, body: "report", reportId }) const all = await h.app.inject({ method: "GET", url: "/v1/feed/home", headers: bearer(me) }) - expect(all.json().items.map((p: PostDTO) => p.id).sort()).toEqual( - [plain, withEvent, withReport].sort(), - ) + expect( + all + .json() + .items.map((p: PostDTO) => p.id) + .sort(), + ).toEqual([plain, withEvent, withReport].sort()) const events = await h.app.inject({ method: "GET", @@ -1394,10 +1493,20 @@ describe("list endpoints: replies / user posts / saves / home feed", () => { const h = await makeHarness() const me = await h.signIn("noreplies@example.com", "NoReplies") const parent = h.repo.seed({ authorId: me.userId, body: "the original thought" }) - const reply = h.repo.seed({ authorId: me.userId, kind: "reply", replyToId: parent, body: "count me in" }) + const reply = h.repo.seed({ + authorId: me.userId, + kind: "reply", + 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 — // amplifying is a deliberate act, and this is the documented carve-out, not an oversight. - const repostOfReply = h.repo.seed({ authorId: me.userId, kind: "repost", repostOfId: reply, body: null }) + const repostOfReply = h.repo.seed({ + authorId: me.userId, + kind: "repost", + repostOfId: reply, + body: null, + }) const home = await h.app.inject({ method: "GET", url: "/v1/feed/home", headers: bearer(me) }) expect(home.statusCode).toBe(200) @@ -1407,7 +1516,8 @@ describe("list endpoints: replies / user posts / saves / home feed", () => { expect(homeIds).toContain(repostOfReply) // 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() + const publicIds = (await h.app.inject({ method: "GET", url: "/v1/feed/home" })) + .json() .items.map((p: PostDTO) => p.id) expect(publicIds).toContain(parent) expect(publicIds).not.toContain(reply) @@ -1421,21 +1531,27 @@ describe("list endpoints: replies / user posts / saves / home feed", () => { expect(replies.statusCode).toBe(200) expect(replies.json().items.map((p: PostDTO) => p.id)).toEqual([reply]) expect( - (await h.app.inject({ method: "GET", url: `/v1/posts/${reply}`, headers: bearer(me) })).json().id, + (await h.app.inject({ method: "GET", url: `/v1/posts/${reply}`, headers: bearer(me) })).json() + .id, ).toBe(reply) // The profile "Posts" tab excludes it too (`kind <> 'reply'`), but a BOOKMARKED reply still shows: // the viewer asked for that one by name. expect( - (await h.app.inject({ - method: "GET", - url: `/v1/people/${me.userId}/posts`, - headers: bearer(me), - })).json().items.map((p: PostDTO) => p.id), + ( + await h.app.inject({ + method: "GET", + url: `/v1/people/${me.userId}/posts`, + headers: bearer(me), + }) + ) + .json() + .items.map((p: PostDTO) => p.id), ).not.toContain(reply) await h.app.inject({ method: "POST", url: `/v1/posts/${reply}/save`, headers: bearer(me) }) expect( - (await h.app.inject({ method: "GET", url: "/v1/me/saves", headers: bearer(me) })).json() + (await h.app.inject({ method: "GET", url: "/v1/me/saves", headers: bearer(me) })) + .json() .items.map((p: PostDTO) => p.id), ).toContain(reply) }) diff --git a/services/api/test/unit/primary-affiliation-settings.test.ts b/services/api/test/unit/primary-affiliation-settings.test.ts index 0d29bc26..ecb4c06c 100644 --- a/services/api/test/unit/primary-affiliation-settings.test.ts +++ b/services/api/test/unit/primary-affiliation-settings.test.ts @@ -32,9 +32,7 @@ describe("updateSettings: primaryOrganizationId", () => { it("422s an organization the user does not belong to, and changes nothing", async () => { const id = await seeded() - await expect( - store.updateSettings(id, { primaryOrganizationId: ORG_B }), - ).rejects.toMatchObject({ + await expect(store.updateSettings(id, { primaryOrganizationId: ORG_B })).rejects.toMatchObject({ code: "VALIDATION", fields: { primaryOrganizationId: PRIMARY_ORGANIZATION_NOT_A_MEMBER }, }) diff --git a/services/api/test/unit/push-expo.test.ts b/services/api/test/unit/push-expo.test.ts index 9adda5b1..edb61232 100644 --- a/services/api/test/unit/push-expo.test.ts +++ b/services/api/test/unit/push-expo.test.ts @@ -3,11 +3,13 @@ import { makeExpoDispatcher, isExpoPushToken } from "../../src/adapters/push-exp import type { PushLogger } from "../../src/adapters/push-sender.js" import type { PushPayload } from "@civfix/shared/interfaces" - const logger: PushLogger = { warn: () => {}, error: () => {} } const PAYLOAD: PushPayload = { title: "Hi", body: "there", link: "/x", data: { k: "v" } } -function jsonFetch(body: unknown): { fetchImpl: typeof fetch; calls: Array<{ url: string; init: RequestInit }> } { +function jsonFetch(body: unknown): { + fetchImpl: typeof fetch + calls: Array<{ url: string; init: RequestInit }> +} { const calls: Array<{ url: string; init: RequestInit }> = [] const fetchImpl = vi.fn(async (url: string, init: RequestInit) => { calls.push({ url, init }) @@ -77,7 +79,9 @@ describe("makeExpoDispatcher", () => { makeExpoDispatcher({ fetchImpl: throwing }, logger)(["ExponentPushToken[a]"], PAYLOAD), ).resolves.toEqual({ invalidTokens: [] }) - const http500 = vi.fn(async () => new Response("nope", { status: 500 })) as unknown as typeof fetch + const http500 = vi.fn( + async () => new Response("nope", { status: 500 }), + ) as unknown as typeof fetch await expect( makeExpoDispatcher({ fetchImpl: http500 }, logger)(["ExponentPushToken[a]"], PAYLOAD), ).resolves.toEqual({ invalidTokens: [] }) diff --git a/services/api/test/unit/push-sender.test.ts b/services/api/test/unit/push-sender.test.ts index abd9cc30..474620c8 100644 --- a/services/api/test/unit/push-sender.test.ts +++ b/services/api/test/unit/push-sender.test.ts @@ -13,7 +13,6 @@ import { PUSH_MAX_PER_USER_PER_MINUTE, allowedByPushRate } from "../../src/adapt import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" import type { PushPayload, PushPlatform } from "@civfix/shared/interfaces" - function recordingDispatcher(invalid: string[] = []): { calls: Array<{ tokens: string[]; payload: PushPayload }> fn: PlatformDispatcher @@ -73,7 +72,6 @@ function fakeDb(rows: TokenRow[], prune: PruneCapture): Db { const PAYLOAD: PushPayload = { title: "Hi", body: "there", link: "/x", data: { k: "v" } } - describe("groupByPlatform", () => { it("groups tokens by platform and de-duplicates within a platform", () => { const tokens: ActiveToken[] = [ @@ -90,7 +88,6 @@ describe("groupByPlatform", () => { }) }) - describe("MultiPushSender.send routing", () => { it("routes each platform's tokens to the matching dispatcher (ios->ios, android->fcm, web->webpush)", async () => { const rows: TokenRow[] = [ @@ -199,7 +196,6 @@ describe("MultiPushSender.registerToken", () => { }) }) - describe("MultiPushSender.send Expo routing", () => { const expoTok = "ExponentPushToken[aaa]" const expoTok2 = "ExponentPushToken[bbb]" @@ -298,9 +294,10 @@ describe("isSafePushEndpoint (SSRF guard, IP-literal paths)", () => { } }) - describe("per-user push rate cap (H15)", () => { - const rateRows = (userId: string): TokenRow[] => [{ userId, platform: "ios", token: `${userId}-tok` }] + const rateRows = (userId: string): TokenRow[] => [ + { userId, platform: "ios", token: `${userId}-tok` }, + ] it("stops dispatching to a recipient past the per-minute cap and keeps the window per user", async () => { const ios = recordingDispatcher() diff --git a/services/api/test/unit/push-token-registration.test.ts b/services/api/test/unit/push-token-registration.test.ts index d6a459f3..e10664e7 100644 --- a/services/api/test/unit/push-token-registration.test.ts +++ b/services/api/test/unit/push-token-registration.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect, vi } from "vitest" import { FakePushSender } from "@civfix/shared/fakes" import { makeNotificationService } from "../../src/services/notification-service.js" @@ -53,7 +52,10 @@ describe("classifyPushToken", () => { }) it("accepts a real browser PushSubscription JSON for platform web", () => { - const shape = classifyPushToken("web", subscription("https://fcm.googleapis.com/fcm/send/abc123")) + const shape = classifyPushToken( + "web", + subscription("https://fcm.googleapis.com/fcm/send/abc123"), + ) expect(shape).toEqual({ ok: true, kind: "web", @@ -66,15 +68,20 @@ describe("classifyPushToken", () => { expect(classifyPushToken("web", JSON.stringify({ endpoint: "https://x/y" })).ok).toBe(false) expect(classifyPushToken("web", subscription("http://push.example/x")).ok).toBe(false) expect(classifyPushToken("web", subscription("not a url")).ok).toBe(false) - expect(classifyPushToken("web", subscription("https://push.example/x", { p256dh: "short" })).ok).toBe( - false, - ) expect( - classifyPushToken("web", subscription("https://push.example/x", { auth: "!!!not-base64url!!!" })).ok, + classifyPushToken("web", subscription("https://push.example/x", { p256dh: "short" })).ok, + ).toBe(false) + expect( + classifyPushToken( + "web", + subscription("https://push.example/x", { auth: "!!!not-base64url!!!" }), + ).ok, ).toBe(false) expect( - classifyPushToken("web", subscription("https://push.example/x", { auth: Buffer.alloc(32).toString("base64url") })) - .ok, + classifyPushToken( + "web", + subscription("https://push.example/x", { auth: Buffer.alloc(32).toString("base64url") }), + ).ok, ).toBe(false) }) @@ -117,7 +124,10 @@ describe("registerPushToken validation (H15)", () => { throw new Error("endpoint check must not run for a structurally invalid token") }) await expect( - service.registerPushToken(U, { platform: "web", token: subscription("http://push.example/x") }), + service.registerPushToken(U, { + platform: "web", + token: subscription("http://push.example/x"), + }), ).rejects.toMatchObject({ code: "VALIDATION" }) await expect( service.registerPushToken(U, { diff --git a/services/api/test/unit/push-webpush-deadline.test.ts b/services/api/test/unit/push-webpush-deadline.test.ts index 141d8249..654bb612 100644 --- a/services/api/test/unit/push-webpush-deadline.test.ts +++ b/services/api/test/unit/push-webpush-deadline.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect, vi } from "vitest" import { makeWebPushDispatcher, @@ -14,7 +13,10 @@ const PUBLIC_ENDPOINT = "https://93.184.216.34/push/abc" function subscriptionToken(endpoint = PUBLIC_ENDPOINT): string { return JSON.stringify({ endpoint, - keys: { p256dh: Buffer.alloc(65, 7).toString("base64url"), auth: Buffer.alloc(16, 3).toString("base64url") }, + keys: { + p256dh: Buffer.alloc(65, 7).toString("base64url"), + auth: Buffer.alloc(16, 3).toString("base64url"), + }, }) } @@ -137,7 +139,9 @@ describe("web push request deadline (H15)", () => { await vi.advanceTimersByTimeAsync(60_000) await run - expect(logger.warns.some(([, msg]) => String(msg).includes("batch budget exhausted"))).toBe(true) + expect(logger.warns.some(([, msg]) => String(msg).includes("batch budget exhausted"))).toBe( + true, + ) } finally { vi.useRealTimers() } diff --git a/services/api/test/unit/rate-limit-plugin.test.ts b/services/api/test/unit/rate-limit-plugin.test.ts index 2e44d169..ca912123 100644 --- a/services/api/test/unit/rate-limit-plugin.test.ts +++ b/services/api/test/unit/rate-limit-plugin.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect } from "vitest" import { readdirSync, readFileSync } from "node:fs" import { fileURLToPath } from "node:url" @@ -48,7 +47,11 @@ import { CERTIFICATE_REVOKE_RATE_LIMIT, CERTIFICATE_VERIFY_RATE_LIMIT, } from "../../src/routes/service-hours-certificates.routes.js" -import { OTP_REQUEST_RATE_LIMIT, OTP_VERIFY_RATE_LIMIT, OAUTH_RATE_LIMIT } from "../../src/routes/auth.routes.js" +import { + OTP_REQUEST_RATE_LIMIT, + OTP_VERIFY_RATE_LIMIT, + OAUTH_RATE_LIMIT, +} from "../../src/routes/auth.routes.js" import { HOME_TURF_RATE_LIMIT } from "../../src/routes/forms.routes.js" import { GUEST_RSVP_REQUEST_RATE_LIMIT, @@ -185,11 +188,9 @@ async function buildApp( { config: { rateLimit: ADMIN_OUTBOUND_MAIL_RATE_LIMIT } }, async () => ({ ok: true }), ) - app.post( - "/v1/me/data-export", - { config: { rateLimit: DATA_EXPORT_RATE_LIMIT } }, - async () => ({ ok: true }), - ) + app.post("/v1/me/data-export", { config: { rateLimit: DATA_EXPORT_RATE_LIMIT } }, async () => ({ + ok: true, + })) await app.ready() return app } @@ -736,7 +737,9 @@ describe("rate limiter: authenticated route buckets key by user (CVX-012)", () = expect(() => app.post( "/v1/hand-rolled", - { config: { rateLimit: { max: 100, timeWindow: "1 minute", keyGenerator: () => "user:x" } } }, + { + config: { rateLimit: { max: 100, timeWindow: "1 minute", keyGenerator: () => "user:x" } }, + }, async () => ({ ok: true }), ), ).toThrow(/perIdentity\(\) or perHost\(\)/) diff --git a/services/api/test/unit/report-chat-bell-wiring.test.ts b/services/api/test/unit/report-chat-bell-wiring.test.ts index c1a46619..f66baa22 100644 --- a/services/api/test/unit/report-chat-bell-wiring.test.ts +++ b/services/api/test/unit/report-chat-bell-wiring.test.ts @@ -27,7 +27,8 @@ vi.mock("../../src/ws/gateway.js", async (importOriginal) => { } }) -const { wireChatGateway, makeGatewayReportChat } = await import("../../src/routes/chat-gateway-wiring.js") +const { wireChatGateway, makeGatewayReportChat } = + await import("../../src/routes/chat-gateway-wiring.js") const REPORT = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" const BOB = "22222222-2222-2222-2222-222222222222" @@ -92,9 +93,14 @@ describe("report chat bell wiring", () => { }) it("the real wiring clears the report bell when a member reads (fails if the wiring drops the clear)", async () => { - const advance = vi.fn((_reportId: string, _userId: string, _upToId: string) => Promise.resolve()) + const advance = vi.fn((_reportId: string, _userId: string, _upToId: string) => + Promise.resolve(), + ) const notifRepo = new InMemoryNotificationRepository() - const notifications = makeNotificationService({ repo: notifRepo, pushSender: new FakePushSender() }) + const notifications = makeNotificationService({ + repo: notifRepo, + pushSender: new FakePushSender(), + }) wire(makeReportChatSource(advance), notifications) expect(captured.reportChat).toBeDefined() 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 5bca8594..03427ae1 100644 --- a/services/api/test/unit/report-chat-emitter-seams.test.ts +++ b/services/api/test/unit/report-chat-emitter-seams.test.ts @@ -139,7 +139,8 @@ function containerFor(opts: { batchBlocks?: string[] } = {}): Container { } function bells(userId: string): number { - return notifRepo.notifications.filter((n) => n.userId === userId && n.type === "report_chat").length + return notifRepo.notifications.filter((n) => n.userId === userId && n.type === "report_chat") + .length } describe("makeContainerReportChatEmitter: the mute seam is probed, never defaulted to empty", () => { diff --git a/services/api/test/unit/report-chat-membership.test.ts b/services/api/test/unit/report-chat-membership.test.ts index 88855d64..56f19248 100644 --- a/services/api/test/unit/report-chat-membership.test.ts +++ b/services/api/test/unit/report-chat-membership.test.ts @@ -10,15 +10,19 @@ import { buildAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" import { InMemoryChatRepository, InMemoryThreadsRepository } from "../helpers/chat.js" import { InMemoryDiscussionRepository } from "../helpers/discussion.js" -import { InMemoryBlocksRepository, InMemoryDmRepository } from "../../src/services/dm-repository.memory.js" +import { + InMemoryBlocksRepository, + InMemoryDmRepository, +} from "../../src/services/dm-repository.memory.js" import type { ReportChatRepository } from "../../src/services/report-chat-repository.drizzle.js" - const REPORT = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" -function makeFakeReportChat(over: { - isMember?: boolean -} = {}): ReportChatRepository & { +function makeFakeReportChat( + over: { + isMember?: boolean + } = {}, +): ReportChatRepository & { join: ReturnType leave: ReturnType isMember: ReturnType @@ -55,7 +59,10 @@ interface Harness { let current: Harness | undefined async function makeHarness( - opts: { isMember?: boolean; seedChat?: (chatRepo: InMemoryChatRepository, userId: string) => void } = {}, + opts: { + isMember?: boolean + seedChat?: (chatRepo: InMemoryChatRepository, userId: string) => void + } = {}, ): Promise { const env = loadEnv({ NODE_ENV: "test" }) const stores = makeInMemoryStores() @@ -71,10 +78,17 @@ async function makeHarness( }) const discussionRepo = new InMemoryDiscussionRepository() - discussionRepo.seedReport({ id: REPORT, status: "published", visibility: "public", reporterUserId: null }) + discussionRepo.seedReport({ + id: REPORT, + status: "published", + visibility: "public", + reporterUserId: null, + }) const chatRepo = new InMemoryChatRepository() const blocks = new InMemoryBlocksRepository() - const reportChat = makeFakeReportChat({ ...(opts.isMember !== undefined ? { isMember: opts.isMember } : {}) }) + const reportChat = makeFakeReportChat({ + ...(opts.isMember !== undefined ? { isMember: opts.isMember } : {}), + }) const app = await buildServer({ env, @@ -112,7 +126,10 @@ function auth(token: string): Record { return { authorization: `Bearer ${token}` } } -async function seedReportMessage(chatRepo: InMemoryChatRepository, userId: string): Promise { +async function seedReportMessage( + chatRepo: InMemoryChatRepository, + userId: string, +): Promise { const id = "dddddddd-dddd-dddd-dddd-dddddddddddd" const msg: ChatMessageDTO = await chatRepo.insertMessage( { cleanupId: REPORT, userId, body: "hello", roomKind: "report", kind: "text" }, @@ -231,9 +248,16 @@ describe("POST /reports/:id/messages/:messageId/reactions — membership gate", }) 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 }) + const { app, token, chatRepo, discussionRepo, reportChat } = await makeHarness({ + isMember: true, + }) const messageId = await seedReportMessage(chatRepo, "someone-else") - discussionRepo.seedReport({ id: REPORT, status: "held", visibility: "public", reporterUserId: null }) + discussionRepo.seedReport({ + id: REPORT, + status: "held", + visibility: "public", + reporterUserId: null, + }) const res = await app.inject({ method: "POST", url: `/v1/reports/${REPORT}/messages/${messageId}/reactions`, diff --git a/services/api/test/unit/report-chat-notifications.test.ts b/services/api/test/unit/report-chat-notifications.test.ts index 20f38e14..178d553a 100644 --- a/services/api/test/unit/report-chat-notifications.test.ts +++ b/services/api/test/unit/report-chat-notifications.test.ts @@ -10,7 +10,10 @@ import { ROOM_FANOUT_MEMBER_CAP, } from "../../src/services/chat-room-fanout-notifier.js" import { makeDmBellNotifier } from "../../src/services/chat-bells.js" -import { InMemoryNotificationRepository, flushNotificationDispatch } from "../helpers/notifications.js" +import { + InMemoryNotificationRepository, + flushNotificationDispatch, +} from "../helpers/notifications.js" import type { NotificationPrefsRecord } from "../../src/services/notification-service.js" import { makeNotificationService, @@ -88,7 +91,12 @@ function userMessage( } function replyPreviewFrom(senderId: string, senderName: string): ReplyToDTO { - return { id: "target-1", from: { id: senderId, displayName: senderName }, excerpt: "orig", kind: "text" } + return { + id: "target-1", + from: { id: senderId, displayName: senderName }, + excerpt: "orig", + kind: "text", + } } function systemMessage(): ChatMessageDTO { @@ -200,7 +208,10 @@ describe("makeReportChatNotifier (D-E2)", () => { isBlockedEitherWay: () => Promise.resolve(false), }) - await notify(REPORT, userMessage(ACTOR, "Dana", "replying", { replyTo: replyPreviewFrom(A, "Ann") })) + await notify( + REPORT, + userMessage(ACTOR, "Dana", "replying", { replyTo: replyPreviewFrom(A, "Ann") }), + ) expect(reportNotifs(A)).toHaveLength(0) expect(reportNotifs(B)).toHaveLength(1) @@ -253,7 +264,9 @@ describe("report fan-out mute seam (batch lookup vs per-user fallback)", () => { expect(reportNotifs(A)).toHaveLength(1) expect(reportNotifs(B)).toHaveLength(0) - expect(mutes.isMutedCalls.sort()).toEqual([`${A}|report|${REPORT}`, `${B}|report|${REPORT}`].sort()) + expect(mutes.isMutedCalls.sort()).toEqual( + [`${A}|report|${REPORT}`, `${B}|report|${REPORT}`].sort(), + ) }) it("a repo WITH mutedUserIdsFor suppresses via ONE batch query and never calls the per-user isMuted", async () => { @@ -289,7 +302,11 @@ describe("DM bell (makeDmBellNotifier: mute gate + P2 2.5 reply override)", () = const THREAD = "22222222-2222-2222-2222-222222222222" function makeIsMutedFor(mutes: ConversationMutesRepository | undefined) { - return async (userId: string, kind: ConversationMuteRoomKind, roomId: string): Promise => { + return async ( + userId: string, + kind: ConversationMuteRoomKind, + roomId: string, + ): Promise => { if (!mutes) return false try { return await mutes.isMuted(userId, kind, roomId) @@ -300,7 +317,10 @@ describe("DM bell (makeDmBellNotifier: mute gate + P2 2.5 reply override)", () = } function makeOnDmDelivered(mutes: ConversationMutesRepository | undefined) { - return makeDmBellNotifier({ notificationService: notifications, isMutedFor: makeIsMutedFor(mutes) }) + return makeDmBellNotifier({ + notificationService: notifications, + isMutedFor: makeIsMutedFor(mutes), + }) } function dmNotifs(userId: string): typeof notifRepo.notifications { @@ -333,7 +353,11 @@ describe("DM bell (makeDmBellNotifier: mute gate + P2 2.5 reply override)", () = mutes.mute(B, "dm", THREAD) const onDmDelivered = makeOnDmDelivered(mutes) - await onDmDelivered(THREAD, B, userMessage(A, "Alice", "re: hey", { replyTo: replyPreviewFrom(B, "Bee") })) + await onDmDelivered( + THREAD, + B, + userMessage(A, "Alice", "re: hey", { replyTo: replyPreviewFrom(B, "Bee") }), + ) expect(dmNotifs(B)).toHaveLength(1) expect(dmNotifs(B)[0]!.title).toBe("Alice replied to you") @@ -346,7 +370,11 @@ describe("DM bell (makeDmBellNotifier: mute gate + P2 2.5 reply override)", () = await notifications.updatePrefs(B, { mentions: false }) const onDmDelivered = makeOnDmDelivered(mutes) - await onDmDelivered(THREAD, B, userMessage(A, "Alice", "re: hey", { replyTo: replyPreviewFrom(B, "Bee") })) + await onDmDelivered( + THREAD, + B, + userMessage(A, "Alice", "re: hey", { replyTo: replyPreviewFrom(B, "Bee") }), + ) expect(dmNotifs(B)).toHaveLength(0) }) @@ -355,7 +383,11 @@ describe("DM bell (makeDmBellNotifier: mute gate + P2 2.5 reply override)", () = const mutes = new InMemoryConversationMutes() const onDmDelivered = makeOnDmDelivered(mutes) - await onDmDelivered(THREAD, B, userMessage(A, "Alice", "re: hey", { replyTo: replyPreviewFrom(B, "Bee") })) + await onDmDelivered( + THREAD, + B, + userMessage(A, "Alice", "re: hey", { replyTo: replyPreviewFrom(B, "Bee") }), + ) expect(dmNotifs(B)).toHaveLength(1) expect(dmNotifs(B)[0]!.title).toBe("Alice replied to you") @@ -366,7 +398,11 @@ describe("DM bell (makeDmBellNotifier: mute gate + P2 2.5 reply override)", () = mutes.mute(B, "dm", THREAD) const onDmDelivered = makeOnDmDelivered(mutes) - await onDmDelivered(THREAD, B, userMessage(A, "Alice", "self-thread", { replyTo: replyPreviewFrom(A, "Alice") })) + await onDmDelivered( + THREAD, + B, + userMessage(A, "Alice", "self-thread", { replyTo: replyPreviewFrom(A, "Alice") }), + ) expect(dmNotifs(B)).toHaveLength(0) }) @@ -483,7 +519,9 @@ describe("F084: the shared room fan-out batches push delivery", () => { await notify(REPORT, systemMessage()) - expect(notifRepo.notifications.filter((n) => n.type === "group_chat")).toHaveLength(roster.length) + expect(notifRepo.notifications.filter((n) => n.type === "group_chat")).toHaveLength( + roster.length, + ) await flushNotificationDispatch() expect(pushSpy.singleSends).toHaveLength(0) await flushNotificationDispatch() @@ -515,7 +553,9 @@ describe("F084: the shared room fan-out batches push delivery", () => { await notify(REPORT, systemMessage()) - expect(notifRepo.notifications.filter((n) => n.type === "group_chat")).toHaveLength(roster.length) + expect(notifRepo.notifications.filter((n) => n.type === "group_chat")).toHaveLength( + roster.length, + ) await flushNotificationDispatch() expect(pushSpy.singleSends).toHaveLength(0) await flushNotificationDispatch() diff --git a/services/api/test/unit/report-chat.test.ts b/services/api/test/unit/report-chat.test.ts index 1efbaa16..369e91f6 100644 --- a/services/api/test/unit/report-chat.test.ts +++ b/services/api/test/unit/report-chat.test.ts @@ -10,7 +10,11 @@ import { import { WsChatService } from "../../src/adapters/chat-service.ws.js" import { InMemoryChatPubSub } from "../../src/adapters/chat-pubsub.js" import { InMemoryChatPresence } from "../../src/adapters/chat-presence.js" -import { InMemoryChatRepository, InMemoryThreadsRepository, MockConnection } from "../helpers/chat.js" +import { + InMemoryChatRepository, + InMemoryThreadsRepository, + MockConnection, +} from "../helpers/chat.js" import { forwardReportCityMention } from "../../src/services/report-city-forward.js" import { makeTokenBucketLimiter, type RateLimiter } from "../../src/ws/report-rate-limit.js" import type { @@ -28,10 +32,12 @@ import { InMemoryCacheClient } from "../../src/auth/cache.js" import { makeInMemoryStores } from "../../src/auth/stores.js" import { buildAuthServices } from "../../src/auth/auth-services.js" import { StubJwksVerifier } from "../helpers/auth.js" -import { InMemoryBlocksRepository, InMemoryDmRepository } from "../../src/services/dm-repository.memory.js" +import { + InMemoryBlocksRepository, + InMemoryDmRepository, +} from "../../src/services/dm-repository.memory.js" import { InMemoryDiscussionRepository } from "../helpers/discussion.js" - const REPORT = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" const HELD_REPORT = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" const CLEANUP = "cccccccc-cccc-cccc-cccc-cccccccccccc" @@ -150,7 +156,13 @@ function sessionFor(userId: string, conn: MockConnection): GatewaySession { ...(sendLimiter ? { reportSendLimiter: sendLimiter } : {}), ...(reportChat ? { reportChat } : {}), } - return { userId, conn, joined: new Set(), typingThrottle: new Map(), deps } + return { + userId, + conn, + joined: new Set(), + typingThrottle: new Map(), + deps, + } } beforeEach(() => { @@ -169,7 +181,10 @@ describe("report chat gateway", () => { it("authorizes a report JOIN with no membership (public-read/join)", async () => { const conn = new MockConnection("A") const session = sessionFor(ALICE, conn) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) + await handleClientFrame( + session, + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) expect(conn.framesOfType("error")).toHaveLength(0) expect(conn.framesOfType("presence_snapshot")).toHaveLength(1) @@ -191,10 +206,19 @@ describe("report chat gateway", () => { reportChat = makeReportChat(true) const conn = new MockConnection("A") const session = sessionFor(ALICE, conn) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) await handleClientFrame( session, - JSON.stringify({ type: "send", cleanupId: REPORT, roomKind: "report", clientId: "c1", body: "hello" }), + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) + await handleClientFrame( + session, + JSON.stringify({ + type: "send", + cleanupId: REPORT, + roomKind: "report", + clientId: "c1", + body: "hello", + }), ) const acks = conn.framesOfType("ack") @@ -209,10 +233,19 @@ describe("report chat gateway", () => { reportChat = makeReportChat(true) const conn = new MockConnection("A") const session = sessionFor(ALICE, conn) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: HELD_REPORT, roomKind: "report" })) await handleClientFrame( session, - JSON.stringify({ type: "send", cleanupId: HELD_REPORT, roomKind: "report", clientId: "c1", body: "leak?" }), + JSON.stringify({ type: "join", cleanupId: HELD_REPORT, roomKind: "report" }), + ) + await handleClientFrame( + session, + JSON.stringify({ + type: "send", + cleanupId: HELD_REPORT, + roomKind: "report", + clientId: "c1", + body: "leak?", + }), ) const errors = conn.framesOfType("error") @@ -228,11 +261,20 @@ describe("report chat gateway", () => { sendLimiter = makeTokenBucketLimiter({ capacity: 2, refillPerSec: 0 }) const conn = new MockConnection("A") const session = sessionFor(ALICE, conn) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) + await handleClientFrame( + session, + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) for (const body of ["m1", "m2", "m3"]) { await handleClientFrame( session, - JSON.stringify({ type: "send", cleanupId: REPORT, roomKind: "report", clientId: body, body }), + JSON.stringify({ + type: "send", + cleanupId: REPORT, + roomKind: "report", + clientId: body, + body, + }), ) } @@ -246,10 +288,19 @@ describe("report chat gateway", () => { reportChat = makeReportChat(true) const conn = new MockConnection("A") const session = sessionFor(ALICE, conn) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) await handleClientFrame( session, - JSON.stringify({ type: "send", cleanupId: REPORT, roomKind: "report", clientId: "c1", body: "pls fix @sf" }), + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) + await handleClientFrame( + session, + JSON.stringify({ + type: "send", + cleanupId: REPORT, + roomKind: "report", + clientId: "c1", + body: "pls fix @sf", + }), ) await new Promise((r) => setTimeout(r, 0)) @@ -266,10 +317,19 @@ describe("report chat gateway", () => { mail.reportThread = null const conn = new MockConnection("A") const session = sessionFor(ALICE, conn) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) await handleClientFrame( session, - JSON.stringify({ type: "send", cleanupId: REPORT, roomKind: "report", clientId: "c1", body: "pls fix @sf" }), + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) + await handleClientFrame( + session, + JSON.stringify({ + type: "send", + cleanupId: REPORT, + roomKind: "report", + clientId: "c1", + body: "pls fix @sf", + }), ) await new Promise((r) => setTimeout(r, 0)) @@ -280,11 +340,20 @@ describe("report chat gateway", () => { reportChat = makeReportChat(true) const conn = new MockConnection("A") const session = sessionFor(ALICE, conn) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) + await handleClientFrame( + session, + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) for (const clientId of ["c1", "c2", "c3"]) { await handleClientFrame( session, - JSON.stringify({ type: "send", cleanupId: REPORT, roomKind: "report", clientId, body: "@sf urgent" }), + JSON.stringify({ + type: "send", + cleanupId: REPORT, + roomKind: "report", + clientId, + body: "@sf urgent", + }), ) } await new Promise((r) => setTimeout(r, 0)) @@ -296,10 +365,19 @@ describe("report chat gateway", () => { reportChat = makeReportChat(true) const conn = new MockConnection("A") const session = sessionFor(ALICE, conn) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) await handleClientFrame( session, - JSON.stringify({ type: "send", cleanupId: REPORT, roomKind: "report", clientId: "c1", body: "just chatting" }), + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) + await handleClientFrame( + session, + JSON.stringify({ + type: "send", + cleanupId: REPORT, + roomKind: "report", + clientId: "c1", + body: "just chatting", + }), ) await new Promise((r) => setTimeout(r, 0)) @@ -310,10 +388,19 @@ describe("report chat gateway", () => { reportChat = makeReportChat(true) const conn = new MockConnection("A") const session = sessionFor(ALICE, conn) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) await handleClientFrame( session, - JSON.stringify({ type: "send", cleanupId: REPORT, roomKind: "report", clientId: "c1", body: "public msg" }), + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) + await handleClientFrame( + session, + JSON.stringify({ + type: "send", + cleanupId: REPORT, + roomKind: "report", + clientId: "c1", + body: "public msg", + }), ) const page = await chatRepo.reportHistory(REPORT, undefined, 30, null) @@ -328,7 +415,10 @@ describe("report chat gateway — member-only send/typing + read watermark (D-C3 reportChat = makeReportChat(false) const conn = new MockConnection("A") const session = sessionFor(ALICE, conn) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) + await handleClientFrame( + session, + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) expect(conn.framesOfType("error")).toHaveLength(0) expect(conn.framesOfType("presence_snapshot")).toHaveLength(1) @@ -339,15 +429,29 @@ describe("report chat gateway — member-only send/typing + read watermark (D-C3 reportChat = makeReportChat(false) const conn = new MockConnection("A") const session = sessionFor(ALICE, conn) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) await handleClientFrame( session, - JSON.stringify({ type: "send", cleanupId: REPORT, roomKind: "report", clientId: "c1", body: "let me in" }), + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) + await handleClientFrame( + session, + JSON.stringify({ + type: "send", + cleanupId: REPORT, + roomKind: "report", + clientId: "c1", + body: "let me in", + }), ) const errors = conn.framesOfType("error") expect(errors).toHaveLength(1) - expect(errors[0]).toMatchObject({ type: "error", code: "FORBIDDEN", roomKind: "report", cleanupId: REPORT }) + expect(errors[0]).toMatchObject({ + type: "error", + code: "FORBIDDEN", + roomKind: "report", + cleanupId: REPORT, + }) expect(conn.framesOfType("ack")).toHaveLength(0) expect(conn.framesOfType("message")).toHaveLength(0) expect(chatRepo.count(REPORT)).toBe(0) @@ -357,13 +461,25 @@ describe("report chat gateway — member-only send/typing + read watermark (D-C3 reportChat = undefined const conn = new MockConnection("A") const session = sessionFor(ALICE, conn) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) + await handleClientFrame( + session, + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) expect(session.joined.has(`report:${REPORT}`)).toBe(true) await handleClientFrame( session, - JSON.stringify({ type: "send", cleanupId: REPORT, roomKind: "report", clientId: "c1", body: "unwired" }), + JSON.stringify({ + type: "send", + cleanupId: REPORT, + roomKind: "report", + clientId: "c1", + body: "unwired", + }), + ) + await handleClientFrame( + session, + JSON.stringify({ type: "typing", cleanupId: REPORT, roomKind: "report" }), ) - await handleClientFrame(session, JSON.stringify({ type: "typing", cleanupId: REPORT, roomKind: "report" })) const errors = conn.framesOfType("error") expect(errors).toHaveLength(2) @@ -378,11 +494,23 @@ describe("report chat gateway — member-only send/typing + read watermark (D-C3 const session = sessionFor(ALICE, conn) const observerConn = new MockConnection("B") const observer = sessionFor(ALICE, observerConn) - await handleClientFrame(observer, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) + await handleClientFrame( + observer, + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) + await handleClientFrame( + session, + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) await handleClientFrame( session, - JSON.stringify({ type: "send", cleanupId: REPORT, roomKind: "report", clientId: "c1", body: "fix it @sf" }), + JSON.stringify({ + type: "send", + cleanupId: REPORT, + roomKind: "report", + clientId: "c1", + body: "fix it @sf", + }), ) await new Promise((r) => setTimeout(r, 0)) @@ -401,8 +529,20 @@ describe("report chat gateway — member-only send/typing + read watermark (D-C3 reportChat = rc const conn = new MockConnection("A") const session = sessionFor(ALICE, conn) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) - await handleClientFrame(session, JSON.stringify({ type: "send", cleanupId: REPORT, roomKind: "report", clientId: "c1", body: "hi" })) + await handleClientFrame( + session, + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) + await handleClientFrame( + session, + JSON.stringify({ + type: "send", + cleanupId: REPORT, + roomKind: "report", + clientId: "c1", + body: "hi", + }), + ) const UP_TO = "55555555-5555-5555-5555-555555555555" await handleClientFrame( session, @@ -419,10 +559,19 @@ describe("report chat gateway — member-only send/typing + read watermark (D-C3 const session = sessionFor(ALICE, conn) const observerConn = new MockConnection("B") const observer = sessionFor(ALICE, observerConn) - await handleClientFrame(observer, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) - await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" })) + await handleClientFrame( + observer, + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) + await handleClientFrame( + session, + JSON.stringify({ type: "join", cleanupId: REPORT, roomKind: "report" }), + ) observerConn.sent.length = 0 - await handleClientFrame(session, JSON.stringify({ type: "typing", cleanupId: REPORT, roomKind: "report" })) + await handleClientFrame( + session, + JSON.stringify({ type: "typing", cleanupId: REPORT, roomKind: "report" }), + ) const errors = conn.framesOfType("error") expect(errors).toHaveLength(1) @@ -454,8 +603,18 @@ describe("GET /reports/:id/messages visibility gate", () => { now: () => Date.now(), }) const discussionRepo = new InMemoryDiscussionRepository() - discussionRepo.seedReport({ id: HELD_REPORT, status: "held", visibility: "public", reporterUserId: null }) - discussionRepo.seedReport({ id: REPORT, status: "published", visibility: "public", reporterUserId: null }) + discussionRepo.seedReport({ + id: HELD_REPORT, + status: "held", + visibility: "public", + reporterUserId: null, + }) + discussionRepo.seedReport({ + id: REPORT, + status: "published", + visibility: "public", + reporterUserId: null, + }) const blocks = new InMemoryBlocksRepository() const built = await buildServer({ env, diff --git a/services/api/test/unit/report-content-routes.test.ts b/services/api/test/unit/report-content-routes.test.ts index 21f13323..c7f5bf1e 100644 --- a/services/api/test/unit/report-content-routes.test.ts +++ b/services/api/test/unit/report-content-routes.test.ts @@ -166,7 +166,12 @@ describe("POST /content-reports", () => { method: "POST", url: "/v1/content-reports", headers: { authorization: `Bearer ${token}`, "x-client": "mobile" }, - payload: { subjectType: "report", subjectId: SUBJECT, reason: "other", details: "remove please" }, + payload: { + subjectType: "report", + subjectId: SUBJECT, + reason: "other", + details: "remove please", + }, }) expect(res.statusCode).toBe(200) diff --git a/services/api/test/unit/report-forward-audit.test.ts b/services/api/test/unit/report-forward-audit.test.ts index acc1d1dd..60e8c38e 100644 --- a/services/api/test/unit/report-forward-audit.test.ts +++ b/services/api/test/unit/report-forward-audit.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect, vi } from "vitest" import { forwardReportCityMention, @@ -217,7 +216,13 @@ describe("forwardReportCityMention audit writes (report_message_forwards)", () = expect(named.calls[0]!.body).toContain("there is a tag here") const anon = mailer() - await forwardReportCityMention(anon, { ...ctx(SF), actorDisplayName: null }, "@sf hi", CREATED, {}) + await forwardReportCityMention( + anon, + { ...ctx(SF), actorDisplayName: null }, + "@sf hi", + CREATED, + {}, + ) expect(anon.calls[0]!.body).toContain("A neighbor commented on a graffiti report in SF") }) 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 72af16de..dfefd90a 100644 --- a/services/api/test/unit/report-message-city-forward.test.ts +++ b/services/api/test/unit/report-message-city-forward.test.ts @@ -21,7 +21,11 @@ import { const SF: ReportCityContext = { geoid: "0600001", name: "City of San Francisco", handle: "sf" } // No stored handle: the effective handle derives from the name ("City of San Francisco" -> "san_francisco"). -const SF_DERIVED: ReportCityContext = { geoid: "0600001", name: "City of San Francisco", handle: null } +const SF_DERIVED: ReportCityContext = { + geoid: "0600001", + name: "City of San Francisco", + handle: null, +} // Neither a stored nor a derivable handle (all-punctuation name): there is nothing to @mention, so the tint // degrades to plain text (cityMention null). const NO_HANDLE: ReportCityContext = { geoid: "0600001", name: "!!!", handle: null } @@ -65,16 +69,23 @@ describe("cityForwardFields (report @city surfacing)", () => { it("matches the DERIVED handle when the jurisdiction has no stored handle", () => { // "City of San Francisco" -> effective handle "san_francisco"; the body must @mention that. - const out = cityForwardFields({ body: "hey @san_francisco fix this", forwarded_to_city: false }, SF_DERIVED) + const out = cityForwardFields( + { body: "hey @san_francisco fix this", forwarded_to_city: false }, + SF_DERIVED, + ) expect(out.cityMention).not.toBeNull() expect(out.cityMention?.handle).toBe("san_francisco") // "@sf" would NOT match the derived handle. - expect(cityForwardFields({ body: "@sf", forwarded_to_city: false }, SF_DERIVED).cityMention).toBeNull() + expect( + cityForwardFields({ body: "@sf", forwarded_to_city: false }, SF_DERIVED).cityMention, + ).toBeNull() }) it("omits cityMention when no handle is stored or derivable (nothing to @mention)", () => { // With no usable handle, there is nothing for the body to @mention, so cityMention is null. - expect(cityForwardFields({ body: "@0600001", forwarded_to_city: false }, NO_HANDLE).cityMention).toBeNull() + expect( + cityForwardFields({ body: "@0600001", forwarded_to_city: false }, NO_HANDLE).cityMention, + ).toBeNull() }) it("does not @city-tint a system/empty body (null body coalesces to empty, no mention)", () => { diff --git a/services/api/test/unit/report-routes.test.ts b/services/api/test/unit/report-routes.test.ts index 90d21246..f8beff58 100644 --- a/services/api/test/unit/report-routes.test.ts +++ b/services/api/test/unit/report-routes.test.ts @@ -281,7 +281,12 @@ describe("POST /reports", () => { geomSource: "device", mediaUploadIds: [], } - const first = await app.inject({ method: "POST", url: "/v1/reports", headers: auth(token), payload }) + const first = await app.inject({ + method: "POST", + url: "/v1/reports", + headers: auth(token), + payload, + }) expect(first.statusCode).toBe(201) const firstId = first.json().id @@ -437,7 +442,11 @@ describe("GET /reports/:id", () => { }, }) // The signed-in caller is NOT the owner -> 404. - const res = await app.inject({ method: "GET", url: `/v1/reports/${heldId}`, headers: auth(token) }) + const res = await app.inject({ + method: "GET", + url: `/v1/reports/${heldId}`, + headers: auth(token), + }) expect(res.statusCode).toBe(404) expect(res.json().code).toBe("NOT_FOUND") }) @@ -455,7 +464,11 @@ describe("GET /reports/:id", () => { let code = "" const { app } = await makeHarness({ seed: (repo) => { - const r = repo.seedReport({ status: "published", visibility: "public", referenceCode: "DU-42-000001" }) + const r = repo.seedReport({ + status: "published", + visibility: "public", + referenceCode: "DU-42-000001", + }) code = r.referenceCode! }, }) @@ -609,8 +622,20 @@ describe("GET /map/reports", () => { it("returns clusters at low zoom and pins at high zoom for points in the bbox (client-encoded bbox)", async () => { const { app } = await makeHarness({ seed: (repo) => { - repo.seedReport({ status: "published", visibility: "public", category: "trash", lat: 34.10, lng: -118.35 }) - repo.seedReport({ status: "published", visibility: "public", category: "graffiti", lat: 34.11, lng: -118.34 }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "graffiti", + lat: 34.11, + lng: -118.34, + }) }, }) @@ -639,14 +664,26 @@ describe("GET /map/reports", () => { const { app } = await makeHarness({ seed: (repo) => { const withPhoto = repo.seedReport({ - status: "published", visibility: "public", category: "trash", - lat: 34.10, lng: -118.35, title: "Mattress dumped", + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + title: "Mattress dumped", + }) + repo.seedMedia({ + reportId: withPhoto.id, + status: "ready", + r2Key: "uploads/a", + thumbKey: "thumbs/a", }) - repo.seedMedia({ reportId: withPhoto.id, status: "ready", r2Key: "uploads/a", thumbKey: "thumbs/a" }) // A second report with no media -> thumbUrl null, title omitted (null). repo.seedReport({ - status: "published", visibility: "public", category: "graffiti", - lat: 34.11, lng: -118.34, + status: "published", + visibility: "public", + category: "graffiti", + lat: 34.11, + lng: -118.34, }) }, }) @@ -656,7 +693,11 @@ describe("GET /map/reports", () => { url: `/v1/map/reports${clientQuery({ bbox: BBOX, zoom: 16 })}`, }) expect(res.statusCode).toBe(200) - const pins = res.json().pins as { category: string; title?: string | null; thumbUrl: string | null }[] + const pins = res.json().pins as { + category: string + title?: string | null + thumbUrl: string | null + }[] const trash = pins.find((p) => p.category === "trash")! expect(trash.title).toBe("Mattress dumped") expect(trash.thumbUrl).toBe("memory://thumbs/a") @@ -668,8 +709,20 @@ describe("GET /map/reports", () => { it("filters by categories sent as repeated params (the client's array encoding)", async () => { const { app } = await makeHarness({ seed: (repo) => { - repo.seedReport({ status: "published", visibility: "public", category: "trash", lat: 34.10, lng: -118.35 }) - repo.seedReport({ status: "published", visibility: "public", category: "graffiti", lat: 34.11, lng: -118.34 }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "graffiti", + lat: 34.11, + lng: -118.34, + }) }, }) // clientQuery({categories:["trash"]}) -> ?...&categories=trash (repeated-param form). @@ -685,9 +738,27 @@ describe("GET /map/reports", () => { it("accepts MULTIPLE repeated categories params", async () => { const { app } = await makeHarness({ seed: (repo) => { - repo.seedReport({ status: "published", visibility: "public", category: "trash", lat: 34.10, lng: -118.35 }) - repo.seedReport({ status: "published", visibility: "public", category: "graffiti", lat: 34.11, lng: -118.34 }) - repo.seedReport({ status: "published", visibility: "public", category: "water", lat: 34.12, lng: -118.33 }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "graffiti", + lat: 34.11, + lng: -118.34, + }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "water", + lat: 34.12, + lng: -118.33, + }) }, }) // ?...&categories=trash&categories=graffiti -> both kept, water excluded. @@ -703,8 +774,20 @@ describe("GET /map/reports", () => { it("still accepts a categories CSV (resilience)", async () => { const { app } = await makeHarness({ seed: (repo) => { - repo.seedReport({ status: "published", visibility: "public", category: "trash", lat: 34.10, lng: -118.35 }) - repo.seedReport({ status: "published", visibility: "public", category: "graffiti", lat: 34.11, lng: -118.34 }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "graffiti", + lat: 34.11, + lng: -118.34, + }) }, }) // A single CSV param (hand-built) is tolerated: categories=trash,graffiti. @@ -725,8 +808,20 @@ describe("GET /map/reports", () => { it("M14: a continental bbox is forced to CLUSTER even when the client claims max zoom", async () => { const { app } = await makeHarness({ seed: (repo) => { - repo.seedReport({ status: "published", visibility: "public", category: "trash", lat: 34.10, lng: -118.35 }) - repo.seedReport({ status: "published", visibility: "public", category: "graffiti", lat: 40.71, lng: -74.0 }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "graffiti", + lat: 40.71, + lng: -74.0, + }) }, }) // Just inside MAX_MAP_BBOX_AREA_DEG2 (100 x 60 = 6000 deg^2) so it is the ZOOM clamp under test @@ -754,7 +849,13 @@ describe("GET /map/reports", () => { it("M14: a genuine neighborhood viewport still returns individual pins", async () => { const { app } = await makeHarness({ seed: (repo) => { - repo.seedReport({ status: "published", visibility: "public", category: "trash", lat: 34.10, lng: -118.35 }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + }) }, }) const res = await app.inject({ @@ -787,7 +888,13 @@ describe("GET /map/reports", () => { it("P2: 422s an INVERTED bbox (west >= east or south >= north) instead of silently empty", async () => { const { app } = await makeHarness({ seed: (repo) => { - repo.seedReport({ status: "published", visibility: "public", category: "trash", lat: 34.10, lng: -118.35 }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + }) }, }) // west > east (transposed longitude). Before the guard this built an empty envelope -> 200 with no diff --git a/services/api/test/unit/report-service.test.ts b/services/api/test/unit/report-service.test.ts index f32b32d1..83472c99 100644 --- a/services/api/test/unit/report-service.test.ts +++ b/services/api/test/unit/report-service.test.ts @@ -15,7 +15,6 @@ import { } from "../../src/services/report-service.js" import { InMemoryReportRepository } from "../helpers/reports.js" - const VALID_UUID = "11111111-1111-1111-1111-111111111111" function fakePresign(r2Key: string, thumbKey: string | null) { @@ -27,8 +26,18 @@ function fakePresign(r2Key: string, thumbKey: string | null) { } class FakeReportChatEmitter { - readonly events: { reportId: string; status: string; kind?: string | null; note?: string | null }[] = [] - emit(event: { reportId: string; status: string; kind?: string | null; note?: string | null }): Promise { + readonly events: { + reportId: string + status: string + kind?: string | null + note?: string | null + }[] = [] + emit(event: { + reportId: string + status: string + kind?: string | null + note?: string | null + }): Promise { this.events.push(event) return Promise.resolve() } @@ -81,7 +90,6 @@ function createReq(over: Partial = {}): CreateReportRequest } } - describe("clusterCellSizeDeg", () => { it("halves with each zoom step and is the world width at zoom 0", () => { expect(clusterCellSizeDeg(0)).toBeCloseTo(180, 6) @@ -98,10 +106,62 @@ describe("clusterCellSizeDeg", () => { describe("clusterByZoom", () => { const pts: ReportMapPoint[] = [ - { id: "a", lat: 34.10, lng: -118.350, category: "trash", type: "dump", status: "published", title: "Mattress dumped", description: "blocking the sidewalk", addr: "12 Spring St", referenceCode: "DU-42-000001", thumbKey: "thumbs/a", r2Key: "uploads/a" }, - { id: "b", lat: 34.11, lng: -118.351, category: "graffiti", type: "graffiti", status: "published", title: null, description: null, addr: null, referenceCode: null, thumbKey: null, r2Key: null }, - { id: "c", lat: 34.12, lng: -118.352, category: "trash", type: "dump", status: "published", title: null, description: null, addr: null, referenceCode: null, thumbKey: null, r2Key: null }, - { id: "d", lat: 40.71, lng: -74.000, category: "hazard", type: "encampment", status: "published", title: null, description: null, addr: null, referenceCode: null, thumbKey: null, r2Key: null }, + { + id: "a", + lat: 34.1, + lng: -118.35, + category: "trash", + type: "dump", + status: "published", + title: "Mattress dumped", + description: "blocking the sidewalk", + addr: "12 Spring St", + referenceCode: "DU-42-000001", + thumbKey: "thumbs/a", + r2Key: "uploads/a", + }, + { + id: "b", + lat: 34.11, + lng: -118.351, + category: "graffiti", + type: "graffiti", + status: "published", + title: null, + description: null, + addr: null, + referenceCode: null, + thumbKey: null, + r2Key: null, + }, + { + id: "c", + lat: 34.12, + lng: -118.352, + category: "trash", + type: "dump", + status: "published", + title: null, + description: null, + addr: null, + referenceCode: null, + thumbKey: null, + r2Key: null, + }, + { + id: "d", + lat: 40.71, + lng: -74.0, + category: "hazard", + type: "encampment", + status: "published", + title: null, + description: null, + addr: null, + referenceCode: null, + thumbKey: null, + r2Key: null, + }, ] it("at/above the threshold returns individual pins and no clusters", () => { @@ -149,9 +209,48 @@ describe("clusterByZoom", () => { describe("countByCategory", () => { it("counts all candidates per category, omitting zero categories", () => { const pts: ReportMapPoint[] = [ - { id: "a", lat: 0, lng: 0, category: "trash", type: "dump", status: "published", title: null, description: null, addr: null, referenceCode: null, thumbKey: null, r2Key: null }, - { id: "b", lat: 0, lng: 0, category: "trash", type: "dump", status: "published", title: null, description: null, addr: null, referenceCode: null, thumbKey: null, r2Key: null }, - { id: "c", lat: 0, lng: 0, category: "graffiti", type: "graffiti", status: "published", title: null, description: null, addr: null, referenceCode: null, thumbKey: null, r2Key: null }, + { + id: "a", + lat: 0, + lng: 0, + category: "trash", + type: "dump", + status: "published", + title: null, + description: null, + addr: null, + referenceCode: null, + thumbKey: null, + r2Key: null, + }, + { + id: "b", + lat: 0, + lng: 0, + category: "trash", + type: "dump", + status: "published", + title: null, + description: null, + addr: null, + referenceCode: null, + thumbKey: null, + r2Key: null, + }, + { + id: "c", + lat: 0, + lng: 0, + category: "graffiti", + type: "graffiti", + status: "published", + title: null, + description: null, + addr: null, + referenceCode: null, + thumbKey: null, + r2Key: null, + }, ] expect(countByCategory(pts)).toEqual({ trash: 2, graffiti: 1 }) expect(countByCategory([])).toEqual({}) @@ -169,7 +268,6 @@ describe("reportH3Cell", () => { }) }) - describe("createReport: honeypot", () => { it("rejects a non-empty honeypot with VALIDATION and creates nothing", async () => { const { repo, service } = makeHarness() @@ -197,7 +295,12 @@ describe("createReport: happy path (authed publish-immediately)", () => { it("creates a published+public report with geom_source, jurisdiction, h3, timeline, mine=true", async () => { const { repo, service } = makeHarness({ geoid: "0644000" }) const dto = await service.createReport( - createReq({ category: "graffiti", type: "graffiti", description: "tagging on the wall", geomSource: "device" }), + createReq({ + category: "graffiti", + type: "graffiti", + description: "tagging on the wall", + geomSource: "device", + }), { userId: "u1" }, ) @@ -254,10 +357,9 @@ describe("createReport: happy path (authed publish-immediately)", () => { const { repo, service } = makeHarness() const asset = repo.seedMedia({ status: "validating", r2Key: "uploads/2026/01/pic" }) - const dto = await service.createReport( - createReq({ mediaUploadIds: [asset.uploadId] }), - { userId: "u1" }, - ) + const dto = await service.createReport(createReq({ mediaUploadIds: [asset.uploadId] }), { + userId: "u1", + }) expect(dto.media).toHaveLength(1) expect(dto.media[0]!.id).toBe(asset.id) @@ -269,10 +371,9 @@ describe("createReport: happy path (authed publish-immediately)", () => { const { repo, service } = makeHarness() const foreign = repo.seedMedia({ reportId: "other-report" }) - const dto = await service.createReport( - createReq({ mediaUploadIds: [foreign.uploadId] }), - { userId: "u1" }, - ) + const dto = await service.createReport(createReq({ mediaUploadIds: [foreign.uploadId] }), { + userId: "u1", + }) expect(repo.media.find((m) => m.id === foreign.id)!.reportId).toBe("other-report") expect(dto.media).toHaveLength(0) }) @@ -427,7 +528,11 @@ describe("createReport: credits no volunteer hours", () => { describe("getReport: visibility / held hiding", () => { it("returns a published+public report to anyone, with mine reflecting ownership", async () => { const { repo, service } = makeHarness() - const r = repo.seedReport({ reporterUserId: "owner", status: "published", visibility: "public" }) + const r = repo.seedReport({ + reporterUserId: "owner", + status: "published", + visibility: "public", + }) const asStranger = await service.getReport(r.id, { userId: "stranger" }) expect(asStranger.id).toBe(r.id) @@ -502,7 +607,11 @@ describe("getReport: visibility / held hiding", () => { return Promise.resolve({ joined: true, memberCount: 3, messageCount: 12, unread: 4 }) }, }) - const r = repo.seedReport({ reporterUserId: "owner", status: "published", visibility: "public" }) + const r = repo.seedReport({ + reporterUserId: "owner", + status: "published", + visibility: "public", + }) const dto = await service.getReport(r.id, { userId: "member" }) expect(dto.chatJoined).toBe(true) @@ -520,7 +629,11 @@ describe("getReport: visibility / held hiding", () => { return Promise.resolve({ joined: false, memberCount: 2, messageCount: 5, unread: 0 }) }, }) - const r = repo.seedReport({ reporterUserId: "owner", status: "published", visibility: "public" }) + const r = repo.seedReport({ + reporterUserId: "owner", + status: "published", + visibility: "public", + }) const dto = await service.getReport(r.id, {}) expect(dto.chatJoined).toBe(false) @@ -532,7 +645,11 @@ describe("getReport: visibility / held hiding", () => { it("omits the chat metadata entirely when no loader is wired (offline/fake path)", async () => { const { repo, service } = makeHarness() - const r = repo.seedReport({ reporterUserId: "owner", status: "published", visibility: "public" }) + const r = repo.seedReport({ + reporterUserId: "owner", + status: "published", + visibility: "public", + }) const dto = await service.getReport(r.id, { userId: "member" }) expect(dto.chatJoined).toBeUndefined() expect(dto.chatMemberCount).toBeUndefined() @@ -542,14 +659,19 @@ describe("getReport: visibility / held hiding", () => { it("surfaces a city reply's kind + full body on the timeline DTO (D13)", async () => { const { repo, service } = makeHarness() - const r = repo.seedReport({ reporterUserId: "owner", status: "published", visibility: "public" }) + const r = repo.seedReport({ + reporterUserId: "owner", + status: "published", + visibility: "public", + }) repo.timeline.push({ reportId: r.id, status: "published", note: null, createdAt: new Date(Date.now() - 1000), }) - const fullBody = "We've scheduled a crew and will follow up after the visit — thanks for the report." + const fullBody = + "We've scheduled a crew and will follow up after the visit — thanks for the report." repo.timeline.push({ reportId: r.id, status: "published", @@ -632,7 +754,6 @@ describe("resolveReport (owner status toggle)", () => { }) }) - it("L12: 404s (not 403) a stranger probing a HELD report", async () => { const { repo, service } = makeHarness() const r = repo.seedReport({ reporterUserId: "owner", status: "held", visibility: "public" }) @@ -647,7 +768,11 @@ describe("resolveReport (owner status toggle)", () => { it("L12: 404s a stranger probing an owner-UNLISTED report", async () => { const { repo, service } = makeHarness() - const r = repo.seedReport({ reporterUserId: "owner", status: "published", visibility: "hidden" }) + const r = repo.seedReport({ + reporterUserId: "owner", + status: "published", + visibility: "hidden", + }) await expect(service.resolveReport("stranger", r.id, true)).rejects.toMatchObject({ code: "NOT_FOUND", }) @@ -678,7 +803,11 @@ describe("resolveReport (owner status toggle)", () => { it("F057: 409s the OWNER resolving a submitted (pre-publish) report", async () => { const { repo, service } = makeHarness() - const sub = repo.seedReport({ reporterUserId: "owner", status: "submitted", visibility: "public" }) + const sub = repo.seedReport({ + reporterUserId: "owner", + status: "submitted", + visibility: "public", + }) await expect(service.resolveReport("owner", sub.id, true)).rejects.toMatchObject({ code: "CONFLICT", }) @@ -721,7 +850,11 @@ describe("unlistReport (owner visibility toggle)", () => { it("re-listing a hidden report returns it to public with a 'Re-listed' timeline entry", async () => { const { repo, service } = makeHarness() - const r = repo.seedReport({ reporterUserId: "owner", status: "published", visibility: "hidden" }) + const r = repo.seedReport({ + reporterUserId: "owner", + status: "published", + visibility: "hidden", + }) const dto = await service.unlistReport("owner", r.id, false) expect(dto.visibility).toBe("public") @@ -814,17 +947,32 @@ describe("listMyReports", () => { const { repo, service } = makeHarness() const tie = new Date("2026-05-31T12:00:00.000Z") const later = new Date("2026-05-31T12:00:01.000Z") - repo.seedReport({ id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", reporterUserId: "me", createdAt: tie }) - repo.seedReport({ id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", reporterUserId: "me", createdAt: tie }) - repo.seedReport({ id: "cccccccc-cccc-cccc-cccc-cccccccccccc", reporterUserId: "me", createdAt: later }) + repo.seedReport({ + id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + reporterUserId: "me", + createdAt: tie, + }) + repo.seedReport({ + id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + reporterUserId: "me", + createdAt: tie, + }) + repo.seedReport({ + id: "cccccccc-cccc-cccc-cccc-cccccccccccc", + reporterUserId: "me", + createdAt: later, + }) const seen: string[] = [] let cursor: string | null | undefined = undefined for (let guard = 0; guard < 10; guard++) { - const page: Awaited> = await service.listMyReports("me", { - limit: 1, - ...(cursor ? { cursor } : {}), - }) + const page: Awaited> = await service.listMyReports( + "me", + { + limit: 1, + ...(cursor ? { cursor } : {}), + }, + ) for (const item of page.items) seen.push(item.id) if (page.nextCursor === null) break cursor = page.nextCursor @@ -842,11 +990,41 @@ describe("listMyReports", () => { describe("listReportsInBBox", () => { it("clusters at low zoom and returns per-category counts over the candidates", async () => { const { repo, service } = makeHarness() - repo.seedReport({ status: "published", visibility: "public", category: "trash", lat: 34.10, lng: -118.35 }) - repo.seedReport({ status: "published", visibility: "public", category: "trash", lat: 34.11, lng: -118.34 }) - repo.seedReport({ status: "published", visibility: "public", category: "graffiti", lat: 34.12, lng: -118.33 }) - repo.seedReport({ status: "published", visibility: "public", category: "hazard", lat: 10, lng: 10 }) - repo.seedReport({ status: "held", visibility: "public", category: "trash", lat: 34.1, lng: -118.35 }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + lat: 34.11, + lng: -118.34, + }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "graffiti", + lat: 34.12, + lng: -118.33, + }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "hazard", + lat: 10, + lng: 10, + }) + repo.seedReport({ + status: "held", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + }) const bbox = { west: -118.5, south: 34.0, east: -118.2, north: 34.2 } const low = await service.listReportsInBBox(bbox, null, null, 3) @@ -859,8 +1037,20 @@ describe("listReportsInBBox", () => { it("returns individual pins at high zoom", async () => { const { repo, service } = makeHarness() - repo.seedReport({ status: "published", visibility: "public", category: "trash", lat: 34.10, lng: -118.35 }) - repo.seedReport({ status: "published", visibility: "public", category: "graffiti", lat: 34.11, lng: -118.34 }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "graffiti", + lat: 34.11, + lng: -118.34, + }) const bbox = { west: -118.36, south: 34.09, east: -118.31, north: 34.14 } const high = await service.listReportsInBBox(bbox, null, null, 16) @@ -873,34 +1063,73 @@ describe("listReportsInBBox", () => { const bbox = { west: -118.36, south: 34.09, east: -118.31, north: 34.14 } const withThumb = repo.seedReport({ - status: "published", visibility: "public", category: "trash", - lat: 34.10, lng: -118.35, title: "Mattress dumped", description: "blocking the sidewalk", + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + title: "Mattress dumped", + description: "blocking the sidewalk", + }) + repo.seedMedia({ + reportId: withThumb.id, + status: "ready", + r2Key: "uploads/a", + thumbKey: "thumbs/a", }) - repo.seedMedia({ reportId: withThumb.id, status: "ready", r2Key: "uploads/a", thumbKey: "thumbs/a" }) const noThumb = repo.seedReport({ - status: "published", visibility: "public", category: "graffiti", - lat: 34.11, lng: -118.34, title: "Graffiti on the wall", + status: "published", + visibility: "public", + category: "graffiti", + lat: 34.11, + lng: -118.34, + title: "Graffiti on the wall", }) repo.seedMedia({ reportId: noThumb.id, status: "ready", r2Key: "uploads/b", thumbKey: null }) const noMedia = repo.seedReport({ - status: "published", visibility: "public", category: "hazard", - lat: 34.12, lng: -118.33, title: "Pothole", + status: "published", + visibility: "public", + category: "hazard", + lat: 34.12, + lng: -118.33, + title: "Pothole", }) const pendingOnly = repo.seedReport({ - status: "published", visibility: "public", category: "water", - lat: 34.13, lng: -118.32, title: null, + status: "published", + visibility: "public", + category: "water", + lat: 34.13, + lng: -118.32, + title: null, + }) + repo.seedMedia({ + reportId: pendingOnly.id, + status: "validating", + r2Key: "uploads/d", + thumbKey: "thumbs/d", }) - repo.seedMedia({ reportId: pendingOnly.id, status: "validating", r2Key: "uploads/d", thumbKey: "thumbs/d" }) const high = await service.listReportsInBBox(bbox, null, null, 16) const byId = new Map(high.pins.map((p) => [p.id, p])) - expect(byId.get(withThumb.id)).toMatchObject({ title: "Mattress dumped", description: "blocking the sidewalk", thumbUrl: "memory://thumbs/a" }) - expect(byId.get(noThumb.id)).toMatchObject({ title: "Graffiti on the wall", description: null, thumbUrl: "memory://uploads/b" }) - expect(byId.get(noMedia.id)).toMatchObject({ title: "Pothole", description: null, thumbUrl: null }) + expect(byId.get(withThumb.id)).toMatchObject({ + title: "Mattress dumped", + description: "blocking the sidewalk", + thumbUrl: "memory://thumbs/a", + }) + expect(byId.get(noThumb.id)).toMatchObject({ + title: "Graffiti on the wall", + description: null, + thumbUrl: "memory://uploads/b", + }) + expect(byId.get(noMedia.id)).toMatchObject({ + title: "Pothole", + description: null, + thumbUrl: null, + }) const pending = byId.get(pendingOnly.id)! expect(pending.thumbUrl).toBeNull() @@ -912,21 +1141,35 @@ describe("listReportsInBBox", () => { const bbox = { west: -118.36, south: 34.09, east: -118.31, north: 34.14 } const posterOnly = repo.seedReport({ - status: "published", visibility: "public", category: "trash", - lat: 34.10, lng: -118.35, title: "Dumping caught on video", + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + title: "Dumping caught on video", }) repo.seedMedia({ - reportId: posterOnly.id, status: "ready", kind: "video", - r2Key: "uploads/clip.mp4", thumbKey: "thumbs/clip.jpg", + reportId: posterOnly.id, + status: "ready", + kind: "video", + r2Key: "uploads/clip.mp4", + thumbKey: "thumbs/clip.jpg", }) const thumbless = repo.seedReport({ - status: "published", visibility: "public", category: "hazard", - lat: 34.11, lng: -118.34, title: "Video still transcoding", + status: "published", + visibility: "public", + category: "hazard", + lat: 34.11, + lng: -118.34, + title: "Video still transcoding", }) repo.seedMedia({ - reportId: thumbless.id, status: "ready", kind: "video", - r2Key: "uploads/raw.mp4", thumbKey: null, + reportId: thumbless.id, + status: "ready", + kind: "video", + r2Key: "uploads/raw.mp4", + thumbKey: null, }) const high = await service.listReportsInBBox(bbox, null, null, 16) @@ -937,8 +1180,20 @@ describe("listReportsInBBox", () => { it("filters by category when provided", async () => { const { repo, service } = makeHarness() - repo.seedReport({ status: "published", visibility: "public", category: "trash", lat: 34.10, lng: -118.35 }) - repo.seedReport({ status: "published", visibility: "public", category: "graffiti", lat: 34.11, lng: -118.34 }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "graffiti", + lat: 34.11, + lng: -118.34, + }) const bbox = { west: -118.36, south: 34.09, east: -118.31, north: 34.14 } const onlyTrash = await service.listReportsInBBox(bbox, ["trash"], null, 16) @@ -958,8 +1213,20 @@ describe("listReportsInBBox", () => { it("M14: a world bbox at zoom 22 produces ZERO per-pin rows (no presign fan-out)", async () => { const { repo, service } = makeHarness() - repo.seedReport({ status: "published", visibility: "public", category: "trash", lat: 34.10, lng: -118.35 }) - repo.seedReport({ status: "published", visibility: "public", category: "graffiti", lat: 40.71, lng: -74.0 }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + lat: 34.1, + lng: -118.35, + }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "graffiti", + lat: 40.71, + lng: -74.0, + }) const world = { west: -180, south: -85, east: 180, north: 85 } const attack = await service.listReportsInBBox(world, null, null, 22) @@ -998,7 +1265,7 @@ describe("effectiveMapZoom / impliedZoomForBBox (M14)", () => { const pxPerDeg = (512 * Math.pow(2, mapZoom)) / 360 const halfLng = (PHONE_WIDTH_PT / pxPerDeg / 2) * REGION_PAD_FACTOR const halfLat = - ((PHONE_HEIGHT_PT / pxPerDeg) * Math.cos((DOWNTOWN_LA.lat * Math.PI) / 180) / 2) * + (((PHONE_HEIGHT_PT / pxPerDeg) * Math.cos((DOWNTOWN_LA.lat * Math.PI) / 180)) / 2) * REGION_PAD_FACTOR return { west: DOWNTOWN_LA.lng - halfLng, @@ -1035,13 +1302,22 @@ describe("searchReports", () => { it("returns only published+public+non-deleted reports as ReportPinDTOs with description/addr/referenceCode carried", async () => { const { repo, service } = makeHarness() const pub = repo.seedReport({ - status: "published", visibility: "public", category: "trash", - title: "Broken streetlight", description: "out for a week", addr: "5th Ave", + status: "published", + visibility: "public", + category: "trash", + title: "Broken streetlight", + description: "out for a week", + addr: "5th Ave", referenceCode: "TR-7-000009", }) repo.seedReport({ status: "held", visibility: "public", title: "Held one", publishedAt: null }) repo.seedReport({ status: "published", visibility: "hidden", title: "Hidden one" }) - repo.seedReport({ status: "published", visibility: "public", title: "Deleted one", deletedAt: new Date() }) + repo.seedReport({ + status: "published", + visibility: "public", + title: "Deleted one", + deletedAt: new Date(), + }) const res = await service.searchReports({}) expect(res.items).toHaveLength(1) @@ -1060,9 +1336,22 @@ describe("searchReports", () => { it("carries thumbUrl from the first ready photo (presigned) and null when there is no media", async () => { const { repo, service } = makeHarness() - const withPhoto = repo.seedReport({ status: "published", visibility: "public", title: "with photo" }) - repo.seedMedia({ reportId: withPhoto.id, status: "ready", r2Key: "uploads/x", thumbKey: "thumbs/x" }) - const noPhoto = repo.seedReport({ status: "published", visibility: "public", title: "no photo" }) + const withPhoto = repo.seedReport({ + status: "published", + visibility: "public", + title: "with photo", + }) + repo.seedMedia({ + reportId: withPhoto.id, + status: "ready", + r2Key: "uploads/x", + thumbKey: "thumbs/x", + }) + const noPhoto = repo.seedReport({ + status: "published", + visibility: "public", + title: "no photo", + }) const res = await service.searchReports({}) const byId = new Map(res.items.map((p) => [p.id, p])) @@ -1072,9 +1361,23 @@ describe("searchReports", () => { it("filters by free-text q (case-insensitive) over title OR address", async () => { const { repo, service } = makeHarness() - const byTitle = repo.seedReport({ status: "published", visibility: "public", title: "Pothole on Main" }) - const byAddr = repo.seedReport({ status: "published", visibility: "public", title: "Graffiti", addr: "12 POTHOLE Lane" }) - repo.seedReport({ status: "published", visibility: "public", title: "Trash pile", addr: "9 Elm St" }) + const byTitle = repo.seedReport({ + status: "published", + visibility: "public", + title: "Pothole on Main", + }) + const byAddr = repo.seedReport({ + status: "published", + visibility: "public", + title: "Graffiti", + addr: "12 POTHOLE Lane", + }) + repo.seedReport({ + status: "published", + visibility: "public", + title: "Trash pile", + addr: "9 Elm St", + }) const res = await service.searchReports({ q: "pothole" }) const ids = new Set(res.items.map((p) => p.id)) @@ -1085,7 +1388,12 @@ describe("searchReports", () => { it("filters by category set", async () => { const { repo, service } = makeHarness() - const trash = repo.seedReport({ status: "published", visibility: "public", category: "trash", title: "t" }) + const trash = repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + title: "t", + }) repo.seedReport({ status: "published", visibility: "public", category: "graffiti", title: "g" }) const res = await service.searchReports({ categories: ["trash"] }) @@ -1096,8 +1404,20 @@ describe("searchReports", () => { it("filters by fine-grained type set (0021), alongside category", async () => { const { repo, service } = makeHarness() - const dump = repo.seedReport({ status: "published", visibility: "public", category: "trash", type: "dump", title: "dumped mattress" }) - repo.seedReport({ status: "published", visibility: "public", category: "graffiti", type: "graffiti", title: "tag" }) + const dump = repo.seedReport({ + status: "published", + visibility: "public", + category: "trash", + type: "dump", + title: "dumped mattress", + }) + repo.seedReport({ + status: "published", + visibility: "public", + category: "graffiti", + type: "graffiti", + title: "tag", + }) const res = await service.searchReports({ types: ["dump"] }) expect(res.items).toHaveLength(1) @@ -1124,9 +1444,27 @@ describe("searchReports", () => { const { repo, service } = makeHarness() const tie = new Date("2026-05-31T12:00:00.000Z") const later = new Date("2026-05-31T12:00:01.000Z") - repo.seedReport({ id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", status: "published", visibility: "public", title: "a", createdAt: tie }) - repo.seedReport({ id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", status: "published", visibility: "public", title: "b", createdAt: tie }) - repo.seedReport({ id: "cccccccc-cccc-cccc-cccc-cccccccccccc", status: "published", visibility: "public", title: "c", createdAt: later }) + repo.seedReport({ + id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + status: "published", + visibility: "public", + title: "a", + createdAt: tie, + }) + repo.seedReport({ + id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + status: "published", + visibility: "public", + title: "b", + createdAt: tie, + }) + repo.seedReport({ + id: "cccccccc-cccc-cccc-cccc-cccccccccccc", + status: "published", + visibility: "public", + title: "c", + createdAt: later, + }) const seen: string[] = [] let cursor: string | null | undefined = undefined @@ -1164,15 +1502,27 @@ describe("report media presigner selection (F058)", () => { it("signs a HELD or UNLISTED report's media privately, and a published+public one publicly", async () => { const { repo, service } = presignHarness() - const published = repo.seedReport({ reporterUserId: "owner", status: "published", visibility: "public" }) + const published = repo.seedReport({ + reporterUserId: "owner", + status: "published", + visibility: "public", + }) repo.seedMedia({ reportId: published.id, status: "ready", r2Key: "k/pub.jpg" }) const held = repo.seedReport({ reporterUserId: "owner", status: "held", visibility: "public" }) repo.seedMedia({ reportId: held.id, status: "ready", r2Key: "k/held.jpg" }) - const unlisted = repo.seedReport({ reporterUserId: "owner", status: "published", visibility: "hidden" }) + const unlisted = repo.seedReport({ + reporterUserId: "owner", + status: "published", + visibility: "hidden", + }) repo.seedMedia({ reportId: unlisted.id, status: "ready", r2Key: "k/unlisted.jpg" }) - expect((await service.getReport(published.id, { userId: "owner" })).media[0]!.url).toBe("public://k/pub.jpg") - expect((await service.getReport(held.id, { userId: "owner" })).media[0]!.url).toBe("signed://k/held.jpg") + expect((await service.getReport(published.id, { userId: "owner" })).media[0]!.url).toBe( + "public://k/pub.jpg", + ) + expect((await service.getReport(held.id, { userId: "owner" })).media[0]!.url).toBe( + "signed://k/held.jpg", + ) expect((await service.getReport(unlisted.id, { userId: "owner" })).media[0]!.url).toBe( "signed://k/unlisted.jpg", ) @@ -1180,7 +1530,11 @@ describe("report media presigner selection (F058)", () => { it("signs a still-VALIDATING asset privately even on a published+public report", async () => { const { repo, service } = presignHarness() - const r = repo.seedReport({ reporterUserId: "owner", status: "published", visibility: "public" }) + const r = repo.seedReport({ + reporterUserId: "owner", + status: "published", + visibility: "public", + }) repo.seedMedia({ reportId: r.id, status: "validating", r2Key: "k/pending.jpg" }) const dto = await service.getReport(r.id, { userId: "owner" }) diff --git a/services/api/test/unit/report-system-message.test.ts b/services/api/test/unit/report-system-message.test.ts index 1ceaaf42..51d9949e 100644 --- a/services/api/test/unit/report-system-message.test.ts +++ b/services/api/test/unit/report-system-message.test.ts @@ -11,7 +11,10 @@ import { describe, expect, it } from "vitest" import { ReportStatusSchema } from "@civfix/shared" -import { mapSystemRow, type SystemChatRow } from "../../src/services/report-chat-repository.drizzle.js" +import { + mapSystemRow, + type SystemChatRow, +} from "../../src/services/report-chat-repository.drizzle.js" const REPORT = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" const MSG = "dddddddd-dddd-dddd-dddd-dddddddddddd" diff --git a/services/api/test/unit/report-timeline-event.test.ts b/services/api/test/unit/report-timeline-event.test.ts index 25a1abd6..cbf3f440 100644 --- a/services/api/test/unit/report-timeline-event.test.ts +++ b/services/api/test/unit/report-timeline-event.test.ts @@ -62,7 +62,12 @@ describe("report-chat system-message emitter (D-D1 choke point)", () => { const { calls, deps } = recorder() const emitter = makeReportChatSystemEmitter(deps) - await emitter.emit({ reportId: REPORT, status: "in_progress", kind: "status", note: "Status set to In progress" }) + await emitter.emit({ + reportId: REPORT, + status: "in_progress", + kind: "status", + note: "Status set to In progress", + }) expect(calls).toEqual([ `insert:${REPORT}:in_progress`, @@ -96,9 +101,7 @@ describe("report-chat system-message emitter (D-D1 choke point)", () => { }, }) const emitter = makeReportChatSystemEmitter(deps) - await expect( - emitter.emit({ reportId: REPORT, status: "in_progress" }), - ).resolves.toBeUndefined() + await expect(emitter.emit({ reportId: REPORT, status: "in_progress" })).resolves.toBeUndefined() }) it("swallows a broadcast failure and does not reach notify", async () => { @@ -119,8 +122,6 @@ describe("report-chat system-message emitter (D-D1 choke point)", () => { }, }) const emitter = makeReportChatSystemEmitter(deps) - await expect( - emitter.emit({ reportId: REPORT, status: "in_progress" }), - ).resolves.toBeUndefined() + await expect(emitter.emit({ reportId: REPORT, status: "in_progress" })).resolves.toBeUndefined() }) }) diff --git a/services/api/test/unit/report-verification.test.ts b/services/api/test/unit/report-verification.test.ts index 638a3573..c534be19 100644 --- a/services/api/test/unit/report-verification.test.ts +++ b/services/api/test/unit/report-verification.test.ts @@ -24,10 +24,8 @@ import { runAutoForwardWith } from "../../src/services/admin/autoforward-jobs.js import { makeReportService, REPORT_AUTOFORWARD_JOB } from "../../src/services/report-service.js" import { InMemoryReportRepository } from "../helpers/reports.js" - const NOW = new Date("2026-06-22T00:00:00.000Z") - function reporter(id: string) { return { id, @@ -57,7 +55,6 @@ function verdictHarness(): VerdictHarness { return { repo, svc } } - 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() @@ -169,7 +166,6 @@ describe("setVerdict (D7) — verdict write + count + flip", () => { }) }) - describe("setUserReportVerified (D18) — manual override/revoke", () => { function userHarness(): { repo: InMemoryAdminUserRepository; svc: AdminUserService } { const repo = new InMemoryAdminUserRepository() @@ -194,7 +190,10 @@ describe("setUserReportVerified (D18) — manual override/revoke", () => { await svc.setReportVerified("u1", { value: false, actorId: "op-1" }) expect(repo.users.get("u1")?.reportVerified).toBe(false) - expect(repo.audits.at(-1)).toMatchObject({ action: "user.report_unverified", target: "user:u1" }) + expect(repo.audits.at(-1)).toMatchObject({ + action: "user.report_unverified", + target: "user:u1", + }) const dto = await svc.get("u1") expect(dto.reportVerified).toBe(false) @@ -208,7 +207,6 @@ describe("setUserReportVerified (D18) — manual override/revoke", () => { }) }) - describe("createReport auto-forward enqueue gate (D9)", () => { const VERIFIED_UID = "11111111-1111-1111-1111-111111111111" const UNVERIFIED_UID = "22222222-2222-2222-2222-222222222222" @@ -295,7 +293,6 @@ describe("createReport auto-forward enqueue gate (D9)", () => { }) }) - describe("report.autoforward handler (D9) — runAutoForwardWith", () => { function handlerHarness(opts: { sendError?: unknown } = {}) { const repo = new InMemoryAdminReportRepository() diff --git a/services/api/test/unit/reverse-geocode-mapbox.test.ts b/services/api/test/unit/reverse-geocode-mapbox.test.ts index 3922bcc5..a25f6053 100644 --- a/services/api/test/unit/reverse-geocode-mapbox.test.ts +++ b/services/api/test/unit/reverse-geocode-mapbox.test.ts @@ -7,7 +7,10 @@ import { function okFetch(props: unknown): typeof fetch { return (async () => - ({ ok: true, json: async () => ({ features: [{ properties: props }] }) }) as unknown as Response) as unknown as typeof fetch + ({ + ok: true, + json: async () => ({ features: [{ properties: props }] }), + }) as unknown as Response) as unknown as typeof fetch } describe("formatMapboxReverse", () => { @@ -47,7 +50,11 @@ describe("makeMapboxReverseGeocode", () => { fetchImpl: okFetch({ feature_type: "address", name: "1 Main St", - context: { place: { name: "Springfield" }, region: { region_code: "IL" }, country: { country_code: "us" } }, + context: { + place: { name: "Springfield" }, + region: { region_code: "IL" }, + country: { country_code: "us" }, + }, }), }) expect(await geocode(39.8, -89.6)).toEqual({ @@ -75,7 +82,11 @@ describe("makeMapboxReverseGeocode", () => { it("returns null for empty features", async () => { const geocode = makeMapboxReverseGeocode({ token: "pk.test", - fetchImpl: (async () => ({ ok: true, json: async () => ({ features: [] }) }) as unknown as Response) as unknown as typeof fetch, + fetchImpl: (async () => + ({ + ok: true, + json: async () => ({ features: [] }), + }) as unknown as Response) as unknown as typeof fetch, }) expect(await geocode(39.8, -89.6)).toBeNull() }) @@ -112,9 +123,9 @@ describe("makeMapboxReverseGeocode", () => { */ describe("mapboxPrecision", () => { it("claims street only with a house number", () => { - expect(mapboxPrecision({ context: { address: { address_number: "123", name: "123 Main St" } } })).toBe( - "street", - ) + expect( + mapboxPrecision({ context: { address: { address_number: "123", name: "123 Main St" } } }), + ).toBe("street") expect(mapboxPrecision({ feature_type: "address", name: "123 Main St" })).toBe("street") }) @@ -136,7 +147,9 @@ describe("mapboxPrecision", () => { }) it("claims NOTHING for a place-only hit, so the chain keeps going", () => { - expect(mapboxPrecision({ name: "Los Angeles", context: { place: { name: "Los Angeles" } } })).toBeNull() + expect( + mapboxPrecision({ name: "Los Angeles", context: { place: { name: "Los Angeles" } } }), + ).toBeNull() expect(mapboxPrecision({})).toBeNull() }) }) diff --git a/services/api/test/unit/reverse-geocode-photon.test.ts b/services/api/test/unit/reverse-geocode-photon.test.ts index ef73c018..01051557 100644 --- a/services/api/test/unit/reverse-geocode-photon.test.ts +++ b/services/api/test/unit/reverse-geocode-photon.test.ts @@ -29,7 +29,12 @@ describe("formatPhotonReverse", () => { it("falls back to the place name and keeps a non-US country", () => { expect( - formatPhotonReverse({ name: "Stanley Park", city: "Vancouver", state: "BC", country: "Canada" }), + formatPhotonReverse({ + name: "Stanley Park", + city: "Vancouver", + state: "BC", + country: "Canada", + }), ).toBe("Stanley Park, Vancouver, BC, Canada") }) @@ -41,7 +46,10 @@ describe("formatPhotonReverse", () => { /** A fake fetch returning a single Photon feature with the given properties. */ function okFetch(props: unknown): typeof fetch { return (async () => - ({ ok: true, json: async () => ({ features: [{ properties: props }] }) }) as unknown as Response) as unknown as typeof fetch + ({ + ok: true, + json: async () => ({ features: [{ properties: props }] }), + }) as unknown as Response) as unknown as typeof fetch } describe("makePhotonReverseGeocode", () => { @@ -70,7 +78,10 @@ describe("makePhotonReverseGeocode", () => { it("returns null when Photon has no features", async () => { const fetchImpl = (async () => - ({ ok: true, json: async () => ({ features: [] }) }) as unknown as Response) as unknown as typeof fetch + ({ + ok: true, + json: async () => ({ features: [] }), + }) as unknown as Response) as unknown as typeof fetch expect(await makePhotonReverseGeocode({ fetchImpl })(1, 2)).toBeNull() }) @@ -96,7 +107,10 @@ describe("makePhotonReverseGeocode", () => { const PIN = { lat: 34.05, lng: -118.25 } /** A feature at an offset in metres roughly north of the pin. */ -function featureAt(props: Record, northMeters: number): { +function featureAt( + props: Record, + northMeters: number, +): { properties: Record geometry: { coordinates: [number, number] } } { @@ -116,12 +130,18 @@ describe("distanceMeters", () => { describe("isResidentialName", () => { it("flags a building or a place=house, which can carry an occupant name", () => { - expect(isResidentialName({ osm_key: "building", osm_value: "residential", name: "The Smiths" })).toBe(true) - expect(isResidentialName({ osm_key: "place", osm_value: "house", name: "Rose Cottage" })).toBe(true) + expect( + isResidentialName({ osm_key: "building", osm_value: "residential", name: "The Smiths" }), + ).toBe(true) + expect(isResidentialName({ osm_key: "place", osm_value: "house", name: "Rose Cottage" })).toBe( + true, + ) }) it("does not flag a public amenity", () => { - expect(isResidentialName({ osm_key: "leisure", osm_value: "park", name: "Vista Hermosa Park" })).toBe(false) + expect( + isResidentialName({ osm_key: "leisure", osm_value: "park", name: "Vista Hermosa Park" }), + ).toBe(false) }) }) @@ -131,7 +151,13 @@ describe("composePhotonReverse ladder", () => { [ featureAt({ osm_key: "leisure", name: "Some Park", city: "Los Angeles", state: "CA" }, 5), featureAt( - { housenumber: "123", street: "Main St", city: "Inglewood", state: "CA", osm_key: "place" }, + { + housenumber: "123", + street: "Main St", + city: "Inglewood", + state: "CA", + osm_key: "place", + }, 12, ), ], @@ -148,7 +174,10 @@ describe("composePhotonReverse ladder", () => { ], PIN, ) - expect(composed).toEqual({ line: "Main St & 5th Ave, Inglewood, CA", precision: "intersection" }) + expect(composed).toEqual({ + line: "Main St & 5th Ave, Inglewood, CA", + precision: "intersection", + }) }) it("intersection: one road alone degrades to the bare street, never an invented number", () => { diff --git a/services/api/test/unit/room-read-service.test.ts b/services/api/test/unit/room-read-service.test.ts index deb09d49..85338bdb 100644 --- a/services/api/test/unit/room-read-service.test.ts +++ b/services/api/test/unit/room-read-service.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from "vitest" import { makeMarkRoomRead } from "../../src/services/room-read-service.js" import type { ConversationBellKind } from "../../src/services/conversation-bell.js" - const ROOM = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" const USER = "11111111-1111-1111-1111-111111111111" const AT = new Date("2026-06-01T12:00:00.000Z") diff --git a/services/api/test/unit/route-coverage.test.ts b/services/api/test/unit/route-coverage.test.ts index d95c2bca..2ccd5218 100644 --- a/services/api/test/unit/route-coverage.test.ts +++ b/services/api/test/unit/route-coverage.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect, beforeAll, afterAll } from "vitest" import { randomUUID } from "node:crypto" import type { FastifyInstance, InjectOptions } from "fastify" diff --git a/services/api/test/unit/route-geo-helpers.test.ts b/services/api/test/unit/route-geo-helpers.test.ts index 9d59b7ed..a73a8d03 100644 --- a/services/api/test/unit/route-geo-helpers.test.ts +++ b/services/api/test/unit/route-geo-helpers.test.ts @@ -64,9 +64,18 @@ describe("makeRouteJurisdictionService", () => { const container = fakeContainer(lookup) // Each call rebuilds the service exactly as a request handler does. - await makeRouteJurisdictionService(container, { cacheLookup: true }).resolveForPoint(34.05, -118.25) - await makeRouteJurisdictionService(container, { cacheLookup: true }).resolveForPoint(34.05, -118.25) - await makeRouteJurisdictionService(container, { cacheLookup: true }).resolveForPoint(34.05, -118.25) + await makeRouteJurisdictionService(container, { cacheLookup: true }).resolveForPoint( + 34.05, + -118.25, + ) + await makeRouteJurisdictionService(container, { cacheLookup: true }).resolveForPoint( + 34.05, + -118.25, + ) + await makeRouteJurisdictionService(container, { cacheLookup: true }).resolveForPoint( + 34.05, + -118.25, + ) expect(lookup.calls).toHaveLength(1) }) @@ -95,14 +104,12 @@ describe("makeRouteJurisdictionService", () => { const lookupA = countingLookup() const lookupB = countingLookup() - await makeRouteJurisdictionService(fakeContainer(lookupA), { cacheLookup: true }).resolveForPoint( - 34.05, - -118.25, - ) - await makeRouteJurisdictionService(fakeContainer(lookupB), { cacheLookup: true }).resolveForPoint( - 34.05, - -118.25, - ) + await makeRouteJurisdictionService(fakeContainer(lookupA), { + cacheLookup: true, + }).resolveForPoint(34.05, -118.25) + await makeRouteJurisdictionService(fakeContainer(lookupB), { + cacheLookup: true, + }).resolveForPoint(34.05, -118.25) // The second container asked its OWN lookup rather than reading the first one's answer. expect(lookupA.calls).toHaveLength(1) @@ -113,8 +120,14 @@ describe("makeRouteJurisdictionService", () => { const statements: string[] = [] const container = fakeContainer(countingLookup(), statements) - await makeRouteJurisdictionService(container, { cacheLookup: true }).resolveForPoint(34.05, -118.25) - await makeRouteJurisdictionService(container, { cacheLookup: true }).resolveForPoint(34.05, -118.25) + await makeRouteJurisdictionService(container, { cacheLookup: true }).resolveForPoint( + 34.05, + -118.25, + ) + await makeRouteJurisdictionService(container, { cacheLookup: true }).resolveForPoint( + 34.05, + -118.25, + ) expect(statements).not.toHaveLength(0) for (const text of statements) { diff --git a/services/api/test/unit/server-timeouts.test.ts b/services/api/test/unit/server-timeouts.test.ts index 9c011ad7..08d67175 100644 --- a/services/api/test/unit/server-timeouts.test.ts +++ b/services/api/test/unit/server-timeouts.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { buildServer } from "../../src/server.js" diff --git a/services/api/test/unit/slur-filter.test.ts b/services/api/test/unit/slur-filter.test.ts index 40470bcf..03918e6b 100644 --- a/services/api/test/unit/slur-filter.test.ts +++ b/services/api/test/unit/slur-filter.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from "vitest" import { assertNoSlur, containsSlur } from "../../src/abuse/slur-filter.js" diff --git a/services/api/test/unit/sms-twilio.test.ts b/services/api/test/unit/sms-twilio.test.ts index 4ce60c9c..fe1c1c4a 100644 --- a/services/api/test/unit/sms-twilio.test.ts +++ b/services/api/test/unit/sms-twilio.test.ts @@ -14,9 +14,10 @@ function jsonResponse(status: number, body: unknown): Response { }) } -function senderWith( - respond: (recorded: Recorded) => Response | Promise, -): { sender: TwilioSmsSender; calls: Recorded[] } { +function senderWith(respond: (recorded: Recorded) => Response | Promise): { + sender: TwilioSmsSender + calls: Recorded[] +} { const calls: Recorded[] = [] const fetchImpl = ((url: string, init: RequestInit) => { const recorded = { url, init } @@ -34,16 +35,16 @@ function senderWith( describe("TwilioSmsSender", () => { it("posts a form-encoded message with basic auth and returns the provider message id", async () => { - const { sender, calls } = senderWith(() => jsonResponse(201, { sid: "SM123", status: "queued" })) + const { sender, calls } = senderWith(() => + jsonResponse(201, { sid: "SM123", status: "queued" }), + ) await expect(sender.send("+15552223333", "your code is 424242")).resolves.toEqual({ id: "SM123", }) const call = calls[0] - expect(call?.url).toBe( - "https://api.twilio.com/2010-04-01/Accounts/AC0123456789/Messages.json", - ) + expect(call?.url).toBe("https://api.twilio.com/2010-04-01/Accounts/AC0123456789/Messages.json") expect(call?.init.method).toBe("POST") const headers = call?.init.headers as Record const expected = Buffer.from("AC0123456789:super-secret-token", "utf8").toString("base64") diff --git a/services/api/test/unit/social-mentions.test.ts b/services/api/test/unit/social-mentions.test.ts index fdcbde2a..e9bab173 100644 --- a/services/api/test/unit/social-mentions.test.ts +++ b/services/api/test/unit/social-mentions.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from "vitest" import type { Sql } from "../../src/db/client.js" import { diff --git a/services/api/test/unit/social-routes.test.ts b/services/api/test/unit/social-routes.test.ts index 1d99c5fe..b923ee30 100644 --- a/services/api/test/unit/social-routes.test.ts +++ b/services/api/test/unit/social-routes.test.ts @@ -11,7 +11,6 @@ import { InMemorySocialRepository, makeCleanupRecord } from "../helpers/social.j import type { SocialServiceOverrides } from "../../src/routes/social.routes.js" import type { SocialNotifier, PersonView } from "../../src/services/social-service.js" - class SpyNotifier implements SocialNotifier { readonly calls: Array<{ followeeId: string; follower: PersonView }> = [] onNewFollower(args: { followeeId: string; follower: PersonView }): Promise { @@ -108,7 +107,11 @@ describe("GET /people", () => { }) const noQ = await app.inject({ method: "GET", url: "/v1/people", headers: auth(token) }) expect(noQ.statusCode).toBe(422) - const blankQ = await app.inject({ method: "GET", url: "/v1/people?q=%20", headers: auth(token) }) + const blankQ = await app.inject({ + method: "GET", + url: "/v1/people?q=%20", + headers: auth(token), + }) expect(blankQ.statusCode).toBe(422) }) @@ -124,13 +127,21 @@ describe("GET /people", () => { expect(ids).not.toContain(userId) expect(body.items[0].avatar).toHaveLength(2) - const miss = await app.inject({ method: "GET", url: "/v1/people?q=nobody", headers: auth(token) }) + const miss = await app.inject({ + method: "GET", + url: "/v1/people?q=nobody", + headers: auth(token), + }) expect(miss.json().items).toEqual([]) }) it("422s a bad limit", async () => { const { app, token } = await makeHarness() - const res = await app.inject({ method: "GET", url: "/v1/people?q=zel&limit=999", headers: auth(token) }) + const res = await app.inject({ + method: "GET", + url: "/v1/people?q=zel&limit=999", + headers: auth(token), + }) expect(res.statusCode).toBe(422) }) }) @@ -216,7 +227,11 @@ describe("GET /people/:id (profile)", () => { repo.seedUser({ id: OTHER, displayName: "Pro" }) }) await app.inject({ method: "POST", url: `/v1/people/${OTHER}/follow`, headers: auth(token) }) - const res = await app.inject({ method: "GET", url: `/v1/people/${OTHER}`, headers: auth(token) }) + const res = await app.inject({ + method: "GET", + url: `/v1/people/${OTHER}`, + headers: auth(token), + }) expect(res.json().profile.isFollowing).toBe(true) }) diff --git a/services/api/test/unit/social-service.test.ts b/services/api/test/unit/social-service.test.ts index ce7f5876..66c01d36 100644 --- a/services/api/test/unit/social-service.test.ts +++ b/services/api/test/unit/social-service.test.ts @@ -17,11 +17,7 @@ import { type PersonRowSelect, } from "../../src/services/social-repository.drizzle.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" -import { - InMemorySocialRepository, - makeCleanupRecord, -} from "../helpers/social.js" - +import { InMemorySocialRepository, makeCleanupRecord } from "../helpers/social.js" const A = "11111111-1111-1111-1111-111111111111" const B = "22222222-2222-2222-2222-222222222222" @@ -48,7 +44,6 @@ function makeHarness(): { return { repo, notifier, service } } - describe("avatarGradient", () => { it("is deterministic: same seed yields the same pair across calls", () => { const first = avatarGradient(A) @@ -134,7 +129,6 @@ describe("toPersonDTO", () => { }) }) - describe("listPeople", () => { it("excludes the viewer and soft-deleted users", async () => { const { repo, service } = makeHarness() @@ -248,7 +242,6 @@ describe("listPeople", () => { }) }) - describe("followPerson", () => { it("follows, is idempotent, and returns the new follower count", async () => { const { repo, service } = makeHarness() @@ -362,7 +355,6 @@ describe("unfollowPerson", () => { }) }) - describe("getProfile", () => { it("returns followers/following, isFollowing, stats, and recent-first pastEvents", async () => { const { repo, service } = makeHarness() @@ -1288,7 +1280,9 @@ describe("profile events block gate", () => { const { repo, service } = makeBlockedHarness([{ blocker: A, blocked: B }]) repo.seedUser({ id: A, displayName: "Alice" }) repo.seedUser({ id: B, displayName: "Bob" }) - repo.seedCleanup(makeCleanupRecord({ organizerUserId: B, title: "Past", scheduledAt: pastAt(3) })) + repo.seedCleanup( + makeCleanupRecord({ organizerUserId: B, title: "Past", scheduledAt: pastAt(3) }), + ) const page = await service.listProfileEvents(B, { userId: A }, {}) expect(page).toEqual({ items: [], nextCursor: null }) }) diff --git a/services/api/test/unit/social-suggest-sql.test.ts b/services/api/test/unit/social-suggest-sql.test.ts index 33cf52f7..b9a57fd7 100644 --- a/services/api/test/unit/social-suggest-sql.test.ts +++ b/services/api/test/unit/social-suggest-sql.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect } from "vitest" import { makeFakeSql } from "../helpers/fake-sql.js" import { diff --git a/services/api/test/unit/storage-local.test.ts b/services/api/test/unit/storage-local.test.ts index 963f6592ab72a453ca5bdfb9f475e1eafbdaba61..942d00d2547ba04bf0f46f1decf1c79aabe99677 100644 GIT binary patch delta 117 zcmZ2keXx4NA$gXf)Wnp@54GeczmXR+*3>IX%}Px!Db_2=_f0HG&hSqHaW$&B6u=-k zKPA;lLCMw6-_7FF0h&2Sg%WJwF<$$Ta% delta 81 zcmX?Dy|Q}4A^FMma_lTcsfj6*?`z3Vekw1[] = [] diff --git a/services/api/test/unit/thread-read-routes.test.ts b/services/api/test/unit/thread-read-routes.test.ts index ad35f49b..4e822a46 100644 --- a/services/api/test/unit/thread-read-routes.test.ts +++ b/services/api/test/unit/thread-read-routes.test.ts @@ -22,7 +22,6 @@ import type { ConversationMutesRepository } from "../../src/services/conversatio import type { ReportChatRepository } from "../../src/services/report-chat-repository.drizzle.js" import type { ChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" - const ROOM = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" const OTHER = "99999999-9999-9999-9999-999999999999" @@ -253,7 +252,10 @@ describe("PUT /threads/read — participation gate (L9)", () => { 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" }) - const src = readFileSync(new URL("../../src/routes/conversations.routes.ts", import.meta.url), "utf8") + const src = readFileSync( + new URL("../../src/routes/conversations.routes.ts", import.meta.url), + "utf8", + ) const readRoute = src.slice(src.indexOf('route(\n app,\n "markThreadRead"')) expect(readRoute.slice(0, 260)).toContain("preHandler: csrfProtect") expect(readRoute.slice(0, 260)).toContain("rateLimit: THREAD_READ_RATE_LIMIT") diff --git a/services/api/test/unit/threads-report.test.ts b/services/api/test/unit/threads-report.test.ts index 4a0eb662..544f5e16 100644 --- a/services/api/test/unit/threads-report.test.ts +++ b/services/api/test/unit/threads-report.test.ts @@ -8,7 +8,6 @@ import { } from "../../src/services/threads-service.js" import { InMemoryThreadsRepository } from "../helpers/chat.js" - const ME = "11111111-1111-1111-1111-111111111111" const OTHER = "22222222-2222-2222-2222-222222222222" const REPORT_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" @@ -31,9 +30,7 @@ function mutesSource(muted: Array<{ roomKind: "cleanup" | "dm" | "report"; roomI source: { mutedRoomIdsFor: (_userId, roomKind, roomIds) => { calls.push({ roomKind, roomIds }) - return Promise.resolve( - new Set(roomIds.filter((id) => set.has(`${roomKind}:${id}`))), - ) + return Promise.resolve(new Set(roomIds.filter((id) => set.has(`${roomKind}:${id}`)))) }, }, } diff --git a/services/api/test/unit/threads.test.ts b/services/api/test/unit/threads.test.ts index 7249db5e..b16b5e93 100644 --- a/services/api/test/unit/threads.test.ts +++ b/services/api/test/unit/threads.test.ts @@ -1,12 +1,8 @@ import { describe, it, expect } from "vitest" import { relativeAgo } from "@civfix/shared" -import { - makeThreadsService, - InMemoryChatReadState, -} from "../../src/services/threads-service.js" +import { makeThreadsService, InMemoryChatReadState } from "../../src/services/threads-service.js" import { InMemoryThreadsRepository } from "../helpers/chat.js" - const ME = "11111111-1111-1111-1111-111111111111" const OTHER = "22222222-2222-2222-2222-222222222222" @@ -29,8 +25,16 @@ describe("threads service", () => { repo.addMember(c1, ME, new Date("2026-06-01T10:00:00.000Z")) repo.addMember(c1, OTHER, new Date("2026-06-01T09:00:00.000Z")) - repo.addMessage(c1, { senderId: OTHER, body: "hi all", createdAt: new Date("2026-06-01T11:00:00.000Z") }) - repo.addMessage(c1, { senderId: OTHER, body: "see you there", createdAt: new Date("2026-06-01T11:30:00.000Z") }) + repo.addMessage(c1, { + senderId: OTHER, + body: "hi all", + createdAt: new Date("2026-06-01T11:00:00.000Z"), + }) + repo.addMessage(c1, { + senderId: OTHER, + body: "see you there", + createdAt: new Date("2026-06-01T11:30:00.000Z"), + }) const svc = makeThreadsService({ repo, readState: new InMemoryChatReadState(), now: () => NOW }) const { items } = await svc.listThreads(ME) @@ -51,8 +55,16 @@ describe("threads service", () => { const repo = new InMemoryThreadsRepository() const c1 = repo.seedCleanup("Park cleanup") repo.addMember(c1, ME, new Date("2026-06-01T10:00:00.000Z")) - repo.addMessage(c1, { senderId: OTHER, body: "yo", createdAt: new Date("2026-06-01T10:30:00.000Z") }) - repo.addMessage(c1, { senderId: ME, body: "on my way", createdAt: new Date("2026-06-01T11:00:00.000Z") }) + repo.addMessage(c1, { + senderId: OTHER, + body: "yo", + createdAt: new Date("2026-06-01T10:30:00.000Z"), + }) + repo.addMessage(c1, { + senderId: ME, + body: "on my way", + createdAt: new Date("2026-06-01T11:00:00.000Z"), + }) const svc = makeThreadsService({ repo, readState: new InMemoryChatReadState(), now: () => NOW }) const { items } = await svc.listThreads(ME) @@ -66,8 +78,16 @@ describe("threads service", () => { const repo = new InMemoryThreadsRepository() const c1 = repo.seedCleanup("Trail day") repo.addMember(c1, ME, new Date("2026-06-01T08:00:00.000Z")) - repo.addMessage(c1, { senderId: OTHER, body: "old", createdAt: new Date("2026-06-01T09:00:00.000Z") }) - repo.addMessage(c1, { senderId: OTHER, body: "new", createdAt: new Date("2026-06-01T11:00:00.000Z") }) + repo.addMessage(c1, { + senderId: OTHER, + body: "old", + createdAt: new Date("2026-06-01T09:00:00.000Z"), + }) + repo.addMessage(c1, { + senderId: OTHER, + body: "new", + createdAt: new Date("2026-06-01T11:00:00.000Z"), + }) const readState = new InMemoryChatReadState() await readState.markRead(c1, ME, new Date("2026-06-01T10:00:00.000Z")) @@ -109,8 +129,16 @@ describe("threads service", () => { const b = repo.seedCleanup("B-newer-activity") repo.addMember(a, ME, new Date("2026-06-01T08:00:00.000Z")) repo.addMember(b, ME, new Date("2026-06-01T08:00:00.000Z")) - repo.addMessage(a, { senderId: OTHER, body: "old", createdAt: new Date("2026-06-01T09:00:00.000Z") }) - repo.addMessage(b, { senderId: OTHER, body: "new", createdAt: new Date("2026-06-01T11:00:00.000Z") }) + repo.addMessage(a, { + senderId: OTHER, + body: "old", + createdAt: new Date("2026-06-01T09:00:00.000Z"), + }) + repo.addMessage(b, { + senderId: OTHER, + body: "new", + createdAt: new Date("2026-06-01T11:00:00.000Z"), + }) const svc = makeThreadsService({ repo, readState: new InMemoryChatReadState(), now: () => NOW }) const { items } = await svc.listThreads(ME) diff --git a/services/api/test/unit/trust-proxy.test.ts b/services/api/test/unit/trust-proxy.test.ts index a4538aa3..8aa744d1 100644 --- a/services/api/test/unit/trust-proxy.test.ts +++ b/services/api/test/unit/trust-proxy.test.ts @@ -12,8 +12,6 @@ import type { AnonSubmitResult, } from "../../src/services/anon-service.js" - - describe("parseTrustProxy", () => { it("defaults to the internal loopback+private CIDR set (NOT trust-all) when unset or blank", () => { expect(parseTrustProxy(undefined)).toEqual([...DEFAULT_TRUSTED_PROXY_CIDRS]) @@ -60,7 +58,6 @@ describe("parseTrustProxy", () => { }) }) - describe("Fastify request.ip under TRUST_PROXY default", () => { let app: FastifyInstance | undefined @@ -102,7 +99,6 @@ describe("Fastify request.ip under TRUST_PROXY default", () => { }) }) - describe("anon submit abuse key is the real client IP (not a spoofed XFF)", () => { let app: FastifyInstance | undefined diff --git a/services/api/test/unit/user-channel-gateway.test.ts b/services/api/test/unit/user-channel-gateway.test.ts index 7328e2ea..fe3eeb94 100644 --- a/services/api/test/unit/user-channel-gateway.test.ts +++ b/services/api/test/unit/user-channel-gateway.test.ts @@ -105,7 +105,11 @@ describe("subscribeUserChannel (per-socket lifecycle)", () => { await channel.publishToUser(ALICE, { topic: "threads", id: ROOM }) expect(a1.framesOfType("signal")).toHaveLength(1) expect(a2.framesOfType("signal")).toHaveLength(1) - expect(a1.framesOfType("signal")[0]).toMatchObject({ type: "signal", topic: "threads", id: ROOM }) + expect(a1.framesOfType("signal")[0]).toMatchObject({ + type: "signal", + topic: "threads", + id: ROOM, + }) // Close the first device; the second still gets later signals. await dispose1!() @@ -139,7 +143,13 @@ describe("send fires a {topic:'threads'} signal to recipients (sender excluded b userChannel: channel, threadRecipientsOf, } - return { userId, conn, joined: new Set(), typingThrottle: new Map(), deps } + return { + userId, + conn, + joined: new Set(), + typingThrottle: new Map(), + deps, + } } it("publishes a threads signal to the resolved recipients on a cleanup send", async () => { @@ -300,10 +310,9 @@ describe("registerChatGateway (subscribe-on-handshake / unsubscribe-on-close wir * A mock FastifyInstance whose `app.get("/ws", opts, handler)` captures the gateway handler so a test can * invoke it directly with a mock socket. registerChatGateway calls only app.get. */ - function captureGatewayHandler(opts: Parameters[1]): ( - socket: WebSocket, - request: FastifyRequest, - ) => void { + function captureGatewayHandler( + opts: Parameters[1], + ): (socket: WebSocket, request: FastifyRequest) => void { let captured: ((socket: WebSocket, request: FastifyRequest) => void) | undefined const app = { get( diff --git a/services/api/test/unit/user-channel-redis.test.ts b/services/api/test/unit/user-channel-redis.test.ts index a36cfe0f..95781a76 100644 --- a/services/api/test/unit/user-channel-redis.test.ts +++ b/services/api/test/unit/user-channel-redis.test.ts @@ -4,7 +4,6 @@ import { InMemoryChatPubSub, type ChatPubSub } from "../../src/adapters/chat-pub import { MockConnection } from "../helpers/chat.js" import { WsServerMessageSchema } from "@civfix/shared" - const ALICE = "11111111-1111-1111-1111-111111111111" const BOB = "22222222-2222-2222-2222-222222222222" @@ -50,7 +49,10 @@ describe("RedisUserChannel over a shared in-memory pub/sub", () => { await channel.subscribeUser(ALICE, aConn) await channel.subscribeUser(BOB, bConn) - await channel.publishToUser(ALICE, { topic: "threads", id: "cccccccc-cccc-cccc-cccc-cccccccccccc" }) + await channel.publishToUser(ALICE, { + topic: "threads", + id: "cccccccc-cccc-cccc-cccc-cccccccccccc", + }) expect(aConn.framesOfType("signal")).toHaveLength(1) expect(aConn.framesOfType("signal")[0]).toMatchObject({ diff --git a/services/api/test/unit/users-routes.test.ts b/services/api/test/unit/users-routes.test.ts index 15664ab4..a972cf07 100644 --- a/services/api/test/unit/users-routes.test.ts +++ b/services/api/test/unit/users-routes.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect, afterEach } from "vitest" import type { FastifyInstance } from "fastify" import { FakeMailer } from "@civfix/shared/fakes" @@ -172,7 +171,11 @@ describe("POST /users/:id/block", () => { it("422s a non-uuid :id before any store lookup", async () => { const h = await makeHarness() const me = await h.signIn("badid@example.com", "BadId") - const res = await h.app.inject({ method: "POST", url: blockUrl("not-a-uuid"), headers: bearer(me) }) + const res = await h.app.inject({ + method: "POST", + url: blockUrl("not-a-uuid"), + headers: bearer(me), + }) expect(res.statusCode).toBe(422) expect(res.json().fields.id).toBeDefined() }) @@ -180,11 +183,16 @@ describe("POST /users/:id/block", () => { it("404s a user who does not exist", async () => { const h = await makeHarness() const me = await h.signIn("ghost@example.com", "Ghost") - const res = await h.app.inject({ method: "POST", url: blockUrl(UNKNOWN_ID), headers: bearer(me) }) + const res = await h.app.inject({ + method: "POST", + url: blockUrl(UNKNOWN_ID), + headers: bearer(me), + }) expect(res.statusCode).toBe(404) expect(res.json().code).toBe("NOT_FOUND") - expect((await h.app.inject({ method: "GET", url: "/v1/me/blocks", headers: bearer(me) })).json()) - .toEqual({ blocked: [] }) + expect( + (await h.app.inject({ method: "GET", url: "/v1/me/blocks", headers: bearer(me) })).json(), + ).toEqual({ blocked: [] }) }) it("CVX-033: a user who ALREADY BLOCKED you answers exactly like an unknown user, writing no edge", async () => { @@ -211,8 +219,9 @@ describe("POST /users/:id/block", () => { expect(blockedByThem.statusCode).toBe(unknown.statusCode) expect(body(blockedByThem)).toEqual(body(unknown)) expect((await h.blocks.blockState(me.userId, them.userId)).blockedByViewer).toBe(false) - expect((await h.app.inject({ method: "GET", url: "/v1/me/blocks", headers: bearer(me) })).json()) - .toEqual({ blocked: [] }) + expect( + (await h.app.inject({ method: "GET", url: "/v1/me/blocks", headers: bearer(me) })).json(), + ).toEqual({ blocked: [] }) }) it("still 200s a target the VIEWER blocked (their own edge is idempotent, not an oracle)", async () => { @@ -254,7 +263,11 @@ describe("POST /users/:id/block", () => { const gone = await h.signIn("gone@example.com", "Gone") await h.stores.users.softDeleteAndAnonymize(gone.userId) - const res = await h.app.inject({ method: "POST", url: blockUrl(gone.userId), headers: bearer(me) }) + const res = await h.app.inject({ + method: "POST", + url: blockUrl(gone.userId), + headers: bearer(me), + }) expect(res.statusCode).toBe(404) }) @@ -327,10 +340,17 @@ describe("DELETE /users/:id/block", () => { await h.blocks.block(web.userId, them.userId) expect( - (await h.app.inject({ method: "DELETE", url: blockUrl("nope"), headers: { cookie: web.cookie, "x-csrf-token": web.csrfToken } })) - .statusCode, + ( + await h.app.inject({ + method: "DELETE", + url: blockUrl("nope"), + headers: { cookie: web.cookie, "x-csrf-token": web.csrfToken }, + }) + ).statusCode, ).toBe(422) - expect((await h.app.inject({ method: "DELETE", url: blockUrl(them.userId) })).statusCode).toBe(401) + expect((await h.app.inject({ method: "DELETE", url: blockUrl(them.userId) })).statusCode).toBe( + 401, + ) const noCsrf = await h.app.inject({ method: "DELETE", @@ -349,8 +369,9 @@ describe("GET /me/blocks", () => { const one = await h.signIn("one@example.com", "One") const two = await h.signIn("two@example.com", "Two") - expect((await h.app.inject({ method: "GET", url: "/v1/me/blocks", headers: bearer(me) })).json()) - .toEqual({ blocked: [] }) + expect( + (await h.app.inject({ method: "GET", url: "/v1/me/blocks", headers: bearer(me) })).json(), + ).toEqual({ blocked: [] }) await h.app.inject({ method: "POST", url: blockUrl(one.userId), headers: bearer(me) }) await h.app.inject({ method: "POST", url: blockUrl(two.userId), headers: bearer(me) }) @@ -374,7 +395,11 @@ describe("GET /me/blocks", () => { const bob = await h.signIn("bob@example.com", "Bob") await h.app.inject({ method: "POST", url: blockUrl(alice.userId), headers: bearer(bob) }) - const bobList = await h.app.inject({ method: "GET", url: "/v1/me/blocks", headers: bearer(bob) }) + const bobList = await h.app.inject({ + method: "GET", + url: "/v1/me/blocks", + headers: bearer(bob), + }) expect((bobList.json().blocked as PersonDTO[]).map((p) => p.id)).toEqual([alice.userId]) const aliceList = await h.app.inject({ @@ -426,7 +451,12 @@ describe("block SIDE EFFECT: the DM lane closes both ways", () => { const bob = await h.signIn("dm-b@example.com", "DmB") const open = async (from: Session, to: Session) => - h.app.inject({ method: "POST", url: "/v1/dm", headers: bearer(from), payload: { userId: to.userId } }) + h.app.inject({ + method: "POST", + url: "/v1/dm", + headers: bearer(from), + payload: { userId: to.userId }, + }) const before = await open(alice, bob) expect(before.statusCode).toBe(200) @@ -489,12 +519,14 @@ describe("PUT /me/settings", () => { const stranger = await h.signIn("stranger@example.com", "Stranger") expect( - (await h.app.inject({ - method: "POST", - url: "/v1/dm", - headers: bearer(keen), - payload: { userId: shy.userId }, - })).statusCode, + ( + await h.app.inject({ + method: "POST", + url: "/v1/dm", + headers: bearer(keen), + payload: { userId: shy.userId }, + }) + ).statusCode, ).toBe(200) await h.app.inject({ diff --git a/services/api/test/unit/volunteer-hours-entries.test.ts b/services/api/test/unit/volunteer-hours-entries.test.ts index c8266509..e019ec79 100644 --- a/services/api/test/unit/volunteer-hours-entries.test.ts +++ b/services/api/test/unit/volunteer-hours-entries.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect } from "vitest" import { makeVolunteerHoursService, @@ -47,7 +46,8 @@ function makeCleanups( load: () => Promise.resolve(view), listMemberIds: () => Promise.resolve(members), roleOf: (_cleanupId: string, userId: string) => { - if (view !== null && view.organizerUserId === userId) return Promise.resolve("organizer" as const) + if (view !== null && view.organizerUserId === userId) + return Promise.resolve("organizer" as const) if (cohosts.includes(userId)) return Promise.resolve("cohost" as const) if (members.includes(userId)) return Promise.resolve("member" as const) return Promise.resolve(null) @@ -361,7 +361,6 @@ describe("hours ledger: the public projection (C18's two gates)", () => { expect(ownStillVisible.items.map((e) => e.source)).toEqual(["event", "event"]) }) - it("isSelf bypasses both gates even when the owner has opted OUT", async () => { const repo = makeRepo() await seedBob(repo) @@ -458,9 +457,7 @@ describe("hours ledger: getEventHours scope matrix (C10)", () => { ) const res = await service.getEventHours(EVENT, CAROL) expect(res.scope).toBe("self") - expect(res.entries).toEqual([ - { userId: CAROL, hours: 1.5, loggedAt: res.entries[0]!.loggedAt }, - ]) + expect(res.entries).toEqual([{ userId: CAROL, hours: 1.5, loggedAt: res.entries[0]!.loggedAt }]) expect(res.anyLogged).toBe(true) }) diff --git a/services/api/test/unit/volunteer-hours-service.test.ts b/services/api/test/unit/volunteer-hours-service.test.ts index 56209465..2e2a6ec4 100644 --- a/services/api/test/unit/volunteer-hours-service.test.ts +++ b/services/api/test/unit/volunteer-hours-service.test.ts @@ -42,7 +42,8 @@ function makeCleanups( load: () => Promise.resolve(view), listMemberIds: () => Promise.resolve(members), roleOf: (_cleanupId: string, userId: string) => { - if (view !== null && view.organizerUserId === userId) return Promise.resolve("organizer" as const) + if (view !== null && view.organizerUserId === userId) + return Promise.resolve("organizer" as const) if (cohosts.includes(userId)) return Promise.resolve("cohost" as const) if (members.includes(userId)) return Promise.resolve("member" as const) return Promise.resolve(null) @@ -270,8 +271,16 @@ describe("volunteer hours: logEventHours (service gating + crediting)", () => { const repo = new InMemoryVolunteerHoursRepository() const service = makeService({ repo, view: doneEvent, members: [HOST, BOB, CAROL] }) - await service.logEventHours({ cleanupId: CLEANUP, actorId: HOST, entries: flat([CAROL, BOB], 2) }) - await service.logEventHours({ cleanupId: CLEANUP, actorId: HOST, entries: [{ userId: BOB, hours: 3 }] }) + await service.logEventHours({ + cleanupId: CLEANUP, + actorId: HOST, + entries: flat([CAROL, BOB], 2), + }) + await service.logEventHours({ + cleanupId: CLEANUP, + actorId: HOST, + entries: [{ userId: BOB, hours: 3 }], + }) expect((await repo.totalsFor(BOB)).totalHours).toBe(3) expect((await repo.totalsFor(CAROL)).totalHours).toBe(2) @@ -368,14 +377,24 @@ describe("volunteer hours: logEventHours (service gating + crediting)", () => { const repo = new InMemoryVolunteerHoursRepository() const service = makeService({ repo, view: doneEvent, members: [HOST, BOB] }) await expect( - service.logEventHours({ cleanupId: CLEANUP, actorId: HOST, entries: [{ userId: BOB, hours: 0.001 }] }), + service.logEventHours({ + cleanupId: CLEANUP, + actorId: HOST, + entries: [{ userId: BOB, hours: 0.001 }], + }), ).rejects.toMatchObject({ code: "VALIDATION" }) await service.logEventHours({ cleanupId: CLEANUP, actorId: HOST, entries: [{ userId: BOB, hours: 3.14159 }], }) - const page = await repo.entriesForCertificate({ userId: BOB, geoid: null, from: null, to: null, limit: 10 }) + const page = await repo.entriesForCertificate({ + userId: BOB, + geoid: null, + from: null, + to: null, + limit: 10, + }) expect(page.items[0]?.hours).toBe(3.14) }) @@ -1187,7 +1206,12 @@ describe("#110: hours grouped by the organization that hosted the event", () => it("hides a soft-deleted or suspended organization without changing the total", async () => { const repo = new InMemoryVolunteerHoursRepository() repo.seedOrganization({ id: ORG_A, slug: "coast-guard", name: "Coast Guard", deleted: true }) - repo.seedOrganization({ id: ORG_B, slug: "river-keepers", name: "River Keepers", suspended: true }) + repo.seedOrganization({ + id: ORG_B, + slug: "river-keepers", + name: "River Keepers", + suspended: true, + }) repo.seedCleanup(CLEANUP_A1, { title: "Sweep", referenceCode: null, diff --git a/services/api/test/unit/ws-security-hardening.test.ts b/services/api/test/unit/ws-security-hardening.test.ts index 05c9b41a..9e2a2cd3 100644 --- a/services/api/test/unit/ws-security-hardening.test.ts +++ b/services/api/test/unit/ws-security-hardening.test.ts @@ -17,7 +17,6 @@ import RedisMock from "ioredis-mock" import type { RedisClient } from "../../src/adapters/redis.js" import { InMemoryChatRepository, MockConnection } from "../helpers/chat.js" - const ROOM = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" const OTHER_ROOM = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" const ALICE = "11111111-1111-1111-1111-111111111111" @@ -28,9 +27,19 @@ const allMembers = (): Promise => Promise.resolve(true) let chat: WsChatService let presence: InMemoryChatPresence -function sessionFor(userId: string, conn: MockConnection, over: Partial = {}): GatewaySession { +function sessionFor( + userId: string, + conn: MockConnection, + over: Partial = {}, +): GatewaySession { const deps: GatewayDeps = { chat, isMember: allMembers, presence, ...over } - return { userId, conn, joined: new Set(), typingThrottle: new Map(), deps } + return { + userId, + conn, + joined: new Set(), + typingThrottle: new Map(), + deps, + } } beforeEach(() => { @@ -63,7 +72,10 @@ describe("H6: the leave frame is gated on what THIS socket joined", () => { markRead: () => Promise.resolve(), }, }) - await handleClientFrame(victim, JSON.stringify({ type: "join", roomKind: "dm", cleanupId: ROOM })) + await handleClientFrame( + victim, + JSON.stringify({ type: "join", roomKind: "dm", cleanupId: ROOM }), + ) victimConn.sent.length = 0 const attackerConn = new MockConnection("attacker") @@ -349,7 +361,10 @@ describe("F041: ack does nothing for a room the socket never joined", () => { const conn = new MockConnection("a") const markRead = vi.fn(() => Promise.resolve()) const session = sessionFor(ALICE, conn, { markRead }) - await handleClientFrame(session, JSON.stringify({ type: "ack", cleanupId: ROOM, upToId: UP_TO })) + await handleClientFrame( + session, + JSON.stringify({ type: "ack", cleanupId: ROOM, upToId: UP_TO }), + ) expect(markRead).not.toHaveBeenCalled() }) @@ -358,7 +373,10 @@ describe("F041: ack does nothing for a room the socket never joined", () => { const markRead = vi.fn(() => Promise.resolve()) const session = sessionFor(ALICE, conn, { markRead }) await handleClientFrame(session, JSON.stringify({ type: "join", cleanupId: ROOM })) - await handleClientFrame(session, JSON.stringify({ type: "ack", cleanupId: ROOM, upToId: UP_TO })) + await handleClientFrame( + session, + JSON.stringify({ type: "ack", cleanupId: ROOM, upToId: UP_TO }), + ) expect(markRead).toHaveBeenCalledTimes(1) }) }) @@ -373,7 +391,9 @@ describe("F022: live sockets are re-authorized against room membership", () => { expect(await canStillRead(session, key)).toBe(true) member = false expect(await canStillRead(session, key)).toBe(false) - const throwing = sessionFor(ALICE, conn, { isMember: () => Promise.reject(new Error("redis down")) }) + const throwing = sessionFor(ALICE, conn, { + isMember: () => Promise.reject(new Error("redis down")), + }) expect(await canStillRead(throwing, key)).toBe(true) }) @@ -448,7 +468,11 @@ describe("H4: a suspended account is read-only on the socket", () => { await handleClientFrame(session, JSON.stringify({ type: "typing", cleanupId: ROOM })) await handleClientFrame( session, - JSON.stringify({ type: "ack", cleanupId: ROOM, upToId: "00000000-0000-0000-0000-000000000001" }), + JSON.stringify({ + type: "ack", + cleanupId: ROOM, + upToId: "00000000-0000-0000-0000-000000000001", + }), ) expect(conn.framesOfType("error")).toHaveLength(0) }) diff --git a/services/api/test/unit/ws-socket-lifecycle.test.ts b/services/api/test/unit/ws-socket-lifecycle.test.ts index 7ed1fcee..e8a77397 100644 --- a/services/api/test/unit/ws-socket-lifecycle.test.ts +++ b/services/api/test/unit/ws-socket-lifecycle.test.ts @@ -30,7 +30,6 @@ import { } from "../../src/ws/types.js" import { SESSION_COOKIE } from "../../src/auth/transport.js" - const WS_OPEN = 1 const WS_CLOSED = 3 @@ -418,15 +417,15 @@ describe("socket close DURING an in-flight join", () => { joinRoom: (roomKey: string, conn: unknown, userId: string) => new Promise((resolve) => { gate.release = () => - resolve(chat.joinRoom(roomKey, conn as Parameters[1], userId)) + resolve( + chat.joinRoom(roomKey, conn as Parameters[1], userId), + ) }), leaveRoom: (roomKey: string, conn: unknown) => chat.leaveRoom(roomKey, conn as Parameters[1]), persist: (input: unknown) => chat.persist(input as Parameters[0]), - history: (...args: unknown[]) => - (chat.history as (...a: unknown[]) => unknown)(...args), - broadcast: (...args: unknown[]) => - (chat.broadcast as (...a: unknown[]) => unknown)(...args), + history: (...args: unknown[]) => (chat.history as (...a: unknown[]) => unknown)(...args), + broadcast: (...args: unknown[]) => (chat.broadcast as (...a: unknown[]) => unknown)(...args), broadcastEvent: (...args: unknown[]) => (chat.broadcastEvent as (...a: unknown[]) => unknown)(...args), } as unknown as GatewayOptions["chat"] @@ -663,7 +662,12 @@ describe("send limiter routes each room kind to its own bucket", () => { it("F042: an UNAUTHORIZED report send creates no bucket key (authorization gates first)", async () => { const socket = await sendFrame( { type: "send", roomKind: "report", cleanupId: ROOM, clientId: "c1", body: "hi" }, - { reportChat: { isMember: () => Promise.resolve(false), advanceReadWatermark: () => Promise.resolve() } }, + { + reportChat: { + isMember: () => Promise.resolve(false), + advanceReadWatermark: () => Promise.resolve(), + }, + }, ) expect(consumed).toEqual([]) expect(socket.framesOfType("error")[0]).toMatchObject({ code: "FORBIDDEN" }) @@ -708,15 +712,15 @@ describe("F038: post-handshake frames are dispatched in wire order, one at a tim persist: (input: { body?: string }) => { if (input.body === "first") { return new Promise((resolve) => { - gate.release = () => - resolve((chat.persist as (i: unknown) => unknown)(input)) + gate.release = () => resolve((chat.persist as (i: unknown) => unknown)(input)) }) } return (chat.persist as (i: unknown) => unknown)(input) }, history: (...a: unknown[]) => (chat.history as (...x: unknown[]) => unknown)(...a), broadcast: (...a: unknown[]) => (chat.broadcast as (...x: unknown[]) => unknown)(...a), - broadcastEvent: (...a: unknown[]) => (chat.broadcastEvent as (...x: unknown[]) => unknown)(...a), + broadcastEvent: (...a: unknown[]) => + (chat.broadcastEvent as (...x: unknown[]) => unknown)(...a), } as unknown as GatewayOptions["chat"] } @@ -727,8 +731,14 @@ describe("F038: post-handshake frames are dispatched in wire order, one at a tim handler(socket as unknown as WebSocket, authedRequest(ALICE)) await flush() - socket.emit("message", JSON.stringify({ type: "send", cleanupId: ROOM, clientId: "c1", body: "first" })) - socket.emit("message", JSON.stringify({ type: "send", cleanupId: ROOM, clientId: "c2", body: "second" })) + socket.emit( + "message", + JSON.stringify({ type: "send", cleanupId: ROOM, clientId: "c1", body: "first" }), + ) + socket.emit( + "message", + JSON.stringify({ type: "send", cleanupId: ROOM, clientId: "c2", body: "second" }), + ) await flush() expect(gate.release).toBeTypeOf("function") diff --git a/services/api/test/unit/ws-upgrade-rate-limit.test.ts b/services/api/test/unit/ws-upgrade-rate-limit.test.ts index 8e7caab1..0ab5805f 100644 --- a/services/api/test/unit/ws-upgrade-rate-limit.test.ts +++ b/services/api/test/unit/ws-upgrade-rate-limit.test.ts @@ -55,7 +55,8 @@ describe("/ws upgrade rate limit", () => { it("keys the upgrade bucket per IP under its own namespace", async () => { app = await buildApp() const blocked = async (ip: string) => { - for (let i = 0; i < 60; i++) await app!.inject({ method: "GET", url: "/ws", remoteAddress: ip }) + for (let i = 0; i < 60; i++) + await app!.inject({ method: "GET", url: "/ws", remoteAddress: ip }) return app!.inject({ method: "GET", url: "/ws", remoteAddress: ip }) } expect((await blocked("203.0.113.9")).statusCode).toBe(429) diff --git a/services/media-worker/src/config.ts b/services/media-worker/src/config.ts index c74decbc..5afda1d7 100644 --- a/services/media-worker/src/config.ts +++ b/services/media-worker/src/config.ts @@ -1,4 +1,3 @@ - import { dirname, join } from "node:path" import { MAX_VIDEO_BYTES } from "@civfix/shared" @@ -121,7 +120,8 @@ export function loadLimits(source: NodeJS.ProcessEnv = process.env): WorkerLimit maxVideoBitrateBps: parsePosInt(source.MEDIA_VIDEO_MAX_BITRATE, 50_000_000), thumbnailMaxEdge: parsePosInt(source.MEDIA_THUMBNAIL_MAX_EDGE, 400), nsfwHoldThreshold: clampUnit(source.MEDIA_NSFW_HOLD_THRESHOLD, 0.8), - nsfwUnscoredPolicy: source.MEDIA_UNSCORED_POLICY?.trim().toLowerCase() === "hold" ? "hold" : "flag", + nsfwUnscoredPolicy: + source.MEDIA_UNSCORED_POLICY?.trim().toLowerCase() === "hold" ? "hold" : "flag", mediaChecksConcurrency: parsePosInt(source.MEDIA_CHECKS_CONCURRENCY, 2), orphanTtlMs: parsePosInt(source.MEDIA_ORPHAN_TTL_MS, 6 * 60 * 60 * 1000), orphanSweepBatch: parsePosInt(source.MEDIA_ORPHAN_SWEEP_BATCH, 1000), diff --git a/services/media-worker/src/download.ts b/services/media-worker/src/download.ts index 78bee0f7..0d60fb46 100644 --- a/services/media-worker/src/download.ts +++ b/services/media-worker/src/download.ts @@ -1,4 +1,3 @@ - import type { Storage } from "@civfix/shared/interfaces" import { normalizeEtag, readEtag } from "@civfix/api/media-repo" import { loadHttpsProxy } from "./config.js" diff --git a/services/media-worker/src/jobs.ts b/services/media-worker/src/jobs.ts index 8ee2820f..8e120209 100644 --- a/services/media-worker/src/jobs.ts +++ b/services/media-worker/src/jobs.ts @@ -260,7 +260,8 @@ export class PgBossWorkerJobs implements WorkerJobs { // pg-boss's batch-level fail retries the batch rather than losing the outcome silently. Jobs // already marked complete are unaffected (failJobsById only matches state < 'completed'). const broken = settled.find((r): r is PromiseRejectedResult => r.status === "rejected") - if (broken) throw broken.reason instanceof Error ? broken.reason : new Error(String(broken.reason)) + if (broken) + throw broken.reason instanceof Error ? broken.reason : new Error(String(broken.reason)) }) } @@ -268,19 +269,25 @@ export class PgBossWorkerJobs implements WorkerJobs { // The shared Jobs.complete is name-agnostic; pg-boss v10 needs the queue name. workWithSettings // completes each delivered job itself (where the queue name IS known), so nothing on the worker calls // this. Log if it ever is (a silent no-op would mask a misuse) rather than guessing a queue name. - console.warn("PgBossWorkerJobs.complete is unsupported on the worker (the work loop completes jobs)", { - jobId, - }) + console.warn( + "PgBossWorkerJobs.complete is unsupported on the worker (the work loop completes jobs)", + { + jobId, + }, + ) return Promise.resolve() } async fail(jobId: string, err?: unknown): Promise { // Likewise unsupported for want of a queue name: a silent no-op would discard both the job AND its // error. Handlers signal failure by throwing; the work loop fails that job by id. - console.warn("PgBossWorkerJobs.fail is unsupported on the worker (throw to fail a job instead)", { - jobId, - err: err === undefined ? undefined : String(err), - }) + console.warn( + "PgBossWorkerJobs.fail is unsupported on the worker (throw to fail a job instead)", + { + jobId, + err: err === undefined ? undefined : String(err), + }, + ) return Promise.resolve() } } diff --git a/services/media-worker/src/jobs/hold-release-sweep.ts b/services/media-worker/src/jobs/hold-release-sweep.ts index 8c5f1468..638f35e6 100644 --- a/services/media-worker/src/jobs/hold-release-sweep.ts +++ b/services/media-worker/src/jobs/hold-release-sweep.ts @@ -23,10 +23,7 @@ */ import type { AbuseChecks } from "@civfix/shared/interfaces" -import { - releaseAnonHoldIfReady, - type AnonHoldReleaseRepo, -} from "@civfix/api/anon-hold-release" +import { releaseAnonHoldIfReady, type AnonHoldReleaseRepo } from "@civfix/api/anon-hold-release" import { resolveJobObs, type JobObsDeps } from "./obs.js" export interface HoldReleaseSweepDeps extends JobObsDeps { diff --git a/services/media-worker/src/jobs/media-checks.ts b/services/media-worker/src/jobs/media-checks.ts index d1539437..3e2f4526 100644 --- a/services/media-worker/src/jobs/media-checks.ts +++ b/services/media-worker/src/jobs/media-checks.ts @@ -1,11 +1,6 @@ - import type { MediaKind, MediaStatus } from "@civfix/shared" import type { AbuseChecks, Storage, StorageHead } from "@civfix/shared/interfaces" -import type { - MediaResultPatch, - MediaWorkerAsset, - MediaWorkerRepo, -} from "@civfix/api/media-repo" +import type { MediaResultPatch, MediaWorkerAsset, MediaWorkerRepo } from "@civfix/api/media-repo" import type { FindPhashDuplicateFn } from "@civfix/api/adapters/abuse-checks" import type { WorkerLimits } from "../config.js" import { DownloadTooLargeError, type DownloadedObject, type DownloadFn } from "../download.js" @@ -15,11 +10,7 @@ import { readEtag } from "@civfix/api/media-repo" import { SandboxSpawnError } from "../sandbox/exec.js" import { servedKey, thumbnailKey } from "./media-keys.js" import { deleteRejectedObjects, deleteSupersededUpload } from "./reject-cleanup.js" -import { - processMedia, - errNote, - type MediaProcessResult, -} from "./media-pipeline.js" +import { processMedia, errNote, type MediaProcessResult } from "./media-pipeline.js" export * from "./media-pipeline.js" diff --git a/services/media-worker/src/jobs/media-keys.ts b/services/media-worker/src/jobs/media-keys.ts index a8a7d30d..760db49d 100644 --- a/services/media-worker/src/jobs/media-keys.ts +++ b/services/media-worker/src/jobs/media-keys.ts @@ -1,4 +1,3 @@ - export function thumbnailKey(r2Key: string): string { return `thumbs/${r2Key}.jpg` } diff --git a/services/media-worker/src/jobs/media-pipeline.ts b/services/media-worker/src/jobs/media-pipeline.ts index 8726fa44..691e88be 100644 --- a/services/media-worker/src/jobs/media-pipeline.ts +++ b/services/media-worker/src/jobs/media-pipeline.ts @@ -1,4 +1,3 @@ - import type { AbuseChecks } from "@civfix/shared/interfaces" import type { MediaKind, MediaStatus } from "@civfix/shared" import type { WorkerAbuseReason } from "@civfix/api/media-repo" diff --git a/services/media-worker/src/jobs/orphan-sweep.ts b/services/media-worker/src/jobs/orphan-sweep.ts index b278bcdb..cdf10f18 100644 --- a/services/media-worker/src/jobs/orphan-sweep.ts +++ b/services/media-worker/src/jobs/orphan-sweep.ts @@ -1,4 +1,3 @@ - import type { Storage } from "@civfix/shared/interfaces" import type { LeakedObjectRow, MediaWorkerRepo, OrphanRow } from "@civfix/api/media-repo" import type { WorkerLimits } from "../config.js" @@ -7,7 +6,11 @@ import { resolveJobObs, type JobObsDeps, type JobLogFn, type JobReportFn } from import { R2_PUT_TTL_SEC } from "@civfix/api/adapters/storage" import { servedKey, thumbnailKey } from "./media-keys.js" -async function mapWithLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { +async function mapWithLimit( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { let cursor = 0 const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { while (cursor < items.length) { @@ -256,7 +259,11 @@ async function sweepPage( }) } -async function deleteObjects(o: OrphanRow, deps: OrphanSweepDeps, log: JobLogFn): Promise { +async function deleteObjects( + o: OrphanRow, + deps: OrphanSweepDeps, + log: JobLogFn, +): Promise { const outcomes = await Promise.all( derivedKeys(o).map(async (key) => { try { diff --git a/services/media-worker/src/jobs/partition-maintenance.ts b/services/media-worker/src/jobs/partition-maintenance.ts index f17525fc..50a8c35b 100644 --- a/services/media-worker/src/jobs/partition-maintenance.ts +++ b/services/media-worker/src/jobs/partition-maintenance.ts @@ -1,4 +1,3 @@ - import { ensureChatPartitionWindow, ensureDmPartitionWindow } from "@civfix/api/media-repo" import type { Sql } from "@civfix/api/db" import { resolveJobObs, type JobObsDeps } from "./obs.js" diff --git a/services/media-worker/src/jobs/reject-cleanup.ts b/services/media-worker/src/jobs/reject-cleanup.ts index 60582f34..989b0934 100644 --- a/services/media-worker/src/jobs/reject-cleanup.ts +++ b/services/media-worker/src/jobs/reject-cleanup.ts @@ -54,10 +54,12 @@ export async function deleteSupersededUpload( err: String(err), }), ) - report( - new Error("media.checks leaked the superseded upload object (tombstoned for retry)"), - { job: "media.checks", phase: "upload-cleanup", mediaId: asset.id, key: asset.r2Key }, - ) + report(new Error("media.checks leaked the superseded upload object (tombstoned for retry)"), { + job: "media.checks", + phase: "upload-cleanup", + mediaId: asset.id, + key: asset.r2Key, + }) } export async function deleteRejectedObjects( @@ -94,7 +96,11 @@ export async function deleteRejectedObjects( if (leaked.length === 0) return await deps.repo - .recordLeakedObjects?.({ mediaId: asset.id, keys: leaked, error: "rejected-media delete failed" }) + .recordLeakedObjects?.({ + mediaId: asset.id, + keys: leaked, + error: "rejected-media delete failed", + }) .catch((err: unknown) => log("media.checks: rejected-media tombstone write failed (leak unrecoverable)", { mediaId: asset.id, @@ -103,7 +109,9 @@ export async function deleteRejectedObjects( }), ) report( - new Error(`media.checks leaked ${leaked.length} rejected-media R2 object(s) (tombstoned for retry)`), + new Error( + `media.checks leaked ${leaked.length} rejected-media R2 object(s) (tombstoned for retry)`, + ), { job: "media.checks", phase: "reject-cleanup", mediaId: asset.id, keys: leaked }, ) } diff --git a/services/media-worker/src/jobs/retention-sweep.ts b/services/media-worker/src/jobs/retention-sweep.ts index 1a6fcf86..e49b9491 100644 --- a/services/media-worker/src/jobs/retention-sweep.ts +++ b/services/media-worker/src/jobs/retention-sweep.ts @@ -1,4 +1,3 @@ - import type { Storage } from "@civfix/shared/interfaces" import type { Sql } from "@civfix/api/db" import { @@ -218,7 +217,8 @@ export async function runInboundEmailRetentionLane( let stalled = false try { await drainPages( - (limit) => (stalled ? Promise.resolve([]) : repo.findArchivedBefore({ before: opts.before, limit })), + (limit) => + stalled ? Promise.resolve([]) : repo.findArchivedBefore({ before: opts.before, limit }), async (rows) => { const reaped: string[] = [] for (const row of rows) { diff --git a/services/media-worker/src/jobs/upload-reap.ts b/services/media-worker/src/jobs/upload-reap.ts index 254d3755..f474229f 100644 --- a/services/media-worker/src/jobs/upload-reap.ts +++ b/services/media-worker/src/jobs/upload-reap.ts @@ -1,4 +1,3 @@ - import type { Storage } from "@civfix/shared/interfaces" import type { MediaWorkerRepo } from "@civfix/api/media-repo" import { R2_PUT_TTL_SEC } from "@civfix/api/adapters/storage" @@ -116,7 +115,11 @@ export async function runUploadReapJob( } } -function uploadKeyIsDead(asset: { status: string; r2Key: string; servedKey: string | null }): boolean { +function uploadKeyIsDead(asset: { + status: string + r2Key: string + servedKey: string | null +}): boolean { if (asset.status === "rejected") return true return asset.servedKey !== null && asset.servedKey !== asset.r2Key } diff --git a/services/media-worker/src/sandbox/binaries.ts b/services/media-worker/src/sandbox/binaries.ts index 606c9f3a..c40cffc0 100644 --- a/services/media-worker/src/sandbox/binaries.ts +++ b/services/media-worker/src/sandbox/binaries.ts @@ -1,4 +1,3 @@ - import { accessSync, constants } from "node:fs" export type MediaTool = "ffmpeg" | "ffprobe" diff --git a/services/media-worker/src/sandbox/exec.ts b/services/media-worker/src/sandbox/exec.ts index d7f9754f..7f42cd7f 100644 --- a/services/media-worker/src/sandbox/exec.ts +++ b/services/media-worker/src/sandbox/exec.ts @@ -1,4 +1,3 @@ - import { dirname } from "node:path" import { tmpdir } from "node:os" import { execa, type Options as ExecaOptions } from "execa" diff --git a/services/media-worker/src/sandbox/ffmpeg-remux.ts b/services/media-worker/src/sandbox/ffmpeg-remux.ts index e645416c..c6a8caee 100644 --- a/services/media-worker/src/sandbox/ffmpeg-remux.ts +++ b/services/media-worker/src/sandbox/ffmpeg-remux.ts @@ -1,4 +1,3 @@ - import { runTool, sandboxIdentity } from "./exec.js" import { mediaToolPath } from "./binaries.js" import { makeScratch, readScratchOutput } from "./tmp.js" diff --git a/services/media-worker/src/sandbox/ffprobe.ts b/services/media-worker/src/sandbox/ffprobe.ts index d58d3f25..04e292dc 100644 --- a/services/media-worker/src/sandbox/ffprobe.ts +++ b/services/media-worker/src/sandbox/ffprobe.ts @@ -1,4 +1,3 @@ - import { runTool, SandboxToolError } from "./exec.js" import { mediaToolPath } from "./binaries.js" import { makeScratch } from "./tmp.js" diff --git a/services/media-worker/src/sandbox/image-lane-main.ts b/services/media-worker/src/sandbox/image-lane-main.ts index 5efae585..6fcf1654 100644 --- a/services/media-worker/src/sandbox/image-lane-main.ts +++ b/services/media-worker/src/sandbox/image-lane-main.ts @@ -1,4 +1,3 @@ - import { readFile, writeFile } from "node:fs/promises" import { join } from "node:path" import type { WorkerLimits } from "../config.js" @@ -40,7 +39,8 @@ export function requestArg(argv: string[]): string | undefined { export function parseRequest(raw: string | undefined): ImageLaneRequest { if (raw === undefined) throw new Error("image-lane: missing request argument") const parsed: unknown = JSON.parse(raw) - if (typeof parsed !== "object" || parsed === null) throw new Error("image-lane: request is not an object") + if (typeof parsed !== "object" || parsed === null) + throw new Error("image-lane: request is not an object") const req = parsed as Record if (typeof req.inputPath !== "string" || typeof req.outDir !== "string") { throw new Error("image-lane: request is missing inputPath/outDir") diff --git a/services/media-worker/src/sandbox/image-lane.ts b/services/media-worker/src/sandbox/image-lane.ts index ddabb355..6ad46a16 100644 --- a/services/media-worker/src/sandbox/image-lane.ts +++ b/services/media-worker/src/sandbox/image-lane.ts @@ -1,4 +1,3 @@ - import { existsSync } from "node:fs" import { z } from "zod" import { loadImageLaneEntry, type WorkerLimits } from "../config.js" @@ -85,7 +84,9 @@ export async function processImageLane( } const parsed = envelopeSchema(limits).safeParse(raw) if (!parsed.success) { - throw new ImageProcessingError(`image lane returned an invalid result: ${parsed.error.message}`) + throw new ImageProcessingError( + `image lane returned an invalid result: ${parsed.error.message}`, + ) } if (!parsed.data.ok) throw new ImageProcessingError(parsed.data.error) @@ -108,10 +109,7 @@ export async function processImageLane( } } -async function processInProcess( - bytes: Uint8Array, - limits: WorkerLimits, -): Promise { +async function processInProcess(bytes: Uint8Array, limits: WorkerLimits): Promise { const processed = await processImage(bytes, limits) let phash: string | null = null try { diff --git a/services/media-worker/src/sandbox/preflight.ts b/services/media-worker/src/sandbox/preflight.ts index a3ad13d2..1e70d8b0 100644 --- a/services/media-worker/src/sandbox/preflight.ts +++ b/services/media-worker/src/sandbox/preflight.ts @@ -1,4 +1,3 @@ - import { existsSync } from "node:fs" import { access, constants } from "node:fs/promises" import type { SandboxIdentity } from "../config.js" @@ -113,7 +112,9 @@ export async function assertSandboxPreflight( const tools = await resolveMediaToolPaths(source) const identity = sandboxIdentity(source) if (identity === null) { - throw new Error("media-worker: MEDIA_SANDBOX_UID / MEDIA_SANDBOX_GID are required in production") + throw new Error( + "media-worker: MEDIA_SANDBOX_UID / MEDIA_SANDBOX_GID are required in production", + ) } await assertScratchHandover(identity) @@ -121,7 +122,9 @@ export async function assertSandboxPreflight( await assertVideoLaneRuns(tools.ffprobe) if (process.platform !== "linux") { - log("media-worker: sandbox capability proof skipped (not Linux)", { platform: process.platform }) + log("media-worker: sandbox capability proof skipped (not Linux)", { + platform: process.platform, + }) return } diff --git a/services/media-worker/src/sandbox/tmp.ts b/services/media-worker/src/sandbox/tmp.ts index def31f64..01ab448a 100644 --- a/services/media-worker/src/sandbox/tmp.ts +++ b/services/media-worker/src/sandbox/tmp.ts @@ -1,5 +1,14 @@ - -import { constants, open, chmod, chown, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises" +import { + constants, + open, + chmod, + chown, + mkdtemp, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { sandboxIdentity } from "./exec.js" diff --git a/services/media-worker/src/seams.ts b/services/media-worker/src/seams.ts index 02579c4a..d9159398 100644 --- a/services/media-worker/src/seams.ts +++ b/services/media-worker/src/seams.ts @@ -1,4 +1,3 @@ - import { FakeStorage, FakeAbuseChecks } from "@civfix/shared/fakes" import type { AbuseChecks, NearDuplicateResult, Storage } from "@civfix/shared/interfaces" import type { FindPhashDuplicateFn } from "@civfix/api/adapters/abuse-checks" @@ -7,10 +6,7 @@ import { makeDrizzleMediaWorkerRepo, type MediaWorkerRepo } from "@civfix/api/me import { makeDrizzleAnonHoldReleaseRepo } from "@civfix/api/anon-hold-repo" import type { AnonHoldReleaseRepo } from "@civfix/api/anon-hold-release" import { R2Storage } from "@civfix/api/adapters/storage" -import { - LOCAL_STORAGE_DEV_SIGNING_KEY, - LocalDiskStorage, -} from "@civfix/api/adapters/storage-local" +import { LOCAL_STORAGE_DEV_SIGNING_KEY, LocalDiskStorage } from "@civfix/api/adapters/storage-local" import { captureError, initErrorReporting, flushErrorReporting } from "@civfix/api/errors" import { assertRealSeamInProd, loadLimits, parseBool, type WorkerLimits } from "./config.js" import { makeDownloader, type DownloadFn } from "./download.js" diff --git a/services/media-worker/src/worker.ts b/services/media-worker/src/worker.ts index 99d66a45..8cc2392d 100644 --- a/services/media-worker/src/worker.ts +++ b/services/media-worker/src/worker.ts @@ -1,4 +1,3 @@ - import type { JobHandler } from "@civfix/shared/interfaces" import { MEDIA_CHECKS_JOB } from "@civfix/api/media-repo" import { releaseAnonHoldIfReady, type HeldReportView } from "@civfix/api/anon-hold-release" @@ -98,11 +97,7 @@ function makeMediaChecksHandler(jobs: WorkerJobs, seams: WorkerSeams): JobHandle outcome.reportId ?? (outcome.status === "missing" ? null : await findReportId(repo, payload)) if (reportId && (await shouldEnqueueHoldRelease(seams, reportId))) { - await jobs.enqueue( - ANON_HOLD_RELEASE_JOB, - { reportId }, - { singletonKey: reportId }, - ) + await jobs.enqueue(ANON_HOLD_RELEASE_JOB, { reportId }, { singletonKey: reportId }) } else { console.debug("media.checks: hold-release skipped (no row/reportId, or not anon-held)", { uploadId: payload.uploadId, @@ -179,7 +174,12 @@ function makeOrphanSweepHandler(seams: WorkerSeams): JobHandler { await sweepStaleScratchDirs().catch(() => 0) const repo = requireRepo(seams, ORPHAN_SWEEP_JOB, "repo") if (!repo) return - await runOrphanSweep({ repo, storage: seams.storage, limits: seams.limits, report: seams.report }) + await runOrphanSweep({ + repo, + storage: seams.storage, + limits: seams.limits, + report: seams.report, + }) } } @@ -257,7 +257,11 @@ async function registerHandlers( await jobs.createQueue(ANON_HOLD_RELEASE_SWEEP_JOB, { policy: "singleton" }) await jobs.createQueue(RETENTION_SWEEP_JOB, { policy: "singleton" }) await jobs.createQueue(MEDIA_STUCK_SWEEP_JOB, { policy: "singleton" }) - await jobs.createQueue(MEDIA_UPLOAD_REAP_JOB, { policy: "short", retryLimit: 3, retryBackoff: true }) + await jobs.createQueue(MEDIA_UPLOAD_REAP_JOB, { + policy: "short", + retryLimit: 3, + retryBackoff: true, + }) await jobs.workWithSettings(MEDIA_CHECKS_JOB, makeMediaChecksHandler(jobs, seams), { batchSize: limits.mediaChecksConcurrency, @@ -272,15 +276,30 @@ async function registerHandlers( await jobs.work(MEDIA_STUCK_SWEEP_JOB, makeStuckSweepHandler(jobs, seams)) await jobs.work(MEDIA_UPLOAD_REAP_JOB, makeUploadReapHandler(seams)) - await jobs.schedule(ORPHAN_SWEEP_JOB, ORPHAN_SWEEP_CRON, undefined, cronSchedule(ORPHAN_SWEEP_JOB)) - await jobs.schedule(CHAT_PARTITION_JOB, CHAT_PARTITION_CRON, undefined, cronSchedule(CHAT_PARTITION_JOB)) + await jobs.schedule( + ORPHAN_SWEEP_JOB, + ORPHAN_SWEEP_CRON, + undefined, + cronSchedule(ORPHAN_SWEEP_JOB), + ) + await jobs.schedule( + CHAT_PARTITION_JOB, + CHAT_PARTITION_CRON, + undefined, + cronSchedule(CHAT_PARTITION_JOB), + ) await jobs.schedule( ANON_HOLD_RELEASE_SWEEP_JOB, HOLD_RELEASE_SWEEP_CRON, undefined, cronSchedule(ANON_HOLD_RELEASE_SWEEP_JOB), ) - await jobs.schedule(RETENTION_SWEEP_JOB, RETENTION_SWEEP_CRON, undefined, cronSchedule(RETENTION_SWEEP_JOB)) + await jobs.schedule( + RETENTION_SWEEP_JOB, + RETENTION_SWEEP_CRON, + undefined, + cronSchedule(RETENTION_SWEEP_JOB), + ) await jobs.schedule( MEDIA_STUCK_SWEEP_JOB, MEDIA_STUCK_SWEEP_CRON, diff --git a/services/media-worker/test/fixtures/make.ts b/services/media-worker/test/fixtures/make.ts index 99a87d6c..47270abc 100644 --- a/services/media-worker/test/fixtures/make.ts +++ b/services/media-worker/test/fixtures/make.ts @@ -1,4 +1,3 @@ - import sharp from "sharp" import ffmpegPath from "ffmpeg-static" import { execa } from "execa" diff --git a/services/media-worker/test/helpers/ffprobe-tags.ts b/services/media-worker/test/helpers/ffprobe-tags.ts index 1c98b7ae..5bf01687 100644 --- a/services/media-worker/test/helpers/ffprobe-tags.ts +++ b/services/media-worker/test/helpers/ffprobe-tags.ts @@ -1,4 +1,3 @@ - import ffprobeStatic from "ffprobe-static" import { runTool } from "../../src/sandbox/exec.js" import { makeScratch } from "../../src/sandbox/tmp.js" diff --git a/services/media-worker/test/helpers/in-memory-repo.ts b/services/media-worker/test/helpers/in-memory-repo.ts index bb43db2b..bf25e8e2 100644 --- a/services/media-worker/test/helpers/in-memory-repo.ts +++ b/services/media-worker/test/helpers/in-memory-repo.ts @@ -1,4 +1,3 @@ - import type { LeakedObjectRow, LegacyServedKeyAdoption, 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 5ab78574..60fb7229 100644 --- a/services/media-worker/test/integration/media-checks-pg.test.ts +++ b/services/media-worker/test/integration/media-checks-pg.test.ts @@ -336,7 +336,9 @@ describe.skipIf(!pg)("worker media.checks (integration)", () => { const afterCommit = await repo.findStuckValidating(cutoff, 10) expect(afterCommit.map((r) => r.id)).not.toContain(id) expect(await repo.terminalizeStuck(id)).toBeNull() - const [row] = await h.sql<{ status: string }[]>`SELECT status FROM media_assets WHERE id = ${id}` + const [row] = await h.sql< + { status: string }[] + >`SELECT status FROM media_assets WHERE id = ${id}` expect(row!.status).toBe("ready") }) 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 ad752c33..2c9cd9ca 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 @@ -236,9 +236,7 @@ describe.skipIf(!pg)("retention.sweep against the real schema", () => { expect(points.map((r) => r.point_key)).not.toContain(staleGeocode) const emails = await h.sql<{ id: string }[]>`SELECT id FROM inbound_emails` - expect(emails.map((r) => r.id).sort()).toEqual( - [recentlyArchivedEmail, unarchivedEmail].sort(), - ) + expect(emails.map((r) => r.id).sort()).toEqual([recentlyArchivedEmail, unarchivedEmail].sort()) expect(emails.map((r) => r.id)).not.toContain(archivedEmail) expect(storage.get(attachmentKey)).toBeNull() }) @@ -348,7 +346,11 @@ describe.skipIf(!pg)("retention.sweep against the real schema", () => { it("never deletes more aged rows than the batch limit in a single page", async () => { const key = randomUUID() for (let i = 0; i < 5; i++) { - await insertIdempotencyKey(at(-72 * HOUR), { key, scope: `report.create.${i}`, owner: "user-a" }) + await insertIdempotencyKey(at(-72 * HOUR), { + key, + scope: `report.create.${i}`, + owner: "user-a", + }) } const res = await runRetentionSweep({ @@ -445,7 +447,11 @@ describe.skipIf(!pg)("phash near-duplicate lookup against the real media_assets" it("#43: a SIBLING in the same report is not a duplicate, but a third report still is", async () => { const shared = await insertReport() await insertMedia({ phash: HASH, reportId: shared, createdAt: "2026-06-02T00:00:00Z" }) - const sibling = await insertMedia({ phash: HASH, reportId: shared, createdAt: "2026-06-03T00:00:00Z" }) + const sibling = await insertMedia({ + phash: HASH, + reportId: shared, + createdAt: "2026-06-03T00:00:00Z", + }) await expect( lookup(HASH, { excludeAssetId: sibling, excludeReportId: shared }), diff --git a/services/media-worker/test/integration/served-key-and-orphan-pg.test.ts b/services/media-worker/test/integration/served-key-and-orphan-pg.test.ts index ba687d40..21cc7e9c 100644 --- a/services/media-worker/test/integration/served-key-and-orphan-pg.test.ts +++ b/services/media-worker/test/integration/served-key-and-orphan-pg.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { FakeStorage } from "@civfix/shared/fakes" diff --git a/services/media-worker/test/unit/anon-hold-release.test.ts b/services/media-worker/test/unit/anon-hold-release.test.ts index 4c2e487c..f8370591 100644 --- a/services/media-worker/test/unit/anon-hold-release.test.ts +++ b/services/media-worker/test/unit/anon-hold-release.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect } from "vitest" import { FakeStorage, FakeAbuseChecks } from "@civfix/shared/fakes" import type { LatLng } from "@civfix/shared" @@ -190,7 +189,14 @@ describe("hold-release self-healing sweep (P2-8)", () => { it("leaves a held report held when its media are NOT yet ready (re-checked next run)", async () => { const repo = new InMemoryWorkerRepo() const reportId = "anon-report-pending" - repo.seed({ id: "m1", uploadId: "u1", kind: "image", r2Key: "k1", reportId, status: "validating" }) + repo.seed({ + id: "m1", + uploadId: "u1", + kind: "image", + r2Key: "k1", + reportId, + status: "validating", + }) const holdRepo = new MemHoldRepo(heldReport(reportId), repo) const result = await runHoldReleaseSweep({ diff --git a/services/media-worker/test/unit/download.test.ts b/services/media-worker/test/unit/download.test.ts index 1a547fc8..650999e3 100644 --- a/services/media-worker/test/unit/download.test.ts +++ b/services/media-worker/test/unit/download.test.ts @@ -1,9 +1,12 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http" import type { AddressInfo } from "node:net" import type { Storage } from "@civfix/shared/interfaces" -import { makeDownloader, DownloadTooLargeError, StorageUnavailableError } from "../../src/download.js" +import { + makeDownloader, + DownloadTooLargeError, + StorageUnavailableError, +} from "../../src/download.js" type Route = (res: ServerResponse) => void diff --git a/services/media-worker/test/unit/egress-proxy.test.ts b/services/media-worker/test/unit/egress-proxy.test.ts index 8034e13f..5eb174ae 100644 --- a/services/media-worker/test/unit/egress-proxy.test.ts +++ b/services/media-worker/test/unit/egress-proxy.test.ts @@ -1,4 +1,3 @@ - import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type { Storage } from "@civfix/shared/interfaces" import { makeDownloader } from "../../src/download.js" diff --git a/services/media-worker/test/unit/exec-real-spawn.test.ts b/services/media-worker/test/unit/exec-real-spawn.test.ts index 6ad6cdae..8cfbea6c 100644 --- a/services/media-worker/test/unit/exec-real-spawn.test.ts +++ b/services/media-worker/test/unit/exec-real-spawn.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from "vitest" import { runTool, SandboxSpawnError, SandboxToolError } from "../../src/sandbox/exec.js" @@ -39,12 +38,9 @@ describe("runTool classification against real children", () => { }) it("a missing binary IS an infra fault", async () => { - const err = await runTool( - "missing", - "/nonexistent/civfix/decoder", - ["-version"], - OPTS, - ).catch((e: unknown) => e) + const err = await runTool("missing", "/nonexistent/civfix/decoder", ["-version"], OPTS).catch( + (e: unknown) => e, + ) expect(err).toBeInstanceOf(SandboxSpawnError) }) diff --git a/services/media-worker/test/unit/image-lane.test.ts b/services/media-worker/test/unit/image-lane.test.ts index e3d5cb69..e9913765 100644 --- a/services/media-worker/test/unit/image-lane.test.ts +++ b/services/media-worker/test/unit/image-lane.test.ts @@ -1,4 +1,3 @@ - import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { link, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises" import { fileURLToPath } from "node:url" @@ -119,12 +118,10 @@ describe("image-lane child protocol", () => { const inputPath = join(dir, "input.bin") await writeFile(inputPath, await fx.makeValidPng()) const written: string[] = [] - const spy = vi - .spyOn(process.stdout, "write") - .mockImplementation(((chunk: string) => { - written.push(String(chunk)) - return true - }) as typeof process.stdout.write) + const spy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: string) => { + written.push(String(chunk)) + return true + }) as typeof process.stdout.write) const errSpy = vi.spyOn(process.stderr, "write").mockImplementation((() => true) as never) const okCode = await laneMain.main([ @@ -229,10 +226,15 @@ describe("B3': the parent trusts nothing the child says", () => { it("refuses an envelope that tries to name its own output paths", async () => { withSandboxIdentity() runToolMock.mockImplementation( - childReturning({ strippedFile: "../../../proc/self/environ", thumbFile: "../../etc/hostname" }), + childReturning({ + strippedFile: "../../../proc/self/environ", + thumbFile: "../../etc/hostname", + }), ) - await expect(processImageLane(await fx.makeValidPng(), limits)).rejects.toThrow(/invalid result/) + await expect(processImageLane(await fx.makeValidPng(), limits)).rejects.toThrow( + /invalid result/, + ) }) it("reads the two FIXED names inside the scratch dir it created", async () => { @@ -249,7 +251,9 @@ describe("B3': the parent trusts nothing the child says", () => { withSandboxIdentity() runToolMock.mockImplementation(childReturning({ strippedContentType: "text/html" })) - await expect(processImageLane(await fx.makeValidPng(), limits)).rejects.toThrow(/invalid result/) + await expect(processImageLane(await fx.makeValidPng(), limits)).rejects.toThrow( + /invalid result/, + ) }) it("refuses NaN / non-positive / over-budget dimensions", async () => { @@ -271,10 +275,14 @@ describe("B3': the parent trusts nothing the child says", () => { it("refuses a malformed phash or a non-finite exifGps", async () => { withSandboxIdentity() runToolMock.mockImplementation(childReturning({ phash: "not-a-hash" })) - await expect(processImageLane(await fx.makeValidPng(), limits)).rejects.toThrow(/invalid result/) + await expect(processImageLane(await fx.makeValidPng(), limits)).rejects.toThrow( + /invalid result/, + ) runToolMock.mockImplementation(childReturning({ exifGps: { lat: "37", lng: 1 } })) - await expect(processImageLane(await fx.makeValidPng(), limits)).rejects.toThrow(/invalid result/) + await expect(processImageLane(await fx.makeValidPng(), limits)).rejects.toThrow( + /invalid result/, + ) }) it("refuses a SYMLINK at the fixed name (it never resolves the path twice)", async () => { @@ -303,15 +311,12 @@ describe("B3': the parent trusts nothing the child says", () => { }) try { - await expect(processImageLane(await fx.makeValidPng(), limits)).rejects.toThrow( - /hard-linked/, - ) + await expect(processImageLane(await fx.makeValidPng(), limits)).rejects.toThrow(/hard-linked/) } finally { await rm(outside, { force: true }) } }) - it("refuses when the child wrote no output at all", async () => { withSandboxIdentity() runToolMock.mockResolvedValue(envelope({})) diff --git a/services/media-worker/test/unit/maintenance.test.ts b/services/media-worker/test/unit/maintenance.test.ts index dfec4c19..5db7e7cf 100644 --- a/services/media-worker/test/unit/maintenance.test.ts +++ b/services/media-worker/test/unit/maintenance.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from "vitest" import { FakeStorage } from "@civfix/shared/fakes" import { loadLimits } from "../../src/config.js" @@ -127,7 +126,13 @@ describe("orphan.sweep", () => { } const paged = { ...limits, orphanSweepBatch: 10, orphanSweepMaxPages: 50 } - const res = await runOrphanSweep({ repo, storage, limits: paged, now: () => now, log: () => {} }) + const res = await runOrphanSweep({ + repo, + storage, + limits: paged, + now: () => now, + log: () => {}, + }) expect(res.deleted).toBe(total) expect(res.scanned).toBe(total) @@ -153,7 +158,13 @@ describe("orphan.sweep", () => { } const capped = { ...limits, orphanSweepBatch: 10, orphanSweepMaxPages: 2 } - const res = await runOrphanSweep({ repo, storage, limits: capped, now: () => now, log: () => {} }) + const res = await runOrphanSweep({ + repo, + storage, + limits: capped, + now: () => now, + log: () => {}, + }) expect(res.deleted).toBe(20) expect(repo.byId.size).toBe(10) @@ -438,9 +449,33 @@ describe("media.stuck.sweep", () => { it("re-enqueues media.checks for FINALIZED rows stuck at validating past the TTL (singletonKey = uploadId)", async () => { const repo = makeRepo() const fresh = new Date(now.getTime() - 60_000) - repo.seed({ id: "stuck-1", uploadId: "u1", kind: "image", r2Key: "uploads/s1", status: "validating", createdAt: old, finalizedAt: old }) - repo.seed({ id: "fresh-1", uploadId: "u2", kind: "image", r2Key: "uploads/s2", status: "validating", createdAt: fresh, finalizedAt: fresh }) - repo.seed({ id: "ready-1", uploadId: "u3", kind: "image", r2Key: "uploads/s3", status: "ready", createdAt: old, finalizedAt: old }) + repo.seed({ + id: "stuck-1", + uploadId: "u1", + kind: "image", + r2Key: "uploads/s1", + status: "validating", + createdAt: old, + finalizedAt: old, + }) + repo.seed({ + id: "fresh-1", + uploadId: "u2", + kind: "image", + r2Key: "uploads/s2", + status: "validating", + createdAt: fresh, + finalizedAt: fresh, + }) + repo.seed({ + id: "ready-1", + uploadId: "u3", + kind: "image", + r2Key: "uploads/s3", + status: "ready", + createdAt: old, + finalizedAt: old, + }) const { jobs, enqueued } = makeJobsSpy() const res = await run(repo, jobs) @@ -459,7 +494,15 @@ describe("media.stuck.sweep", () => { it("never throws: an enqueue failure is counted + reported, the sweep continues", async () => { const repo = makeRepo() - repo.seed({ id: "stuck-1", uploadId: "u1", kind: "image", r2Key: "uploads/s1", status: "validating", createdAt: old, finalizedAt: old }) + repo.seed({ + id: "stuck-1", + uploadId: "u1", + kind: "image", + r2Key: "uploads/s1", + status: "validating", + createdAt: old, + finalizedAt: old, + }) const reports: unknown[] = [] const { jobs } = makeJobsSpy(true) @@ -473,8 +516,24 @@ describe("media.stuck.sweep", () => { it("F087b: NEVER-FINALIZED rows are out of scope - a presigned upload whose bytes never arrived is the orphan sweep's job", async () => { const repo = makeRepo() - repo.seed({ id: "intent-1", uploadId: "u1", kind: "image", r2Key: "uploads/i1", status: "validating", createdAt: old, finalizedAt: null }) - repo.seed({ id: "stuck-1", uploadId: "u2", kind: "image", r2Key: "uploads/s1", status: "validating", createdAt: old, finalizedAt: old }) + repo.seed({ + id: "intent-1", + uploadId: "u1", + kind: "image", + r2Key: "uploads/i1", + status: "validating", + createdAt: old, + finalizedAt: null, + }) + repo.seed({ + id: "stuck-1", + uploadId: "u2", + kind: "image", + r2Key: "uploads/s1", + status: "validating", + createdAt: old, + finalizedAt: old, + }) const { jobs, enqueued } = makeJobsSpy() const res = await run(repo, jobs) @@ -486,8 +545,24 @@ describe("media.stuck.sweep", () => { it("F087d: staleness is measured from FINALIZE, not presign - a late-finalized asset gets a full TTL", async () => { const repo = makeRepo() - repo.seed({ id: "late-1", uploadId: "u1", kind: "image", r2Key: "uploads/l1", status: "validating", createdAt: old, finalizedAt: new Date(now.getTime() - 60_000) }) - repo.seed({ id: "stuck-1", uploadId: "u2", kind: "image", r2Key: "uploads/s1", status: "validating", createdAt: old, finalizedAt: old }) + repo.seed({ + id: "late-1", + uploadId: "u1", + kind: "image", + r2Key: "uploads/l1", + status: "validating", + createdAt: old, + finalizedAt: new Date(now.getTime() - 60_000), + }) + repo.seed({ + id: "stuck-1", + uploadId: "u2", + kind: "image", + r2Key: "uploads/s1", + status: "validating", + createdAt: old, + finalizedAt: old, + }) const { jobs, enqueued } = makeJobsSpy() const res = await run(repo, jobs) @@ -500,7 +575,16 @@ describe("media.stuck.sweep", () => { it("F087d: the give-up budget starts at finalize too - a late-finalized asset is never terminalized early", async () => { const repo = makeRepo() - repo.seed({ id: "late-1", uploadId: "u1", kind: "image", r2Key: "uploads/l1", status: "validating", createdAt: old, finalizedAt: new Date(now.getTime() - 60_000), stuckCheckCount: 9 }) + repo.seed({ + id: "late-1", + uploadId: "u1", + kind: "image", + r2Key: "uploads/l1", + status: "validating", + createdAt: old, + finalizedAt: new Date(now.getTime() - 60_000), + stuckCheckCount: 9, + }) const { jobs } = makeJobsSpy() const res = await run(repo, jobs, { stuckSweepMaxAttempts: 3 }) @@ -512,7 +596,15 @@ describe("media.stuck.sweep", () => { it("F087b: rotates - every pick is stamped, so an over-full batch serves never-checked rows first and cannot starve", async () => { const repo = makeRepo() for (const id of ["a", "b", "c"]) { - repo.seed({ id, uploadId: `up-${id}`, kind: "image", r2Key: `uploads/${id}`, status: "validating", createdAt: old, finalizedAt: old }) + repo.seed({ + id, + uploadId: `up-${id}`, + kind: "image", + r2Key: `uploads/${id}`, + status: "validating", + createdAt: old, + finalizedAt: old, + }) } repo.get("a")!.stuckCheckedAt = new Date(now.getTime() - 60_000) repo.get("b")!.stuckCheckedAt = new Date(now.getTime() - 120_000) @@ -529,7 +621,15 @@ describe("media.stuck.sweep", () => { it("F087b: terminalizes a hopeless row after the attempt cap - status 'rejected', no further enqueue, ever", async () => { const repo = makeRepo() - repo.seed({ id: "hopeless", uploadId: "u1", kind: "image", r2Key: "uploads/h1", status: "validating", createdAt: old, finalizedAt: old }) + repo.seed({ + id: "hopeless", + uploadId: "u1", + kind: "image", + r2Key: "uploads/h1", + status: "validating", + createdAt: old, + finalizedAt: old, + }) const { jobs, enqueued } = makeJobsSpy() for (let i = 0; i < 3; i++) { @@ -555,7 +655,16 @@ describe("media.stuck.sweep", () => { it("F087b: a terminalize failure is counted + reported, never thrown", async () => { const repo = makeRepo() - repo.seed({ id: "hopeless", uploadId: "u1", kind: "image", r2Key: "uploads/h1", status: "validating", createdAt: old, finalizedAt: old, stuckCheckCount: 9 }) + repo.seed({ + id: "hopeless", + uploadId: "u1", + kind: "image", + r2Key: "uploads/h1", + status: "validating", + createdAt: old, + finalizedAt: old, + stuckCheckCount: 9, + }) repo.failApplyResult = new Error("db down") const reports: unknown[] = [] @@ -567,7 +676,16 @@ describe("media.stuck.sweep", () => { }) 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", uploadId: "u1", kind: "image", r2Key: "uploads/r1", status: "validating", createdAt: old, finalizedAt: old, stuckCheckCount: 9 }) + repo.seed({ + id: "raced", + uploadId: "u1", + kind: "image", + r2Key: "uploads/r1", + status: "validating", + createdAt: old, + finalizedAt: old, + stuckCheckCount: 9, + }) const { jobs } = makeJobsSpy() await repo.applyResult("raced", { status: "ready" }) @@ -583,8 +701,16 @@ describe("media.stuck.sweep", () => { it("F087b: a terminalized row's bytes are reclaimed, exactly like an in-band rejection", async () => { const repo = makeRepo() repo.seed({ - id: "hopeless", uploadId: "u1", kind: "image", r2Key: "uploads/h1", thumbKey: "uploads/h1.thumb", - reportId: "report-1", status: "validating", createdAt: old, finalizedAt: old, stuckCheckCount: 9, + id: "hopeless", + uploadId: "u1", + kind: "image", + r2Key: "uploads/h1", + thumbKey: "uploads/h1.thumb", + reportId: "report-1", + status: "validating", + createdAt: old, + finalizedAt: old, + stuckCheckCount: 9, }) storage = new FakeStorage() await storage.put("uploads/h1", Buffer.from([1, 2, 3])) diff --git a/services/media-worker/test/unit/media-checks.test.ts b/services/media-worker/test/unit/media-checks.test.ts index 1226ff0e..62699e2c 100644 --- a/services/media-worker/test/unit/media-checks.test.ts +++ b/services/media-worker/test/unit/media-checks.test.ts @@ -1,4 +1,3 @@ - import { beforeEach, describe, expect, it } from "vitest" import { FakeStorage, FakeAbuseChecks } from "@civfix/shared/fakes" import { RealAbuseChecks } from "@civfix/api/adapters/abuse-checks" @@ -107,14 +106,17 @@ describe("media.checks IMAGE path", () => { const input = await fx.makeValidJpegWithGps() const { id, uploadId, r2Key } = await seedAsset(local.storage, local.repo, "image", input) - const status = await runMediaChecksJob({ mediaId: id, uploadId, r2Key, kind: "image" }, local.deps) + const status = await runMediaChecksJob( + { mediaId: id, uploadId, r2Key, kind: "image" }, + local.deps, + ) expect(status).toBe("ready") const ready = logged.find((l) => l.line === "media.checks: ready") expect(ready).toBeDefined() expect(ready!.extra.exifGpsPresent).toBe(true) const serialized = JSON.stringify(logged) - expect(serialized).not.toContain("exifGps\"") + expect(serialized).not.toContain('exifGps"') expect(serialized).not.toContain("latitude") expect(serialized).not.toContain("longitude") expect(serialized).not.toContain("37.76") @@ -189,7 +191,10 @@ describe("media.checks IMAGE path", () => { env.repo.seed({ id, uploadId, kind: "image", r2Key, reportId: "report-123" }) await env.storage.put(r2Key, Buffer.from(input), { contentType: "image/jpeg" }) - const status = await runMediaChecksJob({ mediaId: id, uploadId, r2Key, kind: "image" }, env.deps) + const status = await runMediaChecksJob( + { mediaId: id, uploadId, r2Key, kind: "image" }, + env.deps, + ) expect(status).toBe("held") expect(env.repo.moderationEnqueues).toHaveLength(1) expect(env.repo.moderationEnqueues[0]).toMatchObject({ @@ -207,7 +212,10 @@ describe("media.checks IMAGE path", () => { await env.storage.put(r2Key, Buffer.from(input), { contentType: "image/jpeg" }) env.repo.failModerationEnqueue = new Error("moderation insert down") - const status = await runMediaChecksJob({ mediaId: id, uploadId, r2Key, kind: "image" }, env.deps) + const status = await runMediaChecksJob( + { mediaId: id, uploadId, r2Key, kind: "image" }, + env.deps, + ) expect(status).toBe("held") expect(env.repo.get(id)!.status).toBe("held") }) @@ -506,7 +514,8 @@ describe("media.checks orchestration robustness", () => { const uploadId = "up-too-large" const r2Key = `uploads/2026/06/${id}` env.repo.seed({ id, uploadId, kind: "image", r2Key }) - const download: DownloadFn = () => Promise.reject(new DownloadTooLargeError(limits.maxDownloadBytes)) + const download: DownloadFn = () => + Promise.reject(new DownloadTooLargeError(limits.maxDownloadBytes)) const status = await runMediaChecksJob( { mediaId: id, uploadId, r2Key, kind: "image" }, @@ -537,7 +546,10 @@ describe("media.checks orchestration robustness", () => { env.repo.findById = () => Promise.reject(new Error("connection reset")) env.repo.findByUploadId = () => Promise.reject(new Error("connection reset")) await expect( - runMediaChecksJob({ mediaId: "x", uploadId: "y", r2Key: "uploads/x", kind: "image" }, env.deps), + runMediaChecksJob( + { mediaId: "x", uploadId: "y", r2Key: "uploads/x", kind: "image" }, + env.deps, + ), ).rejects.toBeInstanceOf(MediaInfraError) }) @@ -643,7 +655,10 @@ describe("media.checks terminal-status CAS (F087d)", () => { return realPut(key, bytes, opts) } - const status = await runMediaChecksJob({ mediaId: id, uploadId, r2Key, kind: "image" }, env.deps) + const status = await runMediaChecksJob( + { mediaId: id, uploadId, r2Key, kind: "image" }, + env.deps, + ) expect(status).toBe("rejected") expect(env.repo.get(id)!.status).toBe("rejected") @@ -667,7 +682,10 @@ describe("media.checks terminal-status CAS (F087d)", () => { return realPut(key, bytes, opts) } - const status = await runMediaChecksJob({ mediaId: id, uploadId, r2Key, kind: "image" }, env.deps) + const status = await runMediaChecksJob( + { mediaId: id, uploadId, r2Key, kind: "image" }, + env.deps, + ) expect(status).toBe("ready") expect(env.repo.get(id)!.status).toBe("ready") @@ -677,7 +695,12 @@ describe("media.checks terminal-status CAS (F087d)", () => { it("a rejection that loses the CAS does not clobber the winner, and leaves the winner's bytes alone", async () => { const env = makeDeps() - const { id, uploadId, r2Key } = await seedAsset(env.storage, env.repo, "image", fx.makeGarbageImage()) + const { id, uploadId, r2Key } = await seedAsset( + env.storage, + env.repo, + "image", + fx.makeGarbageImage(), + ) const inner = makeDownloader(env.storage) const download: DownloadFn = async (key, maxBytes, signal) => { const bytes = await inner(key, maxBytes, signal) @@ -710,7 +733,10 @@ describe("media.checks terminal-status CAS (F087d)", () => { return realPut(key, bytes, opts) } - const status = await runMediaChecksJob({ mediaId: id, uploadId, r2Key, kind: "image" }, env.deps) + const status = await runMediaChecksJob( + { mediaId: id, uploadId, r2Key, kind: "image" }, + env.deps, + ) expect(status).toBe("rejected") expect(env.repo.get(id)).toBeUndefined() @@ -754,7 +780,7 @@ describe("media.checks with the REAL AbuseChecks (default-flag PUBLISH path)", ( expect(status).toBe("ready") const row = repo.get(id)! expect(row.status).toBe("ready") - expect((row.phash as string)).toMatch(/^[0-9a-f]{16}$/) + expect(row.phash as string).toMatch(/^[0-9a-f]{16}$/) expect(row.thumbKey).toBe(`thumbs/${r2Key}.jpg`) expect(repo.flags).toHaveLength(0) }) @@ -803,7 +829,10 @@ describe("media.checks with the REAL AbuseChecks (default-flag PUBLISH path)", ( repo.seed({ id, uploadId, kind: "image", r2Key }) await storage.put(r2Key, Buffer.from(input), { contentType: "image/png" }) - const status = await runMediaChecksJob({ mediaId: id, uploadId, r2Key, kind: "image" }, holdDeps) + const status = await runMediaChecksJob( + { mediaId: id, uploadId, r2Key, kind: "image" }, + holdDeps, + ) expect(status).toBe("held") expect(repo.get(id)!.status).toBe("held") expect(repo.flags[0]).toMatchObject({ subjectId: id, reason: "nsfw" }) diff --git a/services/media-worker/test/unit/orphan-race.test.ts b/services/media-worker/test/unit/orphan-race.test.ts index b5e6164b..68f034bc 100644 --- a/services/media-worker/test/unit/orphan-race.test.ts +++ b/services/media-worker/test/unit/orphan-race.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from "vitest" import { FakeStorage } from "@civfix/shared/fakes" import { loadLimits, type WorkerLimits } from "../../src/config.js" diff --git a/services/media-worker/test/unit/pg-boss-jobs.test.ts b/services/media-worker/test/unit/pg-boss-jobs.test.ts index 9614595d..a675b16b 100644 --- a/services/media-worker/test/unit/pg-boss-jobs.test.ts +++ b/services/media-worker/test/unit/pg-boss-jobs.test.ts @@ -91,13 +91,18 @@ vi.mock("pg-boss", () => { this.scheduleCalls.push({ name, cron, data, opts }) return Promise.resolve() } - work(name: string, options: unknown, handler: (jobs: unknown[]) => Promise): Promise { + work( + name: string, + options: unknown, + handler: (jobs: unknown[]) => Promise, + ): Promise { this.workCalls.push({ name, options }) this.handler = handler return Promise.resolve() } complete(name: string, id: string): Promise { - if (this.rejectCompleteFor === id) return Promise.reject(new Error(`complete write failed ${id}`)) + if (this.rejectCompleteFor === id) + return Promise.reject(new Error(`complete write failed ${id}`)) this.completeCalls.push({ name, id }) return Promise.resolve() } @@ -274,7 +279,10 @@ describe("PgBossWorkerJobs enqueue / schedule mapping", () => { }) describe("PgBossWorkerJobs work(): PER-JOB completion of a delivered batch", () => { - async function startWithHandler(handler: JobHandler, settings?: { batchSize?: number }): Promise { + async function startWithHandler( + handler: JobHandler, + settings?: { batchSize?: number }, + ): Promise { const jobs = new PgBossWorkerJobs("postgres://stub/civfix") await jobs.start() await jobs.workWithSettings(QUEUE, handler, settings) @@ -328,7 +336,11 @@ describe("PgBossWorkerJobs work(): PER-JOB completion of a delivered batch", () await startWithHandler((job) => (job.id === "j2" ? Promise.reject(boom) : Promise.resolve())) await expect( - lastBoss().deliver([{ id: "j1", data: 1 }, { id: "j2", data: 2 }, { id: "j3", data: 3 }]), + lastBoss().deliver([ + { id: "j1", data: 1 }, + { id: "j2", data: 2 }, + { id: "j3", data: 3 }, + ]), ).resolves.toBeUndefined() expect(lastBoss().failCalls).toEqual([{ name: QUEUE, id: "j2", output: boom }]) @@ -358,7 +370,10 @@ describe("PgBossWorkerJobs work(): PER-JOB completion of a delivered batch", () lastBoss().rejectCompleteFor = "j2" await expect( - lastBoss().deliver([{ id: "j1", data: 1 }, { id: "j2", data: 2 }]), + lastBoss().deliver([ + { id: "j1", data: 1 }, + { id: "j2", data: 2 }, + ]), ).rejects.toThrow(/complete write failed j2/) // The sibling's completion still landed before the rethrow. @@ -366,11 +381,16 @@ describe("PgBossWorkerJobs work(): PER-JOB completion of a delivered batch", () }) it("RETHROWS when the FAIL write fails too (the failure must not be swallowed)", async () => { - await startWithHandler((job) => (job.id === "j1" ? Promise.reject(new Error("nope")) : Promise.resolve())) + await startWithHandler((job) => + job.id === "j1" ? Promise.reject(new Error("nope")) : Promise.resolve(), + ) lastBoss().rejectFailFor = "j1" await expect( - lastBoss().deliver([{ id: "j1", data: 1 }, { id: "j2", data: 2 }]), + lastBoss().deliver([ + { id: "j1", data: 1 }, + { id: "j2", data: 2 }, + ]), ).rejects.toThrow(/fail write failed j1/) expect(lastBoss().completeCalls).toEqual([{ name: QUEUE, id: "j2" }]) }) @@ -424,9 +444,9 @@ describe("buildJobs seam selection", () => { }) it("THROWS when the real seam is selected without a DATABASE_URL", () => { - expect(() => - buildJobs({ NODE_ENV: "test", USE_FAKE_JOBS: "0" } as NodeJS.ProcessEnv), - ).toThrow(/DATABASE_URL is required when USE_FAKE_JOBS is off/) + expect(() => buildJobs({ NODE_ENV: "test", USE_FAKE_JOBS: "0" } as NodeJS.ProcessEnv)).toThrow( + /DATABASE_URL is required when USE_FAKE_JOBS is off/, + ) }) it("builds the real handle in production and derives its stop grace from MEDIA_JOB_TIMEOUT_MS", async () => { diff --git a/services/media-worker/test/unit/preflight-proof.test.ts b/services/media-worker/test/unit/preflight-proof.test.ts index 70445735..a1833bcf 100644 --- a/services/media-worker/test/unit/preflight-proof.test.ts +++ b/services/media-worker/test/unit/preflight-proof.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from "vitest" import { parseProcStatus, sandboxProofFailure } from "../../src/sandbox/preflight.js" import { assertSandboxPreflight } from "../../src/sandbox/preflight.js" @@ -54,28 +53,32 @@ describe("sandboxProofFailure", () => { }) it("FAILS the ambient-capability leak this finding is about", () => { - const leaked = status({ CapAmb: "00000000000000c0", CapPrm: "00000000000000c0", CapEff: "00000000000000c0" }) + const leaked = status({ + CapAmb: "00000000000000c0", + CapPrm: "00000000000000c0", + CapEff: "00000000000000c0", + }) expect(sandboxProofFailure(leaked, IDENTITY, WORKER)).toMatch(/CapInh|CapPrm|CapEff|CapAmb/) }) it("fails any non-zero effective, permitted or inheritable set", () => { for (const name of ["CapInh", "CapPrm", "CapEff", "CapAmb"]) { - expect(sandboxProofFailure(status({ [name]: "0000000000000040" }), IDENTITY, WORKER)).toContain( - name, - ) + expect( + sandboxProofFailure(status({ [name]: "0000000000000040" }), IDENTITY, WORKER), + ).toContain(name) } }) it("fails a child that is not fully the sandbox uid/gid (incl. a saved-set escape hatch)", () => { - expect(sandboxProofFailure(status({ Uid: "1001\t1001\t1000\t1001" }), IDENTITY, WORKER)).toMatch( - /Uid/, - ) - expect(sandboxProofFailure(status({ Gid: "1001\t1001\t1001\t1000" }), IDENTITY, WORKER)).toMatch( - /Gid/, - ) - expect(sandboxProofFailure(status({ Uid: "1000\t1000\t1000\t1000" }), IDENTITY, WORKER)).toMatch( - /Uid/, - ) + expect( + sandboxProofFailure(status({ Uid: "1001\t1001\t1000\t1001" }), IDENTITY, WORKER), + ).toMatch(/Uid/) + expect( + sandboxProofFailure(status({ Gid: "1001\t1001\t1001\t1000" }), IDENTITY, WORKER), + ).toMatch(/Gid/) + expect( + sandboxProofFailure(status({ Uid: "1000\t1000\t1000\t1000" }), IDENTITY, WORKER), + ).toMatch(/Uid/) }) it("tolerates the container's own CAP_SETUID/CAP_SETGID bounding set, rejects anything wider", () => { diff --git a/services/media-worker/test/unit/retention-sweep.test.ts b/services/media-worker/test/unit/retention-sweep.test.ts index 58e78e1a..79091f55 100644 --- a/services/media-worker/test/unit/retention-sweep.test.ts +++ b/services/media-worker/test/unit/retention-sweep.test.ts @@ -170,7 +170,10 @@ describe("inbound_emails retention lane (H10)", () => { it("deletes the archived page and every attachment object on it", async () => { const storage = fakeInboundStorage() const { repo, calls } = fakeRepo([ - [reaped("e1", "inbound-emails/a/1-x.pdf"), reaped("e2", "inbound-emails/b/1-y.pdf", "inbound-emails/b/2-z.pdf")], + [ + reaped("e1", "inbound-emails/a/1-x.pdf"), + reaped("e2", "inbound-emails/b/1-y.pdf", "inbound-emails/b/2-z.pdf"), + ], ]) const result = laneResult() diff --git a/services/media-worker/test/unit/sandbox-hardening.test.ts b/services/media-worker/test/unit/sandbox-hardening.test.ts index 8802ec2b..00ce6587 100644 --- a/services/media-worker/test/unit/sandbox-hardening.test.ts +++ b/services/media-worker/test/unit/sandbox-hardening.test.ts @@ -1,4 +1,3 @@ - import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" const execaMock = vi.fn() @@ -41,7 +40,13 @@ let saved: NodeJS.ProcessEnv beforeEach(() => { saved = envSnapshot() execaMock.mockReset() - execaMock.mockResolvedValue({ stdout: "", stderr: "", exitCode: 0, failed: false, timedOut: false }) + execaMock.mockResolvedValue({ + stdout: "", + stderr: "", + exitCode: 0, + failed: false, + timedOut: false, + }) resetSandboxIdentity() resetMediaToolPaths() }) @@ -139,9 +144,9 @@ describe("H7: MEDIA_SANDBOX_UID/GID are required in production", () => { }) it("throws when only one of the pair is set, in any environment", () => { - expect(() => - loadSandboxIdentity({ MEDIA_SANDBOX_UID: "1001" } as NodeJS.ProcessEnv), - ).toThrow(/must be set together/) + expect(() => loadSandboxIdentity({ MEDIA_SANDBOX_UID: "1001" } as NodeJS.ProcessEnv)).toThrow( + /must be set together/, + ) }) it("returns null outside production when neither is set", () => { diff --git a/services/media-worker/test/unit/sandbox.test.ts b/services/media-worker/test/unit/sandbox.test.ts index 41df6226..87240bd7 100644 --- a/services/media-worker/test/unit/sandbox.test.ts +++ b/services/media-worker/test/unit/sandbox.test.ts @@ -1,4 +1,3 @@ - import { afterAll, beforeAll, describe, expect, it } from "vitest" import { createServer, type Server } from "node:http" import type { AddressInfo } from "node:net" @@ -181,7 +180,9 @@ describe("sandbox/phash", () => { raw[y * w + x] = reverse ? 255 - v : v } } - return sharp(raw, { raw: { width: w, height: h, channels: 1 } }).png().toBuffer() + return sharp(raw, { raw: { width: w, height: h, channels: 1 } }) + .png() + .toBuffer() } it("hashes an ascending ramp to all-zero bits and a descending ramp to all-one bits", async () => { @@ -202,7 +203,9 @@ describe("sandbox/phash", () => { raw[y * w + x] = Math.round(invert ? 255 - v : v) } } - return sharp(raw, { raw: { width: w, height: h, channels: 1 } }).png().toBuffer() + return sharp(raw, { raw: { width: w, height: h, channels: 1 } }) + .png() + .toBuffer() } it("is PERCEPTUAL: a downscaled, JPEG-re-encoded copy hashes identically", async () => { @@ -222,7 +225,9 @@ describe("sandbox/phash", () => { }) it("refuses a non-allowlisted container (the L15 sniff applies to the hash path too)", async () => { - const svg = new TextEncoder().encode('') + const svg = new TextEncoder().encode( + '', + ) await expect(perceptualHash(svg, limits)).rejects.toThrow(/unsupported image container/i) }) }) @@ -363,9 +368,7 @@ describe("sandbox/image magic-byte container gate (L15)", () => { expect( sniffAllowedImageContainer(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), ).toBe("png") - const webp = new Uint8Array([ - 0x52, 0x49, 0x46, 0x46, 1, 2, 3, 4, 0x57, 0x45, 0x42, 0x50, - ]) + const webp = new Uint8Array([0x52, 0x49, 0x46, 0x46, 1, 2, 3, 4, 0x57, 0x45, 0x42, 0x50]) expect(sniffAllowedImageContainer(webp)).toBe("webp") }) @@ -385,7 +388,9 @@ describe("sandbox/image magic-byte container gate (L15)", () => { }) it("processImage refuses a non-allowlisted container so libvips never sees it", async () => { - const svg = new TextEncoder().encode('') + const svg = new TextEncoder().encode( + '', + ) await expect(processImage(svg, limits)).rejects.toThrow(/unsupported image container/i) }) diff --git a/services/media-worker/test/unit/scratch-io.test.ts b/services/media-worker/test/unit/scratch-io.test.ts index bda24dd4..176671fa 100644 --- a/services/media-worker/test/unit/scratch-io.test.ts +++ b/services/media-worker/test/unit/scratch-io.test.ts @@ -1,4 +1,3 @@ - import { afterEach, beforeEach, describe, expect, it } from "vitest" import { execFileSync } from "node:child_process" import { mkdtemp, link, rm, stat, symlink, writeFile } from "node:fs/promises" @@ -30,7 +29,9 @@ describe("readScratchOutput", () => { it("refuses a SYMLINK at open time (O_NOFOLLOW), never following it", async () => { await symlink("/etc/hosts", join(dir, "out.bin")) - const err = await readScratchOutput(dir, "out.bin", SELF_UID, MAX_BYTES).catch((e: unknown) => e) + const err = await readScratchOutput(dir, "out.bin", SELF_UID, MAX_BYTES).catch( + (e: unknown) => e, + ) expect(err).toBeInstanceOf(ScratchOutputError) expect(String(err)).toMatch(/could not be opened safely/) @@ -42,7 +43,9 @@ describe("readScratchOutput", () => { try { await link(outside, join(dir, "out.bin")) - await expect(readScratchOutput(dir, "out.bin", SELF_UID, MAX_BYTES)).rejects.toThrow(/hard-linked/) + await expect(readScratchOutput(dir, "out.bin", SELF_UID, MAX_BYTES)).rejects.toThrow( + /hard-linked/, + ) } finally { await rm(outside, { force: true }) } @@ -51,7 +54,9 @@ describe("readScratchOutput", () => { it("refuses a FIFO instead of blocking on it (O_NONBLOCK)", async () => { execFileSync("/usr/bin/mkfifo", [join(dir, "out.bin")]) - const err = await readScratchOutput(dir, "out.bin", SELF_UID, MAX_BYTES).catch((e: unknown) => e) + const err = await readScratchOutput(dir, "out.bin", SELF_UID, MAX_BYTES).catch( + (e: unknown) => e, + ) expect(err).toBeInstanceOf(ScratchOutputError) expect(String(err)).toMatch(/not a regular file|could not be opened safely/) @@ -60,9 +65,9 @@ describe("readScratchOutput", () => { it("refuses a file owned by someone other than the sandbox uid", async () => { await writeFile(join(dir, "out.bin"), Buffer.from("bytes")) - await expect(readScratchOutput(dir, "out.bin", (SELF_UID ?? 0) + 1234, MAX_BYTES)).rejects.toThrow( - /not owned by the sandbox uid/, - ) + await expect( + readScratchOutput(dir, "out.bin", (SELF_UID ?? 0) + 1234, MAX_BYTES), + ).rejects.toThrow(/not owned by the sandbox uid/) }) it("refuses an output larger than the lane cap, without reading it", async () => { @@ -96,7 +101,9 @@ describe("scratch.seal", () => { await writeFile(join(scratch.dir, "out.bin"), Buffer.from("ok")) await scratch.seal() - expect((await readScratchOutput(scratch.dir, "out.bin", SELF_UID, MAX_BYTES)).toString()).toBe("ok") + expect((await readScratchOutput(scratch.dir, "out.bin", SELF_UID, MAX_BYTES)).toString()).toBe( + "ok", + ) await scratch.cleanup() }) }) diff --git a/services/media-worker/test/unit/served-key.test.ts b/services/media-worker/test/unit/served-key.test.ts index 5ceb213a..aefb8963 100644 --- a/services/media-worker/test/unit/served-key.test.ts +++ b/services/media-worker/test/unit/served-key.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from "vitest" import { FakeStorage, FakeAbuseChecks } from "@civfix/shared/fakes" import type { StorageHead } from "@civfix/shared/interfaces" diff --git a/services/media-worker/test/unit/spawn-failure-classification.test.ts b/services/media-worker/test/unit/spawn-failure-classification.test.ts index 518de0e3..51c93667 100644 --- a/services/media-worker/test/unit/spawn-failure-classification.test.ts +++ b/services/media-worker/test/unit/spawn-failure-classification.test.ts @@ -1,4 +1,3 @@ - import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { fileURLToPath } from "node:url" @@ -11,9 +10,8 @@ vi.mock("../../src/sandbox/image-lane.js", () => ({ const { FakeStorage, FakeAbuseChecks } = await import("@civfix/shared/fakes") const { loadLimits } = await import("../../src/config.js") -const { SandboxSpawnError, SandboxToolError, resetSandboxIdentity } = await import( - "../../src/sandbox/exec.js" -) +const { SandboxSpawnError, SandboxToolError, resetSandboxIdentity } = + await import("../../src/sandbox/exec.js") const { processMedia } = await import("../../src/jobs/media-pipeline.js") const { runMediaChecksJob, MediaInfraError } = await import("../../src/jobs/media-checks.js") const { assertSandboxPreflight } = await import("../../src/sandbox/preflight.js") diff --git a/services/media-worker/test/unit/upload-reap.test.ts b/services/media-worker/test/unit/upload-reap.test.ts index 4f979923..152ae213 100644 --- a/services/media-worker/test/unit/upload-reap.test.ts +++ b/services/media-worker/test/unit/upload-reap.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from "vitest" import { FakeStorage } from "@civfix/shared/fakes" import { servedKey } from "../../src/jobs/media-keys.js" @@ -75,9 +74,9 @@ describe("media.upload.reap", () => { const r2Key = "uploads/2026/09/ghost" await storage.put(r2Key, Buffer.from([9]), { contentType: "image/jpeg" }) - expect( - await runUploadReapJob({ mediaId: "gone", uploadId: "gone", r2Key }, deps), - ).toBe("deleted") + expect(await runUploadReapJob({ mediaId: "gone", uploadId: "gone", r2Key }, deps)).toBe( + "deleted", + ) expect(storage.get(r2Key)).toBeNull() }) @@ -94,9 +93,9 @@ describe("media.upload.reap", () => { }) await storage.put(r2Key, Buffer.from([1]), { contentType: "image/jpeg" }) - expect( - await runUploadReapJob({ mediaId: "legacy", uploadId: "legacy-u", r2Key }, deps), - ).toBe("kept") + expect(await runUploadReapJob({ mediaId: "legacy", uploadId: "legacy-u", r2Key }, deps)).toBe( + "kept", + ) expect(storage.get(r2Key)).not.toBeNull() }) @@ -140,9 +139,9 @@ describe("media.upload.reap", () => { repo.findById = () => Promise.reject(new Error("db down")) repo.findByUploadId = () => Promise.reject(new Error("db down")) - await expect( - runUploadReapJob({ mediaId: id, uploadId, r2Key }, deps), - ).rejects.toBeInstanceOf(UploadReapInfraError) + await expect(runUploadReapJob({ mediaId: id, uploadId, r2Key }, deps)).rejects.toBeInstanceOf( + UploadReapInfraError, + ) expect(storage.get(r2Key)).not.toBeNull() }) @@ -152,9 +151,9 @@ describe("media.upload.reap", () => { await storage.put(r2Key, Buffer.from([1]), { contentType: "image/jpeg" }) repo.r2KeyReferencedByOthers = () => Promise.reject(new Error("db down")) - await expect( - runUploadReapJob({ mediaId: id, uploadId, r2Key }, deps), - ).rejects.toBeInstanceOf(UploadReapInfraError) + await expect(runUploadReapJob({ mediaId: id, uploadId, r2Key }, deps)).rejects.toBeInstanceOf( + UploadReapInfraError, + ) expect(storage.get(r2Key)).not.toBeNull() }) diff --git a/services/media-worker/test/unit/video-caps.test.ts b/services/media-worker/test/unit/video-caps.test.ts index a0b25e8c..2044bc10 100644 --- a/services/media-worker/test/unit/video-caps.test.ts +++ b/services/media-worker/test/unit/video-caps.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it, vi } from "vitest" import { FakeAbuseChecks } from "@civfix/shared/fakes" import { loadLimits, type WorkerLimits } from "../../src/config.js" diff --git a/services/media-worker/test/unit/video-policy.test.ts b/services/media-worker/test/unit/video-policy.test.ts index f0c8b33d..de3c8fe5 100644 --- a/services/media-worker/test/unit/video-policy.test.ts +++ b/services/media-worker/test/unit/video-policy.test.ts @@ -1,4 +1,3 @@ - import { beforeEach, describe, expect, it, vi } from "vitest" import { FakeStorage, FakeAbuseChecks } from "@civfix/shared/fakes" import { loadLimits, type WorkerLimits } from "../../src/config.js" @@ -187,7 +186,9 @@ describe("video frame-grab failure => HELD (never published unscored)", () => { stub.grabFrameError = new Error("ffmpeg: no decodable frame") const bytes = await fx.makeValidMp4() const { deps, storage, repo } = makeEnv() - const { id, uploadId, r2Key } = await seedVideo(storage, repo, bytes, { reportId: "report-vid" }) + const { id, uploadId, r2Key } = await seedVideo(storage, repo, bytes, { + reportId: "report-vid", + }) const status = await runMediaChecksJob({ mediaId: id, uploadId, r2Key, kind: "video" }, deps) diff --git a/services/media-worker/test/unit/worker.test.ts b/services/media-worker/test/unit/worker.test.ts index 71ac10b7..648c7c11 100644 --- a/services/media-worker/test/unit/worker.test.ts +++ b/services/media-worker/test/unit/worker.test.ts @@ -1,4 +1,3 @@ - import { describe, it, expect } from "vitest" import { buildWorker, @@ -78,10 +77,16 @@ describe("media-worker wiring", () => { const mediaQueue = calls.find((c) => c.name === MEDIA_CHECKS_JOB) expect(mediaQueue?.options).toEqual({ policy: "short", retryLimit: 5, retryBackoff: true }) - expect(calls.find((c) => c.name === ANON_HOLD_RELEASE_JOB)?.options).toEqual({ policy: "short" }) + expect(calls.find((c) => c.name === ANON_HOLD_RELEASE_JOB)?.options).toEqual({ + policy: "short", + }) expect(calls.find((c) => c.name === ORPHAN_SWEEP_JOB)?.options).toEqual({ policy: "singleton" }) - expect(calls.find((c) => c.name === RETENTION_SWEEP_JOB)?.options).toEqual({ policy: "singleton" }) - expect(calls.find((c) => c.name === MEDIA_STUCK_SWEEP_JOB)?.options).toEqual({ policy: "singleton" }) + expect(calls.find((c) => c.name === RETENTION_SWEEP_JOB)?.options).toEqual({ + policy: "singleton", + }) + expect(calls.find((c) => c.name === MEDIA_STUCK_SWEEP_JOB)?.options).toEqual({ + policy: "singleton", + }) await worker.stop() }) From 3c3a22415e6341d0d4fd741ef7e2bfc45cc4d1b6 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:24:45 +0000 Subject: [PATCH 03/45] oauth sign-in never stores or adopts an unverified provider email; provider display names are sanitized --- services/api/src/auth/oauth.ts | 97 +++++-- services/api/src/auth/stores.ts | 16 ++ .../api/test/unit/auth-oauth-security.test.ts | 236 ++++++++++++++++++ 3 files changed, 331 insertions(+), 18 deletions(-) create mode 100644 services/api/test/unit/auth-oauth-security.test.ts diff --git a/services/api/src/auth/oauth.ts b/services/api/src/auth/oauth.ts index fa5b91fe..e3c5bf39 100644 --- a/services/api/src/auth/oauth.ts +++ b/services/api/src/auth/oauth.ts @@ -1,6 +1,12 @@ import { Apple, Google, generateCodeVerifier, generateState } from "arctic" import { AppError } from "@civfix/shared" -import type { OAuthIdentityStore, UserRecord, UserStore } from "./stores.js" +import { containsSlur } from "../abuse/slur-filter.js" +import { + EmailTakenError, + type OAuthIdentityStore, + type UserRecord, + type UserStore, +} from "./stores.js" import { RemoteJwksVerifier, type JwksVerifier, type VerifiedIdToken } from "./jwks.js" export const GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"] @@ -163,24 +169,46 @@ export class OAuthService { if (user && user.deletedAt === null) return user } - if (claims.email && claims.emailVerified) { - const byEmail = await this.users.findByEmail(claims.email) - if (byEmail && byEmail.deletedAt === null) { - await this.oauthStore.linkIdentity(byEmail.id, provider, claims.sub) - return byEmail - } + // An address the provider has not verified proves nothing about who owns it: it is never stored and + // never matched, or a token naming someone else's email would sign in as them, and an OTP sign-in by + // the real owner would later walk into an account the token holder planted. + const verifiedEmail = claims.email !== null && claims.emailVerified ? claims.email : null + if (verifiedEmail !== null) { + const linked = await this.linkExistingByEmail(verifiedEmail, provider, claims.sub) + if (linked) return linked } - const created = await this.users.create(claims.email ?? null, { - displayName: fullName ?? deriveDisplayName(claims, provider), - role: "citizen", - emailVerified: claims.email !== null && claims.emailVerified, - avatarUrl: safeAvatarUrl(claims.picture), - }) + let created: UserRecord + try { + created = await this.users.create(verifiedEmail, { + displayName: providerDisplayName(fullName, claims, provider), + role: "citizen", + emailVerified: verifiedEmail !== null, + avatarUrl: safeAvatarUrl(claims.picture), + onEmailConflict: "reject", + }) + } catch (err) { + if (!(err instanceof EmailTakenError) || verifiedEmail === null) throw err + // A concurrent first sign-in for the same verified address won the insert; link to its account. + const linked = await this.linkExistingByEmail(verifiedEmail, provider, claims.sub) + if (!linked) throw err + return linked + } await this.oauthStore.linkIdentity(created.id, provider, claims.sub) return created } + private async linkExistingByEmail( + verifiedEmail: string, + provider: string, + providerUserId: string, + ): Promise { + const byEmail = await this.users.findByEmail(verifiedEmail) + if (!byEmail || byEmail.deletedAt !== null) return null + await this.oauthStore.linkIdentity(byEmail.id, provider, providerUserId) + return byEmail + } + private requireGoogle(): Google { const cfg = this.requireGoogleConfig() if (!this.googleClient) { @@ -235,15 +263,48 @@ export class OAuthService { } } -function deriveDisplayName(claims: VerifiedIdToken, provider: string): string { - if (claims.name && claims.name.trim().length > 0) return claims.name.trim() - if (claims.email) { - const at = claims.email.indexOf("@") - if (at > 0) return claims.email.slice(0, at) +// The same cap the profile editor enforces on a display name. Provider names (Apple's client-sent +// fullName, Google's name claim) are user-controlled and reach every author DTO before the profile step, +// so they are held to that cap and the slur filter here rather than rejected: a too-long or filtered name +// must not fail the sign-in itself. +const MAX_PROVIDER_DISPLAY_NAME_LENGTH = 80 + +function providerDisplayName( + fullName: string | undefined, + claims: VerifiedIdToken, + provider: string, +): string { + const candidates = [fullName, claims.name, emailLocalPart(claims.email)] + for (const candidate of candidates) { + const name = sanitizeDisplayName(candidate) + if (name !== null) return name } return provider === PROVIDER_APPLE ? "Apple user" : "Google user" } +function sanitizeDisplayName(raw: string | null | undefined): string | null { + if (raw === null || raw === undefined) return null + const collapsed = raw.replace(/\s+/g, " ").trim() + const name = truncateCodePoints(collapsed, MAX_PROVIDER_DISPLAY_NAME_LENGTH).trimEnd() + if (name === "" || containsSlur(name)) return null + return name +} + +function truncateCodePoints(value: string, maxLength: number): string { + let out = "" + for (const codePoint of value) { + if (out.length + codePoint.length > maxLength) break + out += codePoint + } + return out +} + +function emailLocalPart(email: string | null): string | null { + if (!email) return null + const at = email.indexOf("@") + return at > 0 ? email.slice(0, at) : null +} + function safeAvatarUrl(picture: string | null): string | null { if (!picture) return null try { diff --git a/services/api/src/auth/stores.ts b/services/api/src/auth/stores.ts index 524d333c..3afc90e4 100644 --- a/services/api/src/auth/stores.ts +++ b/services/api/src/auth/stores.ts @@ -133,6 +133,13 @@ export interface UserRecord { deletedAt: Date | null } +/** + * "return-existing" answers an email conflict with the account that already holds the address, which is + * only safe for a caller that has proved control of that inbox (email code, operator allowlist). A caller + * holding no such proof must pass "reject" so a conflict can never hand it someone else's account. + */ +export type EmailConflictPolicy = "return-existing" | "reject" + export interface CreateUserInput { displayName: string role?: Role @@ -140,6 +147,14 @@ export interface CreateUserInput { avatarUrl?: string | null handle?: string profileComplete?: boolean + onEmailConflict?: EmailConflictPolicy +} + +export class EmailTakenError extends Error { + constructor() { + super("email address already belongs to another account") + this.name = "EmailTakenError" + } } export interface UpdateProfileInput { @@ -212,6 +227,7 @@ export class InMemoryUserStore implements UserStore { const normalized = email.toLowerCase() for (const existing of this.byId.values()) { if (existing.email !== null && existing.email.toLowerCase() === normalized) { + if (input.onEmailConflict === "reject") return Promise.reject(new EmailTakenError()) return Promise.resolve({ ...existing }) } } diff --git a/services/api/test/unit/auth-oauth-security.test.ts b/services/api/test/unit/auth-oauth-security.test.ts new file mode 100644 index 00000000..85b7f15a --- /dev/null +++ b/services/api/test/unit/auth-oauth-security.test.ts @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, it } from "vitest" +import { FakeMailer } from "@civfix/shared/fakes" +import { OAuthService } from "../../src/auth/oauth.js" +import type { JwksVerifier, VerifiedIdToken, VerifyParams } from "../../src/auth/jwks.js" +import { OtpService } from "../../src/auth/otp.js" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { + EmailTakenError, + InMemoryOAuthIdentityStore, + InMemoryOtpStore, + InMemoryUserStore, +} from "../../src/auth/stores.js" +import { makeAuthHarness, type AuthHarness } from "../helpers/auth.js" + +const VICTIM_EMAIL = "victim@example.org" +const MAX_DISPLAY_NAME = 80 +const SLUR_NAME = "you faggot" + +class StubVerifier implements JwksVerifier { + private readonly map = new Map() + register(token: string, claims: VerifiedIdToken): void { + this.map.set(token, claims) + } + verify(idToken: string, _params: VerifyParams): Promise { + const c = this.map.get(idToken) + return c ? Promise.resolve(c) : Promise.reject(new Error("unknown token")) + } +} + +function claims( + sub: string, + email: string | null, + opts: { verified?: boolean; name?: string | null } = {}, +): VerifiedIdToken { + return { + sub, + email, + emailVerified: opts.verified ?? true, + name: opts.name ?? null, + picture: null, + } +} + +function makeServices() { + const nowMs = { value: 1_700_000_000_000 } + const users = new InMemoryUserStore() + const oauthStore = new InMemoryOAuthIdentityStore() + const verifier = new StubVerifier() + const oauth = new OAuthService({ + config: { + google: { clientId: "gid", clientSecret: "gsecret", redirectUri: "http://localhost/cb" }, + apple: { + clientId: "aid", + teamId: "team", + keyId: "key", + privateKey: "pk", + redirectUri: "http://localhost/apple/cb", + }, + }, + oauthStore, + users, + verifier, + }) + const mailer = new FakeMailer() + const otp = new OtpService({ + store: new InMemoryOtpStore(), + users, + cache: new InMemoryCacheClient(() => nowMs.value), + mailer, + now: () => nowMs.value, + }) + return { oauth, otp, mailer, users, oauthStore, verifier } +} + +describe("provider sign-in with an unverified email", () => { + it("never adopts the existing account that owns the address", async () => { + const { oauth, users, oauthStore, verifier } = makeServices() + const victim = await users.create(VICTIM_EMAIL, { + displayName: "Victim", + role: "operator", + emailVerified: true, + }) + verifier.register("t", claims("attacker-sub", "Victim@Example.org", { verified: false })) + + const signedIn = await oauth.signInWithGoogleIdToken("t") + + expect(signedIn.id).not.toBe(victim.id) + expect(signedIn.role).toBe("citizen") + expect(signedIn.email).toBeNull() + expect(signedIn.emailVerified).toBe(false) + expect((await oauthStore.findByProvider("google", "attacker-sub"))?.userId).toBe(signedIn.id) + expect((await users.findById(victim.id))?.email).toBe(VICTIM_EMAIL) + }) + + it("does not plant the address for a later email-code sign-in to walk into", async () => { + const { oauth, otp, mailer, verifier } = makeServices() + verifier.register("t", claims("attacker-sub", VICTIM_EMAIL, { verified: false })) + const attacker = await oauth.signInWithGoogleIdToken("t") + + await otp.issueOtp(VICTIM_EMAIL, null) + const ownerId = await otp.verifyOtp(VICTIM_EMAIL, mailer.lastOtpFor(VICTIM_EMAIL)!, null) + + expect(ownerId).not.toBe(attacker.id) + const again = await oauth.signInWithGoogleIdToken("t") + expect(again.id).toBe(attacker.id) + }) + + it("keeps the verified-email link for the account that owns the address", async () => { + const { oauth, users, verifier } = makeServices() + const owner = await users.create(VICTIM_EMAIL, { displayName: "Owner", emailVerified: true }) + verifier.register("t", claims("owner-sub", VICTIM_EMAIL)) + + expect((await oauth.signInWithGoogleIdToken("t")).id).toBe(owner.id) + }) +}) + +describe("strict user creation for provider sign-in", () => { + it("rejects an email that another account holds instead of returning that account", async () => { + const users = new InMemoryUserStore() + await users.create(VICTIM_EMAIL, { displayName: "Victim", emailVerified: true }) + + await expect( + users.create("VICTIM@example.org", { displayName: "Other", onEmailConflict: "reject" }), + ).rejects.toBeInstanceOf(EmailTakenError) + }) + + it("links the winner when two verified first sign-ins race on one address", async () => { + const { oauth, users, oauthStore, verifier } = makeServices() + verifier.register("t", claims("racer-sub", VICTIM_EMAIL)) + const winner = await users.create(VICTIM_EMAIL, { displayName: "Winner", emailVerified: true }) + const findByEmail = users.findByEmail.bind(users) + let calls = 0 + users.findByEmail = (email: string) => { + calls += 1 + return calls === 1 ? Promise.resolve(null) : findByEmail(email) + } + + const signedIn = await oauth.signInWithGoogleIdToken("t") + + expect(signedIn.id).toBe(winner.id) + expect((await oauthStore.findByProvider("google", "racer-sub"))?.userId).toBe(winner.id) + }) +}) + +describe("provider-supplied display names", () => { + it("caps an oversized Apple fullName", async () => { + const { oauth, verifier } = makeServices() + verifier.register("a", claims("apple-sub", "long@example.com")) + + const user = await oauth.signInWithAppleIdToken("a", ` ${"x".repeat(5000)} `) + + expect(user.displayName.length).toBeLessThanOrEqual(MAX_DISPLAY_NAME) + expect(user.displayName).toBe("x".repeat(MAX_DISPLAY_NAME)) + }) + + it("collapses runs of whitespace in the Apple fullName", async () => { + const { oauth, verifier } = makeServices() + verifier.register("a", claims("apple-sub", "ws@example.com")) + + const user = await oauth.signInWithAppleIdToken("a", " Ada \n\t Lovelace ") + + expect(user.displayName).toBe("Ada Lovelace") + }) + + it("replaces a slur Apple fullName with the name derived from the token", async () => { + const { oauth, verifier } = makeServices() + verifier.register("a", claims("apple-sub", "jordan@example.com")) + + const user = await oauth.signInWithAppleIdToken("a", SLUR_NAME) + + expect(user.displayName).toBe("jordan") + }) + + it("replaces a slur Google name claim with the email local part", async () => { + const { oauth, verifier } = makeServices() + verifier.register("g", claims("google-sub", "casey@example.com", { name: SLUR_NAME })) + + const user = await oauth.signInWithGoogleIdToken("g") + + expect(user.displayName).toBe("casey") + }) + + it("falls back to the generic provider label when every candidate is a slur", async () => { + const { oauth, verifier } = makeServices() + verifier.register("g", claims("google-sub", "faggot@example.com", { name: SLUR_NAME })) + verifier.register("a", claims("apple-sub", null)) + + expect((await oauth.signInWithGoogleIdToken("g")).displayName).toBe("Google user") + expect((await oauth.signInWithAppleIdToken("a", SLUR_NAME)).displayName).toBe("Apple user") + }) + + it("falls back when the fullName is only whitespace", async () => { + const { oauth, verifier } = makeServices() + verifier.register("a", claims("apple-sub", null)) + + expect((await oauth.signInWithAppleIdToken("a", " \n ")).displayName).toBe("Apple user") + }) +}) + +describe("POST /v1/auth/google with an unverified email", () => { + let harness: AuthHarness | undefined + afterEach(async () => { + await harness?.app.close() + harness = undefined + }) + + it("mints a session for a new account, not for the account that owns the address", async () => { + harness = await makeAuthHarness() + const victim = await harness.signIn(VICTIM_EMAIL) + harness.verifier.register("attacker-token", { + sub: "attacker-sub", + email: VICTIM_EMAIL, + emailVerified: false, + name: null, + picture: null, + }) + + const res = await harness.app.inject({ + method: "POST", + url: "/v1/auth/google", + headers: { "x-client": "mobile" }, + payload: { idToken: "attacker-token" }, + }) + + expect(res.statusCode).toBe(200) + const body = res.json() as { user: { id: string; email: string | null }; token: string } + expect(body.user.id).not.toBe(victim.userId) + expect(body.user.email ?? null).toBeNull() + const session = await harness.app.inject({ + method: "GET", + url: "/v1/auth/session", + headers: { authorization: `Bearer ${body.token}` }, + }) + expect(session.json().user.id).not.toBe(victim.userId) + }) +}) From 6092ab2d9d1e0b631e133277d73220b256998313 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:24:45 +0000 Subject: [PATCH 04/45] account erasure deletes sessions, push tokens, notifications and pending org invites inside its transaction --- docs/erasure-behavior.md | 41 +++--- services/api/src/auth/pg-stores.ts | 27 +++- services/api/src/routes/users.routes.ts | 17 +-- .../erasure-host-ladder-pg.test.ts | 40 ++++++ .../test/unit/delete-account-route.test.ts | 12 +- .../api/test/unit/pg-stores-security.test.ts | 128 ++++++++++++++++++ .../test/unit/users-routes-security.test.ts | 40 ++++++ 7 files changed, 270 insertions(+), 35 deletions(-) create mode 100644 services/api/test/unit/pg-stores-security.test.ts create mode 100644 services/api/test/unit/users-routes-security.test.ts diff --git a/docs/erasure-behavior.md b/docs/erasure-behavior.md index 23d07791..8921aafa 100644 --- a/docs/erasure-behavior.md +++ b/docs/erasure-behavior.md @@ -34,8 +34,9 @@ response can be answered truthfully. It is the source of record for the - **Unlists** the user's `public` reports (→ `hidden`). - **Transfers, then cancels** the events they organize — the host-transfer ladder below. Only what nobody could take over is cancelled. - - **Releases the organizations they owned** and scrubs their pending team - invitations — see below. + - **Releases the organizations they owned**, scrubs their pending team + invitations and revokes the pending organization invitations they sent or + received (see below). - **Scrubs their own attendee free text** (event answers, attendee names, host notes) and cancels their live waitlist entries, releasing any seats those entries held. @@ -44,33 +45,38 @@ response can be answered truthfully. It is the source of record for the the rows written while it did are retained. See the ⚖️ DECISION below. - **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 + device can be reached after the erasure commits. - Keeps every content foreign key intact (the rows survive; the author de-links). - AFTER the commit, best-effort deletes each revoked certificate's R2 object. Failures are logged, never thrown: a completed erasure must not surface to the client as "deletion failed". -2. `SessionStore.banUser(userId)` — deletes every durable session row, drops the - write-through cache entries, and sets the ban/veto marker so any warm session - that slipped a revoke is rejected on its next request. -3. Clears the session + CSRF cookies on the response. -4. Three independent best-effort cleanups (`allSettled`, each logged on failure): - unlink the OAuth identities, hard-delete the device push tokens, and write the - audit-log row (`account.deleted`, actor = the user). +2. Clears the session + CSRF cookies on the response. +3. Three independent best-effort steps after the commit (`allSettled`, each logged + on failure, none of them able to fail a deletion that already happened): + `SessionStore.banUser(userId)` sets the ban/veto marker, bumps the session + epoch and drops the write-through cache entries, so a cached session + projection is rejected on its next request; unlink the OAuth identities; and + write the audit-log row (`account.deleted`, actor = the user). ## What is scrubbed vs. kept | Data | After `DELETE /me` | |---|---| -| Live sessions / login | **Revoked** — all sessions deleted, ban marker set, cookies cleared. | +| Live sessions / login | **Revoked**: every session row deleted inside the erasure transaction; afterwards (best-effort) the ban marker is set 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. | -| OAuth identity links | **Deleted** (best-effort, step 4) — otherwise a provider sign-in walks back into the tombstone once the ban marker's TTL lapses. | -| Device push tokens | **Deleted** (best-effort, step 4). | +| 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. | | 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. | @@ -295,11 +301,12 @@ instead of telling them to use an add-email flow that does not exist. ### F088 (delete half) — notifications purged on account deletion -The `DELETE /me` post-revocation cleanup fan-out gained a -`DELETE FROM notifications WHERE user_id = $1` step (alongside oauth-unlink, -push-token purge, and the audit row). Notification rows are private to the deleted -user (they can hold verbatim chat/DM previews) and are not civic record, so they -are erased. Add this table to the "scrubbed on deletion" list. (The time-based +Account deletion erases the user's notification rows +(`DELETE FROM notifications WHERE user_id = $1`), now inside the erasure +transaction together with the session and push-token deletes, so a failure rolls +the whole erasure back instead of leaving them behind. Notification rows are +private to the deleted user (they can hold verbatim chat/DM previews) and are not +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 diff --git a/services/api/src/auth/pg-stores.ts b/services/api/src/auth/pg-stores.ts index 2500cc19..02433174 100644 --- a/services/api/src/auth/pg-stores.ts +++ b/services/api/src/auth/pg-stores.ts @@ -4,8 +4,10 @@ import type { Db } from "../db/client.js" import { cleanups, emailOtps, + notifications, oauthIdentities, posts, + pushTokens, reports, serviceHoursCertificates, sessions, @@ -25,6 +27,7 @@ import { resolveAvatarMediaOrThrow } from "../services/avatar-media.js" import { enqueueWaitlistPromotion } from "../services/host/waitlist-promotion.js" import type { NotificationService } from "../services/notification-service.js" import { + EmailTakenError, generatePlaceholderHandle, generateTombstoneHandle, type AccountStatus, @@ -81,7 +84,7 @@ export class PgSessionStore implements SessionStore { ip: sessions.ip, }) .from(sessions) - .innerJoin(users, eq(users.id, sessions.userId)) + .innerJoin(users, and(eq(users.id, sessions.userId), isNull(users.deletedAt))) .leftJoin(userModeration, eq(userModeration.userId, sessions.userId)) .where(eq(sessions.id, hash)) .limit(1) @@ -193,6 +196,9 @@ export class PgUserStore implements UserStore { const row = inserted[0] if (row) return toUserRecord(row) + if (normalizedEmail !== null && input.onEmailConflict === "reject") { + throw new EmailTakenError() + } if (normalizedEmail !== null) { const existing = await this.findByEmail(normalizedEmail) if (existing) return existing @@ -453,6 +459,20 @@ export class PgUserStore implements UserStore { `) } await tx.execute(sql`DELETE FROM organization_members WHERE user_id = ${id}`) + // Accepting an invite seats the role it names without re-checking the inviter, so an invite must not + // outlive the admin who sent it; one addressed to the closed account can never be accepted. + await tx.execute(sql` + WITH revoked AS ( + UPDATE organization_invites + SET status = 'revoked', revoked_at = now() + WHERE status = 'pending' AND (invited_by = ${id} OR user_id = ${id}) + RETURNING id, organization_id + ) + INSERT INTO audit_log (actor_id, action, target, meta) + SELECT ${id}::uuid, 'org.invite_revoked', 'organization:' || organization_id, + jsonb_build_object('inviteId', id, 'reason', 'account_deleted') + FROM revoked + `) if (ownedOrgIds.length > 0) { const orphaned = await tx.execute<{ id: string }>(sql` UPDATE organizations SET deleted_at = now(), updated_at = now() @@ -548,6 +568,11 @@ export class PgUserStore implements UserStore { .returning() const r = updated[0] if (!r) throw new Error("PgUserStore.softDeleteAndAnonymize: user not found") + // Revocation commits with the tombstone: if these ran after commit, a failure between the two would + // leave a deleted account whose tokens still authenticate and whose devices still get pushes. + await tx.delete(sessions).where(eq(sessions.userId, id)) + await tx.delete(pushTokens).where(eq(pushTokens.userId, id)) + await tx.delete(notifications).where(eq(notifications.userId, id)) await tx .update(reports) .set({ visibility: "hidden" }) diff --git a/services/api/src/routes/users.routes.ts b/services/api/src/routes/users.routes.ts index e2ed0e09..ffdf2d2a 100644 --- a/services/api/src/routes/users.routes.ts +++ b/services/api/src/routes/users.routes.ts @@ -23,7 +23,6 @@ import { searchByHandlePrefix, searchMentionable } from "../services/social-repo import { toUserDTO } from "../auth/auth-services.js" import { writeAudit } from "../services/admin/audit.js" import { DATA_EXPORT_JOB, dataExportSupportEmail } from "../services/data-export-jobs.js" -import { makeDrizzleNotificationRepository } from "../services/notification-repository.drizzle.js" import type { BlocksRepository } from "../services/blocks-repository.drizzle.js" import { dropContainerSuggestions } from "../services/social-suggestions-wiring.js" import { route } from "../versioning/route.js" @@ -198,24 +197,16 @@ export async function registerUsersRoutes( } } + // The erasure transaction also deletes the durable sessions, push tokens and notifications. The + // steps below run after it commits: the ban marker and epoch bump retire cached session projections, + // and a failure is logged rather than failing a deletion that already happened. await store.softDeleteAndAnonymize(userId) - await sessions.banUser(userId) clearSessionCookie(reply) clearCsrfCookie(reply) const cleanups: ReadonlyArray Promise]> = [ + ["sessions.ban", () => sessions.banUser(userId)], ["oauth.unlink", () => oauth.unlinkAllForUser(userId)], - [ - "push-tokens.delete", - () => - makeDrizzleNotificationRepository(container.getDb().sql).deletePushTokensForUser( - userId, - ), - ], - [ - "notifications.delete", - () => container.getDb().sql`DELETE FROM notifications WHERE user_id = ${userId}`, - ], [ "audit.account-deleted", () => 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 e61f1bf1..a24a1a67 100644 --- a/services/api/test/integration/erasure-host-ladder-pg.test.ts +++ b/services/api/test/integration/erasure-host-ladder-pg.test.ts @@ -226,6 +226,46 @@ describe.skipIf(!pg)("erasure: the host-transfer ladder", () => { expect(rows[0]!.email_scrubbed_at).not.toBeNull() }) + it("revokes the pending organization invites the departing user sent or received, audited", async () => { + const h = pg! + const orgOwner = await user(h, "org-invite-owner") + const leavingAdmin = await user(h, "org-invite-admin") + const bystander = await user(h, "org-invite-bystander") + const orgId = await organization(h, `org-invites-${Date.now()}`, orgOwner) + await addOrgMember(h, orgId, leavingAdmin, "admin", 3) + const stamp = Date.now() + const [sent, received, unrelated] = await h.sql<{ id: string }[]>` + INSERT INTO organization_invites + (organization_id, email, user_id, role, token_hash, status, invited_by, expires_at) + VALUES + (${orgId}, 'sock@example.test', NULL, 'admin', ${`sent-${stamp}`}, 'pending', + ${leavingAdmin}, now() + interval '14 days'), + (${orgId}, NULL, ${leavingAdmin}, 'member', ${`received-${stamp}`}, 'pending', + ${orgOwner}, now() + interval '14 days'), + (${orgId}, NULL, ${bystander}, 'member', ${`unrelated-${stamp}`}, 'pending', + ${orgOwner}, now() + interval '14 days') + RETURNING id + ` + + await new PgUserStore(h.db).softDeleteAndAnonymize(leavingAdmin) + + const statuses = await h.sql<{ id: string; status: string; revoked_at: Date | null }[]>` + SELECT id, status, revoked_at FROM organization_invites WHERE organization_id = ${orgId} + ` + const statusOfInvite = (id: string) => statuses.find((row) => row.id === id) + expect(statusOfInvite(sent!.id)?.status).toBe("revoked") + expect(statusOfInvite(sent!.id)?.revoked_at).not.toBeNull() + expect(statusOfInvite(received!.id)?.status).toBe("revoked") + expect(statusOfInvite(unrelated!.id)?.status).toBe("pending") + + const audits = await h.sql<{ meta: Record }[]>` + SELECT meta FROM audit_log + WHERE action = 'org.invite_revoked' AND target = ${`organization:${orgId}`} + ` + expect(audits.map((a) => a.meta.inviteId).sort()).toEqual([sent!.id, received!.id].sort()) + expect(audits.every((a) => a.meta.reason === "account_deleted")).toBe(true) + }) + it("releases the seats an OFFERED waitlist entry reserved, and only those", async () => { const h = pg! const host = await user(h, "host-waitlist") diff --git a/services/api/test/unit/delete-account-route.test.ts b/services/api/test/unit/delete-account-route.test.ts index dcb9560d..d4ded1de 100644 --- a/services/api/test/unit/delete-account-route.test.ts +++ b/services/api/test/unit/delete-account-route.test.ts @@ -163,8 +163,10 @@ describe("DELETE /me post-revocation cleanup isolation", () => { expect(res.json()).toEqual({ ok: true }) const pushDelete = fake.statements.find((s) => /DELETE FROM push_tokens/i.test(s.sql)) - expect(pushDelete, "push-token erasure must not be skipped by the unlink failure").toBeDefined() - expect(pushDelete?.values).toContain(userId) + expect( + pushDelete, + "push tokens are purged inside the erasure transaction, not by a post-commit route step", + ).toBeUndefined() const audit = fake.statements.find((s) => /INSERT INTO audit_log/i.test(s.sql)) expect( @@ -174,8 +176,10 @@ describe("DELETE /me post-revocation cleanup isolation", () => { expect(audit?.values.slice(0, 3)).toEqual([userId, "account.deleted", `user:${userId}`]) const notifDelete = fake.statements.find((s) => /DELETE FROM notifications/i.test(s.sql)) - expect(notifDelete, "the deleted user's notification rows must be purged (F088)").toBeDefined() - expect(notifDelete?.values).toContain(userId) + expect( + notifDelete, + "notifications are purged inside the erasure transaction, not by a post-commit route step", + ).toBeUndefined() const after = await services.users.findById(userId) expect(after?.deletedAt ?? null).not.toBeNull() diff --git a/services/api/test/unit/pg-stores-security.test.ts b/services/api/test/unit/pg-stores-security.test.ts new file mode 100644 index 00000000..8d3250eb --- /dev/null +++ b/services/api/test/unit/pg-stores-security.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest" +import { getTableColumns } from "drizzle-orm" +import { drizzle } from "drizzle-orm/postgres-js" +import * as schema from "../../src/db/schema/index.js" +import type { Db } from "../../src/db/client.js" +import { PgSessionStore, PgUserStore } from "../../src/auth/pg-stores.js" + +const USER_ID = "77777777-7777-4777-8777-777777777777" + +interface Recorded { + sql: string + params: unknown[] + inTransaction: boolean +} + +type Responder = (query: string) => { rows?: unknown[][]; error?: Error } + +// A postgres.js stand-in for drizzle: records every statement, marks the ones issued inside `begin`, and +// answers row arrays in the column order drizzle asks for. +function recordingDb(respond: Responder = () => ({})): { db: Db; statements: Recorded[] } { + const statements: Recorded[] = [] + let inTransaction = false + const client = { + options: { parsers: {}, serializers: {} }, + unsafe(query: string, params: unknown[] = []) { + statements.push({ sql: query, params, inTransaction }) + const answer = respond(query) + const settle = (value: T): Promise => + answer.error ? Promise.reject(answer.error) : Promise.resolve(value) + const result = settle([]) as Promise & { values(): Promise } + result.catch(() => {}) + result.values = () => settle(answer.rows ?? []) + return result + }, + async begin(callback: (tx: unknown) => Promise): Promise { + inTransaction = true + try { + return await callback(client) + } finally { + inTransaction = false + } + }, + } + return { db: drizzle(client as never, { schema }) as unknown as Db, statements } +} + +function erasedUserRow(): unknown[] { + return Object.keys(getTableColumns(schema.users)).map((key) => (key === "id" ? USER_ID : null)) +} + +function answerErasure(extra: Responder = () => ({})): Responder { + return (query) => { + const override = extra(query) + if (override.rows || override.error) return override + if (/^update "users"/i.test(query)) return { rows: [erasedUserRow()] } + return {} + } +} + +function deleteOf(statements: Recorded[], table: string): Recorded | undefined { + return statements.find((s) => new RegExp(`^delete from "${table}"`, "i").test(s.sql)) +} + +describe("account erasure revokes sessions and device reach atomically", () => { + it.each(["sessions", "push_tokens", "notifications"])( + "deletes the user's %s rows inside the erasure transaction", + async (table) => { + const { db, statements } = recordingDb(answerErasure()) + + await new PgUserStore(db).softDeleteAndAnonymize(USER_ID) + + const statement = deleteOf(statements, table) + expect(statement, `no DELETE FROM ${table}`).toBeDefined() + expect(statement!.inTransaction).toBe(true) + expect(statement!.sql).toMatch(new RegExp(`"${table}"\\."user_id" = \\$1`, "i")) + expect(statement!.params).toEqual([USER_ID]) + }, + ) + + it("fails the erasure instead of committing it when a revocation delete fails", async () => { + const { db } = recordingDb( + answerErasure((query) => + /^delete from "push_tokens"/i.test(query) ? { error: new Error("push_tokens down") } : {}, + ), + ) + + await expect(new PgUserStore(db).softDeleteAndAnonymize(USER_ID)).rejects.toMatchObject({ + cause: { message: "push_tokens down" }, + }) + }) +}) + +describe("session lookup for a deleted account", () => { + it("only resolves a session whose user is not soft-deleted", async () => { + const { db, statements } = recordingDb() + + expect(await new PgSessionStore(db).findById("hash")).toBeNull() + + const lookup = statements.find((s) => /from "sessions"/i.test(s.sql))! + expect(lookup.sql).toMatch(/inner join "users" on \(?"users"\."id" = "sessions"\."user_id"/i) + expect(lookup.sql).toMatch(/"users"\."deleted_at" is null/i) + }) +}) + +describe("account erasure revokes the user's pending organization invites", () => { + it("revokes invites the user sent or received, with an audit row each, inside the transaction", async () => { + const { db, statements } = recordingDb(answerErasure()) + + await new PgUserStore(db).softDeleteAndAnonymize(USER_ID) + + const membershipDelete = statements.findIndex((s) => + /delete from organization_members where user_id/i.test(s.sql), + ) + const revokeAt = statements.findIndex((s) => /update organization_invites/i.test(s.sql)) + expect(revokeAt, "no organization_invites revocation").toBeGreaterThan(membershipDelete) + expect(membershipDelete).toBeGreaterThanOrEqual(0) + + const revoke = statements[revokeAt]! + const text = revoke.sql.replace(/\s+/g, " ") + expect(revoke.inTransaction).toBe(true) + expect(text).toMatch(/set status = 'revoked', revoked_at = now\(\)/i) + expect(text).toMatch(/status = 'pending' and \(invited_by = \$1 or user_id = \$2\)/i) + expect(text).toMatch(/insert into audit_log \(actor_id, action, target, meta\)/i) + expect(text).toContain("'org.invite_revoked'") + expect(text).toContain("'account_deleted'") + expect(revoke.params.slice(0, 2)).toEqual([USER_ID, USER_ID]) + }) +}) diff --git a/services/api/test/unit/users-routes-security.test.ts b/services/api/test/unit/users-routes-security.test.ts new file mode 100644 index 00000000..b3d86a44 --- /dev/null +++ b/services/api/test/unit/users-routes-security.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from "vitest" +import { makeAuthHarness, type AuthHarness } from "../helpers/auth.js" + +const EMAIL = "leaving@example.com" + +let harness: AuthHarness | undefined +afterEach(async () => { + await harness?.app.close() + harness = undefined +}) + +describe("DELETE /v1/me after the erasure commits", () => { + it("reports the deletion as done when the session ban marker cannot be written", async () => { + harness = await makeAuthHarness() + const { token, userId } = await harness.signIn(EMAIL) + await harness.app.inject({ + method: "POST", + url: "/v1/auth/otp/request", + payload: { email: EMAIL }, + }) + const code = harness.mailer.lastOtpFor(EMAIL)! + let banAttempts = 0 + harness.services.sessions.banUser = () => { + banAttempts += 1 + return Promise.reject(new Error("redis unavailable")) + } + + const res = await harness.app.inject({ + method: "DELETE", + url: "/v1/me", + headers: { authorization: `Bearer ${token}`, "x-client": "mobile" }, + payload: { emailOtp: code }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + expect(banAttempts).toBe(1) + expect((await harness.stores.users.findById(userId))?.deletedAt ?? null).not.toBeNull() + }) +}) From 5db7bff4479d67a172ecf4926bb6e6c7aa990d0f Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:24:45 +0000 Subject: [PATCH 05/45] production 5xx bodies hide internal messages; logs redact at any depth and drop query strings --- services/api/src/auth/jwks.ts | 9 +- services/api/src/errors/exposed-message.ts | 15 ++ services/api/src/errors/http-mapper.ts | 32 +++- services/api/src/errors/log-redaction.ts | 127 +++++++++++++ services/api/src/lib/request-url.ts | 5 + services/api/src/routes/forms.routes.ts | 5 +- services/api/src/server.ts | 86 ++++----- .../api/test/unit/exposed-5xx-copy.test.ts | 108 +++++++++++ .../test/unit/http-mapper-security.test.ts | 178 ++++++++++++++++++ .../test/unit/log-redaction-security.test.ts | 152 +++++++++++++++ 10 files changed, 654 insertions(+), 63 deletions(-) create mode 100644 services/api/src/errors/exposed-message.ts create mode 100644 services/api/src/errors/log-redaction.ts create mode 100644 services/api/src/lib/request-url.ts create mode 100644 services/api/test/unit/exposed-5xx-copy.test.ts create mode 100644 services/api/test/unit/http-mapper-security.test.ts create mode 100644 services/api/test/unit/log-redaction-security.test.ts diff --git a/services/api/src/auth/jwks.ts b/services/api/src/auth/jwks.ts index fe314e81..3547492c 100644 --- a/services/api/src/auth/jwks.ts +++ b/services/api/src/auth/jwks.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto" import { AppError, ErrorCode } from "@civfix/shared" +import { exposeMessage } from "../errors/exposed-message.js" import { constantTimeStringEqual } from "./crypto.js" export interface VerifiedIdToken { @@ -141,9 +142,11 @@ export class RemoteJwksVerifier implements JwksVerifier { // Reporting it as "unauthorized" told the client to re-authenticate during a provider outage — // and every retry burns another single-use sign-in nonce. 503 says "retry", not "sign in again". // A genuinely unknown `kid` still yields 401 (resolveKey). - throw new AppError(ErrorCode.INTERNAL, "Could not reach the identity provider.", { - httpStatus: 503, - }) + throw exposeMessage( + new AppError(ErrorCode.INTERNAL, "Could not reach the identity provider.", { + httpStatus: 503, + }), + ) } const body = (await res.json()) as { keys?: Jwk[] } const keys = Array.isArray(body.keys) ? body.keys : [] diff --git a/services/api/src/errors/exposed-message.ts b/services/api/src/errors/exposed-message.ts new file mode 100644 index 00000000..29cc8576 --- /dev/null +++ b/services/api/src/errors/exposed-message.ts @@ -0,0 +1,15 @@ +import type { AppError } from "@civfix/shared" + +// A 5xx message is hidden from production clients by default because most carry operator diagnostics +// (vendor responses, env var names). The few written for end users opt in here. The brand lives in the +// backend so the shared AppError contract stays unchanged. +const exposed = new WeakSet() + +export function exposeMessage(error: E): E { + exposed.add(error) + return error +} + +export function isMessageExposed(error: AppError): boolean { + return exposed.has(error) +} diff --git a/services/api/src/errors/http-mapper.ts b/services/api/src/errors/http-mapper.ts index 481c6e73..835340e4 100644 --- a/services/api/src/errors/http-mapper.ts +++ b/services/api/src/errors/http-mapper.ts @@ -7,6 +7,9 @@ * Fastify validation -> 422 VALIDATION with field details * anything else -> mapped client code (<500) or 500 INTERNAL (message hidden in production) * + * A 5xx AppError's message is hidden in production too, unless the error was marked with exposeMessage() + * or the route is on the operator plane, whose console needs the diagnostic. + * * Every unknown (non-AppError, or AppError with httpStatus >= 500) error is forwarded to * GlitchTip/Sentry via captureError. There are NO silent catches: the handler always logs and responds. */ @@ -14,8 +17,16 @@ import { AppError, ErrorCode } from "@civfix/shared" import type { FastifyError, FastifyReply, FastifyRequest } from "fastify" import { isProd } from "../env.js" +import { loggedRequestUrl } from "../lib/request-url.js" +import { isMessageExposed } from "./exposed-message.js" import { captureError } from "./glitchtip.js" +const INTERNAL_ERROR_MESSAGE = "Internal error" + +// Operator routes sit behind Cloudflare Access and the operator guard, and the console shows server-side +// diagnostics (an SMTP rejection, a misconfigured provider) that the operator is there to fix. +const OPERATOR_ROUTE_PREFIX = "/v1/admin/" + interface ErrorBody { code: ErrorCode message: string @@ -61,13 +72,17 @@ export function makeErrorHandler() { error.requestId = requestId const body: ErrorBody = { code: error.code, - message: error.message, + message: serverMessageHidden(error, request) ? INTERNAL_ERROR_MESSAGE : error.message, requestId, ...(error.fields ? { fields: error.fields } : {}), } if (error.httpStatus >= 500) { request.log.error({ err: error, requestId }, "AppError (server)") - captureError(error, { requestId, url: request.url.split("?")[0], method: request.method }) + captureError(error, { + requestId, + url: loggedRequestUrl(request.url), + method: request.method, + }) } else { request.log.info({ code: error.code, requestId }, "AppError (client)") } @@ -123,20 +138,27 @@ export function makeErrorHandler() { } request.log.error({ err: error, requestId }, "unhandled error") - captureError(error, { requestId, url: request.url.split("?")[0], method: request.method }) + captureError(error, { requestId, url: loggedRequestUrl(request.url), method: request.method }) const body: ErrorBody = { code: ErrorCode.INTERNAL, - message: isProd() ? "Internal error" : (error.message ?? "Internal error"), + message: isProd() ? INTERNAL_ERROR_MESSAGE : (error.message ?? INTERNAL_ERROR_MESSAGE), requestId, } reply.status(500).send(body) } } +function serverMessageHidden(error: AppError, request: FastifyRequest): boolean { + if (error.httpStatus < 500 || !isProd()) return false + if (isMessageExposed(error)) return false + const routePattern = request.routeOptions.url ?? "" + return !routePattern.startsWith(OPERATOR_ROUTE_PREFIX) +} + export function makeNotFoundHandler() { return function notFoundHandler(request: FastifyRequest, reply: FastifyReply): void { request.log.info( - { method: request.method, url: request.url, requestId: request.id }, + { method: request.method, url: loggedRequestUrl(request.url), requestId: request.id }, "route not found", ) const body: ErrorBody = { diff --git a/services/api/src/errors/log-redaction.ts b/services/api/src/errors/log-redaction.ts new file mode 100644 index 00000000..ec7248d1 --- /dev/null +++ b/services/api/src/errors/log-redaction.ts @@ -0,0 +1,127 @@ +export const LOG_REDACTION_CENSOR = "[REDACTED]" + +// Matched as whole key names (any case), not substrings like the error tracker's list: substring matching +// would also blank `code`, `statusCode`, `sessionId` and `title`, which every error log line needs. +const LOG_SENSITIVE_KEYS: ReadonlySet = new Set( + [ + "password", + "token", + "otp", + "email", + "phone", + "tokenHash", + "ticketToken", + "ticketTokens", + "manageToken", + "accessCode", + "attendeeName", + "attendeeNames", + "answer", + "answers", + "hostNote", + "note", + "einNumber", + "ein_number", + "invitedEmail", + "maskedEmail", + "recipientEmail", + "replyTo", + "to", + "clientSecret", + "client_secret", + "card", + "cvc", + "pan", + "last4", + "cardLast4", + "webhookSecret", + ].map((key) => key.toLowerCase()), +) + +// The verbatim SMTP reply echoes the recipient address; `response` is only sensitive under `smtp`. +const SMTP_KEY = "smtp" +const SMTP_RESPONSE_KEY = "response" + +// Every log line pays for this walk, so it stops at a fixed depth; anything deeper is dropped rather than +// written unredacted. +const MAX_REDACTION_DEPTH = 10 +const TRUNCATED = "[Truncated]" +const CIRCULAR = "[Circular]" + +function isSensitiveLogKey(key: string, parentKey: string | null): boolean { + const lower = key.toLowerCase() + if (LOG_SENSITIVE_KEYS.has(lower)) return true + return lower === SMTP_RESPONSE_KEY && parentKey?.toLowerCase() === SMTP_KEY +} + +function isPlainObject(value: object): value is Record { + const proto: unknown = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +interface Walk { + depth: number + ancestors: Set +} + +function redactValue(value: unknown, parentKey: string | null, walk: Walk): unknown { + if (value === null || typeof value !== "object") return value + if (walk.depth >= MAX_REDACTION_DEPTH) return TRUNCATED + if (walk.ancestors.has(value)) return CIRCULAR + if (!Array.isArray(value) && !(value instanceof Error) && !isPlainObject(value)) return value + + walk.ancestors.add(value) + walk.depth += 1 + try { + if (Array.isArray(value)) return value.map((item) => redactValue(item, parentKey, walk)) + if (value instanceof Error) return shadowError(value, walk) + const out: Record = {} + for (const [key, child] of Object.entries(value)) { + out[key] = isSensitiveLogKey(key, parentKey) + ? LOG_REDACTION_CENSOR + : redactValue(child, key, walk) + } + return out + } finally { + walk.depth -= 1 + walk.ancestors.delete(value) + } +} + +// The logger's err serializer runs after this pass and reads the constructor name, message, stack, cause, +// aggregate errors and every enumerable property. A same-prototype copy keeps all of that while the +// caller's error object, which is still being handled and reported, is never mutated. +function shadowError(error: Error, walk: Walk): Error { + const shadow = Object.create(Object.getPrototypeOf(error) as object) as Error + hideOn(shadow, "message", error.message) + hideOn(shadow, "stack", error.stack) + if ("cause" in error) hideOn(shadow, "cause", redactValue(error.cause, null, walk)) + if (error instanceof AggregateError) + hideOn(shadow, "errors", redactValue(error.errors, null, walk)) + const shadowRecord = shadow as unknown as Record + const errorRecord = error as unknown as Record + for (const key in error) { + shadowRecord[key] = isSensitiveLogKey(key, null) + ? LOG_REDACTION_CENSOR + : redactValue(errorRecord[key], key, walk) + } + return shadow +} + +function hideOn(target: object, key: string, value: unknown): void { + Object.defineProperty(target, key, { + value, + enumerable: false, + writable: true, + configurable: true, + }) +} + +/** + * The logger's `formatters.log` hook: returns a copy of the logged object with every sensitive key + * censored at any depth, inside arrays and inside errors. Class instances other than errors (the request + * and reply the framework logs) pass through untouched for their own serializers. + */ +export function redactLogObject(object: Record): Record { + return redactValue(object, null, { depth: 0, ancestors: new Set() }) as Record +} diff --git a/services/api/src/lib/request-url.ts b/services/api/src/lib/request-url.ts new file mode 100644 index 00000000..049b6a1c --- /dev/null +++ b/services/api/src/lib/request-url.ts @@ -0,0 +1,5 @@ +// Query strings carry secrets (ticket access codes, anonymous claim codes, OAuth code/state), so every log +// line and error report records the path only. +export function loggedRequestUrl(url: string): string { + return url.split("?")[0] ?? url +} diff --git a/services/api/src/routes/forms.routes.ts b/services/api/src/routes/forms.routes.ts index 5fb280a1..57faeba8 100644 --- a/services/api/src/routes/forms.routes.ts +++ b/services/api/src/routes/forms.routes.ts @@ -11,6 +11,7 @@ import { heading, kvTable, paragraph } from "../adapters/email-blocks.js" import { renderEmailBody } from "../adapters/email-layout.js" import { sanitizeHeaderValue } from "../adapters/mail-text.js" import { parse } from "./_validate.js" +import { exposeMessage } from "../errors/exposed-message.js" export const HOME_TURF_RATE_LIMIT = perHost({ max: 5, timeWindow: "1 minute" }) @@ -107,7 +108,9 @@ export async function registerHomeTurfRoutes( const store = counters() if (store === null) { app.log.error("home-turf form: no counter store (REDIS_URL unset) — refusing to send") - throw AppError.internal("This form is temporarily unavailable. Please try again later.") + throw exposeMessage( + AppError.internal("This form is temporarily unavailable. Please try again later."), + ) } return store } diff --git a/services/api/src/server.ts b/services/api/src/server.ts index e256b7b6..36ed623f 100644 --- a/services/api/src/server.ts +++ b/services/api/src/server.ts @@ -1,8 +1,10 @@ -import Fastify, { type FastifyInstance } from "fastify" +import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify" import { loadEnv, type Env } from "./env.js" import { assertRedisReachable, buildContainer, type Container } from "./di.js" import { makeErrorHandler, makeNotFoundHandler } from "./errors/http-mapper.js" import { initErrorReporting } from "./errors/glitchtip.js" +import { LOG_REDACTION_CENSOR, redactLogObject } from "./errors/log-redaction.js" +import { loggedRequestUrl } from "./lib/request-url.js" import { genReqId, registerRequestId } from "./plugins/request-id.js" import { registerCors } from "./plugins/cors.js" import { registerHelmet } from "./plugins/helmet.js" @@ -124,42 +126,13 @@ const OVERRIDE_KEYS = [ "contentSubjectGate", ] as const satisfies readonly (keyof BuildServerOptions)[] +// Header and error-envelope paths only. Sensitive keys inside logged objects are censored at any depth by +// redactLogObject; wildcard paths here only ever reached one nesting level. export const LOG_REDACT_PATHS = [ "req.headers.authorization", "req.headers.cookie", "res.headers['set-cookie']", "req.headers['x-csrf-token']", - "*.password", - "*.token", - "*.otp", - "*.email", - "*.phone", - "*.tokenHash", - "*.ticketToken", - "*.ticketTokens", - "*.manageToken", - "*.accessCode", - "*.attendeeName", - "*.attendeeNames", - "*.answer", - "*.answers", - "*.hostNote", - "*.note", - "*.einNumber", - "*.ein_number", - "*.invitedEmail", - "*.maskedEmail", - "*.recipientEmail", - "*.replyTo", - "*.to", - "*.clientSecret", - "*.client_secret", - "*.card", - "*.cvc", - "*.pan", - "*.last4", - "*.cardLast4", - "*.webhookSecret", "err.raw", "err.raw.source", "err.headers", @@ -168,8 +141,32 @@ export const LOG_REDACT_PATHS = [ "err.smtp.response", ] -export function loggedRequestUrl(url: string): string { - return url.split("?")[0] ?? url +export { loggedRequestUrl } + +type LoggerOptions = Exclude, boolean> + +export function loggerOptions(env: Env): LoggerOptions { + return { + level: env.NODE_ENV === "test" ? "silent" : env.NODE_ENV === "production" ? "info" : "debug", + serializers: { + req(request) { + const acceptVersion = request.headers["accept-version"] + return { + method: request.method, + url: loggedRequestUrl(request.url), + version: typeof acceptVersion === "string" ? acceptVersion : undefined, + host: request.host, + remoteAddress: request.ip, + remotePort: request.socket.remotePort, + } + }, + }, + formatters: { log: redactLogObject }, + redact: { + paths: LOG_REDACT_PATHS, + censor: LOG_REDACTION_CENSOR, + }, + } } export async function buildServer(opts: BuildServerOptions = {}): Promise { @@ -184,26 +181,7 @@ export async function buildServer(opts: BuildServerOptions = {}): Promise { + const actual = await importOriginal() + return { ...actual, isProd: () => true } +}) + +const SIGNING_KEY = "unsubscribe-signing-key-for-tests-0123456789" + +let app: FastifyInstance | undefined +afterEach(async () => { + await app?.close() + app = undefined +}) + +describe("5xx copy written for the people who see it survives production masking", () => { + it("tells a home-turf coach the form is temporarily unavailable", async () => { + const env = loadEnv({ NODE_ENV: "test", HOME_TURF_NOTIFY_TO: "home-turf@civfix.test" }) + app = await buildServer({ env, container: buildContainer(env) }) + + const res = await app.inject({ + method: "POST", + url: "/forms/home-turf", + payload: { + coachName: "Alex Rivera", + role: "Head Coach", + school: "Lincoln High School", + city: "Los Angeles", + teamSize: "18", + email: "coach@example.org", + phone: "+1 213 555 0100", + notes: "Tuesdays", + turnstileToken: "ok", + honeypot: "", + }, + }) + + expect(res.statusCode, res.body).toBe(500) + expect(res.json()).toMatchObject({ + message: "This form is temporarily unavailable. Please try again later.", + }) + }) + + it("tells a one-click unsubscriber to try again", async () => { + const repo = new InMemoryBroadcastRepository() + repo.recordUnsubscribe = () => Promise.reject(new Error("connection terminated")) + const config = { + killSwitch: false, + unsubscribeSigningKey: SIGNING_KEY, + webBaseUrl: "http://localhost:3000", + apiBaseUrl: "http://localhost:8080", + } as unknown as BroadcastConfig + const broadcasts = makeBroadcastService({ + repo, + counters: new InMemoryCounterStore(), + config, + mailer: new FakeMailer(), + enqueuePlan: () => Promise.resolve(), + }) + const instance = Fastify({ logger: false }) + instance.setErrorHandler(makeErrorHandler()) + instance.setNotFoundHandler(makeNotFoundHandler()) + instance.decorate("broadcastOverrides", { runtime: { broadcasts } as unknown as CommsRuntime }) + await registerUnsubscribeRoutes(instance, { + env: { NODE_ENV: "test", WEB_ORIGINS: ["http://localhost:3000"] }, + } as unknown as Container) + await instance.ready() + app = instance + const token = mintUnsubscribeToken( + { + subjectKind: "user", + subjectId: "00000000-0000-0000-0000-0000000000aa", + cleanupId: "00000000-0000-0000-0000-0000000000ee", + expiresAtMs: unsubscribeExpiryFrom(Date.now()), + }, + SIGNING_KEY, + ) + + const res = await instance.inject({ + method: "POST", + url: "/v1/broadcasts/unsubscribe", + payload: { token }, + }) + + expect(res.statusCode).toBe(503) + expect(res.json()).toMatchObject({ + message: "We couldn't save your unsubscribe. Please try again.", + }) + }) +}) diff --git a/services/api/test/unit/http-mapper-security.test.ts b/services/api/test/unit/http-mapper-security.test.ts new file mode 100644 index 00000000..9ca5f3b2 --- /dev/null +++ b/services/api/test/unit/http-mapper-security.test.ts @@ -0,0 +1,178 @@ +import { Writable } from "node:stream" +import { beforeEach, describe, expect, it, vi } from "vitest" +import Fastify, { type FastifyInstance } from "fastify" +import { AppError, ErrorCode, MailSendError } from "@civfix/shared" + +let prod = false +vi.mock("../../src/env.js", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isProd: () => prod } +}) + +const captured: unknown[] = [] +vi.mock("../../src/errors/glitchtip.js", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + captureError: (err: unknown) => { + captured.push(err) + }, + } +}) + +const { makeErrorHandler, makeNotFoundHandler } = await import("../../src/errors/http-mapper.js") +const { exposeMessage } = await import("../../src/errors/exposed-message.js") + +const SMTP_DIAGNOSTIC = + "Email not sent: the SMTP server rejected our credentials. Check OCI_EMAIL_SMTP_USER / OCI_EMAIL_SMTP_PASS." +const FORM_UNAVAILABLE = "This form is temporarily unavailable. Please try again later." + +interface Probe { + app: FastifyInstance + logLines: string[] +} + +async function buildProbe(thrown: () => unknown): Promise { + const logLines: string[] = [] + const stream = new Writable({ + write(chunk: Buffer, _enc, done) { + logLines.push(chunk.toString()) + done() + }, + }) + const app = Fastify({ logger: { level: "info", stream }, disableRequestLogging: true }) + app.setErrorHandler(makeErrorHandler()) + app.setNotFoundHandler(makeNotFoundHandler()) + app.get("/v1/throw", async () => { + throw thrown() + }) + app.post("/v1/admin/mail/send", async () => { + throw thrown() + }) + await app.ready() + return { app, logLines } +} + +beforeEach(() => { + prod = false + captured.length = 0 +}) + +describe("5xx AppError messages in production", () => { + it("hides an internal AppError message but still logs and reports it", async () => { + const { app, logLines } = await buildProbe(() => AppError.internal("db exploded")) + prod = true + + const res = await app.inject({ method: "GET", url: "/v1/throw" }) + + expect(res.statusCode).toBe(500) + expect(res.json()).toMatchObject({ code: ErrorCode.INTERNAL, message: "Internal error" }) + expect(captured).toHaveLength(1) + expect(logLines.join("")).toContain("db exploded") + await app.close() + }) + + it("hides an SMTP credential diagnostic on a public route", async () => { + const { app } = await buildProbe( + () => new MailSendError(ErrorCode.INTERNAL, SMTP_DIAGNOSTIC, {}), + ) + prod = true + + const res = await app.inject({ method: "GET", url: "/v1/throw" }) + + expect(res.statusCode).toBe(500) + expect(res.json().message).toBe("Internal error") + expect(captured).toHaveLength(1) + await app.close() + }) + + it("keeps a message that was deliberately marked safe to show", async () => { + const { app } = await buildProbe(() => exposeMessage(AppError.internal(FORM_UNAVAILABLE))) + prod = true + + const res = await app.inject({ method: "GET", url: "/v1/throw" }) + + expect(res.statusCode).toBe(500) + expect(res.json().message).toBe(FORM_UNAVAILABLE) + await app.close() + }) + + it("keeps the diagnostic on an operator-plane route", async () => { + const { app } = await buildProbe( + () => new MailSendError(ErrorCode.INTERNAL, SMTP_DIAGNOSTIC, {}), + ) + prod = true + + const res = await app.inject({ method: "POST", url: "/v1/admin/mail/send" }) + + expect(res.statusCode).toBe(500) + expect(res.json().message).toBe(SMTP_DIAGNOSTIC) + await app.close() + }) + + it("does not treat a query string mentioning the admin plane as the operator plane", async () => { + const { app } = await buildProbe(() => AppError.internal("db exploded")) + prod = true + + const res = await app.inject({ method: "GET", url: "/v1/throw?next=/v1/admin/mail/send" }) + + expect(res.json().message).toBe("Internal error") + await app.close() + }) + + it("still echoes the message outside production", async () => { + const { app } = await buildProbe(() => AppError.internal("db exploded")) + + const res = await app.inject({ method: "GET", url: "/v1/throw" }) + + expect(res.json().message).toBe("db exploded") + await app.close() + }) + + it("leaves 4xx AppError messages untouched in production", async () => { + const { app } = await buildProbe(() => AppError.conflict("Handle already taken.")) + prod = true + + const res = await app.inject({ method: "GET", url: "/v1/throw" }) + + expect(res.statusCode).toBe(409) + expect(res.json().message).toBe("Handle already taken.") + await app.close() + }) +}) + +describe("not-found logging", () => { + it("logs the path without its query string", async () => { + const { app, logLines } = await buildProbe(() => new Error("unused")) + + const res = await app.inject({ method: "GET", url: "/v1/nope?accessCode=SECRET-CODE" }) + + expect(res.statusCode).toBe(404) + const notFound = logLines.filter((l) => l.includes("route not found")) + expect(notFound).toHaveLength(1) + expect(notFound[0]).toContain("/v1/nope") + expect(logLines.join("")).not.toContain("SECRET-CODE") + await app.close() + }) +}) + +describe("user-facing 5xx messages stay readable in production", () => { + it("keeps the identity-provider outage message", async () => { + const { RemoteJwksVerifier } = await import("../../src/auth/jwks.js") + const verifier = new RemoteJwksVerifier({ + fetchImpl: () => Promise.resolve({ ok: false, json: () => Promise.resolve({}) }), + }) + const header = Buffer.from(JSON.stringify({ alg: "RS256", kid: "k1" })).toString("base64url") + const outage = await verifier + .verify(`${header}.e30.c2ln`, { jwksUrl: "https://jwks.test", issuers: [], audiences: [] }) + .catch((err: unknown) => err) + const { app } = await buildProbe(() => outage) + prod = true + + const res = await app.inject({ method: "GET", url: "/v1/throw" }) + + expect(res.statusCode).toBe(503) + expect(res.json().message).toBe("Could not reach the identity provider.") + await app.close() + }) +}) diff --git a/services/api/test/unit/log-redaction-security.test.ts b/services/api/test/unit/log-redaction-security.test.ts new file mode 100644 index 00000000..3a698a96 --- /dev/null +++ b/services/api/test/unit/log-redaction-security.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest" +import Fastify from "fastify" +import { AppError, ErrorCode } from "@civfix/shared" +import { loggerOptions } from "../../src/server.js" +import { loadEnv } from "../../src/env.js" + +const SECRET = "leak-canary-7f3a" +const SENSITIVE_KEYS = ["email", "token", "otp", "phone", "password", "accessCode"] as const + +function capture(write: (log: ReturnType["log"]) => void): string { + const lines: string[] = [] + const app = Fastify({ + logger: { + ...loggerOptions(loadEnv({ NODE_ENV: "test" })), + level: "info", + stream: { + write(line: string) { + lines.push(line) + }, + }, + }, + }) + write(app.log) + return lines.join("") +} + +describe("log lines redact sensitive keys at any depth", () => { + const shapes: ReadonlyArray object]> = [ + ["top level", (k) => ({ [k]: SECRET })], + ["one level down", (k) => ({ a: { [k]: SECRET } })], + ["two levels down", (k) => ({ a: { b: { [k]: SECRET } } })], + ["five levels down", (k) => ({ a: { b: { c: { d: { e: { [k]: SECRET } } } } } })], + ["inside an array", (k) => ({ recipients: [{ [k]: SECRET }] })], + ["inside a nested array", (k) => ({ batch: { rows: [[{ [k]: SECRET }]] } })], + ] + + for (const key of SENSITIVE_KEYS) { + it.each(shapes)(`redacts ${key} at the %s`, (_label, build) => { + const line = capture((log) => log.info(build(key), "probe")) + expect(line).toContain("probe") + expect(line).not.toContain(SECRET) + }) + } + + it("redacts inside the child logger a request handler uses", () => { + const line = capture((log) => + log.child({ reqId: "r1" }).warn({ ctx: { user: { email: SECRET } } }), + ) + expect(line).toContain('"reqId":"r1"') + expect(line).not.toContain(SECRET) + }) + + it("redacts inside a route handler's request logger", async () => { + const lines: string[] = [] + const app = Fastify({ + logger: { + ...loggerOptions(loadEnv({ NODE_ENV: "test" })), + level: "info", + stream: { + write(line: string) { + lines.push(line) + }, + }, + }, + }) + app.get("/probe", async (request) => { + request.log.info({ ctx: { user: { email: SECRET } } }, "handler log") + return { ok: true } + }) + + await app.inject({ method: "GET", url: "/probe" }) + await app.close() + + expect(lines.some((line) => line.includes("handler log"))).toBe(true) + expect(lines.join("")).not.toContain(SECRET) + }) + + it("keeps non-sensitive keys and values readable", () => { + const line = capture((log) => + log.info({ requestId: "req-1", code: "NOT_FOUND", statusCode: 404, nested: { count: 3 } }), + ) + expect(JSON.parse(line)).toMatchObject({ + requestId: "req-1", + code: "NOT_FOUND", + statusCode: 404, + nested: { count: 3 }, + }) + }) + + it("survives a cyclic payload", () => { + const cyclic: Record = { email: SECRET, name: "loop" } + cyclic.self = cyclic + const line = capture((log) => log.info({ ctx: cyclic }, "cyclic")) + expect(line).toContain("cyclic") + expect(line).not.toContain(SECRET) + }) + + it("stops at a bounded depth instead of walking an unbounded structure", () => { + let deep: Record = { email: SECRET } + for (let i = 0; i < 50; i++) deep = { next: deep } + const line = capture((log) => log.info({ deep }, "deep")) + expect(line).toContain("deep") + expect(line).not.toContain(SECRET) + }) +}) + +describe("error serialization through the redaction pass", () => { + it("keeps type, message, stack and code and redacts sensitive props inside err", () => { + const err = Object.assign(new AppError(ErrorCode.CONFLICT, "mail failed"), { + meta: { recipient: { email: SECRET } }, + }) + const line = capture((log) => log.error({ err }, "send failed")) + const parsed = JSON.parse(line) as { err: Record } + + expect(parsed.err.type).toBe(err.constructor.name) + expect(parsed.err.message).toBe("mail failed") + expect(String(parsed.err.stack)).toContain("mail failed") + expect(parsed.err.code).toBe(ErrorCode.CONFLICT) + expect(line).not.toContain(SECRET) + }) + + it("keeps the cause chain in the message and stack while redacting an error-valued property", () => { + const cause = new Error("smtp down") + const detail = Object.assign(new Error("rejected"), { email: SECRET }) + const err = Object.assign( + new AppError(ErrorCode.INTERNAL, "Failed to send email.", { cause }), + { detail }, + ) + const line = capture((log) => log.error({ err }, "send failed")) + const parsed = JSON.parse(line) as { err: { message: string; stack: string; detail: object } } + + expect(parsed.err.message).toContain("smtp down") + expect(parsed.err.stack).toContain("caused by") + expect(parsed.err.detail).toMatchObject({ message: "rejected", email: "[REDACTED]" }) + expect(line).not.toContain(SECRET) + }) + + it("does not alter the error object the caller still holds", () => { + const err = Object.assign(new Error("boom"), { email: SECRET }) + capture((log) => log.error({ err }, "boom")) + expect(err.email).toBe(SECRET) + }) + + it("still strips the verbatim SMTP reply wherever it sits", () => { + const smtp = { responseCode: 550, response: `550 <${SECRET}>: recipient unknown` } + for (const payload of [{ smtp }, { a: { b: { smtp } } }, { list: [{ smtp }] }]) { + const line = capture((log) => log.error(payload, "mail send failed")) + expect(line).not.toContain(SECRET) + expect(line).toContain('"responseCode":550') + } + }) +}) From 97cc94a370069db3330d4bb7730f601c0b82c023 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:24:45 +0000 Subject: [PATCH 06/45] operator access exchange refuses a banned or suspended account before promoting it --- services/api/src/routes/admin/auth.routes.ts | 44 +++++-- .../unit/admin-auth-routes-security.test.ts | 111 ++++++++++++++++++ 2 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 services/api/test/unit/admin-auth-routes-security.test.ts diff --git a/services/api/src/routes/admin/auth.routes.ts b/services/api/src/routes/admin/auth.routes.ts index e81ad42d..174f70f3 100644 --- a/services/api/src/routes/admin/auth.routes.ts +++ b/services/api/src/routes/admin/auth.routes.ts @@ -9,8 +9,9 @@ import { import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify" import type { Container } from "../../di.js" import type { AuthServices } from "../../auth/auth-services.js" -import type { UserRecord } from "../../auth/stores.js" +import type { AccountStatus, UserRecord } from "../../auth/stores.js" import { SUSPENDED_MESSAGE } from "../../auth/account-status.js" +import { exposeMessage } from "../../errors/exposed-message.js" import type { Env } from "../../env.js" import { resolveLocale } from "../../i18n/locales.js" import { isAdminEmail } from "../../auth/admin-allowlist.js" @@ -55,7 +56,7 @@ export async function registerAdminAuthRoutes( const defaultVerify: VerifyAccessJwt | null = teamDomain && aud ? createAccessVerifier({ teamDomain, aud }) : null - async function provisionOperator(email: string): Promise { + async function provisionOperator(email: string, request: FastifyRequest): Promise { let user = await services.users.findByEmail(email) if (!user) { user = await services.users.create(email, { @@ -64,6 +65,21 @@ export async function registerAdminAuthRoutes( emailVerified: true, }) } + // The status gate runs before the role grant: a restricted account must leave no operator role and + // no successful-login audit behind, even though establishOperatorSession would refuse it anyway. + const accountStatus = await services.users.accountStatus(user.id) + const refusal = restrictedAccountRefusal(accountStatus) + if (refusal) { + await auditOperatorAuth(app, container, { + actorId: user.id, + action: "operator.login_denied", + target: `user:${user.id}`, + meta: { email: user.email, status: accountStatus, via: "cf-access" }, + }).catch((err: unknown) => { + request.log.warn({ err }, "operator.login_denied audit write failed") + }) + throw refusal + } const operator = user.role === "operator" ? user : await services.users.setRole(user.id, "operator") await auditOperatorAuth(app, container, { @@ -82,16 +98,18 @@ export async function registerAdminAuthRoutes( async (request, reply) => { const verify = app.adminAuthOverrides?.verifyAccessJwt ?? defaultVerify if (!verify) { - throw new AppError(ErrorCode.INTERNAL, "Cloudflare Access is not configured.", { - httpStatus: 503, - }) + throw exposeMessage( + new AppError(ErrorCode.INTERNAL, "Cloudflare Access is not configured.", { + httpStatus: 503, + }), + ) } const identity = await verifyHeader(verify, request) const email = identity.email?.toLowerCase() if (!email || !isAdminEmail(env, email)) { throw AppError.forbidden("This account is not authorized for the operator dashboard.") } - const operator = await provisionOperator(email) + const operator = await provisionOperator(email, request) const payload = await establishOperatorSession(services, csrf, request, reply, operator) reply.status(200).send(payload) }, @@ -157,6 +175,12 @@ async function auditOperatorAuth( await writeAudit(container.getDb().sql, input) } +function restrictedAccountRefusal(accountStatus: AccountStatus): AppError | null { + if (accountStatus === "banned") return AppError.forbidden("This account has been banned.") + if (accountStatus === "suspended") return AppError.forbidden(SUSPENDED_MESSAGE) + return null +} + async function establishOperatorSession( services: AuthServices, csrf: Csrf, @@ -165,12 +189,8 @@ async function establishOperatorSession( user: UserRecord, ): Promise { const accountStatus = await services.users.accountStatus(user.id) - if (accountStatus === "banned") { - throw AppError.forbidden("This account has been banned.") - } - if (accountStatus === "suspended") { - throw AppError.forbidden(SUSPENDED_MESSAGE) - } + const refusal = restrictedAccountRefusal(accountStatus) + if (refusal) throw refusal const token = await services.sessions.createSession(user.id, [user.role], { userAgent: request.headers["user-agent"] ?? null, ip: request.ip || null, diff --git a/services/api/test/unit/admin-auth-routes-security.test.ts b/services/api/test/unit/admin-auth-routes-security.test.ts new file mode 100644 index 00000000..7090afcb --- /dev/null +++ b/services/api/test/unit/admin-auth-routes-security.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import type { FastifyInstance } from "fastify" +import { FakeMailer } from "@civfix/shared/fakes" +import { buildServer } from "../../src/server.js" +import { loadEnv } from "../../src/env.js" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { makeInMemoryStores, type AccountStatus } from "../../src/auth/stores.js" +import { buildAuthServices } from "../../src/auth/auth-services.js" +import { StubJwksVerifier } from "../helpers/auth.js" +import type { WriteAuditInput } from "../../src/services/admin/audit.js" + +const ALLOWED = "ops@civfix.org" +const EXCHANGE_URL = "/v1/admin/auth/access/exchange" +const ACCESS_HEADER = { "cf-access-jwt-assertion": "stub.jwt.token" } + +let prod = false +vi.mock("../../src/env.js", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isProd: () => prod } +}) + +let app: FastifyInstance | undefined +afterEach(async () => { + prod = false + await app?.close() + app = undefined +}) + +async function harness(opts: { accessConfigured?: boolean } = {}) { + const stores = makeInMemoryStores() + const services = buildAuthServices({ + stores, + cache: new InMemoryCacheClient(() => Date.now()), + mailer: new FakeMailer(), + oauthConfig: {}, + verifier: new StubJwksVerifier(), + now: () => Date.now(), + }) + const env = loadEnv({ NODE_ENV: "test", ADMIN_EMAILS: ALLOWED }) + app = await buildServer({ env, authServices: services }) + const audits: WriteAuditInput[] = [] + app.adminAuthOverrides = { + auditSink: (input) => { + audits.push(input) + return Promise.resolve() + }, + ...(opts.accessConfigured === false + ? {} + : { + verifyAccessJwt: () => + Promise.resolve({ email: ALLOWED, commonName: null, sub: "cf-sub", raw: {} }), + }), + } + return { app, stores, audits } +} + +describe("admin Cloudflare Access exchange for a restricted account", () => { + const restricted: readonly AccountStatus[] = ["banned", "suspended"] + + it.each(restricted)( + "refuses a %s allowlisted citizen before promoting it or auditing a login", + async (status) => { + const h = await harness() + const user = await h.stores.users.create(ALLOWED, { + displayName: "Ops", + role: "citizen", + emailVerified: true, + }) + h.stores.users.setAccountStatus(user.id, status) + + const res = await h.app.inject({ method: "POST", url: EXCHANGE_URL, headers: ACCESS_HEADER }) + + expect(res.statusCode).toBe(403) + expect((await h.stores.users.findById(user.id))?.role).toBe("citizen") + expect(h.audits.map((a) => a.action)).toEqual(["operator.login_denied"]) + expect(h.audits[0]).toMatchObject({ + actorId: user.id, + target: `user:${user.id}`, + meta: { email: ALLOWED, status, via: "cf-access" }, + }) + expect(res.headers["set-cookie"]).toBeUndefined() + }, + ) + + it("still promotes and audits an active allowlisted citizen", async () => { + const h = await harness() + const user = await h.stores.users.create(ALLOWED, { + displayName: "Ops", + role: "citizen", + emailVerified: true, + }) + + const res = await h.app.inject({ method: "POST", url: EXCHANGE_URL, headers: ACCESS_HEADER }) + + expect(res.statusCode).toBe(200) + expect((await h.stores.users.findById(user.id))?.role).toBe("operator") + expect(h.audits.map((a) => a.action)).toEqual(["operator.login"]) + }) +}) + +describe("admin Cloudflare Access exchange without Access configured", () => { + it("tells the operator in production that Access is not configured", async () => { + const h = await harness({ accessConfigured: false }) + prod = true + + const res = await h.app.inject({ method: "POST", url: EXCHANGE_URL, headers: ACCESS_HEADER }) + + expect(res.statusCode).toBe(503) + expect(res.json().message).toBe("Cloudflare Access is not configured.") + }) +}) From 227d3df7aba9c773f84d713c7f0b709c2542a3a1 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:24:45 +0000 Subject: [PATCH 07/45] only a signed anon token becomes an anon subject --- services/api/src/auth/context.ts | 12 +++- .../test/unit/auth-context-security.test.ts | 71 +++++++++++++++++++ services/api/test/unit/media-routes.test.ts | 2 +- 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 services/api/test/unit/auth-context-security.test.ts diff --git a/services/api/src/auth/context.ts b/services/api/src/auth/context.ts index 4163ca2d..04aaeeed 100644 --- a/services/api/src/auth/context.ts +++ b/services/api/src/auth/context.ts @@ -1,6 +1,7 @@ import { AppError } from "@civfix/shared" import type { AuthContext } from "@civfix/shared" import type { FastifyInstance, FastifyRequest } from "fastify" +import { verifyAnonTokenSignature } from "../abuse/anon-token.js" import type { AuthServices } from "./auth-services.js" import type { AccountStatus } from "./stores.js" import { ANON_COOKIE, presentedSessionToken } from "./transport.js" @@ -33,9 +34,16 @@ export async function registerAuthContext(app: FastifyInstance): Promise { }) } -export async function resolveAuthContext(request: FastifyRequest): Promise { +// Only a cookie carrying our HMAC names an anonymous subject. An unsigned or forged value is treated as +// no cookie at all (never a rejected request), so callers cannot mint arbitrary quota keys with it. +function verifiedAnonSessionId(request: FastifyRequest): string | undefined { const anonCookie = request.cookies?.[ANON_COOKIE] + if (!anonCookie) return undefined + const signingKey = request.server.container.env.ANON_TOKEN_SIGNING_KEY + return verifyAnonTokenSignature(anonCookie, signingKey) ?? undefined +} +export async function resolveAuthContext(request: FastifyRequest): Promise { const services: AuthServices | undefined = request.server.authServices const token = presentedSessionToken(request) if (services && token) { @@ -46,7 +54,7 @@ export async function resolveAuthContext(request: FastifyRequest): Promise { + await app?.close() + app = undefined +}) + +async function uploadWithCookie(cookie: string | null): Promise { + const env = loadEnv({ NODE_ENV: "test" }) + const subjects: string[] = [] + const container = { + ...buildContainer(env), + env: { ...env, REDIS_URL: "redis://cache:6379" }, + getByteMeter: () => ({ + add: (subject: string, bytes: number) => { + subjects.push(subject) + return Promise.resolve(bytes) + }, + }), + } as unknown as ReturnType + app = await buildServer({ env, container, mediaRepo: new InMemoryMediaRepository() }) + const res = await app.inject({ + method: "POST", + url: "/v1/media/upload", + ...(cookie !== null + ? { headers: { cookie: `civfix_anon=${encodeURIComponent(cookie)}` } } + : {}), + payload: { kind: "image", contentType: "image/jpeg", byteSize: 1024, sha256: SHA }, + }) + expect(res.statusCode).toBe(200) + return subjects +} + +describe("anonymous subject from the civfix_anon cookie", () => { + it("ignores an unsigned cookie value and meters the upload by IP only", async () => { + const subjects = await uploadWithCookie("x".repeat(4000)) + + expect(subjects.some((s) => s.startsWith("a:"))).toBe(false) + expect(subjects.filter((s) => s.startsWith("ip:"))).toHaveLength(1) + }) + + it("ignores a cookie signed with a different key", async () => { + const subjects = await uploadWithCookie(signAnonToken(TOKEN_ID, "some-other-signing-key")) + + expect(subjects.some((s) => s.startsWith("a:"))).toBe(false) + }) + + it("meters a validly signed cookie under its bare token id", async () => { + const env = loadEnv({ NODE_ENV: "test" }) + const subjects = await uploadWithCookie(signAnonToken(TOKEN_ID, env.ANON_TOKEN_SIGNING_KEY)) + + expect(subjects).toContain(`a:${TOKEN_ID}`) + expect(subjects.filter((s) => s.startsWith("ip:"))).toHaveLength(1) + }) + + it("does not reject a request that carries a forged cookie", async () => { + const subjects = await uploadWithCookie("forged.signature") + + expect(subjects.length).toBeGreaterThan(0) + }) +}) diff --git a/services/api/test/unit/media-routes.test.ts b/services/api/test/unit/media-routes.test.ts index b510e593..1192613a 100644 --- a/services/api/test/unit/media-routes.test.ts +++ b/services/api/test/unit/media-routes.test.ts @@ -170,7 +170,7 @@ describe("media byte quota wiring", () => { const ipSubjects = charges.filter((c) => c.subject.startsWith("ip:")) expect(ipSubjects).toHaveLength(2) expect(new Set(ipSubjects.map((c) => c.subject)).size).toBe(1) - expect(charges.filter((c) => c.subject.startsWith("a:"))).toHaveLength(2) + expect(charges.filter((c) => c.subject.startsWith("a:"))).toHaveLength(0) }) }) From d3f32b429c4193a8ca36d6dffe62fcb62138b1f5 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:24:45 +0000 Subject: [PATCH 08/45] anonymous claim codes are never stored in plain text; replays rotate the code --- .../src/services/anon-repository.drizzle.ts | 74 +++++-- .../anon-claim-code-security-pg.test.ts | 117 +++++++++++ services/api/test/integration/anon-pg.test.ts | 2 +- .../unit/anon-repository-security.test.ts | 198 ++++++++++++++++++ 4 files changed, 373 insertions(+), 18 deletions(-) create mode 100644 services/api/test/integration/anon-claim-code-security-pg.test.ts create mode 100644 services/api/test/unit/anon-repository-security.test.ts diff --git a/services/api/src/services/anon-repository.drizzle.ts b/services/api/src/services/anon-repository.drizzle.ts index d893e933..15aa3d1e 100644 --- a/services/api/src/services/anon-repository.drizzle.ts +++ b/services/api/src/services/anon-repository.drizzle.ts @@ -31,15 +31,20 @@ * 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 AnonReportResponse snapshot into idempotency_keys.response_snapshot, owner-scoped - * by user_or_anon = the anon token id (0078/0079). + * 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; we - * then read and return the winner's stored snapshot as a "replayed" result - but only when the - * winner is the SAME anon session (F028), so a key squatted by a stranger never replays their - * snapshot (and with it their claim code) to somebody else. + * then replay the winner's stored snapshot - but only when the winner is the SAME anon session (F028), + * so a key squatted by a stranger never replays their report to somebody else. + * + * 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 + * 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. */ import type { Sql } from "../db/client.js" +import { generateToken, sha256Hex } from "../auth/crypto.js" import type { AnonReportRepository, AnonReportStatusRow, @@ -53,6 +58,7 @@ import type { ClaimRepository, PendingAnonReport } from "./claim-service.js" import { AppError } from "@civfix/shared" import type { AnonReportResponse, ReportStatus } from "@civfix/shared" import { insertModerationItem } from "./admin/moderation-repository.drizzle.js" +import { claimableAsReportMedia } from "./media-bindings.js" /** Postgres unique-violation SQLSTATE; surfaced on the idempotency-key race. */ const PG_UNIQUE_VIOLATION = "23505" @@ -105,14 +111,27 @@ function anonTokenStore(sql: Sql): AnonTokenStore { } } -export function makeDrizzleAnonReportRepository(sql: Sql): AnonReportRepository { +type StoredAnonSnapshot = Omit + +const REPLAY_UNCLAIMABLE_MESSAGE = "This report was already submitted and can no longer be claimed." + +export interface DrizzleAnonReportRepositoryOptions { + newClaimCode?: () => string +} + +export function makeDrizzleAnonReportRepository( + sql: Sql, + opts: DrizzleAnonReportRepositoryOptions = {}, +): AnonReportRepository { const tokens = anonTokenStore(sql) - async function readSnapshot( + const newClaimCode = opts.newClaimCode ?? (() => generateToken()) + + async function replaySnapshot( key: string, scope: string, userOrAnon: string | null, ): Promise { - const rows = await sql<{ response_snapshot: AnonReportResponse }[]>` + const rows = await sql<{ response_snapshot: StoredAnonSnapshot }[]>` SELECT response_snapshot FROM idempotency_keys WHERE key = ${key} @@ -120,7 +139,23 @@ export function makeDrizzleAnonReportRepository(sql: Sql): AnonReportRepository AND user_or_anon IS NOT DISTINCT FROM ${userOrAnon} LIMIT 1 ` - return rows[0]?.response_snapshot ?? null + const stored = rows[0]?.response_snapshot + if (!stored) return null + const claimCode = newClaimCode() + const rotated = await sql<{ id: string }[]>` + UPDATE reports + SET claim_code_hash = ${await sha256Hex(claimCode)}, claim_code = ${null} + WHERE id = ${stored.reportId} + AND anon_session_id = ${userOrAnon} + AND reporter_user_id IS NULL + AND deleted_at IS NULL + AND claim_code_hash IS NOT NULL + RETURNING id + ` + // A claimed (or removed) report has no live code to hand back, and the contract has no response + // without one; answering a code that can never claim would be worse than saying so. + if (rotated.length === 0) throw AppError.conflict(REPLAY_UNCLAIMABLE_MESSAGE) + return { reportId: stored.reportId, status: stored.status, claimCode } } return { @@ -131,7 +166,7 @@ export function makeDrizzleAnonReportRepository(sql: Sql): AnonReportRepository scope: string, userOrAnon: string | null, ): Promise { - return readSnapshot(key, scope, userOrAnon) + return replaySnapshot(key, scope, userOrAnon) }, async createAnonReportTx(args: CreateAnonReportTxArgs): Promise { @@ -203,17 +238,17 @@ export function makeDrizzleAnonReportRepository(sql: Sql): AnonReportRepository // 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. + // 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. if (args.mediaUploadIds.length > 0) { const claimed = await tx<{ upload_id: string }[]>` UPDATE media_assets SET report_id = ${args.reportId} WHERE upload_id IN ${tx(args.mediaUploadIds)} AND (report_id IS NULL OR report_id = ${args.reportId}) - -- L18: see the same guard on the authenticated create path. An asset already bound to a post - -- or a chat/DM message is never re-bindable to a report, so an uploadId cannot be used to - -- cross-publish private media into a public report gallery. AND post_id IS NULL AND chat_message_id IS NULL + AND ${claimableAsReportMedia(tx)} AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) RETURNING upload_id ` @@ -257,14 +292,19 @@ export function makeDrizzleAnonReportRepository(sql: Sql): AnonReportRepository desc: args.description ?? "", }) - // 5) Persist the AnonReportResponse snapshot under the idempotency key (same tx). + // 5) Persist the snapshot under the idempotency key (same tx), without the plaintext claim + // code: a replay mints a fresh one instead. + const storedSnapshot: StoredAnonSnapshot = { + reportId: args.responseSnapshot.reportId, + status: args.responseSnapshot.status, + } await tx` INSERT INTO idempotency_keys (key, scope, user_or_anon, response_snapshot) VALUES ( ${args.idempotencyKey}, ${ANON_REPORT_CREATE_SCOPE}, ${args.anonSessionId}, - ${sql.json(args.responseSnapshot as Parameters[0])} + ${sql.json(storedSnapshot as Parameters[0])} ) ` return args.responseSnapshot @@ -272,7 +312,7 @@ export function makeDrizzleAnonReportRepository(sql: Sql): AnonReportRepository return { kind: "created", snapshot } } catch (err) { if (isUniqueViolation(err)) { - const stored = await readSnapshot( + const stored = await replaySnapshot( args.idempotencyKey, ANON_REPORT_CREATE_SCOPE, args.anonSessionId, diff --git a/services/api/test/integration/anon-claim-code-security-pg.test.ts b/services/api/test/integration/anon-claim-code-security-pg.test.ts new file mode 100644 index 00000000..a61bc8ad --- /dev/null +++ b/services/api/test/integration/anon-claim-code-security-pg.test.ts @@ -0,0 +1,117 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import { FakeAbuseChecks } from "@civfix/shared/fakes" +import type { AnonReportRequest } from "@civfix/shared" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { makeAnonService, type AnonService } from "../../src/services/anon-service.js" +import { makeClaimService, type ClaimService } from "../../src/services/claim-service.js" +import { + makeDrizzleAnonReportRepository, + makeDrizzleClaimRepository, +} from "../../src/services/anon-repository.drizzle.js" +import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" +import { makeReportService } from "../../src/services/report-service.js" +import { PROBE_INSIDE_CITY } from "../../src/db/seed-fixtures.js" + +const pg = await withPg() +const SIGNING_KEY = "integration-anon-claim-code-signing-key" + +describe.skipIf(!pg)("anonymous claim code at rest (integration)", () => { + let h: PgHarness + let anon: AnonService + let claim: ClaimService + + beforeAll(() => { + h = pg as PgHarness + const resolveJurisdictionGeoid = async (lat: number, lng: number): Promise => { + const rows = await h.sql<{ geoid: string }[]>` + SELECT geoid FROM jurisdictions + WHERE ST_Contains(geom, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)) + ORDER BY CASE layer WHEN 'place' THEN 0 WHEN 'county' THEN 1 ELSE 2 END + LIMIT 1 + ` + return rows[0]?.geoid ?? null + } + const reports = makeReportService({ + repo: makeDrizzleReportRepository(h.sql), + resolveJurisdictionGeoid, + presignMedia: (r2Key: string) => Promise.resolve({ url: `memory://${r2Key}` }), + }) + anon = makeAnonService({ + repo: makeDrizzleAnonReportRepository(h.sql), + abuseChecks: new FakeAbuseChecks(), + counters: new InMemoryCounterStore(() => 0), + anonTokenSigningKey: SIGNING_KEY, + resolveJurisdictionGeoid, + }) + claim = makeClaimService({ + repo: makeDrizzleClaimRepository(h.sql), + anonTokenSigningKey: SIGNING_KEY, + getReportForOwner: (reportId, owner) => reports.getReport(reportId, owner), + }) + }) + + afterAll(async () => { + await h.teardown() + }) + + function req(idempotencyKey: string, anonToken?: string): AnonReportRequest { + return { + idempotencyKey, + turnstileToken: "ok", + category: "trash", + type: "dump", + lat: PROBE_INSIDE_CITY.lat, + lng: PROBE_INSIDE_CITY.lng, + geomSource: "device", + mediaUploadIds: [], + ...(anonToken !== undefined ? { anonToken } : {}), + } + } + + it("keeps the plaintext code out of the idempotency snapshot", async () => { + const key = randomUUID() + const { response } = await anon.submitAnonReport(req(key), { ip: "203.0.113.40", cfGeo: {} }) + + const rows = await h.sql<{ snapshot: string }[]>` + SELECT response_snapshot::text AS snapshot FROM idempotency_keys WHERE key = ${key} + ` + expect(rows).toHaveLength(1) + expect(rows[0]!.snapshot).toContain(response.reportId) + expect(rows[0]!.snapshot).not.toContain(response.claimCode) + }) + + it("a replay hands back a fresh code that claims the report, and retires the first one", async () => { + const key = randomUUID() + const first = await anon.submitAnonReport(req(key), { ip: "203.0.113.41", cfGeo: {} }) + const replay = await anon.submitAnonReport(req(key, first.issuedAnonToken!), { + ip: "203.0.113.41", + cfGeo: {}, + }) + expect(replay.response.reportId).toBe(first.response.reportId) + expect(replay.response.claimCode).not.toBe(first.response.claimCode) + + const [u] = await h.sql< + { id: string }[] + >`INSERT INTO users (display_name) VALUES ('Replayer') RETURNING id` + await expect(claim.claimReport(first.response.claimCode, u!.id)).rejects.toMatchObject({ + code: "NOT_FOUND", + }) + const claimed = await claim.claimReport(replay.response.claimCode, u!.id) + expect(claimed.report.id).toBe(first.response.reportId) + }) + + it("a replay after the report was claimed answers a conflict instead of a dead code", async () => { + const key = randomUUID() + const first = await anon.submitAnonReport(req(key), { ip: "203.0.113.42", cfGeo: {} }) + const [u] = await h.sql< + { id: string }[] + >`INSERT INTO users (display_name) VALUES ('Claimer') RETURNING id` + await claim.claimReport(first.response.claimCode, u!.id) + + await expect( + anon.submitAnonReport(req(key, first.issuedAnonToken!), { ip: "203.0.113.42", cfGeo: {} }), + ).rejects.toMatchObject({ code: "CONFLICT" }) + }) +}) diff --git a/services/api/test/integration/anon-pg.test.ts b/services/api/test/integration/anon-pg.test.ts index bf0e518f..e67ffb3a 100644 --- a/services/api/test/integration/anon-pg.test.ts +++ b/services/api/test/integration/anon-pg.test.ts @@ -296,7 +296,7 @@ describe.skipIf(!pg)("anon reporting (integration: real transaction path)", () = req({ idempotencyKey: key, anonToken: first.issuedAnonToken! }), { ip: "203.0.113.9", cfGeo: {} }, ) - expect(second.response).toEqual(first.response) + expect(second.response).toEqual({ ...first.response, claimCode: expect.any(String) }) const countRows = await h.sql<{ n: number }[]>` SELECT COUNT(*)::int AS n FROM reports WHERE idempotency_key = ${key} ` diff --git a/services/api/test/unit/anon-repository-security.test.ts b/services/api/test/unit/anon-repository-security.test.ts new file mode 100644 index 00000000..6ad6f4bc --- /dev/null +++ b/services/api/test/unit/anon-repository-security.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest" +import type { AnonReportResponse } from "@civfix/shared" +import { makeDrizzleAnonReportRepository } from "../../src/services/anon-repository.drizzle.js" +import { + ANON_REPORT_CREATE_SCOPE, + type CreateAnonReportTxArgs, +} from "../../src/services/anon-service.js" +import { sha256Hex } from "../../src/auth/crypto.js" +import type { Queryable, Sql } from "../../src/db/client.js" +import { mediaBoundElsewhere } from "../../src/services/media-bindings.js" +import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" + +const REPORT_ID = "44444444-4444-4444-8444-444444444444" +const ANON_ID = "55555555-5555-4555-8555-555555555555" +const IDEMPOTENCY_KEY = "66666666-6666-4666-8666-666666666666" +const ORIGINAL_CODE = "original-claim-code" +const FRESH_CODE = "fresh-claim-code" + +const UPLOAD_ID = "77777777-7777-4777-8777-777777777777" +const UNAVAILABLE = "One or more media uploads are unavailable." + +const UNIQUE_VIOLATION = Object.assign(new Error("duplicate key"), { code: "23505" }) + +function createArgs(): CreateAnonReportTxArgs { + return { + reportId: REPORT_ID, + anonSessionId: ANON_ID, + idempotencyKey: IDEMPOTENCY_KEY, + lat: 34.1, + lng: -118.3, + geomSource: "device", + jurisdictionGeoid: null, + jurCode: 0, + category: "trash", + type: "dump", + title: null, + description: null, + addr: null, + addrSource: null, + addrPrecision: null, + h3Cell: "8a2830828767fff", + mediaUploadIds: [], + claimCodeHash: "hash-of-original", + reportCap: 5, + responseSnapshot: { reportId: REPORT_ID, status: "held", claimCode: ORIGINAL_CODE }, + } +} + +function repoOver(fake: FakeSqlControl) { + return makeDrizzleAnonReportRepository(fake.sql as unknown as Sql, { + newClaimCode: () => FRESH_CODE, + }) +} + +function storedSnapshot(snapshot: object) { + return { match: /SELECT response_snapshot/i, rows: [{ response_snapshot: snapshot }] } +} + +function rotation(rows: unknown[]) { + return { match: /UPDATE reports\s+SET claim_code_hash/i, rows } +} + +describe("anonymous submit idempotency snapshot", () => { + it("never stores the plaintext claim code", async () => { + const fake = makeFakeSql([ + { match: /reference_counters/i, rows: [{ next_val: 1 }] }, + { match: /UPDATE anon_tokens/i, rows: [{ report_count: 1 }] }, + { match: /INSERT INTO moderation_items/i, rows: [{ id: "mod-1" }] }, + ]) + + const result = await repoOver(fake).createAnonReportTx(createArgs()) + + expect(result).toEqual({ + kind: "created", + snapshot: { reportId: REPORT_ID, status: "held", claimCode: ORIGINAL_CODE }, + }) + const insert = fake.statements.find((s) => /INSERT INTO idempotency_keys/i.test(s.sql)) + expect(insert).toBeDefined() + expect(JSON.stringify(insert!.values)).not.toContain(ORIGINAL_CODE) + expect(JSON.stringify(insert!.values)).toContain(REPORT_ID) + }) +}) + +describe("replaying an anonymous submit", () => { + it("answers with a freshly minted code and rotates the report onto it", async () => { + const fake = makeFakeSql([ + storedSnapshot({ reportId: REPORT_ID, status: "held" }), + rotation([{ id: REPORT_ID }]), + ]) + + const replay = await repoOver(fake).findIdempotentSnapshot( + IDEMPOTENCY_KEY, + ANON_REPORT_CREATE_SCOPE, + ANON_ID, + ) + + expect(replay).toEqual({ + reportId: REPORT_ID, + status: "held", + claimCode: FRESH_CODE, + }) + const rotate = fake.statements.find((s) => /UPDATE reports/i.test(s.sql))! + expect(rotate.values).toContain(await sha256Hex(FRESH_CODE)) + expect(rotate.values).toContain(REPORT_ID) + expect(rotate.values).toContain(ANON_ID) + expect(rotate.sql).toMatch(/reporter_user_id IS NULL/i) + expect(rotate.sql).toMatch(/claim_code_hash IS NOT NULL/i) + }) + + it("ignores a plaintext code left in a snapshot written before this change", async () => { + const fake = makeFakeSql([ + storedSnapshot({ reportId: REPORT_ID, status: "held", claimCode: ORIGINAL_CODE }), + rotation([{ id: REPORT_ID }]), + ]) + + const replay = await repoOver(fake).findIdempotentSnapshot( + IDEMPOTENCY_KEY, + ANON_REPORT_CREATE_SCOPE, + ANON_ID, + ) + + expect(replay?.claimCode).toBe(FRESH_CODE) + }) + + it("answers a conflict instead of a dead code when the report was already claimed", async () => { + const fake = makeFakeSql([ + storedSnapshot({ reportId: REPORT_ID, status: "held" }), + rotation([]), + ]) + + await expect( + repoOver(fake).findIdempotentSnapshot(IDEMPOTENCY_KEY, ANON_REPORT_CREATE_SCOPE, ANON_ID), + ).rejects.toMatchObject({ code: "CONFLICT" }) + }) + + it("returns nothing and rotates nothing when no snapshot exists", async () => { + const fake = makeFakeSql([]) + + const replay = await repoOver(fake).findIdempotentSnapshot( + IDEMPOTENCY_KEY, + ANON_REPORT_CREATE_SCOPE, + ANON_ID, + ) + + expect(replay).toBeNull() + expect(fake.statements.some((s) => /UPDATE reports/i.test(s.sql))).toBe(false) + }) + + it("replays with a fresh code when the key race is lost inside the create transaction", async () => { + const fake = makeFakeSql([ + { match: /reference_counters/i, rows: [{ next_val: 1 }] }, + storedSnapshot({ reportId: REPORT_ID, status: "held" }), + rotation([{ id: REPORT_ID }]), + ]) + const sql = fake.sql as unknown as Sql & { begin: unknown } + sql.begin = () => Promise.reject(UNIQUE_VIOLATION) + + const result = await makeDrizzleAnonReportRepository(sql, { + newClaimCode: () => FRESH_CODE, + }).createAnonReportTx(createArgs()) + + expect(result).toEqual({ + kind: "replayed", + snapshot: { reportId: REPORT_ID, status: "held", claimCode: FRESH_CODE }, + }) + }) +}) + +function squash(text: string): string { + return text.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim() +} + +async function renderBoundElsewhere(): Promise { + const fake = makeFakeSql([]) + await fake.sql`${mediaBoundElsewhere(fake.sql as unknown as Queryable, null)}` + return squash(fake.statements[0]?.sql ?? "") +} + +describe("anonymous report create: media claim predicate", () => { + it("only claims report-purpose media that no avatar, logo or event binds", async () => { + const fake = makeFakeSql([ + { match: /reference_counters/i, rows: [{ next_val: 1 }] }, + { match: /UPDATE anon_tokens/i, rows: [{ report_count: 1 }] }, + ]) + + await expect( + repoOver(fake).createAnonReportTx({ ...createArgs(), mediaUploadIds: [UPLOAD_ID] }), + ).rejects.toMatchObject({ httpStatus: 422, fields: { mediaUploadIds: UNAVAILABLE } }) + + const claim = fake.statements.find((s) => /UPDATE media_assets\s+SET report_id/.test(s.sql)) + expect(claim).toBeDefined() + const text = squash(claim!.sql) + expect(text).toContain("purpose = 'report'") + expect(text).toContain(`NOT (${await renderBoundElsewhere()})`) + expect(text).toContain("post_id IS NULL AND chat_message_id IS NULL") + expect(text).not.toContain("--") + }) +}) From 9ffef96cdd04d4b9d05d117965652c0ab38f267b Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:24:45 +0000 Subject: [PATCH 09/45] slur filter sees through combining marks and invisible characters --- services/api/src/abuse/slur-filter.ts | 9 ++++- .../test/unit/slur-filter-security.test.ts | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 services/api/test/unit/slur-filter-security.test.ts diff --git a/services/api/src/abuse/slur-filter.ts b/services/api/src/abuse/slur-filter.ts index 036e60a2..b4d941ef 100644 --- a/services/api/src/abuse/slur-filter.ts +++ b/services/api/src/abuse/slur-filter.ts @@ -74,11 +74,18 @@ function deobfuscate(text: string): string { return collapsed.replace(/\b[a-z0-9](?: [a-z0-9])+\b/gi, (run) => run.replace(/ /g, "")) } +// Combining marks survive NFKD as separate code points and format characters (zero-width joiners, soft +// hyphen, bidi marks, BOM) render as nothing, so either one wedged inside a word hides it from the +// patterns. The unstripped variants stay because a zero-width space can also be the only word gap. +const COMBINING_MARK_RE = /\p{M}/gu +const FORMAT_CHAR_RE = /\p{Cf}/gu + export function containsSlur(text: string | null | undefined): boolean { if (text === null || text === undefined) return false const normalized = text.normalize("NFKD").toLowerCase() if (normalized.trim() === "") return false - const variants = [normalized, deobfuscate(normalized)] + const stripped = normalized.replace(COMBINING_MARK_RE, "").replace(FORMAT_CHAR_RE, "") + const variants = [normalized, deobfuscate(normalized), stripped, deobfuscate(stripped)] for (const variant of variants) { for (const pattern of RAW_PATTERNS) { if (pattern.test(variant)) return true diff --git a/services/api/test/unit/slur-filter-security.test.ts b/services/api/test/unit/slur-filter-security.test.ts new file mode 100644 index 00000000..8e90e3ee --- /dev/null +++ b/services/api/test/unit/slur-filter-security.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest" +import { containsSlur } from "../../src/abuse/slur-filter.js" + +const COMBINING_DIAERESIS = "̈" +const COMBINING_ACUTE = "́" +const ZERO_WIDTH_SPACE = "​" +const ZERO_WIDTH_NON_JOINER = "‌" +const ZERO_WIDTH_JOINER = "‍" +const SOFT_HYPHEN = "­" +const RIGHT_TO_LEFT_MARK = "‏" +const BYTE_ORDER_MARK = "" + +describe("containsSlur sees through invisible and combining characters", () => { + const disguised: ReadonlyArray = [ + ["combining diaeresis", `fa${COMBINING_DIAERESIS}ggot`], + ["combining acute", `re${COMBINING_ACUTE}tard`], + ["precomposed accented letter", "rétard"], + ["precomposed i-diaeresis", "retarded fäggot"], + ["zero-width space", `ret${ZERO_WIDTH_SPACE}ard`], + ["zero-width non-joiner", `tran${ZERO_WIDTH_NON_JOINER}ny`], + ["zero-width joiner", `ret${ZERO_WIDTH_JOINER}ard`], + ["soft hyphen", `tran${SOFT_HYPHEN}ny`], + ["right-to-left mark", `ret${RIGHT_TO_LEFT_MARK}ard`], + ["byte order mark", `tran${BYTE_ORDER_MARK}ny`], + ["mark and format char together", `re${COMBINING_ACUTE}${ZERO_WIDTH_SPACE}tard`], + ] + + it.each(disguised)("blocks a slur disguised with a %s", (_label, text) => { + expect(containsSlur(text)).toBe(true) + }) + + it("still treats a zero-width space as a word gap", () => { + expect(containsSlur(`hello${ZERO_WIDTH_SPACE}retard`)).toBe(true) + }) + + const benign = ["naïve", "café", "résumé", `co${ZERO_WIDTH_JOINER}operate`, "über"] + it.each(benign)("leaves the benign word %j alone", (text) => { + expect(containsSlur(text)).toBe(false) + }) +}) From 1182d7344ad54a01425432bdbcab763fae259112 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:24:45 +0000 Subject: [PATCH 10/45] email footers link only the unsubscribe and manage urls --- services/api/src/adapters/email-layout.ts | 89 ++++++++++----- services/api/test/unit/email-format.test.ts | 4 +- .../test/unit/email-layout-security.test.ts | 103 ++++++++++++++++++ 3 files changed, 166 insertions(+), 30 deletions(-) create mode 100644 services/api/test/unit/email-layout-security.test.ts diff --git a/services/api/src/adapters/email-layout.ts b/services/api/src/adapters/email-layout.ts index f11fb4e6..7ece7d91 100644 --- a/services/api/src/adapters/email-layout.ts +++ b/services/api/src/adapters/email-layout.ts @@ -52,53 +52,86 @@ export interface EventFooterOptions { manageUrl?: string } -export function eventFooter(opts: EventFooterOptions): string { - const lines: string[] = [] +export interface FooterSegment { + text: string + href?: string +} + +// A footer built from segments so that only the URLs the code supplies become links. Host-authored text +// (an event title, a reply address) sits in civfix-branded footer copy, where an auto-linked URL would +// read as a link civfix vouches for. +export interface EmailFooter { + segments: readonly FooterSegment[] +} + +const LINE_BREAK: FooterSegment = { text: "\n" } + +function linkLine(label: string, url: string): FooterSegment[] { + return [{ text: label }, { text: url, href: url }] +} + +export function eventFooter(opts: EventFooterOptions): EmailFooter { + const lines: FooterSegment[][] = [] if (opts.critical === true) { - lines.push( - `This is a service message about "${opts.eventTitle}", an event you signed up for on civfix. ` + - `You receive these even if you have turned off updates from this organizer.`, - ) + lines.push([ + { + text: + `This is a service message about "${opts.eventTitle}", an event you signed up for on civfix. ` + + `You receive these even if you have turned off updates from this organizer.`, + }, + ]) } else { - lines.push( - `You're receiving this because you signed up for "${opts.eventTitle}" on civfix. ` + - `The organizer wrote this message; civfix delivered it and never gave them your email address.`, - ) + lines.push([ + { + text: + `You're receiving this because you signed up for "${opts.eventTitle}" on civfix. ` + + `The organizer wrote this message; civfix delivered it and never gave them your email address.`, + }, + ]) } if (opts.replyTo !== undefined && opts.replyTo !== null && opts.replyTo.length > 0) { - lines.push(`Replies go to the organizer at ${opts.replyTo}.`) + lines.push([{ text: `Replies go to the organizer at ${opts.replyTo}.` }]) } else { - lines.push("Replies to this address are not monitored.") + lines.push([{ text: "Replies to this address are not monitored." }]) } if (opts.critical !== true && opts.unsubscribeUrl !== undefined) { - lines.push(`Stop receiving messages about this event: ${opts.unsubscribeUrl}`) + lines.push(linkLine("Stop receiving messages about this event: ", opts.unsubscribeUrl)) } if (opts.manageUrl !== undefined) { - lines.push(`Manage your signup: ${opts.manageUrl}`) + lines.push(linkLine("Manage your signup: ", opts.manageUrl)) } - lines.push("civfix.org") - return lines.join("\n") + lines.push([{ text: "civfix.org" }]) + return { segments: lines.flatMap((line, i) => (i === 0 ? line : [LINE_BREAK, ...line])) } } -const FOOTER_URL_RE = /https?:\/\/[^\s<>"]+/g +function footerSegments(footer: string | EmailFooter): readonly FooterSegment[] { + return typeof footer === "string" ? [{ text: footer }] : footer.segments +} + +function footerText(footer: string | EmailFooter): string { + return footerSegments(footer) + .map((segment) => segment.text) + .join("") +} -function footerHtml(footer: string): string { - return escapeHtml(footer) - .replace( - FOOTER_URL_RE, - (url) => `${url}`, - ) - .replace(/\n/g, "
") +function footerHtml(footer: string | EmailFooter): string { + return footerSegments(footer) + .map((segment) => { + const text = escapeHtml(segment.text) + if (segment.href === undefined) return text.replace(/\n/g, "
") + return `${text}` + }) + .join("") } export interface RenderEmailOptions { preheader?: string - footer?: string + footer?: string | EmailFooter blocks: EmailBlock[] } export function renderEmailBody(opts: RenderEmailOptions): { text: string; html: string } { - const footerText = opts.footer ?? DEFAULT_FOOTER + const footer = opts.footer ?? DEFAULT_FOOTER const blocksHtml = opts.blocks.map((b) => b.html).join("") const preheader = opts.preheader !== undefined && opts.preheader.length > 0 @@ -130,9 +163,9 @@ export function renderEmailBody(opts: RenderEmailOptions): { text: string; html: `` + `` + `` + - `` + + `` + `` - const text = [...opts.blocks.map((b) => b.text), "--", footerText].join("\n\n") + const text = [...opts.blocks.map((b) => b.text), "--", footerText(footer)].join("\n\n") return { text, html } } diff --git a/services/api/test/unit/email-format.test.ts b/services/api/test/unit/email-format.test.ts index e5e1ff93..c79ec8e4 100644 --- a/services/api/test/unit/email-format.test.ts +++ b/services/api/test/unit/email-format.test.ts @@ -455,7 +455,7 @@ describe("footer rendering", () => { unsubscribeUrl: "https://civfix.org/unsubscribe?t=abc", manageUrl: "https://civfix.org/e/beach", }) - expect(footer).toContain("\n") + expect(footer.segments.map((segment) => segment.text).join("")).toContain("\n") const { html, text } = renderEmailBody({ blocks: [paragraph("x")], footer }) expect(html).toContain('href="https://civfix.org/unsubscribe?t=abc"') expect(html).toContain('href="https://civfix.org/e/beach"') @@ -472,7 +472,7 @@ describe("footer rendering", () => { }) expect(html).not.toContain("bold") expect(html).toContain("<b>bold</b>") - expect(html).toContain('href="https://civfix.org"') + expect(html).not.toContain('href="https://civfix.org"') }) }) diff --git a/services/api/test/unit/email-layout-security.test.ts b/services/api/test/unit/email-layout-security.test.ts new file mode 100644 index 00000000..7bb2ab7f --- /dev/null +++ b/services/api/test/unit/email-layout-security.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest" +import { eventFooter, renderEmailBody } from "../../src/adapters/email-layout.js" +import { paragraph } from "../../src/adapters/email-blocks.js" + +const UNSUBSCRIBE_URL = "https://civfix.org/unsubscribe?t=abc&x=1" +const MANAGE_URL = "https://civfix.org/e/beach" +const LURE_URL = "https://civfix-login.example/verify" + +function render(footer: Parameters[0]["footer"]) { + return renderEmailBody({ blocks: [paragraph("x")], footer }) +} + +describe("event footer links", () => { + it("does not turn a URL in the host-authored event title into a link", () => { + const { html } = render( + eventFooter({ eventTitle: `Cleanup ${LURE_URL}`, unsubscribeUrl: UNSUBSCRIBE_URL }), + ) + + expect(html).not.toContain(`href="${LURE_URL}`) + expect(html).toContain(`Cleanup ${LURE_URL}`) + }) + + it("does not turn a URL in the organizer reply address line into a link", () => { + const { html } = render( + eventFooter({ eventTitle: "Beach", replyTo: `host@example.org ${LURE_URL}` }), + ) + + expect(html).not.toContain(`href="${LURE_URL}`) + }) + + it("still links the unsubscribe and manage URLs the code supplies", () => { + const { html } = render( + eventFooter({ + eventTitle: `Cleanup ${LURE_URL}`, + unsubscribeUrl: UNSUBSCRIBE_URL, + manageUrl: MANAGE_URL, + }), + ) + + expect(html).toContain('href="https://civfix.org/unsubscribe?t=abc&x=1"') + expect(html).toContain(`href="${MANAGE_URL}"`) + expect(html.match(/ { + const { html } = render(eventFooter({ eventTitle: 'x' })) + + expect(html).not.toContain('') + expect(html).toContain("<a href="https://x.example">") + }) + + it("keeps the plain-text footer byte-identical", () => { + const opts = { + eventTitle: `Cleanup ${LURE_URL}`, + unsubscribeUrl: UNSUBSCRIBE_URL, + manageUrl: MANAGE_URL, + replyTo: "host@example.org", + } + const { text } = render(eventFooter(opts)) + + expect(text).toBe( + [ + "x", + "--", + [ + `You're receiving this because you signed up for "Cleanup ${LURE_URL}" on civfix. ` + + "The organizer wrote this message; civfix delivered it and never gave them your email address.", + "Replies go to the organizer at host@example.org.", + `Stop receiving messages about this event: ${UNSUBSCRIBE_URL}`, + `Manage your signup: ${MANAGE_URL}`, + "civfix.org", + ].join("\n"), + ].join("\n\n"), + ) + }) + + it("keeps the critical footer text byte-identical and without an unsubscribe link", () => { + const { text, html } = render( + eventFooter({ eventTitle: "Beach", critical: true, unsubscribeUrl: UNSUBSCRIBE_URL }), + ) + + expect( + text.endsWith( + [ + 'This is a service message about "Beach", an event you signed up for on civfix. ' + + "You receive these even if you have turned off updates from this organizer.", + "Replies to this address are not monitored.", + "civfix.org", + ].join("\n"), + ), + ).toBe(true) + expect(html).not.toContain("unsubscribe") + }) +}) + +describe("plain string footers", () => { + it("never links a URL inside free text", () => { + const { html } = render(`Organizer says hi ${LURE_URL}`) + + expect(html).not.toContain(" Date: Wed, 23 Sep 2026 07:24:45 +0000 Subject: [PATCH 11/45] websocket: rate limit every upgrade path, bound the frame backlog, read the query-token flag from env --- services/api/.env.example | 4 + services/api/src/env.ts | 2 + services/api/src/env/types.ts | 1 + .../api/src/routes/chat-gateway-wiring.ts | 6 +- services/api/src/ws/frame-handler.ts | 3 +- services/api/src/ws/handshake.ts | 9 +- services/api/src/ws/socket-lifecycle.ts | 45 +++- services/api/src/ws/types.ts | 9 + .../unit/chat-gateway-wiring-security.test.ts | 59 ++++++ services/api/test/unit/chat-routes.test.ts | 20 +- .../unit/socket-lifecycle-security.test.ts | 200 ++++++++++++++++++ .../test/unit/ws-handshake-security.test.ts | 64 ++++++ 12 files changed, 403 insertions(+), 19 deletions(-) create mode 100644 services/api/test/unit/chat-gateway-wiring-security.test.ts create mode 100644 services/api/test/unit/socket-lifecycle-security.test.ts create mode 100644 services/api/test/unit/ws-handshake-security.test.ts diff --git a/services/api/.env.example b/services/api/.env.example index 922043c2..21b2f82d 100644 --- a/services/api/.env.example +++ b/services/api/.env.example @@ -208,6 +208,10 @@ GOOGLE_OAUTH_ANDROID_CLIENT_ID= # [OPT] Android OAuth client id (packa # 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. OAUTH_REQUIRE_NONCE= # [OPT] default false; see above before enabling +WS_ALLOW_QUERY_TOKEN= # [OPT] default false. Break-glass: also accept the session bearer as + # GET /ws?token=. The URL lands in proxy/access logs, so clients + # use the single-use ?ticket= instead; enable only for an old + # mobile build that cannot mint a ticket. # ===== push: APNs (bypassed by USE_FAKE_PUSH) [OPT] ===== APNS_KEY_ID= # [OPT] APNs auth key id diff --git a/services/api/src/env.ts b/services/api/src/env.ts index fbb48691..20b774ca 100644 --- a/services/api/src/env.ts +++ b/services/api/src/env.ts @@ -348,6 +348,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { const INBOUND_SWEEP_CRON = reqCron("INBOUND_SWEEP_CRON", "*/5 * * * *") const OAUTH_REQUIRE_NONCE = parseBool(source.OAUTH_REQUIRE_NONCE, false) + const WS_ALLOW_QUERY_TOKEN = parseBool(source.WS_ALLOW_QUERY_TOKEN, false) const REVIEWER_OTP_BYPASS = parseBool(source.REVIEWER_OTP_BYPASS, false) const REVIEWER_OTP_BYPASS_ACK = parseBool(source.REVIEWER_OTP_BYPASS_ACK, false) const REVIEWER_OTP_CODE = (source.REVIEWER_OTP_CODE ?? "").trim() @@ -452,6 +453,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { CF_TURNSTILE_HOSTNAMES: parseCsvLower(source.CF_TURNSTILE_HOSTNAMES), OAUTH_REQUIRE_NONCE, + WS_ALLOW_QUERY_TOKEN, REVIEWER_OTP_BYPASS, REVIEWER_OTP_BYPASS_ACK, ...(REVIEWER_OTP_CODE.length > 0 ? { REVIEWER_OTP_CODE } : {}), diff --git a/services/api/src/env/types.ts b/services/api/src/env/types.ts index 3442f0dc..34620dac 100644 --- a/services/api/src/env/types.ts +++ b/services/api/src/env/types.ts @@ -110,6 +110,7 @@ export interface Env extends CommsEnv, RegistrationEnv { GLITCHTIP_DATABASE_URL?: string OAUTH_REQUIRE_NONCE: boolean + WS_ALLOW_QUERY_TOKEN: boolean REVIEWER_OTP_BYPASS: boolean REVIEWER_OTP_BYPASS_ACK: boolean REVIEWER_OTP_CODE?: string diff --git a/services/api/src/routes/chat-gateway-wiring.ts b/services/api/src/routes/chat-gateway-wiring.ts index b05935c2..728f87d2 100644 --- a/services/api/src/routes/chat-gateway-wiring.ts +++ b/services/api/src/routes/chat-gateway-wiring.ts @@ -90,6 +90,8 @@ import { InMemoryChatReadState, type ChatReadState } from "../services/threads-s import { makeMarkRoomRead, type MarkRoomRead } from "../services/room-read-service.js" import type { ChatGatewayOverrides } from "./chat.routes.js" +const WS_UPGRADE_ROUTE = "/ws" + const WS_UPGRADE_RATE_LIMIT = { max: 60, timeWindow: "1 minute" } as const const REPORT_SEND_LIMIT = { capacity: 30, refillPerSec: 0.5 } as const @@ -233,7 +235,9 @@ export function applyWsUpgradeRateLimit(app: FastifyInstance): void { keyGenerator: wsUpgradeRateLimitKey, }) app.addHook("onRequest", async (request, reply) => { - if ((request.url ?? "").split("?")[0] !== "/ws") return + // The router matches on the percent-decoded path, so only the matched pattern catches every + // spelling of /ws that reaches the upgrade handler. + if (request.routeOptions.url !== WS_UPGRADE_ROUTE) return const result = await limiter(request) if (!result.isAllowed && result.isExceeded) { applyRateLimitHeaders(reply, result) diff --git a/services/api/src/ws/frame-handler.ts b/services/api/src/ws/frame-handler.ts index ead93250..1d0ce385 100644 --- a/services/api/src/ws/frame-handler.ts +++ b/services/api/src/ws/frame-handler.ts @@ -19,6 +19,7 @@ import { TYPING_MIN_INTERVAL_MS, TYPING_THROTTLE_MAX_ROOMS, WS_FRAME_LIMIT, + WS_FRAME_RATE_LIMITED_MESSAGE, WS_MAX_JOINED_ROOMS, WS_SESSION_ENDED_MESSAGE, } from "./types.js" @@ -586,7 +587,7 @@ export async function handleClientFrame(session: GatewaySession, raw: string): P if (session.closed) return const limiter = (session.frameLimiter ??= makeTokenBucketLimiter(WS_FRAME_LIMIT)) if (!limiter.tryConsume(session.conn.id)) { - sendError(session.conn, "RATE_LIMITED", "You're sending frames too fast. Please slow down.") + sendError(session.conn, "RATE_LIMITED", WS_FRAME_RATE_LIMITED_MESSAGE) return } let parsedJson: unknown diff --git a/services/api/src/ws/handshake.ts b/services/api/src/ws/handshake.ts index 4c429f5d..32281604 100644 --- a/services/api/src/ws/handshake.ts +++ b/services/api/src/ws/handshake.ts @@ -29,9 +29,10 @@ function wsHasSessionCookie(request: FastifyRequest): boolean { return sessionCookieValue(request) !== null } -function queryTokenAllowed(): boolean { - const raw = process.env.WS_ALLOW_QUERY_TOKEN - return raw === "1" || raw === "true" +// Break-glass only: a session bearer in a URL lands in proxy and access logs. Off unless the loaded env +// turns it on, including for a request that carries no app container. +function queryTokenAllowed(request: FastifyRequest): boolean { + return request.server?.container?.env.WS_ALLOW_QUERY_TOKEN === true } export async function resolveWsUser( @@ -69,7 +70,7 @@ export async function resolveWsUser( } const queryToken = - queryTokenAllowed() && typeof query?.token === "string" && query.token.length > 0 + typeof query?.token === "string" && query.token.length > 0 && queryTokenAllowed(request) ? query.token : null const presented = queryToken ?? cookieOrBearer diff --git a/services/api/src/ws/socket-lifecycle.ts b/services/api/src/ws/socket-lifecycle.ts index ca62755e..729b1a15 100644 --- a/services/api/src/ws/socket-lifecycle.ts +++ b/services/api/src/ws/socket-lifecycle.ts @@ -19,9 +19,13 @@ import { WS_BUFFER_DROP_THRESHOLD, WS_BUFFER_TERMINATE_TICKS, WS_CLOSE_POLICY_VIOLATION, + WS_FRAME_RATE_LIMITED_MESSAGE, + WS_FRAME_BACKLOG_REASON, WS_HANDSHAKE_BUFFER_BYTES, WS_HANDSHAKE_FRAME_BUFFER, WS_HEARTBEAT_MS, + WS_MAX_QUEUED_BYTES, + WS_MAX_QUEUED_FRAMES, WS_REAUTH_INTERVAL_MS, WS_REAUTH_JITTER_MS, WS_SESSION_ENDED_MESSAGE, @@ -403,8 +407,47 @@ export function registerChatGateway(app: FastifyInstance, opts: RegisterGatewayO } dropPending() let frameChain: Promise = Promise.resolve() + let queuedFrames = 0 + let queuedBytes = 0 + let backlogClosed = false + const closeForBacklog = (): void => { + backlogClosed = true + request.log.warn( + { userId, queuedFrames, queuedBytes }, + "ws: closing socket, inbound frame backlog over cap", + ) + try { + session.conn.send( + serverFrame({ + type: "error", + code: "RATE_LIMITED", + message: WS_FRAME_RATE_LIMITED_MESSAGE, + }), + ) + } catch (err) { + request.log.debug({ err }, "ws: backlog-reject send failed (socket already closing)") + } + socket.close(WS_CLOSE_POLICY_VIOLATION, WS_FRAME_BACKLOG_REASON) + } + // The per-frame token bucket only runs when a frame is dequeued, so while one handler awaits + // a slow store every later frame would otherwise sit in memory unbounded. onFrame = (raw: string): void => { - frameChain = frameChain.then(() => runFrame(raw)) + if (backlogClosed || session.closed) return + const bytes = Buffer.byteLength(raw, "utf8") + if (queuedFrames >= WS_MAX_QUEUED_FRAMES || queuedBytes + bytes > WS_MAX_QUEUED_BYTES) { + closeForBacklog() + return + } + queuedFrames += 1 + queuedBytes += bytes + frameChain = frameChain.then(async () => { + try { + await runFrame(raw) + } finally { + queuedFrames -= 1 + queuedBytes -= bytes + } + }) } })() }) diff --git a/services/api/src/ws/types.ts b/services/api/src/ws/types.ts index ea6cdd97..e8feda23 100644 --- a/services/api/src/ws/types.ts +++ b/services/api/src/ws/types.ts @@ -33,6 +33,15 @@ export const WS_HANDSHAKE_FRAME_BUFFER = 32 export const WS_HANDSHAKE_BUFFER_BYTES = 64 * 1024 +// Counts the frame in flight, so a client may pipeline this many frames behind one slow handler. +export const WS_MAX_QUEUED_FRAMES = 32 + +export const WS_MAX_QUEUED_BYTES = 256 * 1024 + +export const WS_FRAME_RATE_LIMITED_MESSAGE = "You're sending frames too fast. Please slow down." + +export const WS_FRAME_BACKLOG_REASON = "too many queued frames" + export type IsMemberFn = (cleanupId: string, userId: string) => Promise export type MarkReadFn = (cleanupId: string, userId: string, upToId: string) => Promise diff --git a/services/api/test/unit/chat-gateway-wiring-security.test.ts b/services/api/test/unit/chat-gateway-wiring-security.test.ts new file mode 100644 index 00000000..7b11e5dd --- /dev/null +++ b/services/api/test/unit/chat-gateway-wiring-security.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, afterEach } from "vitest" +import Fastify from "fastify" +import type { FastifyInstance } from "fastify" +import { ErrorCode } from "@civfix/shared" +import { registerRateLimit } from "../../src/plugins/rate-limit.js" +import { applyWsUpgradeRateLimit } from "../../src/routes/chat-gateway-wiring.js" + +const WS_UPGRADE_CAP = 60 +const CLIENT_IP = "198.51.100.9" + +describe("/ws upgrade rate limit follows the matched route", () => { + let app: FastifyInstance | undefined + + afterEach(async () => { + await app?.close() + app = undefined + }) + + async function buildApp(): Promise { + const f = Fastify() + await registerRateLimit(f) + f.get("/ws", async () => ({ ok: true })) + applyWsUpgradeRateLimit(f) + await f.ready() + return f + } + + it("limits a percent-encoded path that the router decodes to /ws", async () => { + app = await buildApp() + for (let i = 0; i < WS_UPGRADE_CAP; i++) { + const ok = await app.inject({ method: "GET", url: "/%77s", remoteAddress: CLIENT_IP }) + expect(ok.statusCode).toBe(200) + } + const blocked = await app.inject({ method: "GET", url: "/%77s", remoteAddress: CLIENT_IP }) + expect(blocked.statusCode).toBe(429) + expect(blocked.json()).toMatchObject({ code: ErrorCode.RATE_LIMITED }) + }) + + it("charges /ws and its encoded spellings to one bucket", async () => { + app = await buildApp() + const spellings = ["/ws", "/%77s", "/w%73", "/%77%73?ticket=x"] + for (let i = 0; i < WS_UPGRADE_CAP; i++) { + const url = spellings[i % spellings.length]! + const ok = await app.inject({ method: "GET", url, remoteAddress: CLIENT_IP }) + expect(ok.statusCode).toBe(200) + } + const blocked = await app.inject({ method: "GET", url: "/ws", remoteAddress: CLIENT_IP }) + expect(blocked.statusCode).toBe(429) + }) + + it("does not charge the /ws bucket for unmatched paths", async () => { + app = await buildApp() + for (let i = 0; i < WS_UPGRADE_CAP; i++) { + await app.inject({ method: "GET", url: "/ws/extra", remoteAddress: CLIENT_IP }) + } + const ok = await app.inject({ method: "GET", url: "/ws", remoteAddress: CLIENT_IP }) + expect(ok.statusCode).toBe(200) + }) +}) diff --git a/services/api/test/unit/chat-routes.test.ts b/services/api/test/unit/chat-routes.test.ts index 1b879f49..7f0a27c4 100644 --- a/services/api/test/unit/chat-routes.test.ts +++ b/services/api/test/unit/chat-routes.test.ts @@ -196,18 +196,14 @@ describe("resolveWsUser (dual handshake auth)", () => { it("H5: accepts ?token ONLY under the WS_ALLOW_QUERY_TOKEN break-glass flag", async () => { const { sessions, token } = await withSession() - const prev = process.env.WS_ALLOW_QUERY_TOKEN - process.env.WS_ALLOW_QUERY_TOKEN = "1" - try { - expect(await resolveWsUser(fakeReq({ query: { token } }), sessions)).toEqual({ - userId: ME, - sessionHash: await sha256Hex(token), - accountStatus: "active", - }) - } finally { - if (prev === undefined) delete process.env.WS_ALLOW_QUERY_TOKEN - else process.env.WS_ALLOW_QUERY_TOKEN = prev - } + const server = { + container: { env: { WS_ALLOW_QUERY_TOKEN: true } }, + } as unknown as FastifyRequest["server"] + expect(await resolveWsUser(fakeReq({ query: { token }, server }), sessions)).toEqual({ + userId: ME, + sessionHash: await sha256Hex(token), + accountStatus: "active", + }) }) it("H2: a ?ticket bound to a live session retains that session's hash for the live re-check", async () => { diff --git a/services/api/test/unit/socket-lifecycle-security.test.ts b/services/api/test/unit/socket-lifecycle-security.test.ts new file mode 100644 index 00000000..9dc6d95e --- /dev/null +++ b/services/api/test/unit/socket-lifecycle-security.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, beforeEach } from "vitest" +import type { FastifyInstance, FastifyRequest } from "fastify" +import type { WebSocket } from "@fastify/websocket" +import { registerChatGateway } from "../../src/ws/gateway.js" +import { WsChatService } from "../../src/adapters/chat-service.ws.js" +import { InMemoryChatPubSub } from "../../src/adapters/chat-pubsub.js" +import { InMemoryChatRepository } from "../helpers/chat.js" +import { + WS_CLOSE_POLICY_VIOLATION, + WS_MAX_QUEUED_BYTES, + WS_MAX_QUEUED_FRAMES, +} from "../../src/ws/types.js" + +const WS_OPEN = 1 +const WS_CLOSED = 3 +const ALICE = "11111111-1111-1111-1111-111111111111" +const FLOOD_FRAMES = 100 +const MAX_PAYLOAD_BYTES = 64 * 1024 + +class MockSocket { + readyState = WS_OPEN + bufferedAmount = 0 + readonly sent: string[] = [] + readonly closes: Array<{ code: number | undefined; reason: string | undefined }> = [] + readonly listeners = new Map void>>() + + on(event: string, cb: (...args: unknown[]) => void): this { + const list = this.listeners.get(event) ?? [] + list.push(cb) + this.listeners.set(event, list) + return this + } + + emit(event: string, ...args: unknown[]): void { + for (const cb of [...(this.listeners.get(event) ?? [])]) cb(...args) + } + + send(data: string): void { + this.sent.push(data) + } + + close(code?: number, reason?: string): void { + this.closes.push({ code, reason }) + this.readyState = WS_CLOSED + } + + terminate(): void { + this.readyState = WS_CLOSED + } + + ping(): void { + this.emit("pong") + } +} + +function roomN(n: number): string { + return `aaaaaaaa-aaaa-aaaa-aaaa-${String(n).padStart(12, "0")}` +} + +function joinFrame(n: number): string { + return JSON.stringify({ type: "join", cleanupId: roomN(n) }) +} + +function authedRequest(): FastifyRequest { + return { + auth: { userId: ALICE }, + ip: "203.0.113.7", + headers: {}, + cookies: {}, + query: {}, + log: { warn() {}, error() {}, info() {}, debug() {} }, + } as unknown as FastifyRequest +} + +type GatewayOptions = Parameters[1] + +function captureGatewayHandler( + opts: GatewayOptions, +): (socket: WebSocket, request: FastifyRequest) => void { + let captured: ((socket: WebSocket, request: FastifyRequest) => void) | undefined + const app = { + get( + _path: string, + _opts: unknown, + handler: (socket: WebSocket, request: FastifyRequest) => void, + ): void { + captured = handler + }, + } as unknown as FastifyInstance + registerChatGateway(app, opts) + if (!captured) throw new Error("gateway handler was not registered") + return captured +} + +function flush(): Promise { + return new Promise((r) => setTimeout(r, 0)) +} + +interface StalledMembership { + calls: string[] + release(): void + isMember(cleanupId: string, userId: string): Promise +} + +function stalledMembership(): StalledMembership { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const state: StalledMembership = { + calls: [], + release: () => release(), + async isMember(cleanupId) { + state.calls.push(cleanupId) + if (state.calls.length === 1) await gate + return false + }, + } + return state +} + +async function openLiveSocket(membership: StalledMembership): Promise { + const handler = captureGatewayHandler({ + chat: new WsChatService({ + repo: new InMemoryChatRepository(), + pubsub: new InMemoryChatPubSub(), + }), + isMember: (cleanupId, userId) => membership.isMember(cleanupId, userId), + sessions: undefined, + webOrigins: [], + }) + const socket = new MockSocket() + handler(socket as unknown as WebSocket, authedRequest()) + await flush() + return socket +} + +describe("post-handshake inbound frame backlog is bounded", () => { + let membership: StalledMembership + + beforeEach(() => { + membership = stalledMembership() + }) + + it("closes the socket with a policy violation once the backlog passes the frame cap", async () => { + const socket = await openLiveSocket(membership) + socket.emit("message", joinFrame(0)) + await flush() + for (let i = 1; i <= FLOOD_FRAMES; i += 1) socket.emit("message", joinFrame(i)) + + expect(socket.closes).toHaveLength(1) + expect(socket.closes[0]?.code).toBe(WS_CLOSE_POLICY_VIOLATION) + + membership.release() + for (let i = 0; i < 10; i += 1) await flush() + expect(membership.calls.length).toBeLessThanOrEqual(WS_MAX_QUEUED_FRAMES) + }) + + it("closes the socket once the queued bytes pass the byte cap, even under the frame cap", async () => { + const socket = await openLiveSocket(membership) + socket.emit("message", joinFrame(0)) + await flush() + const filler = JSON.stringify("x".repeat(MAX_PAYLOAD_BYTES - 2)) + const framesToOverflow = Math.ceil(WS_MAX_QUEUED_BYTES / MAX_PAYLOAD_BYTES) + 1 + expect(framesToOverflow).toBeLessThan(WS_MAX_QUEUED_FRAMES) + for (let i = 0; i < framesToOverflow; i += 1) socket.emit("message", filler) + + expect(socket.closes).toHaveLength(1) + expect(socket.closes[0]?.code).toBe(WS_CLOSE_POLICY_VIOLATION) + }) + + it("keeps a backlog under the cap open and drains it in arrival order", async () => { + const socket = await openLiveSocket(membership) + socket.emit("message", joinFrame(0)) + await flush() + const queued = WS_MAX_QUEUED_FRAMES - 1 + for (let i = 1; i <= queued; i += 1) socket.emit("message", joinFrame(i)) + + membership.release() + for (let i = 0; i < 10; i += 1) await flush() + + expect(socket.closes).toHaveLength(0) + expect(membership.calls).toEqual(Array.from({ length: queued + 1 }, (_, i) => roomN(i))) + }) + + it("frees backlog room as frames finish, so a drained socket takes a new burst", async () => { + const socket = await openLiveSocket(membership) + socket.emit("message", joinFrame(0)) + await flush() + const burst = WS_MAX_QUEUED_FRAMES - 1 + for (let i = 1; i <= burst; i += 1) socket.emit("message", joinFrame(i)) + membership.release() + for (let i = 0; i < 10; i += 1) await flush() + + for (let i = 1; i <= burst; i += 1) socket.emit("message", joinFrame(burst + i)) + for (let i = 0; i < 10; i += 1) await flush() + + expect(socket.closes).toHaveLength(0) + }) +}) diff --git a/services/api/test/unit/ws-handshake-security.test.ts b/services/api/test/unit/ws-handshake-security.test.ts new file mode 100644 index 00000000..7800934b --- /dev/null +++ b/services/api/test/unit/ws-handshake-security.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from "vitest" +import type { FastifyRequest } from "fastify" +import { loadEnv } from "../../src/env.js" +import { resolveWsUser } from "../../src/ws/handshake.js" +import { SessionService } from "../../src/auth/session-service.js" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { makeInMemoryStores } from "../../src/auth/stores.js" + +const USER = "11111111-1111-4111-8111-111111111111" +const previousFlag = process.env.WS_ALLOW_QUERY_TOKEN + +afterEach(() => { + if (previousFlag === undefined) delete process.env.WS_ALLOW_QUERY_TOKEN + else process.env.WS_ALLOW_QUERY_TOKEN = previousFlag +}) + +async function sessionToken(): Promise<{ sessions: SessionService; token: string }> { + const stores = makeInMemoryStores() + const sessions = new SessionService({ + store: stores.sessions, + cache: new InMemoryCacheClient(() => Date.now()), + now: () => Date.now(), + }) + return { sessions, token: await sessions.createSession(USER, []) } +} + +function queryTokenRequest(token: string, allowQueryToken: boolean): FastifyRequest { + const env = loadEnv({ NODE_ENV: "test", WS_ALLOW_QUERY_TOKEN: allowQueryToken ? "1" : "" }) + return { + auth: { userId: null, roles: [], anon: true }, + query: { token }, + headers: {}, + cookies: {}, + server: { container: { env } }, + } as unknown as FastifyRequest +} + +describe("WS_ALLOW_QUERY_TOKEN comes from the validated env", () => { + it("defaults to off", () => { + expect(loadEnv({ NODE_ENV: "test" }).WS_ALLOW_QUERY_TOKEN).toBe(false) + }) + + it("turns on for 1 or true", () => { + expect(loadEnv({ NODE_ENV: "test", WS_ALLOW_QUERY_TOKEN: "1" }).WS_ALLOW_QUERY_TOKEN).toBe(true) + expect(loadEnv({ NODE_ENV: "test", WS_ALLOW_QUERY_TOKEN: "true" }).WS_ALLOW_QUERY_TOKEN).toBe( + true, + ) + }) + + it("accepts a ?token handshake when the env flag is on", async () => { + const { sessions, token } = await sessionToken() + + const resolved = await resolveWsUser(queryTokenRequest(token, true), sessions) + + expect(resolved?.userId).toBe(USER) + }) + + it("ignores a process.env flag that the loaded env does not carry", async () => { + const { sessions, token } = await sessionToken() + process.env.WS_ALLOW_QUERY_TOKEN = "1" + + expect(await resolveWsUser(queryTokenRequest(token, false), sessions)).toBeNull() + }) +}) From e3fb4840c5a2eb5cd9bdf120cdca1ae3d291dbc0 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:24:45 +0000 Subject: [PATCH 12/45] chat: participation gate always applies, edits check membership first, off-boarded operators lose report-chat powers --- services/api/src/routes/chat-powers-wiring.ts | 23 ++- .../api/src/routes/conversations.routes.ts | 27 ++-- .../api/src/services/chat-edit-service.ts | 39 +++-- .../unit/chat-edit-service-security.test.ts | 135 ++++++++++++++++++ .../unit/chat-powers-wiring-security.test.ts | 74 ++++++++++ .../unit/conversation-hides-routes.test.ts | 2 +- .../unit/conversation-mutes-routes.test.ts | 2 +- .../conversations-routes-security.test.ts | 112 +++++++++++++++ 8 files changed, 375 insertions(+), 39 deletions(-) create mode 100644 services/api/test/unit/chat-edit-service-security.test.ts create mode 100644 services/api/test/unit/chat-powers-wiring-security.test.ts create mode 100644 services/api/test/unit/conversations-routes-security.test.ts diff --git a/services/api/src/routes/chat-powers-wiring.ts b/services/api/src/routes/chat-powers-wiring.ts index c71f8667..04f6ec26 100644 --- a/services/api/src/routes/chat-powers-wiring.ts +++ b/services/api/src/routes/chat-powers-wiring.ts @@ -25,6 +25,8 @@ import type { FastifyInstance } from "fastify" import type { Container } from "../di.js" +import type { Env } from "../env.js" +import { isAdminEmail } from "../auth/admin-allowlist.js" import { makeChatPowersResolver, type ResolveChatPowers } from "../services/chat-room-roles.js" import type { ROLE_VALUES } from "../db/schema/types.js" import { makeDrizzleCleanupRepository } from "../services/cleanup-repository.drizzle.js" @@ -71,6 +73,25 @@ export async function globalRoleOf( return rows[0]?.role ?? null } +/** + * The global role that counts for chat powers. users.role is never demoted when an operator is + * off-boarded, so the operator role only carries authority while the row's current email passes the same + * ADMIN_EMAILS check the admin guard applies; otherwise the user has no global authority (null). + */ +export async function chatAuthorityRoleOf( + sql: ReturnType["sql"], + env: Pick, + userId: string, +): Promise { + const rows = await sql<{ role: GlobalRole; email: string | null }[]>` + SELECT role, email FROM users WHERE id = ${userId} LIMIT 1 + ` + const row = rows[0] + if (!row) return null + if (row.role === "operator" && (row.email === null || !isAdminEmail(env, row.email))) return null + return row.role +} + /** Per-instance memo (see the module banner). Keyed on the app so two harnesses never share a resolver. */ const resolvers = new WeakMap() @@ -127,7 +148,7 @@ function buildChatPowers(app: FastifyInstance, container: Container): ResolveCha (cleanups ??= makeDrizzleCleanupRepository(container.getDb().sql)).roleOf(cleanupId, userId), reportChatRoleOf: (reportId, userId) => (reportChat ??= makeReportChatRepository(container.getDb().sql)).roleOf(reportId, userId), - globalRoleOf: (userId) => globalRoleOf(container.getDb().sql, userId), + globalRoleOf: (userId) => chatAuthorityRoleOf(container.getDb().sql, container.env, userId), groupRoleOf: (groupId, userId) => (groups ??= makeChatGroupRepository(container.getDb().sql)).roleOf(groupId, userId), }) diff --git a/services/api/src/routes/conversations.routes.ts b/services/api/src/routes/conversations.routes.ts index 246fd969..a70679ea 100644 --- a/services/api/src/routes/conversations.routes.ts +++ b/services/api/src/routes/conversations.routes.ts @@ -37,14 +37,16 @@ import { isReportVisibleTo } from "../services/report-visibility.js" import { conversationReadSeam } from "./chat-gateway-wiring.js" import type { MarkRoomRead } from "../services/room-read-service.js" +type ParticipatesFn = ( + roomKind: ConversationMuteRoomKind, + roomId: string, + userId: string, +) => Promise + export interface ConversationRoutesOverrides { repo: ConversationMutesRepository hides?: ConversationHidesRepository - participates?: ( - roomKind: ConversationMuteRoomKind, - roomId: string, - userId: string, - ) => Promise + participates: ParticipatesFn markRoomRead?: MarkRoomRead } @@ -87,11 +89,7 @@ export async function registerConversationRoutes( let reports: DiscussionRepository | undefined let dmParticipant: ReturnType | undefined - const participatesReal = async ( - roomKind: ConversationMuteRoomKind, - roomId: string, - userId: string, - ): Promise => { + const participatesReal: ParticipatesFn = async (roomKind, roomId, userId) => { const sql = container.getDb().sql if (roomKind === "dm") { return (dmParticipant ??= container.getDmRepo()).isParticipant(roomId, userId) @@ -109,7 +107,8 @@ export async function registerConversationRoutes( return access !== null && (access.role !== null || access.visibility === "public") } - const participates = overrides ? overrides.participates : participatesReal + // An override built without a gate (an untyped or partial test object) must never open these routes. + const participates: ParticipatesFn = overrides?.participates ?? participatesReal let markRoomRead: MarkRoomRead | undefined const getMarkRoomRead = (): MarkRoomRead => @@ -125,7 +124,7 @@ export async function registerConversationRoutes( if (!isMutableRoomKind(body.roomKind)) { throw AppError.validation({ roomKind: "This conversation kind cannot be muted." }) } - if (participates && !(await participates(body.roomKind, body.roomId, userId))) { + if (!(await participates(body.roomKind, body.roomId, userId))) { throw AppError.forbidden("You can't change notifications for this conversation.") } await getRepo().setMuted(userId, body.roomKind, body.roomId, body.muted) @@ -144,7 +143,7 @@ export async function registerConversationRoutes( if (!isMutableRoomKind(body.roomKind)) { throw AppError.validation({ roomKind: "This conversation kind cannot be hidden." }) } - if (participates && !(await participates(body.roomKind, body.roomId, userId))) { + if (!(await participates(body.roomKind, body.roomId, userId))) { throw AppError.forbidden("You can't change this conversation.") } await getHidesRepo().setHidden(userId, body.roomKind, body.roomId, body.hidden) @@ -163,7 +162,7 @@ export async function registerConversationRoutes( async (request, reply) => { const userId = requireAuth(request) const body = parse(MarkThreadReadRequestSchema, request.body) - if (participates && !(await participates(body.roomKind, body.roomId, userId))) { + if (!(await participates(body.roomKind, body.roomId, userId))) { throw AppError.forbidden("You can't open this conversation.") } await getMarkRoomRead()(body.roomKind, body.roomId, userId) diff --git a/services/api/src/services/chat-edit-service.ts b/services/api/src/services/chat-edit-service.ts index 80054eab..0b89c31e 100644 --- a/services/api/src/services/chat-edit-service.ts +++ b/services/api/src/services/chat-edit-service.ts @@ -4,13 +4,13 @@ * 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. 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). - * 2. Room-send permission still held (the SAME checks the WS send path runs): cleanup member, report + * 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 (P4 4.4), dm thread peer + not blocked either way -> plain 403. This runs BEFORE the per-row - * state gates so a non-member probing leaked UUIDs learns nothing about a message's deleted-ness - * or kind — they only ever see the generic 403. + * 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. @@ -156,13 +156,11 @@ export function makeChatEditService(deps: ChatEditServiceDeps): ChatEditService if (!dm || !dmPeerOf || !isBlockedEitherWay) { throw new Error("chat-edit-service: dm deps not wired") } - const meta = await dm.findMessageMeta(messageId) - if (meta === null || meta.threadId !== roomId) throw AppError.notFound("Message not found") - // Room-send permission still held: thread peer + not blocked either way (the WS dm gate). BEFORE the - // per-row state gates so a non-participant learns nothing beyond the generic 403 (no info leak). const peer = await dmPeerOf(roomId, userId) if (peer === null) throw AppError.forbidden(CHAT_EDIT_FORBIDDEN) if (await isBlockedEitherWay(userId, peer)) throw AppError.forbidden(CHAT_EDIT_FORBIDDEN) + const meta = await dm.findMessageMeta(messageId) + if (meta === null || meta.threadId !== roomId) throw AppError.notFound("Message not found") assertEditable(meta, userId) assertNoSlur(body, "body") @@ -183,18 +181,6 @@ export function makeChatEditService(deps: ChatEditServiceDeps): ChatEditService if (!chat) throw new Error("chat-edit-service: chat deps not wired") const isReport = roomKind === "report" const isGroup = roomKind === "group" - const meta = await chat.findMessageMeta(messageId) - const roomMatches = - meta !== null && - (isReport - ? meta.reportId === roomId - : isGroup - ? meta.groupId === roomId - : meta.cleanupId === roomId) - if (meta === null || !roomMatches) throw AppError.notFound("Message not found") - // Room-send permission still held: the SAME membership checks the WS send path runs. BEFORE the - // per-row state gates so a non-member probing leaked UUIDs learns nothing about a message's - // deleted-ness/kind — they only ever see the generic 403. 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. @@ -213,6 +199,15 @@ export function makeChatEditService(deps: ChatEditServiceDeps): ChatEditService if (!isCleanupMember) throw new Error("chat-edit-service: cleanup deps not wired") if (!(await isCleanupMember(roomId, userId))) throw AppError.forbidden(CHAT_EDIT_FORBIDDEN) } + const meta = await chat.findMessageMeta(messageId) + const roomMatches = + meta !== null && + (isReport + ? meta.reportId === roomId + : isGroup + ? meta.groupId === roomId + : meta.cleanupId === roomId) + if (meta === null || !roomMatches) throw AppError.notFound("Message not found") assertEditable(meta, userId) assertNoSlur(body, "body") diff --git a/services/api/test/unit/chat-edit-service-security.test.ts b/services/api/test/unit/chat-edit-service-security.test.ts new file mode 100644 index 00000000..53aa774f --- /dev/null +++ b/services/api/test/unit/chat-edit-service-security.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest" +import { randomUUID } from "node:crypto" +import { makeChatEditService } from "../../src/services/chat-edit-service.js" +import { + InMemoryBlocksRepository, + InMemoryDmRepository, +} from "../../src/services/dm-repository.memory.js" +import { InMemoryChatRepository } from "../helpers/chat.js" + +const ALICE = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +const BOB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +const MALLORY = "dddddddd-dddd-dddd-dddd-dddddddddddd" +const ROOM = "cccccccc-cccc-cccc-cccc-cccccccccccc" + +const onlyAlice = (_roomId: string, userId: string): Promise => + Promise.resolve(userId === ALICE) + +type RoomLane = "cleanup" | "group" | "report" + +async function roomLane(kind: RoomLane) { + const chat = new InMemoryChatRepository() + chat.registerSender({ id: ALICE, displayName: "Alice", handle: "alice" }) + const msg = await chat.insertMessage( + { + cleanupId: ROOM, + userId: ALICE, + body: "hi", + ...(kind === "cleanup" ? {} : { roomKind: kind }), + }, + randomUUID(), + ) + const service = makeChatEditService({ + chat, + isCleanupMember: onlyAlice, + isGroupMember: onlyAlice, + isReportMember: onlyAlice, + isReportVisible: () => Promise.resolve(true), + }) + return { service, messageId: msg.id } +} + +async function dmLane() { + const blocks = new InMemoryBlocksRepository() + const dm = new InMemoryDmRepository((a, b) => blocks.isBlockedEitherWay(a, b)) + dm.registerUser({ id: ALICE, displayName: "Alice", handle: "alice" }) + dm.registerUser({ id: BOB, displayName: "Bob", handle: "bob" }) + const thread = await dm.openOrCreateThread(ALICE, BOB) + const msg = await dm.persist({ threadId: thread.id, senderId: ALICE, body: "hi bob" }) + const service = makeChatEditService({ + dm, + dmPeerOf: (threadId, userId) => Promise.resolve(dm.peerOf(threadId, userId)), + isBlockedEitherWay: (a, b) => blocks.isBlockedEitherWay(a, b), + }) + return { service, threadId: thread.id, messageId: msg.id } +} + +describe("chat edit answers an outsider the same way whatever message id they probe", () => { + it.each(["cleanup", "group", "report"] as const)( + "a %s non-member gets 403 for a message in the room AND for an unknown id", + async (kind) => { + const { service, messageId } = await roomLane(kind) + const edit = (id: string) => + service.editMessage({ + roomKind: kind, + roomId: ROOM, + messageId: id, + userId: MALLORY, + body: "x", + }) + await expect(edit(messageId)).rejects.toMatchObject({ httpStatus: 403 }) + await expect(edit(randomUUID())).rejects.toMatchObject({ httpStatus: 403 }) + }, + ) + + it.each(["cleanup", "group", "report"] as const)( + "a %s member still gets 404 for a message that is not in the room", + async (kind) => { + const { service } = await roomLane(kind) + await expect( + service.editMessage({ + roomKind: kind, + roomId: ROOM, + messageId: randomUUID(), + userId: ALICE, + body: "x", + }), + ).rejects.toMatchObject({ httpStatus: 404 }) + }, + ) + + it("a report that is not visible answers 404 for any id, before membership", async () => { + const chat = new InMemoryChatRepository() + const service = makeChatEditService({ + chat, + isReportMember: onlyAlice, + isReportVisible: () => Promise.resolve(false), + }) + await expect( + service.editMessage({ + roomKind: "report", + roomId: ROOM, + messageId: randomUUID(), + userId: MALLORY, + body: "x", + }), + ).rejects.toMatchObject({ httpStatus: 404 }) + }) + + it("a dm outsider gets 403 for a message in the thread AND for an unknown id", async () => { + const { service, threadId, messageId } = await dmLane() + const edit = (id: string) => + service.editMessage({ + roomKind: "dm", + roomId: threadId, + messageId: id, + userId: MALLORY, + body: "x", + }) + await expect(edit(messageId)).rejects.toMatchObject({ httpStatus: 403 }) + await expect(edit(randomUUID())).rejects.toMatchObject({ httpStatus: 403 }) + }) + + it("a dm participant still gets 404 for a message that is not in the thread", async () => { + const { service, threadId } = await dmLane() + await expect( + service.editMessage({ + roomKind: "dm", + roomId: threadId, + messageId: randomUUID(), + userId: BOB, + body: "x", + }), + ).rejects.toMatchObject({ httpStatus: 404 }) + }) +}) diff --git a/services/api/test/unit/chat-powers-wiring-security.test.ts b/services/api/test/unit/chat-powers-wiring-security.test.ts new file mode 100644 index 00000000..b81442cd --- /dev/null +++ b/services/api/test/unit/chat-powers-wiring-security.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest" +import type { FastifyInstance } from "fastify" +import type { Container } from "../../src/di.js" +import { wireChatPowers } from "../../src/routes/chat-powers-wiring.js" +import { makeFakeSql } from "../helpers/fake-sql.js" + +const REPORT = "cccccccc-cccc-cccc-cccc-cccccccccccc" +const FORMER_OPERATOR = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +const ALLOWLISTED_EMAIL = "ops@civfix.org" + +interface UserRow { + role: "citizen" | "operator" + email: string | null +} + +function resolverFor(user: UserRow, reportChatRole: "owner" | "member" | null = null) { + const { sql } = makeFakeSql([ + { match: /FROM report_chat_members/, rows: reportChatRole ? [{ role: reportChatRole }] : [] }, + { match: /FROM users/, rows: [user] }, + ]) + const container = { + env: { ADMIN_EMAILS: [ALLOWLISTED_EMAIL] }, + getDb: () => ({ sql }), + } as unknown as Container + return wireChatPowers({} as FastifyInstance, container) +} + +const reportPowers = (user: UserRow, reportChatRole: "owner" | "member" | null = null) => + resolverFor( + user, + reportChatRole, + )({ + roomKind: "report", + roomId: REPORT, + userId: FORMER_OPERATOR, + }) + +describe("report-chat operator powers follow the live ADMIN_EMAILS allowlist", () => { + it("an operator whose email left ADMIN_EMAILS can neither pin nor delete others", async () => { + await expect( + reportPowers({ role: "operator", email: "former-ops@civfix.org" }), + ).resolves.toEqual({ canPin: false, canDeleteOthers: false, isModerator: false }) + }) + + it("an operator row with no email (anonymized) has no operator powers", async () => { + await expect(reportPowers({ role: "operator", email: null })).resolves.toEqual({ + canPin: false, + canDeleteOthers: false, + isModerator: false, + }) + }) + + it("an allowlisted operator keeps pin and delete, matching emails like the admin guard", async () => { + await expect(reportPowers({ role: "operator", email: " Ops@CivFix.org " })).resolves.toEqual({ + canPin: true, + canDeleteOthers: true, + isModerator: true, + }) + }) + + it("an allowlisted email without the operator role grants nothing", async () => { + await expect(reportPowers({ role: "citizen", email: ALLOWLISTED_EMAIL })).resolves.toEqual({ + canPin: false, + canDeleteOthers: false, + isModerator: false, + }) + }) + + it("an off-boarded operator who owns the report keeps only the owner's pin power", async () => { + await expect( + reportPowers({ role: "operator", email: "former-ops@civfix.org" }, "owner"), + ).resolves.toEqual({ canPin: true, canDeleteOthers: false, isModerator: true }) + }) +}) diff --git a/services/api/test/unit/conversation-hides-routes.test.ts b/services/api/test/unit/conversation-hides-routes.test.ts index 47ee7e28..8e22f68a 100644 --- a/services/api/test/unit/conversation-hides-routes.test.ts +++ b/services/api/test/unit/conversation-hides-routes.test.ts @@ -55,7 +55,7 @@ async function makeHarness(participates?: Participates): Promise { const conversationRoutesOverrides: ConversationRoutesOverrides = { repo: makeMutesRepo(), hides, - ...(participates ? { participates } : {}), + participates: participates ?? (() => Promise.resolve(true)), } const app = await buildServer({ env, authServices, conversationRoutesOverrides }) diff --git a/services/api/test/unit/conversation-mutes-routes.test.ts b/services/api/test/unit/conversation-mutes-routes.test.ts index 0a2805ce..8de80bfc 100644 --- a/services/api/test/unit/conversation-mutes-routes.test.ts +++ b/services/api/test/unit/conversation-mutes-routes.test.ts @@ -59,7 +59,7 @@ async function makeHarness(participates?: Participates): Promise { const repo = makeFakeRepo() const conversationRoutesOverrides: ConversationRoutesOverrides = { repo, - ...(participates ? { participates } : {}), + participates: participates ?? (() => Promise.resolve(true)), } const app = await buildServer({ env, authServices, conversationRoutesOverrides }) diff --git a/services/api/test/unit/conversations-routes-security.test.ts b/services/api/test/unit/conversations-routes-security.test.ts new file mode 100644 index 00000000..b9da9cac --- /dev/null +++ b/services/api/test/unit/conversations-routes-security.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, afterEach, vi } from "vitest" +import type { FastifyInstance } from "fastify" +import { FakeMailer } from "@civfix/shared/fakes" +import { buildServer } from "../../src/server.js" +import { loadEnv } from "../../src/env.js" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { makeInMemoryStores } from "../../src/auth/stores.js" +import { buildAuthServices } from "../../src/auth/auth-services.js" +import { StubJwksVerifier } from "../helpers/auth.js" +import type { ConversationRoutesOverrides } from "../../src/routes/conversations.routes.js" + +const ROOM_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +const ROOM_KINDS = ["cleanup", "dm", "report", "group"] as const + +interface Harness { + app: FastifyInstance + token: string + setMuted: ReturnType + setHidden: ReturnType + markRoomRead: ReturnType +} + +let current: FastifyInstance | undefined + +afterEach(async () => { + await current?.close() + current = undefined +}) + +async function harnessWithoutGate(): Promise { + const env = loadEnv({ NODE_ENV: "test" }) + const mailer = new FakeMailer() + const authServices = buildAuthServices({ + stores: makeInMemoryStores(), + cache: new InMemoryCacheClient(() => Date.now()), + mailer, + oauthConfig: {}, + verifier: new StubJwksVerifier(), + now: () => Date.now(), + }) + const setMuted = vi.fn(() => Promise.resolve()) + const setHidden = vi.fn(() => Promise.resolve()) + const markRoomRead = vi.fn(() => Promise.resolve()) + const overridesMissingGate = { + repo: { + isMuted: () => Promise.resolve(false), + setMuted, + mutedRoomIdsFor: () => Promise.resolve(new Set()), + }, + hides: { setHidden, hiddenAtFor: () => Promise.resolve(new Map()) }, + markRoomRead, + } as unknown as ConversationRoutesOverrides + + const app = await buildServer({ + env, + authServices, + conversationRoutesOverrides: overridesMissingGate, + }) + current = app + + const email = "outsider@example.com" + await app.inject({ method: "POST", url: "/v1/auth/otp/request", payload: { email } }) + const verify = await app.inject({ + method: "POST", + url: "/v1/auth/otp/verify", + headers: { "x-client": "mobile" }, + payload: { email, code: mailer.lastOtpFor(email)! }, + }) + const token = (verify.json() as { token: string }).token + return { app, token, setMuted, setHidden, markRoomRead } +} + +describe("conversation routes never run ungated when a test override omits the gate", () => { + it.each(ROOM_KINDS)("does not mute a %s room without a participation check", async (kind) => { + const h = await harnessWithoutGate() + const res = await h.app.inject({ + method: "PUT", + url: "/v1/conversations/mute", + headers: { authorization: `Bearer ${h.token}` }, + payload: { roomKind: kind, roomId: ROOM_ID, muted: true }, + }) + expect(res.statusCode).not.toBe(200) + expect(h.setMuted).not.toHaveBeenCalled() + }) + + it.each(ROOM_KINDS)("does not hide a %s room without a participation check", async (kind) => { + const h = await harnessWithoutGate() + const res = await h.app.inject({ + method: "PUT", + url: "/v1/conversations/hidden", + headers: { authorization: `Bearer ${h.token}` }, + payload: { roomKind: kind, roomId: ROOM_ID, hidden: true }, + }) + expect(res.statusCode).not.toBe(200) + expect(h.setHidden).not.toHaveBeenCalled() + }) + + it.each(ROOM_KINDS)( + "does not mark a %s room read without a participation check", + async (kind) => { + const h = await harnessWithoutGate() + const res = await h.app.inject({ + method: "PUT", + url: "/v1/threads/read", + headers: { authorization: `Bearer ${h.token}` }, + payload: { roomKind: kind, roomId: ROOM_ID }, + }) + expect(res.statusCode).not.toBe(200) + expect(h.markRoomRead).not.toHaveBeenCalled() + }, + ) +}) From 602a8328beb117b0f7d23fd211d6d0ed35fd4f03 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:24:45 +0000 Subject: [PATCH 13/45] inbound mail: content-addressed attachment keys, exact-address match on consumer domains, unique pending keys --- infra/email-worker/README.md | 12 +- infra/email-worker/src/index.ts | 20 +- .../test/pending-key-security.test.ts | 50 +++ .../api/src/services/admin/inbound-bounce.ts | 39 +++ .../src/services/admin/inbound-processor.ts | 104 ++++-- .../admin/inbound-thread-correlation.ts | 33 +- .../unit/inbound-processor-security.test.ts | 321 ++++++++++++++++++ ...nbound-thread-correlation-security.test.ts | 175 ++++++++++ 8 files changed, 717 insertions(+), 37 deletions(-) create mode 100644 infra/email-worker/test/pending-key-security.test.ts create mode 100644 services/api/test/unit/inbound-processor-security.test.ts create mode 100644 services/api/test/unit/inbound-thread-correlation-security.test.ts diff --git a/infra/email-worker/README.md b/infra/email-worker/README.md index 9501f983..fb77ca79 100644 --- a/infra/email-worker/README.md +++ b/infra/email-worker/README.md @@ -1,11 +1,18 @@ # civfix Email Worker Cloudflare Email Worker that ingests catch-all `*@civfix.org` mail. It writes the raw `.eml` to -**R2** (`inbound/pending/.eml` — the source of truth) and best-effort POSTs an HMAC-signed +**R2** (`inbound/pending/..eml`, the source of truth) and best-effort POSTs an HMAC-signed `{ key }` nudge to the backend webhook. The backend re-fetches from R2, parses, routes (reply → mail thread; else → inbox), and reconciles `inbound/pending/` on boot + a cron sweep, so a missed nudge is never a lost message. +The pending key is `.`: `` is the Message-ID with `<>` stripped and every character +outside `A-Za-z0-9._@-` replaced by `_`, cut to 120 characters, and `` is the first 32 hex +characters of the SHA-256 of the raw message. The sender chooses the Message-ID, so the digest keeps a +second mail with the same (or a same-slugging) Message-ID from overwriting a pending one, while a +byte-identical redelivery lands on the same key. A message with no Message-ID is stored under the full +64-character digest alone. + The Worker does **not** judge sender authentication. `message.headers` does not expose the `Authentication-Results` header Cloudflare stamps (workerd#6740), so a header check here never fired. The backend is the only gate: it reads the top-most `Authentication-Results` in the raw message and @@ -59,7 +66,8 @@ Content-Type: text/plain We received your report.' ``` -Expect an `inbound/pending/test-001@example.gov.eml` object and a signed POST to your local backend. +Expect an `inbound/pending/test-001@example.gov..eml` object and a signed POST to your local +backend. ## Deploy + enable Email Routing diff --git a/infra/email-worker/src/index.ts b/infra/email-worker/src/index.ts index 9e91ccda..1388fdb9 100644 --- a/infra/email-worker/src/index.ts +++ b/infra/email-worker/src/index.ts @@ -12,7 +12,7 @@ export default { const rawBytes = await new Response(message.raw).arrayBuffer() const messageId = await deriveMessageId(message.headers, rawBytes) - const key = `${PENDING_PREFIX}${messageId}.eml` + const key = await derivePendingKey(message.headers, rawBytes) try { await env.R2_BUCKET.put(key, rawBytes, { @@ -35,9 +35,25 @@ export default { }, } +const PENDING_SLUG_MAX_CHARS = 120 +const PENDING_DIGEST_CHARS = 32 + +// The sender picks the Message-ID, so a key made from it alone lets a later mail (or a Message-ID +// that slugs alike) overwrite a pending .eml before the backend drains it; the content digest makes +// the key follow the bytes, while an identical redelivery still lands on the same key. +export async function derivePendingKey(headers: Headers, raw: ArrayBuffer): Promise { + const digest = await contentDigest(raw) + const slug = slugify(headers.get("message-id") ?? "").slice(0, PENDING_SLUG_MAX_CHARS) + const name = slug.length > 0 ? `${slug}.${digest.slice(0, PENDING_DIGEST_CHARS)}` : digest + return `${PENDING_PREFIX}${name}.eml` +} + export async function deriveMessageId(headers: Headers, raw: ArrayBuffer): Promise { const slug = slugify(headers.get("message-id") ?? "") - if (slug.length > 0) return slug + return slug.length > 0 ? slug : contentDigest(raw) +} + +async function contentDigest(raw: ArrayBuffer): Promise { try { return await sha256Hex(raw) } catch { diff --git a/infra/email-worker/test/pending-key-security.test.ts b/infra/email-worker/test/pending-key-security.test.ts new file mode 100644 index 00000000..83cdb042 --- /dev/null +++ b/infra/email-worker/test/pending-key-security.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest" +import { derivePendingKey } from "../src/index" + +const BACKEND_PENDING_KEY_RE = /^inbound\/pending\/[^/]+\.eml$/ + +function bytes(text: string): ArrayBuffer { + return new TextEncoder().encode(text).buffer as ArrayBuffer +} + +function withMessageId(messageId: string): Headers { + return new Headers({ "message-id": messageId }) +} + +describe("derivePendingKey", () => { + it("gives Message-IDs that slug alike distinct keys when their bytes differ", async () => { + const plus = await derivePendingKey(withMessageId(""), bytes("city reply")) + const equals = await derivePendingKey(withMessageId(""), bytes("other mail")) + expect(plus).not.toBe(equals) + }) + + it("gives a reused Message-ID with different bytes a different key", async () => { + const original = await derivePendingKey(withMessageId(""), bytes("original")) + const reused = await derivePendingKey(withMessageId(""), bytes("forged")) + expect(original).not.toBe(reused) + }) + + it("maps a byte-identical redelivery onto the same key", async () => { + const first = await derivePendingKey(withMessageId(""), bytes("same body")) + const again = await derivePendingKey(withMessageId(""), bytes("same body")) + expect(first).toBe(again) + }) + + it("stays inside the backend's pending-key shape for hostile or huge Message-IDs", async () => { + for (const messageId of ["", `<${"x".repeat(5000)}@x>`, "", "<>"]) { + const key = await derivePendingKey(withMessageId(messageId), bytes("body")) + expect(key).toMatch(BACKEND_PENDING_KEY_RE) + expect(key.length).toBeLessThanOrEqual(256) + } + }) + + it("keeps a readable Message-ID slug in the key", async () => { + const key = await derivePendingKey(withMessageId(""), bytes("body")) + expect(key.startsWith("inbound/pending/test-001@example.gov.")).toBe(true) + }) + + it("falls back to the content hash alone when there is no Message-ID", async () => { + const key = await derivePendingKey(new Headers(), bytes("body")) + expect(key).toMatch(/^inbound\/pending\/[0-9a-f]{64}\.eml$/) + }) +}) diff --git a/services/api/src/services/admin/inbound-bounce.ts b/services/api/src/services/admin/inbound-bounce.ts index cf2dd429..92025530 100644 --- a/services/api/src/services/admin/inbound-bounce.ts +++ b/services/api/src/services/admin/inbound-bounce.ts @@ -61,6 +61,45 @@ export const PROVIDER_DAEMON_DOMAINS: readonly string[] = [ "oracleemaildelivery.com", ] +// Anyone can open a mailbox at these providers, so sharing one with a jurisdiction contact proves +// nothing about the sender: only the contact's exact address does. +export const CONSUMER_MAIL_DOMAINS: ReadonlySet = new Set([ + "gmail.com", + "googlemail.com", + "outlook.com", + "hotmail.com", + "live.com", + "msn.com", + "yahoo.com", + "ymail.com", + "rocketmail.com", + "aol.com", + "icloud.com", + "me.com", + "mac.com", + "proton.me", + "protonmail.com", + "pm.me", + "gmx.com", + "gmx.net", + "mail.com", + "zoho.com", + "yandex.com", + "fastmail.com", + "hey.com", + "att.net", + "sbcglobal.net", + "bellsouth.net", + "comcast.net", + "verizon.net", + "cox.net", + "charter.net", + "earthlink.net", + "optonline.net", + "centurylink.net", + "windstream.net", +]) + export function isPlausibleBounceSender(input: { fromAddr: string | null failedRecipient: string diff --git a/services/api/src/services/admin/inbound-processor.ts b/services/api/src/services/admin/inbound-processor.ts index 481d19db..248d47a7 100644 --- a/services/api/src/services/admin/inbound-processor.ts +++ b/services/api/src/services/admin/inbound-processor.ts @@ -1,8 +1,10 @@ +import { createHash } from "node:crypto" import type { Container } from "../../di.js" import type { InboundMail, ParsedMail, Storage } from "@civfix/shared/interfaces" import type { MailAttachment } from "@civfix/shared" import { makeDrizzleMailRepository, + type MailMessageRecord, type MailRepository, type MailThreadRecord, } from "./mail-repository.drizzle.js" @@ -44,6 +46,12 @@ export const INBOUND_OBJECT_MAX_BYTES = 30 * 1024 * 1024 export const INBOUND_PARSE_TIMEOUT_MS = 20_000 +const CONTENT_DIGEST_HEX_CHARS = 32 + +function contentDigest(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex").slice(0, CONTENT_DIGEST_HEX_CHARS) +} + async function withTimeout(work: Promise, ms: number): Promise { let timer: ReturnType | undefined try { @@ -119,7 +127,7 @@ export async function processInboundObject( const bounce = detectBounce(mail) if (bounce.isBounce) { const bounceVerdict = readMailAuthVerdict(mail) - const result = await routeInbox(storage, inboundRepo, key, mail, messageId, bounceVerdict) + const result = await routeInbox(storage, inboundRepo, bytes, mail, messageId, bounceVerdict) if (result.outcome === "inbox") { await handleBounce(container, mailRepo, bounce, { fromAddr: mail.from?.address ?? null, @@ -142,7 +150,7 @@ export async function processInboundObject( const result = resolvedThread === null - ? await routeInbox(storage, inboundRepo, key, mail, messageId, authVerdict) + ? await routeInbox(storage, inboundRepo, bytes, mail, messageId, authVerdict) : await routeThreaded( container, injected, @@ -195,27 +203,17 @@ async function routeThreaded( ): Promise { const unaffiliated = authVerdict !== "pass" || !(await isJurisdictionSender(mailRepo, thread.id, mail)) - const { attachments, oversize } = await streamAttachments( - storage, - `inbound-mail/${thread.id}`, - mail, - ) - const inserted = await mailRepo.insertMessage({ - threadId: thread.id, - direction: "in", - fromAddr: mail.from?.address ?? null, - toAddr: mail.to[0]?.address ?? null, - subject: mail.subject ?? null, - body: threadBody(mail), - attachments, - messageId, - inReplyTo: mail.inReplyTo ?? null, - unaffiliated, - }) + // Message-ID is globally unique, so a stored row means this mail can only be a replay. Skipping + // the upload keeps a sender who reuses someone else's Message-ID from writing any object. + const existing = await mailRepo.findMessageByMessageId(messageId).catch(() => null) + const inserted = + existing !== null + ? null + : await insertThreadedMessage(storage, mailRepo, mail, messageId, thread, unaffiliated) if (inserted !== null) { await mailRepo.recordEvent({ threadId: thread.id, - messageId: inserted.id, + messageId: inserted.message.id, type: "delivered", meta: { direction: "in", @@ -223,12 +221,15 @@ async function routeThreaded( messageId, authVerdict, ...(unaffiliated ? { unaffiliated: true } : {}), - ...(oversize.length > 0 ? { oversizeAttachments: oversize } : {}), + ...(inserted.oversize.length > 0 ? { oversizeAttachments: inserted.oversize } : {}), }, }) } - const message = inserted ?? (await mailRepo.findMessageByMessageId(messageId).catch(() => null)) + const message = + inserted?.message ?? + existing ?? + (await mailRepo.findMessageByMessageId(messageId).catch(() => null)) if (message !== null && message.threadId === thread.id) { await applyInboundEffects(container, injected, mailRepo, thread, message).catch( (err: unknown) => { @@ -240,7 +241,35 @@ async function routeThreaded( ) } if (inserted === null) return { outcome: "replay" } - return { outcome: "threaded", id: inserted.id } + return { outcome: "threaded", id: inserted.message.id } +} + +async function insertThreadedMessage( + storage: Storage, + mailRepo: MailRepository, + mail: ParsedMail, + messageId: string, + thread: MailThreadRecord, + unaffiliated: boolean, +): Promise<{ message: MailMessageRecord; oversize: string[] } | null> { + const { attachments, oversize } = await streamAttachments( + storage, + `inbound-mail/${thread.id}`, + mail, + ) + const message = await mailRepo.insertMessage({ + threadId: thread.id, + direction: "in", + fromAddr: mail.from?.address ?? null, + toAddr: mail.to[0]?.address ?? null, + subject: mail.subject ?? null, + body: threadBody(mail), + attachments, + messageId, + inReplyTo: mail.inReplyTo ?? null, + unaffiliated, + }) + return message === null ? null : { message, oversize } } function threadBody(mail: ParsedMail): string | null { @@ -258,15 +287,18 @@ function errorText(err: unknown): string { async function routeInbox( storage: Storage, inboundRepo: InboundRepository, - key: string, + raw: Uint8Array, mail: ParsedMail, messageId: string, authVerdict: MailAuthVerdict, ): Promise { - const folder = key.startsWith(INBOUND_PENDING_PREFIX) - ? key.slice(INBOUND_PENDING_PREFIX.length).replace(/\.eml$/i, "") - : sanitizeFilename(messageId) - const { attachments } = await streamAttachments(storage, `inbound-emails/${folder}`, mail) + // One folder per raw message, never shared between rows: the retention reaper deletes a row's + // attachment objects, so a folder another row also pointed at would lose that row's evidence. + const { attachments } = await streamAttachments( + storage, + `inbound-emails/${contentDigest(raw)}`, + mail, + ) const recipient = mail.to[0]?.address ?? null const toAddr = mail.to @@ -284,9 +316,23 @@ async function routeInbox( headers: buildStoredHeaders(mail.headers, authVerdict), attachments, }) + if (!inserted) { + const kept = (await inboundRepo.get(id))?.attachments ?? [] + await discardUnreferenced(storage, attachments, kept) + } return { outcome: inserted ? "inbox" : "replay", id } } +async function discardUnreferenced( + storage: Storage, + written: readonly MailAttachment[], + kept: readonly MailAttachment[], +): Promise { + const keptKeys = new Set(kept.map((att) => att.key)) + const orphans = new Set(written.map((att) => att.key).filter((key) => !keptKeys.has(key))) + for (const key of orphans) await storage.delete(key) +} + export const INBOUND_BODY_TEXT_MAX_CHARS = 256 * 1024 const INBOUND_HEADER_VALUE_MAX_CHARS = 4 * 1024 @@ -359,7 +405,7 @@ async function streamAttachments( recordSkipped(oversize, filename) continue } - const objKey = `${keyPrefix}/${index}-${sanitizeFilename(filename)}` + const objKey = `${keyPrefix}/${contentDigest(bytes)}/${sanitizeFilename(filename)}` await storage.put(objKey, bytes, { contentType: "application/octet-stream" }) attachments.push({ key: objKey, filename, size: bytes.byteLength }) totalBytes += bytes.byteLength diff --git a/services/api/src/services/admin/inbound-thread-correlation.ts b/services/api/src/services/admin/inbound-thread-correlation.ts index 172fc925..ad65adcb 100644 --- a/services/api/src/services/admin/inbound-thread-correlation.ts +++ b/services/api/src/services/admin/inbound-thread-correlation.ts @@ -14,7 +14,13 @@ import type { CleanupRepository } from "../cleanup-service.js" import { makeContainerReportChatEmitter } from "../report-chat-emitter.js" import type { ReportChatSystemEmitter } from "../report-timeline-event.js" import { MESSAGE_BODY_MAX, segmentGraphemes } from "@civfix/shared" -import { domainOf, domainsAligned, replyAddressToken } from "../../adapters/inbound-mail.cf.js" +import { + domainOf, + domainsAligned, + organizationalDomain, + replyAddressToken, +} from "../../adapters/inbound-mail.cf.js" +import { CONSUMER_MAIL_DOMAINS } from "./inbound-bounce.js" export { JURISDICTION_REPLY_NOTE } @@ -165,21 +171,40 @@ export async function isJurisdictionSender( threadId: string, mail: ParsedMail, ): Promise { - const fromDomain = domainOf(mail.from?.address ?? null) - if (fromDomain === null) return false + const fromAddress = mail.from?.address ?? null + const fromDomain = domainOf(fromAddress) + if (fromAddress === null || fromDomain === null) return false let recipients: string[] try { recipients = await mailRepo.outboundRecipients(threadId) } catch { return false } + const sender = normalizedMailbox(fromAddress) for (const recipient of recipients) { const contactDomain = domainOf(recipient) - if (contactDomain !== null && domainsAligned(fromDomain, contactDomain)) return true + if (contactDomain === null) continue + if (isConsumerMailDomain(contactDomain)) { + if (normalizedMailbox(recipient) === sender) return true + } else if (domainsAligned(fromDomain, contactDomain)) { + return true + } } return false } +const BRACKETED_MAILBOX_RE = /<([^<>]*)>/ + +// Provider aliasing (Gmail dots, +tags, googlemail.com) is deliberately not folded: every folding +// rule widens the set of mailboxes that count as the contact. +function normalizedMailbox(address: string): string { + return (BRACKETED_MAILBOX_RE.exec(address)?.[1] ?? address).trim().toLowerCase() +} + +function isConsumerMailDomain(domain: string): boolean { + return CONSUMER_MAIL_DOMAINS.has(organizationalDomain(domain) ?? domain) +} + export async function applyInboundEffects( container: Container, injected: InboundEffectDeps, diff --git a/services/api/test/unit/inbound-processor-security.test.ts b/services/api/test/unit/inbound-processor-security.test.ts new file mode 100644 index 00000000..db826fff --- /dev/null +++ b/services/api/test/unit/inbound-processor-security.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from "vitest" +import { FakeJobs, FakeStorage } from "@civfix/shared/fakes" +import { CfInboundMail } from "../../src/adapters/inbound-mail.cf.js" +import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" +import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" +import { RecordingNotifier } from "../helpers/notifications.js" +import { InMemoryCleanupRepository } from "../helpers/cleanups.js" +import { + processInboundObject, + INBOUND_PENDING_PREFIX, + type InboundProcessorDeps, +} from "../../src/services/admin/inbound-processor.js" +import type { Container } from "../../src/di.js" +import { makeFakeSql } from "../helpers/fake-sql.js" + +const TOKEN = "0123456789abcdef01234567" +const REPLY_TO = `reply+${TOKEN}@civfix.org` +const CITY_BYTES = "CITY-EVIDENCE" +const ATTACKER_BYTES = "ATTACKER-BYTES" + +interface Ctx { + container: Container + deps: InboundProcessorDeps + storage: FakeStorage + mailRepo: InMemoryMailRepository + inboundRepo: InMemoryInboundRepository +} + +function ctx(): Ctx { + const inboundMail = new CfInboundMail() + const storage = new FakeStorage() + const mailRepo = new InMemoryMailRepository() + const inboundRepo = new InMemoryInboundRepository() + const deps: InboundProcessorDeps = { + storage, + inboundMail, + mailRepo, + inboundRepo, + adminReportRepo: new InMemoryAdminReportRepository(), + cleanupRepo: new InMemoryCleanupRepository(), + notifications: new RecordingNotifier(), + chatEmitter: { emit: () => Promise.resolve() }, + } + const container = { + env: {}, + storage, + inboundStorage: storage, + inboundMail, + jobs: new FakeJobs(), + getDb: () => ({ sql: makeFakeSql().sql }), + } as unknown as Container + return { container, deps, storage, mailRepo, inboundRepo } +} + +function mailWithAttachment(opts: { + from: string + to: string + messageId: string + filename: string + content: string +}): Buffer { + const fromDomain = opts.from.slice(opts.from.lastIndexOf("@") + 1) + return Buffer.from( + [ + `From: ${opts.from}`, + `To: ${opts.to}`, + "Subject: Re: pothole", + `Message-ID: ${opts.messageId}`, + `Authentication-Results: mx.cloudflare.net; dmarc=pass header.from=${fromDomain}`, + "Content-Type: multipart/mixed; boundary=b", + "", + "--b", + "Content-Type: text/plain", + "", + "See attached.", + "--b", + "Content-Type: application/octet-stream", + `Content-Disposition: attachment; filename="${opts.filename}"`, + "", + opts.content, + "--b--", + "", + ].join("\r\n"), + "utf8", + ) +} + +async function deliver(c: Ctx, key: string, raw: Buffer): Promise { + await c.storage.put(key, raw) + return (await processInboundObject(c.container, key, c.deps)).outcome +} + +function text(bytes: Uint8Array | null): string | null { + return bytes === null ? null : Buffer.from(bytes).toString("utf8").trim() +} + +async function keysUnder(storage: FakeStorage, prefix: string): Promise { + return (await storage.list(prefix, { limit: 1000 })).keys +} + +function seedReportThread(c: Ctx): string { + const thread = c.mailRepo.seedThread({ threadToken: TOKEN, status: "sent" }) + c.mailRepo.seedMessage({ threadId: thread.id, direction: "out", toAddr: "clerk@city.gov" }) + return thread.id +} + +describe("threaded inbound attachments cannot be overwritten by a later mail", () => { + it("keeps each message's same-named attachment at its own key with its own bytes", async () => { + const c = ctx() + const threadId = seedReportThread(c) + + const cityMail = mailWithAttachment({ + from: "clerk@city.gov", + to: REPLY_TO, + messageId: "", + filename: "photo.jpg", + content: CITY_BYTES, + }) + const attackerMail = mailWithAttachment({ + from: "mallory@gmail.com", + to: REPLY_TO, + messageId: "", + filename: "photo.jpg", + content: ATTACKER_BYTES, + }) + expect(await deliver(c, `${INBOUND_PENDING_PREFIX}a.eml`, cityMail)).toBe("threaded") + expect(await deliver(c, `${INBOUND_PENDING_PREFIX}b.eml`, attackerMail)).toBe("threaded") + + const inbound = c.mailRepo.messagesOf(threadId).filter((m) => m.direction === "in") + const cityKey = inbound.find((m) => m.fromAddr === "clerk@city.gov")?.attachments[0]?.key + const attackerKey = inbound.find((m) => m.fromAddr === "mallory@gmail.com")?.attachments[0]?.key + expect(cityKey).toBeDefined() + expect(attackerKey).toBeDefined() + expect(cityKey).not.toBe(attackerKey) + expect(cityKey!.startsWith(`inbound-mail/${threadId}/`)).toBe(true) + expect(text(await c.storage.getObject(cityKey!))).toBe(CITY_BYTES) + expect(text(await c.storage.getObject(attackerKey!))).toBe(ATTACKER_BYTES) + }) + + it("never rewrites the stored bytes when a later mail reuses the Message-ID with other bytes", async () => { + const c = ctx() + const threadId = seedReportThread(c) + const messageId = "" + + await deliver( + c, + `${INBOUND_PENDING_PREFIX}a.eml`, + mailWithAttachment({ + from: "clerk@city.gov", + to: REPLY_TO, + messageId, + filename: "Work order.pdf", + content: CITY_BYTES, + }), + ) + const outcome = await deliver( + c, + `${INBOUND_PENDING_PREFIX}b.eml`, + mailWithAttachment({ + from: "mallory@gmail.com", + to: REPLY_TO, + messageId, + filename: "Work order.pdf", + content: ATTACKER_BYTES, + }), + ) + expect(outcome).toBe("replay") + + const inbound = c.mailRepo.messagesOf(threadId).filter((m) => m.direction === "in") + expect(inbound).toHaveLength(1) + const cityKey = inbound[0]!.attachments[0]!.key + expect(text(await c.storage.getObject(cityKey))).toBe(CITY_BYTES) + expect(await keysUnder(c.storage, `inbound-mail/${threadId}/`)).toEqual([cityKey]) + }) + + it("stays idempotent when the same pending object is processed twice", async () => { + const c = ctx() + const threadId = seedReportThread(c) + const raw = mailWithAttachment({ + from: "clerk@city.gov", + to: REPLY_TO, + messageId: "", + filename: "photo.jpg", + content: CITY_BYTES, + }) + const key = `${INBOUND_PENDING_PREFIX}same.eml` + expect(await deliver(c, key, raw)).toBe("threaded") + expect(await deliver(c, key, raw)).toBe("replay") + + const inbound = c.mailRepo.messagesOf(threadId).filter((m) => m.direction === "in") + expect(inbound).toHaveLength(1) + const stored = inbound[0]!.attachments[0]!.key + expect(text(await c.storage.getObject(stored))).toBe(CITY_BYTES) + expect(await keysUnder(c.storage, `inbound-mail/${threadId}/`)).toEqual([stored]) + }) +}) + +describe("inbox inbound attachments cannot be overwritten by a later mail", () => { + it("keeps distinct bytes for two mails whose pending keys share one Message-ID slug", async () => { + const c = ctx() + const collidingKey = `${INBOUND_PENDING_PREFIX}a_b@x.example.eml` + + expect( + await deliver( + c, + collidingKey, + mailWithAttachment({ + from: "resident@x.example", + to: "support@civfix.org", + messageId: "", + filename: "photo.jpg", + content: CITY_BYTES, + }), + ), + ).toBe("inbox") + expect( + await deliver( + c, + collidingKey, + mailWithAttachment({ + from: "mallory@x.example", + to: "support@civfix.org", + messageId: "", + filename: "photo.jpg", + content: ATTACKER_BYTES, + }), + ), + ).toBe("inbox") + + const first = c.inboundRepo.rows.find((r) => r.messageId === "") + const second = c.inboundRepo.rows.find((r) => r.messageId === "") + const firstKey = first?.attachments[0]?.key + const secondKey = second?.attachments[0]?.key + expect(firstKey).toBeDefined() + expect(firstKey).not.toBe(secondKey) + expect(text(await c.storage.getObject(firstKey!))).toBe(CITY_BYTES) + expect(text(await c.storage.getObject(secondKey!))).toBe(ATTACKER_BYTES) + }) + + it("keeps distinct bytes for two non-pending keys whose Message-IDs sanitize alike", async () => { + const c = ctx() + await deliver( + c, + "inbound/manual/one.eml", + mailWithAttachment({ + from: "resident@x.example", + to: "support@civfix.org", + messageId: "", + filename: "photo.jpg", + content: CITY_BYTES, + }), + ) + await deliver( + c, + "inbound/manual/two.eml", + mailWithAttachment({ + from: "mallory@x.example", + to: "support@civfix.org", + messageId: "", + filename: "photo.jpg", + content: ATTACKER_BYTES, + }), + ) + + const firstKey = c.inboundRepo.rows[0]?.attachments[0]?.key + const secondKey = c.inboundRepo.rows[1]?.attachments[0]?.key + expect(firstKey).not.toBe(secondKey) + expect(text(await c.storage.getObject(firstKey!))).toBe(CITY_BYTES) + }) + + it("leaves no orphan objects when a later mail reuses the Message-ID with other bytes", async () => { + const c = ctx() + await deliver( + c, + `${INBOUND_PENDING_PREFIX}first.eml`, + mailWithAttachment({ + from: "resident@x.example", + to: "support@civfix.org", + messageId: "", + filename: "photo.jpg", + content: CITY_BYTES, + }), + ) + const outcome = await deliver( + c, + `${INBOUND_PENDING_PREFIX}second.eml`, + mailWithAttachment({ + from: "mallory@x.example", + to: "support@civfix.org", + messageId: "", + filename: "photo.jpg", + content: ATTACKER_BYTES, + }), + ) + expect(outcome).toBe("replay") + + const stored = c.inboundRepo.rows[0]!.attachments[0]!.key + expect(text(await c.storage.getObject(stored))).toBe(CITY_BYTES) + expect(await keysUnder(c.storage, "inbound-emails/")).toEqual([stored]) + }) + + it("stays idempotent when the same pending object is processed twice", async () => { + const c = ctx() + const raw = mailWithAttachment({ + from: "resident@x.example", + to: "support@civfix.org", + messageId: "", + filename: "photo.jpg", + content: CITY_BYTES, + }) + const key = `${INBOUND_PENDING_PREFIX}again.eml` + expect(await deliver(c, key, raw)).toBe("inbox") + expect(await deliver(c, key, raw)).toBe("replay") + + expect(c.inboundRepo.rows).toHaveLength(1) + const stored = c.inboundRepo.rows[0]!.attachments[0]!.key + expect(text(await c.storage.getObject(stored))).toBe(CITY_BYTES) + expect(await keysUnder(c.storage, "inbound-emails/")).toEqual([stored]) + }) +}) diff --git a/services/api/test/unit/inbound-thread-correlation-security.test.ts b/services/api/test/unit/inbound-thread-correlation-security.test.ts new file mode 100644 index 00000000..2d8a360f --- /dev/null +++ b/services/api/test/unit/inbound-thread-correlation-security.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vitest" +import { FakeInboundMail, FakeJobs, FakeStorage } from "@civfix/shared/fakes" +import type { ParsedMail } from "@civfix/shared/interfaces" +import { InMemoryMailRepository } from "../../src/services/admin/mail-repository.memory.js" +import { InMemoryInboundRepository } from "../../src/services/admin/inbound-repository.memory.js" +import { InMemoryAdminReportRepository } from "../../src/services/admin/admin-report-repository.memory.js" +import { isJurisdictionSender } from "../../src/services/admin/inbound-thread-correlation.js" +import { + processInboundObject, + INBOUND_PENDING_PREFIX, + type InboundProcessorDeps, +} from "../../src/services/admin/inbound-processor.js" +import type { ReportTimelineEvent } from "../../src/services/report-timeline-event.js" +import type { Container } from "../../src/di.js" +import { RecordingNotifier } from "../helpers/notifications.js" +import { InMemoryCleanupRepository } from "../helpers/cleanups.js" +import { makeFakeSql } from "../helpers/fake-sql.js" + +const TOKEN = "0123456789abcdef01234567" +const CONSUMER_CONTACT = "clerk.smalltown@gmail.com" + +function mail(from: string): ParsedMail { + return { + from: { address: from }, + to: [{ address: `reply+${TOKEN}@civfix.org` }], + subject: null, + text: null, + html: null, + messageId: null, + inReplyTo: null, + headers: {}, + } +} + +async function sentBy(contacts: string[], from: string): Promise { + const mailRepo = new InMemoryMailRepository() + const thread = mailRepo.seedThread({ threadToken: TOKEN }) + for (const toAddr of contacts) + mailRepo.seedMessage({ threadId: thread.id, direction: "out", toAddr }) + return isJurisdictionSender(mailRepo, thread.id, mail(from)) +} + +describe("isJurisdictionSender on a consumer mail domain", () => { + it("rejects a different mailbox at the same consumer provider", async () => { + expect(await sentBy([CONSUMER_CONTACT], "mallory@gmail.com")).toBe(false) + }) + + it("accepts the contact's own mailbox, ignoring case and surrounding space", async () => { + expect(await sentBy([CONSUMER_CONTACT], "Clerk.SmallTown@Gmail.com")).toBe(true) + expect(await sentBy([` ${CONSUMER_CONTACT.toUpperCase()} `], CONSUMER_CONTACT)).toBe(true) + expect(await sentBy([`Town Clerk <${CONSUMER_CONTACT}>`], CONSUMER_CONTACT)).toBe(true) + }) + + it("does not fold provider aliases, dots or plus tags into a match", async () => { + expect(await sentBy([CONSUMER_CONTACT], "clerk.smalltown@googlemail.com")).toBe(false) + expect(await sentBy([CONSUMER_CONTACT], "clerksmalltown@gmail.com")).toBe(false) + expect(await sentBy([CONSUMER_CONTACT], "clerk.smalltown+city@gmail.com")).toBe(false) + }) + + it("applies the exact-match rule to every consumer provider and its subdomains", async () => { + expect(await sentBy(["clerk@outlook.com"], "other@outlook.com")).toBe(false) + expect(await sentBy(["clerk@yahoo.com"], "other@yahoo.com")).toBe(false) + expect(await sentBy(["clerk@icloud.com"], "other@icloud.com")).toBe(false) + expect(await sentBy(["clerk@proton.me"], "other@proton.me")).toBe(false) + expect(await sentBy(["clerk@mail.yahoo.com"], "other@yahoo.com")).toBe(false) + }) + + it("still aligns by organizational domain for a jurisdiction's own domain", async () => { + expect(await sentBy(["publicworks@lacity.org"], "clerk@bss.lacity.org")).toBe(true) + expect(await sentBy([CONSUMER_CONTACT, "publicworks@lacity.org"], "clerk@lacity.org")).toBe( + true, + ) + expect(await sentBy([CONSUMER_CONTACT, "publicworks@lacity.org"], "mallory@gmail.com")).toBe( + false, + ) + }) +}) + +describe("an unrelated consumer-domain sender on a report thread", () => { + function ctx() { + const inboundMail = new FakeInboundMail() + const storage = new FakeStorage() + const mailRepo = new InMemoryMailRepository() + const adminReportRepo = new InMemoryAdminReportRepository() + const notifier = new RecordingNotifier() + const chatEvents: ReportTimelineEvent[] = [] + const deps: InboundProcessorDeps = { + storage, + inboundMail, + mailRepo, + inboundRepo: new InMemoryInboundRepository(), + adminReportRepo, + cleanupRepo: new InMemoryCleanupRepository(), + notifications: notifier, + chatEmitter: { + emit: (event) => { + chatEvents.push(event) + return Promise.resolve() + }, + }, + } + const container = { + env: {}, + storage, + inboundStorage: storage, + inboundMail, + jobs: new FakeJobs(), + getDb: () => ({ sql: makeFakeSql().sql }), + } as unknown as Container + return { container, deps, storage, mailRepo, adminReportRepo, notifier, chatEvents } + } + + function seedReportThread(c: ReturnType, reportId: string): string { + c.adminReportRepo.seedReport({ + id: reportId, + status: "published", + reporter: { + id: "user-1", + name: "Jane", + handle: "jane", + emailVerified: true, + hasOauth: false, + joinedAt: new Date("2025-01-01T00:00:00Z"), + }, + }) + const thread = c.mailRepo.seedThread({ threadToken: TOKEN, reportId, status: "sent" }) + c.mailRepo.seedMessage({ threadId: thread.id, direction: "out", toAddr: CONSUMER_CONTACT }) + return thread.id + } + + function reply(from: string, messageId: string): Buffer { + return Buffer.from( + [ + `From: ${from}`, + `To: reply+${TOKEN}@civfix.org`, + `Message-ID: ${messageId}`, + "Authentication-Results: mx.cloudflare.net; dmarc=pass header.from=gmail.com", + "", + "We have closed this, the reporter is lying.", + ].join("\n"), + "utf8", + ) + } + + it("is stored as unaffiliated with no status change, chat message or push", async () => { + const c = ctx() + const reportId = "report-consumer" + const threadId = seedReportThread(c, reportId) + const key = `${INBOUND_PENDING_PREFIX}mallory.eml` + await c.storage.put(key, reply("mallory@gmail.com", "")) + + expect((await processInboundObject(c.container, key, c.deps)).outcome).toBe("threaded") + + const stored = c.mailRepo.messagesOf(threadId).filter((m) => m.direction === "in") + expect(stored).toHaveLength(1) + expect(stored[0]?.unaffiliated).toBe(true) + expect(c.chatEvents).toHaveLength(0) + expect(c.notifier.sent).toHaveLength(0) + expect(c.adminReportRepo.reports.get(reportId)?.record.status).toBe("published") + }) + + it("still applies the effects when the contact itself replies", async () => { + const c = ctx() + const reportId = "report-consumer-contact" + const threadId = seedReportThread(c, reportId) + const key = `${INBOUND_PENDING_PREFIX}clerk.eml` + await c.storage.put(key, reply(CONSUMER_CONTACT, "")) + + expect((await processInboundObject(c.container, key, c.deps)).outcome).toBe("threaded") + + const stored = c.mailRepo.messagesOf(threadId).find((m) => m.direction === "in") + expect(stored?.unaffiliated).toBe(false) + expect(c.adminReportRepo.reports.get(reportId)?.record.status).toBe("in_progress") + }) +}) From e83117e2882ca658d3f1d1a8b05dd36acde9d96d Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:24:45 +0000 Subject: [PATCH 14/45] boundary prune re-resolves the rows it touches and needs --yes --- services/api/scripts/refresh-boundaries.ts | 47 ++-- .../api/src/db/backfill-jurisdictions-core.ts | 194 ++++++++++++++++- services/api/src/db/backfill-keyset.ts | 10 +- .../src/db/backfill-reference-codes-core.ts | 6 +- .../refresh-boundaries-prune-pg.test.ts | 142 ++++++++++++ .../unit/refresh-boundaries-security.test.ts | 202 ++++++++++++++++++ 6 files changed, 568 insertions(+), 33 deletions(-) create mode 100644 services/api/test/integration/refresh-boundaries-prune-pg.test.ts create mode 100644 services/api/test/unit/refresh-boundaries-security.test.ts diff --git a/services/api/scripts/refresh-boundaries.ts b/services/api/scripts/refresh-boundaries.ts index caa636db..fe1ecfc9 100644 --- a/services/api/scripts/refresh-boundaries.ts +++ b/services/api/scripts/refresh-boundaries.ts @@ -12,7 +12,10 @@ import { type BoundaryJob, } from "../src/db/boundaries/manifest.js" import { ingestGeoJsonFile, ingestGeoJsonSeqFile } from "../src/db/ingest-jurisdictions-core.js" -import { backfillReports } from "../src/db/backfill-jurisdictions-core.js" +import { + backfillReports, + pruneNonAuthoritativeJurisdictions, +} from "../src/db/backfill-jurisdictions-core.js" import { backfillPopulation } from "../src/db/backfill-population-core.js" const PREFIX = "refresh-boundaries" @@ -24,6 +27,7 @@ const PG_CONTAINER = process.env.BOUNDARIES_PG_CONTAINER ?? "compose-postgres-1" const PG_USER = process.env.BOUNDARIES_PG_USER ?? "civfix" const PG_DB = process.env.BOUNDARIES_PG_DB ?? "civfix" const LOCAL_PORT = Number(process.env.BOUNDARIES_LOCAL_PORT ?? "15433") +const PRUNE_CONFIRMED = process.argv.slice(2).includes("--yes") function isFederal(job: BoundaryJob): boolean { return job.layer === "federal" @@ -211,32 +215,23 @@ async function countLoadedLayers( return { rowCounts, federalLoaded: (rowCounts.federal ?? 0) > 0 } } -async function pruneNonAuthoritative(sql: Sql): Promise { - return await sql.begin(async (tx) => { - const stale = await tx<{ geoid: string }[]>` - SELECT geoid FROM jurisdictions - WHERE (layer = 'federal' AND geoid NOT LIKE 'PADUS-%') - OR (layer = 'tribal' AND geoid NOT LIKE 'AIANNH-%') - ` - if (stale.length === 0) return 0 - const ids = stale.map((s) => s.geoid) - await tx`UPDATE reports SET jurisdiction_geoid = NULL WHERE jurisdiction_geoid IN ${tx(ids)}` - await tx`UPDATE cleanups SET jurisdiction_geoid = NULL WHERE jurisdiction_geoid IN ${tx(ids)}` - await tx`UPDATE volunteer_hours SET jurisdiction_geoid = NULL WHERE jurisdiction_geoid IN ${tx(ids)}` - await tx`UPDATE gov_claims SET jurisdiction_geoid = NULL WHERE jurisdiction_geoid IN ${tx(ids)}` - await tx`UPDATE mail_threads SET jurisdiction_geoid = NULL WHERE jurisdiction_geoid IN ${tx(ids)}` - await tx`DELETE FROM user_jurisdiction_hours WHERE jurisdiction_geoid IN ${tx(ids)}` - await tx`DELETE FROM jurisdiction_contacts WHERE geoid IN ${tx(ids)}` - await tx`DELETE FROM outreach_state WHERE geoid IN ${tx(ids)}` - await tx`DELETE FROM jurisdiction_discovery_tasks WHERE geoid IN ${tx(ids)}` - await tx`DELETE FROM jurisdictions WHERE geoid IN ${tx(ids)}` - return stale.length - }) -} - async function prune(sql: Sql): Promise { - const pruned = await pruneNonAuthoritative(sql) - if (pruned > 0) log(`pruned ${pruned} non-authoritative (dev-seed) federal/tribal row(s)`) + const result = await pruneNonAuthoritativeJurisdictions(sql, { apply: PRUNE_CONFIRMED }) + if (result.staleGeoids.length === 0) return + log( + `${result.staleGeoids.length} non-authoritative (dev-seed) federal/tribal jurisdiction(s); ` + + `rows pointing at them: ${JSON.stringify(result.affected)}`, + ) + if (!result.applied) { + throw new Error("re-run with --yes to prune the rows counted above (nothing was changed)") + } + log(`pruned; re-resolved in the same transaction: ${JSON.stringify(result.reresolved)}`) + if (result.affected.gov_claims > 0 || result.affected.mail_threads > 0) { + warn( + `left without a jurisdiction (no geometry to re-derive from): ` + + `${result.affected.gov_claims} gov_claims, ${result.affected.mail_threads} mail_threads`, + ) + } } async function stampVintage( diff --git a/services/api/src/db/backfill-jurisdictions-core.ts b/services/api/src/db/backfill-jurisdictions-core.ts index e29e9116..f533c5d9 100644 --- a/services/api/src/db/backfill-jurisdictions-core.ts +++ b/services/api/src/db/backfill-jurisdictions-core.ts @@ -11,11 +11,14 @@ * * ONE RANKING, NEVER DRIFTS: the keyset loop itself lives in ./backfill-keyset.ts, which orders candidate * polygons by the SAME shared constant the write-time resolver uses (JURISDICTION_RESOLVE_ORDER_BY from - * src/db/sql/jurisdiction.ts) and is shared with the cleanups backfill. SCOPE here: reports ONLY. + * src/db/sql/jurisdiction.ts) and is shared with the cleanups backfill. SCOPE here: the reports backfill, + * plus the refresh tool's non-authoritative prune, which lives here so it can be tested without running + * that script's `main()`. */ import { resolveGeomJurisdictions } from "./backfill-keyset.js" -import type { Sql } from "./client.js" +import { backfillCleanupJurisdictions } from "./backfill-reference-codes-core.js" +import type { Queryable, Sql } from "./client.js" // Large enough to amortize per-batch latency, small enough to keep each UPDATE's spatial work // (a GiST-indexed ST_Contains per row) bounded. @@ -30,3 +33,190 @@ const BATCH_SIZE = 1000 export async function backfillReports(sql: Sql): Promise<{ resolved: number; stayedNull: number }> { return resolveGeomJurisdictions(sql, "reports", { batchSize: BATCH_SIZE, label: "backfill" }) } + +export const PRUNE_AFFECTED_TABLES = [ + "reports", + "cleanups", + "volunteer_hours", + "gov_claims", + "mail_threads", + "user_jurisdiction_hours", + "jurisdiction_contacts", + "outreach_state", + "jurisdiction_discovery_tasks", + "jurisdictions", +] as const + +export type PruneAffectedTable = (typeof PRUNE_AFFECTED_TABLES)[number] + +export interface NonAuthoritativePrune { + staleGeoids: string[] + affected: Record + applied: boolean + reresolved: { reports: number; cleanups: number; volunteerHours: number; rollups: number } | null +} + +function noneAffected(): Record { + return Object.fromEntries(PRUNE_AFFECTED_TABLES.map((t) => [t, 0])) as Record< + PruneAffectedTable, + number + > +} + +/** + * Drops the dev seed's federal/tribal jurisdictions (geoids outside the PAD-US / AIANNH namespaces) + * and every pointer into them. With `apply: false` it only counts what would change. When applied, + * the SAME transaction re-resolves exactly the reports, cleanups and volunteer_hours rows it nulled + * against the remaining boundaries and rebuilds the hours rollup for the pairs they land on, so a + * failure part-way leaves nothing half-pruned. gov_claims and mail_threads have no geometry to + * re-derive from and stay NULL. + */ +export async function pruneNonAuthoritativeJurisdictions( + sql: Sql, + opts: { apply: boolean }, +): Promise { + return await sql.begin(async (tx) => { + const stale = await tx<{ geoid: string }[]>` + SELECT geoid FROM jurisdictions + WHERE (layer = 'federal' AND geoid NOT LIKE 'PADUS-%') + OR (layer = 'tribal' AND geoid NOT LIKE 'AIANNH-%') + ` + const staleGeoids = stale.map((s) => s.geoid) + if (staleGeoids.length === 0) { + return { staleGeoids, affected: noneAffected(), applied: false, reresolved: null } + } + const affected = await countPruneImpact(tx, staleGeoids) + if (!opts.apply) return { staleGeoids, affected, applied: false, reresolved: null } + + // Same lock order as 0065 and logEventHours (ledger, then rollup): the rollup rebuild below + // overwrites totals, so a concurrent delta landing between its SUM and its write would be lost. + await tx`LOCK TABLE volunteer_hours, user_jurisdiction_hours IN SHARE ROW EXCLUSIVE MODE` + const reports = await tx<{ id: string }[]>` + UPDATE reports SET jurisdiction_geoid = NULL + WHERE jurisdiction_geoid IN ${tx(staleGeoids)} + RETURNING id + ` + const cleanups = await tx<{ id: string }[]>` + UPDATE cleanups SET jurisdiction_geoid = NULL + WHERE jurisdiction_geoid IN ${tx(staleGeoids)} + RETURNING id + ` + const hours = await tx<{ id: string }[]>` + UPDATE volunteer_hours SET jurisdiction_geoid = NULL + WHERE jurisdiction_geoid IN ${tx(staleGeoids)} + RETURNING id + ` + await tx`UPDATE gov_claims SET jurisdiction_geoid = NULL WHERE jurisdiction_geoid IN ${tx(staleGeoids)}` + await tx`UPDATE mail_threads SET jurisdiction_geoid = NULL WHERE jurisdiction_geoid IN ${tx(staleGeoids)}` + await tx`DELETE FROM user_jurisdiction_hours WHERE jurisdiction_geoid IN ${tx(staleGeoids)}` + await tx`DELETE FROM jurisdiction_contacts WHERE geoid IN ${tx(staleGeoids)}` + await tx`DELETE FROM outreach_state WHERE geoid IN ${tx(staleGeoids)}` + await tx`DELETE FROM jurisdiction_discovery_tasks WHERE geoid IN ${tx(staleGeoids)}` + await tx`DELETE FROM jurisdictions WHERE geoid IN ${tx(staleGeoids)}` + + const reportsResolved = + reports.length === 0 + ? 0 + : ( + await resolveGeomJurisdictions(tx, "reports", { + batchSize: BATCH_SIZE, + label: "prune: report-jurisdiction", + ids: reports.map((r) => r.id), + }) + ).resolved + const cleanupsResolved = + cleanups.length === 0 + ? 0 + : (await backfillCleanupJurisdictions(tx, { ids: cleanups.map((c) => c.id) })).resolved + const hoursRepair = await reresolveVolunteerHours( + tx, + hours.map((h) => h.id), + ) + return { + staleGeoids, + affected, + applied: true, + reresolved: { + reports: reportsResolved, + cleanups: cleanupsResolved, + volunteerHours: hoursRepair.resolved, + rollups: hoursRepair.rollups, + }, + } + }) +} + +async function countPruneImpact( + tx: Queryable, + staleGeoids: string[], +): Promise> { + const rows = await tx[]>` + SELECT + (SELECT count(*)::int FROM reports WHERE jurisdiction_geoid IN ${tx(staleGeoids)}) AS reports, + (SELECT count(*)::int FROM cleanups WHERE jurisdiction_geoid IN ${tx(staleGeoids)}) AS cleanups, + (SELECT count(*)::int FROM volunteer_hours WHERE jurisdiction_geoid IN ${tx(staleGeoids)}) AS volunteer_hours, + (SELECT count(*)::int FROM gov_claims WHERE jurisdiction_geoid IN ${tx(staleGeoids)}) AS gov_claims, + (SELECT count(*)::int FROM mail_threads WHERE jurisdiction_geoid IN ${tx(staleGeoids)}) AS mail_threads, + (SELECT count(*)::int FROM user_jurisdiction_hours WHERE jurisdiction_geoid IN ${tx(staleGeoids)}) + AS user_jurisdiction_hours, + (SELECT count(*)::int FROM jurisdiction_contacts WHERE geoid IN ${tx(staleGeoids)}) + AS jurisdiction_contacts, + (SELECT count(*)::int FROM outreach_state WHERE geoid IN ${tx(staleGeoids)}) AS outreach_state, + (SELECT count(*)::int FROM jurisdiction_discovery_tasks WHERE geoid IN ${tx(staleGeoids)}) + AS jurisdiction_discovery_tasks, + (SELECT count(*)::int FROM jurisdictions WHERE geoid IN ${tx(staleGeoids)}) AS jurisdictions + ` + return rows[0] ?? noneAffected() +} + +/** + * A volunteer_hours row carries the jurisdiction of the cleanup (or report) it credits, so it is + * re-derived from that link, never from geometry of its own; an unlinked row stays NULL. Each + * (user, jurisdiction) rollup it lands on is then recomputed from the ledger rather than adjusted, + * which is what keeps it equal to the sum of its non-voided rows. + */ +async function reresolveVolunteerHours( + tx: Queryable, + hourIds: string[], +): Promise<{ resolved: number; rollups: number }> { + if (hourIds.length === 0) return { resolved: 0, rollups: 0 } + const resolved = await tx<{ user_id: string; jurisdiction_geoid: string; voided: boolean }[]>` + UPDATE volunteer_hours vh + SET jurisdiction_geoid = src.geoid + FROM ( + SELECT h.id, COALESCE(c.jurisdiction_geoid, r.jurisdiction_geoid) AS geoid + FROM volunteer_hours h + LEFT JOIN cleanups c ON c.id = h.cleanup_id + LEFT JOIN reports r ON r.id = h.report_id + WHERE h.id = ANY(${hourIds}::uuid[]) + ) src + WHERE vh.id = src.id + AND vh.jurisdiction_geoid IS NULL + AND src.geoid IS NOT NULL + RETURNING vh.user_id, vh.jurisdiction_geoid, (vh.voided_at IS NOT NULL) AS voided + ` + const pairs = new Map() + for (const row of resolved) { + if (row.voided) continue + pairs.set(`${row.user_id}|${row.jurisdiction_geoid}`, { + userId: row.user_id, + geoid: row.jurisdiction_geoid, + }) + } + if (pairs.size === 0) return { resolved: resolved.length, rollups: 0 } + const userIds = [...pairs.values()].map((p) => p.userId) + const geoids = [...pairs.values()].map((p) => p.geoid) + const rebuilt = await tx<{ user_id: string }[]>` + INSERT INTO user_jurisdiction_hours (user_id, jurisdiction_geoid, total_hours) + SELECT vh.user_id, vh.jurisdiction_geoid, SUM(vh.hours) + FROM volunteer_hours vh + JOIN unnest(${userIds}::uuid[], ${geoids}::text[]) AS p(user_id, geoid) + ON vh.user_id = p.user_id AND vh.jurisdiction_geoid = p.geoid + WHERE vh.voided_at IS NULL + GROUP BY vh.user_id, vh.jurisdiction_geoid + ON CONFLICT (user_id, jurisdiction_geoid) + DO UPDATE SET total_hours = EXCLUDED.total_hours + RETURNING user_id + ` + return { resolved: resolved.length, rollups: rebuilt.length } +} diff --git a/services/api/src/db/backfill-keyset.ts b/services/api/src/db/backfill-keyset.ts index 3ceb265f..ea255f32 100644 --- a/services/api/src/db/backfill-keyset.ts +++ b/services/api/src/db/backfill-keyset.ts @@ -34,7 +34,8 @@ export type ReferenceCodeTable = "reports" | "cleanups" /** * Re-resolve every NULL `.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). + * 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. * * 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 @@ -46,9 +47,9 @@ export type ReferenceCodeTable = "reports" | "cleanups" * stay NULL -> absent from RETURNING. The IN(...) / IS NULL guards keep the statement idempotent. */ export async function resolveGeomJurisdictions( - sql: Sql, + sql: Queryable, table: JurisdictionGeomTable, - opts: { batchSize: number; label: string }, + opts: { batchSize: number; label: string; ids?: readonly string[] }, ): Promise<{ resolved: number; stayedNull: number }> { let resolved = 0 let stayedNull = 0 @@ -59,11 +60,14 @@ export async function resolveGeomJurisdictions( // 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`). 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[])` const batch = await sql<{ id: string }[]>` SELECT id FROM ${sql(table)} WHERE jurisdiction_geoid IS NULL ${cursorFilter} + ${idFilter} ORDER BY id LIMIT ${opts.batchSize} ` diff --git a/services/api/src/db/backfill-reference-codes-core.ts b/services/api/src/db/backfill-reference-codes-core.ts index 014343f5..fda73aa7 100644 --- a/services/api/src/db/backfill-reference-codes-core.ts +++ b/services/api/src/db/backfill-reference-codes-core.ts @@ -20,7 +20,7 @@ import { stampReferenceCodes, type ReferenceCodeRow, } from "./backfill-keyset.js" -import type { Sql } from "./client.js" +import type { Queryable, Sql } from "./client.js" import { allocateReportReferenceCode, allocateEventReferenceCode } from "./reference-code.js" const BATCH_SIZE = 500 @@ -54,11 +54,13 @@ export async function backfillReportReferenceCodes( * (only touches NULL rows). Returns how many got a non-NULL geoid. Geometry flows ONLY through the raw tag. */ export async function backfillCleanupJurisdictions( - sql: Sql, + sql: Queryable, + opts: { ids?: readonly string[] } = {}, ): Promise<{ resolved: number; stayedNull: number }> { return resolveGeomJurisdictions(sql, "cleanups", { batchSize: BATCH_SIZE, label: `${LABEL}: cleanup-jurisdiction`, + ...opts, }) } diff --git a/services/api/test/integration/refresh-boundaries-prune-pg.test.ts b/services/api/test/integration/refresh-boundaries-prune-pg.test.ts new file mode 100644 index 00000000..03790dd0 --- /dev/null +++ b/services/api/test/integration/refresh-boundaries-prune-pg.test.ts @@ -0,0 +1,142 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { pruneNonAuthoritativeJurisdictions } from "../../src/db/backfill-jurisdictions-core.js" + +const pg = await withPg() + +const DEV_GEOID = "DEVFED-PRUNE" +const PADUS_GEOID = "PADUS-PRUNE-1" +const SQUARE = "POLYGON((-100.5 40.5,-99.5 40.5,-99.5 41.5,-100.5 41.5,-100.5 40.5))" +const POINT = { lng: -100, lat: 41 } + +describe.skipIf(!pg)("non-authoritative jurisdiction prune (integration)", () => { + let h: PgHarness + let volunteer: string + let voidedVolunteer: string + let prunedCleanup: string + let paddedCleanup: string + + async function newUser(name: string): Promise { + const [u] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name) VALUES (${name}) RETURNING id + ` + return u!.id + } + + async function addJurisdiction(geoid: string): Promise { + await h.sql` + INSERT INTO jurisdictions (geoid, name, layer, priority, geom) + VALUES (${geoid}, ${geoid}, 'federal', 0, ST_Multi(ST_SetSRID(ST_GeomFromText(${SQUARE}), 4326))) + ` + } + + async function addHours(args: { + userId: string + cleanupId: string + hours: number + geoid: string + voided?: boolean + }): Promise { + await h.sql` + INSERT INTO volunteer_hours (user_id, hours, source, cleanup_id, jurisdiction_geoid, voided_at) + VALUES ( + ${args.userId}, ${args.hours}, 'event', ${args.cleanupId}, ${args.geoid}, + ${args.voided === true ? new Date() : null} + ) + ` + } + + async function rollup(userId: string, geoid: string): Promise { + const rows = await h.sql<{ total: number }[]>` + SELECT total_hours::float8 AS total FROM user_jurisdiction_hours + WHERE user_id = ${userId} AND jurisdiction_geoid = ${geoid} + ` + return rows[0]?.total ?? null + } + + beforeAll(async () => { + h = pg as PgHarness + await addJurisdiction(DEV_GEOID) + const organizer = await newUser("Prune Organizer") + volunteer = await newUser("Prune Volunteer") + voidedVolunteer = await newUser("Voided Volunteer") + prunedCleanup = await seedCleanup(h.sql, { + organizerUserId: organizer, + ...POINT, + jurisdictionGeoid: DEV_GEOID, + }) + await addHours({ userId: volunteer, cleanupId: prunedCleanup, hours: 2.5, geoid: DEV_GEOID }) + await addHours({ + userId: voidedVolunteer, + cleanupId: prunedCleanup, + hours: 4, + geoid: DEV_GEOID, + voided: true, + }) + await h.sql` + INSERT INTO user_jurisdiction_hours (user_id, jurisdiction_geoid, total_hours) + VALUES (${volunteer}, ${DEV_GEOID}, 2.5) + ` + + await addJurisdiction(PADUS_GEOID) + paddedCleanup = await seedCleanup(h.sql, { + organizerUserId: organizer, + ...POINT, + jurisdictionGeoid: PADUS_GEOID, + }) + await addHours({ userId: volunteer, cleanupId: paddedCleanup, hours: 1.5, geoid: PADUS_GEOID }) + await h.sql` + INSERT INTO user_jurisdiction_hours (user_id, jurisdiction_geoid, total_hours) + VALUES (${volunteer}, ${PADUS_GEOID}, 1.5) + ` + }) + + afterAll(async () => { + await h.teardown() + }) + + it("changes nothing without confirmation", async () => { + const result = await pruneNonAuthoritativeJurisdictions(h.sql, { apply: false }) + + expect(result.applied).toBe(false) + expect(result.staleGeoids).toContain(DEV_GEOID) + expect(result.staleGeoids).not.toContain(PADUS_GEOID) + expect(result.affected.cleanups).toBeGreaterThanOrEqual(1) + expect(result.affected.volunteer_hours).toBeGreaterThanOrEqual(2) + const [cleanup] = await h.sql<{ geoid: string | null }[]>` + SELECT jurisdiction_geoid AS geoid FROM cleanups WHERE id = ${prunedCleanup} + ` + expect(cleanup?.geoid).toBe(DEV_GEOID) + expect(await rollup(volunteer, DEV_GEOID)).toBe(2.5) + }) + + it("moves the cleanup, its hours and the rollup onto the authoritative boundary", async () => { + const result = await pruneNonAuthoritativeJurisdictions(h.sql, { apply: true }) + expect(result.applied).toBe(true) + expect(result.reresolved?.cleanups).toBeGreaterThanOrEqual(1) + + const [cleanup] = await h.sql<{ geoid: string | null }[]>` + SELECT jurisdiction_geoid AS geoid FROM cleanups WHERE id = ${prunedCleanup} + ` + expect(cleanup?.geoid).toBe(PADUS_GEOID) + + const hours = await h.sql<{ user_id: string; geoid: string | null }[]>` + SELECT user_id, jurisdiction_geoid AS geoid FROM volunteer_hours WHERE cleanup_id = ${prunedCleanup} + ` + expect(hours.map((r) => r.geoid)).toEqual([PADUS_GEOID, PADUS_GEOID]) + + expect(await rollup(volunteer, PADUS_GEOID)).toBe(4) + expect(await rollup(volunteer, DEV_GEOID)).toBeNull() + expect(await rollup(voidedVolunteer, PADUS_GEOID)).toBeNull() + + const gone = await h.sql`SELECT 1 FROM jurisdictions WHERE geoid = ${DEV_GEOID}` + expect(gone).toHaveLength(0) + }) + + it("is a no-op once nothing non-authoritative remains", async () => { + const result = await pruneNonAuthoritativeJurisdictions(h.sql, { apply: true }) + expect(result.staleGeoids).toEqual([]) + expect(await rollup(volunteer, PADUS_GEOID)).toBe(4) + }) +}) diff --git a/services/api/test/unit/refresh-boundaries-security.test.ts b/services/api/test/unit/refresh-boundaries-security.test.ts new file mode 100644 index 00000000..b2780e46 --- /dev/null +++ b/services/api/test/unit/refresh-boundaries-security.test.ts @@ -0,0 +1,202 @@ +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { describe, expect, it } from "vitest" +import { pruneNonAuthoritativeJurisdictions } from "../../src/db/backfill-jurisdictions-core.js" +import type { Sql } from "../../src/db/client.js" +import { makeFakeSql, type RecordedStatement, type SqlHandler } from "../helpers/fake-sql.js" + +const STALE_GEOID = "NPS-YELL" +const REPORT_ID = "11111111-1111-4111-8111-111111111111" +const CLEANUP_ID = "22222222-2222-4222-8222-222222222222" +const HOURS_ID = "33333333-3333-4333-8333-333333333333" +const USER_ID = "44444444-4444-4444-8444-444444444444" +const NEW_GEOID = "PADUS-1" + +interface Harness { + sql: Sql + statements: RecordedStatement[] + txSpan: () => { start: number; end: number } +} + +function harness(handlers: SqlHandler[]): Harness { + const fake = makeFakeSql(handlers) + const tag = fake.sql as unknown as Record + tag.unsafe = (text: string) => + fake.sql(Object.assign([text], { raw: [text] }) as unknown as TemplateStringsArray) + let start = -1 + let end = -1 + const begin = fake.sql.begin.bind(fake.sql) + fake.sql.begin = async (cb: (tx: typeof fake.sql) => Promise): Promise => { + start = fake.statements.length + const out = await begin(cb) + end = fake.statements.length + return out + } + return { + sql: fake.sql as unknown as Sql, + statements: fake.statements, + txSpan: () => ({ start, end }), + } +} + +function pagedOnce(match: RegExp, rows: unknown[]): SqlHandler { + let served = false + return { + match, + rows: () => { + if (served) return [] + served = true + return rows + }, + } +} + +function staleWorld(): SqlHandler[] { + return [ + { match: /SELECT geoid FROM jurisdictions/, rows: [{ geoid: STALE_GEOID }] }, + { + match: /AS reports,/, + rows: [ + { + reports: 1, + cleanups: 1, + volunteer_hours: 1, + gov_claims: 2, + mail_threads: 3, + user_jurisdiction_hours: 1, + jurisdiction_contacts: 0, + outreach_state: 0, + jurisdiction_discovery_tasks: 0, + jurisdictions: 1, + }, + ], + }, + { match: /UPDATE reports\s+SET jurisdiction_geoid = NULL/, rows: [{ id: REPORT_ID }] }, + { match: /UPDATE cleanups\s+SET jurisdiction_geoid = NULL/, rows: [{ id: CLEANUP_ID }] }, + { match: /UPDATE volunteer_hours\s+SET jurisdiction_geoid = NULL/, rows: [{ id: HOURS_ID }] }, + pagedOnce(/SELECT id\s+FROM reports/, [{ id: REPORT_ID }]), + pagedOnce(/SELECT id\s+FROM cleanups/, [{ id: CLEANUP_ID }]), + { match: /UPDATE reports t/, rows: [{ id: REPORT_ID }] }, + { match: /UPDATE cleanups t/, rows: [{ id: CLEANUP_ID }] }, + { + match: /UPDATE volunteer_hours vh/, + rows: [{ user_id: USER_ID, jurisdiction_geoid: NEW_GEOID, voided: false }], + }, + { + match: /INSERT INTO user_jurisdiction_hours/, + rows: [{ user_id: USER_ID }], + }, + ] +} + +function indexOf(statements: RecordedStatement[], re: RegExp): number { + return statements.findIndex((s) => re.test(s.sql)) +} + +function statementMatching(statements: RecordedStatement[], re: RegExp): RecordedStatement { + const found = statements.find((s) => re.test(s.sql)) + expect(found, `no statement matched ${re}`).toBeDefined() + return found! +} + +const MUTATION_RE = /^\s*(UPDATE|DELETE|INSERT|LOCK)\b/ + +describe("pruneNonAuthoritativeJurisdictions", () => { + it("only counts, and writes nothing, until the operator confirms", async () => { + const h = harness(staleWorld()) + const result = await pruneNonAuthoritativeJurisdictions(h.sql, { apply: false }) + + expect(result.applied).toBe(false) + expect(result.staleGeoids).toEqual([STALE_GEOID]) + expect(result.affected.gov_claims).toBe(2) + expect(result.affected.mail_threads).toBe(3) + expect(h.statements.filter((s) => MUTATION_RE.test(s.sql))).toHaveLength(0) + }) + + it("does nothing beyond the lookup when no non-authoritative row exists", async () => { + const h = harness([{ match: /SELECT geoid FROM jurisdictions/, rows: [] }]) + const result = await pruneNonAuthoritativeJurisdictions(h.sql, { apply: true }) + + expect(result.staleGeoids).toEqual([]) + expect(result.reresolved).toBeNull() + expect(h.statements).toHaveLength(1) + }) + + it("re-resolves exactly the rows it nulled, inside the prune's transaction", async () => { + const h = harness(staleWorld()) + const result = await pruneNonAuthoritativeJurisdictions(h.sql, { apply: true }) + + expect(result.applied).toBe(true) + expect(result.reresolved).toEqual({ + reports: 1, + cleanups: 1, + volunteerHours: 1, + rollups: 1, + }) + + const { start, end } = h.txSpan() + const within = (re: RegExp) => { + const i = indexOf(h.statements, re) + expect(i, `missing ${re}`).toBeGreaterThanOrEqual(start) + expect(i).toBeLessThan(end) + return i + } + const pruneJurisdictions = within(/DELETE FROM jurisdictions/) + const reportsResolve = within(/UPDATE reports t/) + const cleanupsResolve = within(/UPDATE cleanups t/) + const hoursResolve = within(/UPDATE volunteer_hours vh/) + const rollup = within(/INSERT INTO user_jurisdiction_hours/) + expect(reportsResolve).toBeGreaterThan(pruneJurisdictions) + expect(cleanupsResolve).toBeGreaterThan(pruneJurisdictions) + expect(hoursResolve).toBeGreaterThan(cleanupsResolve) + expect(hoursResolve).toBeGreaterThan(reportsResolve) + expect(rollup).toBeGreaterThan(hoursResolve) + + expect(statementMatching(h.statements, /SELECT id\s+FROM reports/).values).toContainEqual([ + REPORT_ID, + ]) + expect(statementMatching(h.statements, /SELECT id\s+FROM cleanups/).values).toContainEqual([ + CLEANUP_ID, + ]) + expect(statementMatching(h.statements, /UPDATE volunteer_hours vh/).values).toContainEqual([ + HOURS_ID, + ]) + }) + + it("rebuilds the rollup from the ledger instead of adding to it", async () => { + const h = harness(staleWorld()) + await pruneNonAuthoritativeJurisdictions(h.sql, { apply: true }) + + const rollup = statementMatching(h.statements, /INSERT INTO user_jurisdiction_hours/) + expect(rollup.sql).toMatch(/SUM\(vh\.hours\)/) + expect(rollup.sql).toMatch(/voided_at IS NULL/) + expect(rollup.sql).toMatch(/total_hours = EXCLUDED\.total_hours/) + expect(rollup.sql).not.toMatch(/user_jurisdiction_hours\.total_hours \+/) + expect(rollup.values).toContainEqual([USER_ID]) + expect(rollup.values).toContainEqual([NEW_GEOID]) + }) + + it("locks the hours ledger then the rollup before touching either", async () => { + const h = harness(staleWorld()) + await pruneNonAuthoritativeJurisdictions(h.sql, { apply: true }) + + const lock = indexOf( + h.statements, + /LOCK TABLE volunteer_hours, user_jurisdiction_hours IN SHARE ROW EXCLUSIVE MODE/, + ) + expect(lock).toBeGreaterThanOrEqual(0) + expect(lock).toBeLessThan(indexOf(h.statements, /UPDATE volunteer_hours\s+SET/)) + }) +}) + +describe("refresh-boundaries: the prune needs --yes", () => { + const script = readFileSync( + fileURLToPath(new URL("../../scripts/refresh-boundaries.ts", import.meta.url)), + "utf8", + ).replace(/\s+/g, " ") + + it("delegates to the importable prune core and gates it on --yes", () => { + expect(script).toContain("pruneNonAuthoritativeJurisdictions(") + expect(script).toContain('includes("--yes")') + }) +}) From a253fe5f1a8633c635ae708a6a643824c2535dae Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:25:06 +0000 Subject: [PATCH 15/45] media claims bind only unbound report-purpose uploads (reports, anonymous reports, posts, chat attachments) --- services/api/src/services/media-bindings.ts | 14 ++ .../services/message-attachments.drizzle.ts | 2 + .../src/services/post-repository.drizzle.ts | 8 +- .../src/services/report-repository.drizzle.ts | 8 +- .../media-attach-claim-security-pg.test.ts | 134 ++++++++++++++++++ .../report-media-claim-security-pg.test.ts | 126 ++++++++++++++++ .../unit/media-attach-claim-security.test.ts | 94 ++++++++++++ .../unit/report-repository-security.test.ts | 102 +++++++++++++ 8 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 services/api/test/integration/media-attach-claim-security-pg.test.ts create mode 100644 services/api/test/integration/report-media-claim-security-pg.test.ts create mode 100644 services/api/test/unit/media-attach-claim-security.test.ts create mode 100644 services/api/test/unit/report-repository-security.test.ts diff --git a/services/api/src/services/media-bindings.ts b/services/api/src/services/media-bindings.ts index d3186849..61540f2e 100644 --- a/services/api/src/services/media-bindings.ts +++ b/services/api/src/services/media-bindings.ts @@ -32,6 +32,20 @@ export function mediaBoundElsewhere(tag: Queryable, exceptCleanupId: string | nu ` } +// Only report-purpose rows can be attached by uploadId, and the purpose check alone is not enough: +// avatars keep purpose 'report' while users.avatar_media_id and chat_groups.avatar_media_id bind them. +// The uploadId is readable from every served URL, so it is no proof of ownership. +export function claimableAsReportMedia(tag: Queryable) { + return tag`purpose = 'report' AND NOT (${mediaBoundElsewhere(tag, null)})` +} + +// createUpload stores every upload with the default purpose 'report' and only a binding re-purposes it, +// so an author's fresh post or chat upload sits in exactly the pool a report may claim. An UPDATE's +// WHERE reads the row before its SET, so the post path's purpose = 'post' cannot satisfy this check. +export function claimableAsAttachment(tag: Queryable) { + return claimableAsReportMedia(tag) +} + export function eventsBindingMedia(tag: Queryable, mediaId: string) { return tag` SELECT c.id, c.visibility, c.organization_id diff --git a/services/api/src/services/message-attachments.drizzle.ts b/services/api/src/services/message-attachments.drizzle.ts index 2634a40e..9c0992ae 100644 --- a/services/api/src/services/message-attachments.drizzle.ts +++ b/services/api/src/services/message-attachments.drizzle.ts @@ -1,5 +1,6 @@ import type { Queryable } from "../db/client.js" import type { MediaDTO, MediaKind, MediaStatus } from "@civfix/shared" +import { claimableAsAttachment } from "./media-bindings.js" import { mapWithLimit, PRESIGN_CONCURRENCY, type PresignMedia } from "./media-presign.js" import { servableMediaFilter, servedKeyExpr } from "./media-served-key.js" @@ -45,6 +46,7 @@ export function makeAttachmentRepo(column: MessageMediaColumn): MessageAttachmen WHERE upload_id IN ${tx(uploadIds)} AND (${tx(column)} IS NULL OR ${tx(column)} = ${messageId}) ${nullGuards} + AND ${claimableAsAttachment(tx)} AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) ` }, diff --git a/services/api/src/services/post-repository.drizzle.ts b/services/api/src/services/post-repository.drizzle.ts index 2ea77a63..9a8357b9 100644 --- a/services/api/src/services/post-repository.drizzle.ts +++ b/services/api/src/services/post-repository.drizzle.ts @@ -15,7 +15,8 @@ import type { POST_KIND_VALUES, REPORT_VISIBILITY_VALUES } from "../db/schema/ty import { paginate, parseTimeCursor } from "../db/cursor-helpers.js" import { loadMentionsFor, makeMentionRepo } from "./message-mentions.drizzle.js" import { cleanupStatusExpr, goingScalar } from "./cleanup-sql.js" -import { servedKeyExpr } from "./media-served-key.js" +import { claimableAsAttachment } from "./media-bindings.js" +import { publicServedKeyExpr } from "./media-served-key.js" import { mapWithLimit, PRESIGN_CONCURRENCY, type PresignMedia } from "./media-presign.js" import { publicAuthorIdentity } from "./public-author.js" import { presentIds } from "./present-ids.js" @@ -510,7 +511,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep }[] >` SELECT o.id, o.slug, o.name, o.verified_status, o.verified_kind, - ${servedKeyExpr(sql, "am")} AS logo_key + ${publicServedKeyExpr(sql, "am")} AS logo_key FROM organizations o LEFT JOIN media_assets am ON am.id = o.logo_media_id WHERE o.id = ANY(${wanted}::uuid[]) AND o.deleted_at IS NULL @@ -546,7 +547,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep u.bio, u.follower_count AS followers, u.following_count AS following, - ${servedKeyExpr(sql, "am")} AS avatar_r2_key, + ${publicServedKeyExpr(sql, "am")} AS avatar_r2_key, u.avatar_url, u.deleted_at, EXISTS ( @@ -1078,6 +1079,7 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep SET post_id = ${postId}, purpose = 'post' WHERE upload_id IN ${tx(args.mediaUploadIds)} AND post_id IS NULL AND chat_message_id IS NULL AND report_id IS NULL + AND ${claimableAsAttachment(tx)} AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) RETURNING upload_id ` diff --git a/services/api/src/services/report-repository.drizzle.ts b/services/api/src/services/report-repository.drizzle.ts index ac2f17ea..977acf79 100644 --- a/services/api/src/services/report-repository.drizzle.ts +++ b/services/api/src/services/report-repository.drizzle.ts @@ -25,6 +25,7 @@ import type { } from "./report-service.types.js" import { REPORT_CREATE_SCOPE } from "./report-service.types.js" import { servedKeyExpr, servableMediaFilter } from "./media-served-key.js" +import { claimableAsReportMedia } from "./media-bindings.js" import { reportColumns, selectPublicPins, @@ -182,15 +183,16 @@ export function makeDrizzleReportRepository(sql: Sql): ReportRepository { ` if (args.mediaUploadIds.length > 0) { + // 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. const claimed = await tx<{ upload_id: string }[]>` UPDATE media_assets SET report_id = ${args.reportId} WHERE upload_id IN ${tx(args.mediaUploadIds)} AND (report_id IS NULL OR report_id = ${args.reportId}) - -- L18: an asset already bound to a post or a chat/DM message must NOT be re-bindable to a - -- report. Guarding only report_id let the holder of an uploadId cross-publish an image from - -- a private DM into a public report gallery. Mirrors the post path's claim predicate. AND post_id IS NULL AND chat_message_id IS NULL + AND ${claimableAsReportMedia(tx)} AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) RETURNING upload_id ` diff --git a/services/api/test/integration/media-attach-claim-security-pg.test.ts b/services/api/test/integration/media-attach-claim-security-pg.test.ts new file mode 100644 index 00000000..57f27c65 --- /dev/null +++ b/services/api/test/integration/media-attach-claim-security-pg.test.ts @@ -0,0 +1,134 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import { withPg, testHandle, type PgHarness } from "../helpers/pg.js" +import { seedMediaAsset, type SeededMedia } from "../helpers/media-pg.js" +import { attachChatMedia } from "../../src/services/chat-attachments.drizzle.js" +import { + makeDrizzlePostRepository, + type PostRepository, +} from "../../src/services/post-repository.drizzle.js" + +const pg = await withPg() + +const UNAVAILABLE = "One or more media uploads are unavailable." + +describe.skipIf(!pg)("post and chat media claims (integration: only unbound fresh uploads)", () => { + let h: PgHarness + let posts: PostRepository + let attackerId: string + + beforeAll(async () => { + h = pg as PgHarness + posts = makeDrizzlePostRepository(h.sql, { + presignMedia: (r2Key) => Promise.resolve({ url: `memory://${r2Key}` }), + presignAvatar: (r2Key) => Promise.resolve(`memory://${r2Key}`), + }) + attackerId = await newUser("attacker") + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newUser(name: string): Promise { + const [u] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name, handle) VALUES (${name}, ${testHandle()}) RETURNING id + ` + return u!.id + } + + async function victimAvatar(): Promise { + const victim = await newUser("avatar victim") + const media = await seedMediaAsset(h.sql) + await h.sql`UPDATE users SET avatar_media_id = ${media.id} WHERE id = ${victim}` + return media + } + + async function orgLogo(): Promise { + const media = await seedMediaAsset(h.sql, { purpose: "org_logo" }) + const slug = `org-${randomUUID().slice(0, 8)}` + await h.sql` + INSERT INTO organizations (slug, name, logo_media_id) VALUES (${slug}, ${slug}, ${media.id}) + ` + return media + } + + function createPost(uploadId: string) { + return posts.createPost({ + authorId: attackerId, + kind: "post", + body: "look", + replyToId: null, + repostOfId: null, + eventId: null, + reportId: null, + mediaUploadIds: [uploadId], + mentionedUserIds: [], + organizationId: null, + }) + } + + async function mediaRow(id: string) { + const [row] = await h.sql< + { post_id: string | null; chat_message_id: string | null; purpose: string }[] + >`SELECT post_id, chat_message_id, purpose FROM media_assets WHERE id = ${id}` + return row! + } + + it("a post refuses another user's avatar and leaves its purpose alone", async () => { + const media = await victimAvatar() + + await expect(createPost(media.uploadId)).rejects.toMatchObject({ + httpStatus: 422, + fields: { mediaUploadIds: UNAVAILABLE }, + }) + expect(await mediaRow(media.id)).toEqual({ + post_id: null, + chat_message_id: null, + purpose: "report", + }) + }) + + it("a post refuses an organization logo and a verification document", async () => { + const logo = await orgLogo() + const document = await seedMediaAsset(h.sql, { purpose: "verification" }) + + await expect(createPost(logo.uploadId)).rejects.toMatchObject({ httpStatus: 422 }) + await expect(createPost(document.uploadId)).rejects.toMatchObject({ httpStatus: 422 }) + expect((await mediaRow(logo.id)).purpose).toBe("org_logo") + expect((await mediaRow(document.id)).purpose).toBe("verification") + }) + + it("a post still claims the author's own fresh upload", async () => { + const media = await seedMediaAsset(h.sql) + + const postId = await createPost(media.uploadId) + + expect(await mediaRow(media.id)).toEqual({ + post_id: postId, + chat_message_id: null, + purpose: "post", + }) + }) + + it("a chat message leaves another user's avatar and an organization logo unbound", async () => { + const avatar = await victimAvatar() + const logo = await orgLogo() + + await attachChatMedia(h.sql, randomUUID(), [avatar.uploadId, logo.uploadId], new Date()) + + expect((await mediaRow(avatar.id)).chat_message_id).toBeNull() + expect((await mediaRow(logo.id)).chat_message_id).toBeNull() + }) + + it("a chat message still claims the sender's own fresh upload, and re-claims it idempotently", async () => { + const media = await seedMediaAsset(h.sql) + const messageId = randomUUID() + const sentAt = new Date() + + await attachChatMedia(h.sql, messageId, [media.uploadId], sentAt) + await attachChatMedia(h.sql, messageId, [media.uploadId], sentAt) + + expect((await mediaRow(media.id)).chat_message_id).toBe(messageId) + }) +}) diff --git a/services/api/test/integration/report-media-claim-security-pg.test.ts b/services/api/test/integration/report-media-claim-security-pg.test.ts new file mode 100644 index 00000000..139de8c5 --- /dev/null +++ b/services/api/test/integration/report-media-claim-security-pg.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import type { CreateReportRequest } from "@civfix/shared" +import { withPg, testHandle, type PgHarness } from "../helpers/pg.js" +import { seedMediaAsset, type SeededMedia } from "../helpers/media-pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" +import { makeReportService, type ReportService } from "../../src/services/report-service.js" +import { PROBE_INSIDE_CITY } from "../../src/db/seed-fixtures.js" + +const pg = await withPg() + +const UNAVAILABLE = "One or more media uploads are unavailable." + +describe.skipIf(!pg)("report media claim (integration: only unbound report media)", () => { + let h: PgHarness + let service: ReportService + let attackerId: string + + beforeAll(async () => { + h = pg as PgHarness + service = makeReportService({ + repo: makeDrizzleReportRepository(h.sql), + resolveJurisdictionGeoid: () => Promise.resolve(null), + presignMedia: (r2Key) => Promise.resolve({ url: `memory://${r2Key}` }), + }) + attackerId = await newUser("attacker") + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newUser(name: string): Promise { + const [u] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name, handle) VALUES (${name}, ${testHandle()}) RETURNING id + ` + return u!.id + } + + function createReq(mediaUploadIds: string[]): CreateReportRequest { + return { + idempotencyKey: randomUUID(), + category: "trash", + type: "dump", + lat: PROBE_INSIDE_CITY.lat, + lng: PROBE_INSIDE_CITY.lng, + geomSource: "device", + mediaUploadIds, + } + } + + async function expectUnclaimable(media: SeededMedia, purpose: string): Promise { + await expect( + service.createReport(createReq([media.uploadId]), { userId: attackerId }), + ).rejects.toMatchObject({ + httpStatus: 422, + code: "VALIDATION", + fields: { mediaUploadIds: UNAVAILABLE }, + }) + const [row] = await h.sql<{ report_id: string | null; purpose: string }[]>` + SELECT report_id, purpose FROM media_assets WHERE id = ${media.id} + ` + expect(row!.report_id).toBeNull() + expect(row!.purpose).toBe(purpose) + } + + it("refuses another user's avatar, which keeps purpose 'report'", async () => { + const victim = await newUser("avatar victim") + const media = await seedMediaAsset(h.sql) + await h.sql`UPDATE users SET avatar_media_id = ${media.id} WHERE id = ${victim}` + + await expectUnclaimable(media, "report") + }) + + it("refuses a chat group avatar", async () => { + const owner = await newUser("group owner") + const media = await seedMediaAsset(h.sql) + await h.sql` + INSERT INTO chat_groups (name, owner_id, visibility, avatar_media_id) + VALUES ('Neighbors', ${owner}, 'public', ${media.id}) + ` + + await expectUnclaimable(media, "report") + }) + + it("refuses an organization logo", async () => { + const media = await seedMediaAsset(h.sql, { purpose: "org_logo" }) + const slug = `org-${randomUUID().slice(0, 8)}` + await h.sql` + INSERT INTO organizations (slug, name, logo_media_id) VALUES (${slug}, ${slug}, ${media.id}) + ` + + await expectUnclaimable(media, "org_logo") + }) + + it("refuses a private event's cover image", async () => { + const organizer = await newUser("event organizer") + const media = await seedMediaAsset(h.sql, { purpose: "event_cover" }) + const cleanupId = await seedCleanup(h.sql, { + organizerUserId: organizer, + coverMediaId: media.id, + }) + await h.sql`UPDATE cleanups SET visibility = 'private' WHERE id = ${cleanupId}` + + await expectUnclaimable(media, "event_cover") + }) + + it("refuses a verification document even when nothing else binds it", async () => { + const media = await seedMediaAsset(h.sql, { purpose: "verification" }) + + await expectUnclaimable(media, "verification") + }) + + it("still claims the reporter's own unbound report upload", async () => { + const media = await seedMediaAsset(h.sql) + + const dto = await service.createReport(createReq([media.uploadId]), { userId: attackerId }) + + expect(dto.media.map((m) => m.id)).toEqual([media.id]) + const [row] = await h.sql<{ report_id: string | null }[]>` + SELECT report_id FROM media_assets WHERE id = ${media.id} + ` + expect(row!.report_id).toBe(dto.id) + }) +}) diff --git a/services/api/test/unit/media-attach-claim-security.test.ts b/services/api/test/unit/media-attach-claim-security.test.ts new file mode 100644 index 00000000..69e0ff6c --- /dev/null +++ b/services/api/test/unit/media-attach-claim-security.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest" +import type { Queryable, Sql } from "../../src/db/client.js" +import { attachChatMedia } from "../../src/services/chat-attachments.drizzle.js" +import { mediaBoundElsewhere } from "../../src/services/media-bindings.js" +import { + makeDrizzlePostRepository, + type CreatePostArgs, +} from "../../src/services/post-repository.drizzle.js" +import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" + +const AUTHOR = "11111111-1111-4111-8111-111111111111" +const POST_ID = "22222222-2222-4222-8222-222222222222" +const MESSAGE_ID = "33333333-3333-4333-8333-333333333333" +const UPLOAD_ID = "44444444-4444-4444-8444-444444444444" +const UNAVAILABLE = "One or more media uploads are unavailable." + +function squash(text: string): string { + return text.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim() +} + +async function renderBoundElsewhere(): Promise { + const fake = makeFakeSql([]) + await fake.sql`${mediaBoundElsewhere(fake.sql as unknown as Queryable, null)}` + return squash(fake.statements[0]?.sql ?? "") +} + +function claimWhereClause(fake: FakeSqlControl, set: RegExp): string { + const claim = fake.statements.find((s) => set.test(s.sql)) + if (!claim) throw new Error("no media claim statement was sent") + const text = squash(claim.sql) + return text.slice(text.indexOf(" WHERE ")) +} + +function postArgs(mediaUploadIds: string[]): CreatePostArgs { + return { + authorId: AUTHOR, + kind: "post", + body: "hello", + replyToId: null, + repostOfId: null, + eventId: null, + reportId: null, + mediaUploadIds, + mentionedUserIds: [], + organizationId: null, + } +} + +function postRepo(fake: FakeSqlControl) { + return makeDrizzlePostRepository(fake.sql as unknown as Sql, { + presignMedia: () => Promise.resolve({ url: "u" }), + presignAvatar: () => Promise.resolve("a"), + }) +} + +const POST_CLAIM = /UPDATE media_assets\s+SET post_id/ + +describe("post create: media claim predicate", () => { + it("claims only unbound report-purpose uploads, checked before the row is re-purposed", async () => { + const fake = makeFakeSql([{ match: /INSERT INTO posts/, rows: [{ id: POST_ID }] }]) + + await expect(postRepo(fake).createPost(postArgs([UPLOAD_ID]))).rejects.toMatchObject({ + httpStatus: 422, + fields: { mediaUploadIds: UNAVAILABLE }, + }) + + const where = claimWhereClause(fake, POST_CLAIM) + expect(where).toContain("purpose = 'report'") + expect(where).toContain(`NOT (${await renderBoundElsewhere()})`) + expect(where).toContain("post_id IS NULL AND chat_message_id IS NULL AND report_id IS NULL") + }) + + it("keeps attaching an author's fresh uploads", async () => { + const fake = makeFakeSql([ + { match: /INSERT INTO posts/, rows: [{ id: POST_ID }] }, + { match: POST_CLAIM, rows: [{ upload_id: UPLOAD_ID }] }, + ]) + + await expect(postRepo(fake).createPost(postArgs([UPLOAD_ID]))).resolves.toBe(POST_ID) + }) +}) + +describe("chat message attach: media claim predicate", () => { + it("claims only unbound report-purpose uploads and keeps the same-message re-claim", async () => { + const fake = makeFakeSql([]) + + await attachChatMedia(fake.sql as unknown as Queryable, MESSAGE_ID, [UPLOAD_ID], new Date()) + + const where = claimWhereClause(fake, /UPDATE media_assets\s+SET "?chat_message_id/) + expect(where).toContain("purpose = 'report'") + expect(where).toContain(`NOT (${await renderBoundElsewhere()})`) + expect(where).toMatch(/\("?chat_message_id"? IS NULL OR "?chat_message_id"? = \?\)/) + }) +}) diff --git a/services/api/test/unit/report-repository-security.test.ts b/services/api/test/unit/report-repository-security.test.ts new file mode 100644 index 00000000..5f11dccc --- /dev/null +++ b/services/api/test/unit/report-repository-security.test.ts @@ -0,0 +1,102 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it } from "vitest" +import type { ReportDTO } from "@civfix/shared" +import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" +import type { Queryable, Sql } from "../../src/db/client.js" +import { mediaBoundElsewhere } from "../../src/services/media-bindings.js" +import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" +import type { CreateReportTxArgs } from "../../src/services/report-service.types.js" + +const UNAVAILABLE = "One or more media uploads are unavailable." + +function squash(text: string): string { + return text.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim() +} + +async function renderFragment(build: (tag: Queryable) => unknown): Promise { + const fake = makeFakeSql([]) + await fake.sql`${build(fake.sql as unknown as Queryable)}` + return squash(fake.statements[0]?.sql ?? "") +} + +function createArgs(mediaUploadIds: string[]): CreateReportTxArgs { + const reportId = randomUUID() + return { + reportId, + reporterUserId: randomUUID(), + idempotencyKey: randomUUID(), + lat: 34.05, + lng: -118.25, + geomSource: "device", + jurisdictionGeoid: null, + jurCode: 0, + category: "trash", + type: "dump", + title: null, + description: null, + addr: null, + addrSource: null, + addrPrecision: null, + status: "published", + visibility: "public", + h3Cell: "h0", + publishedAt: new Date(), + mediaUploadIds, + timelineNote: null, + idempotency: { key: randomUUID(), scope: "report_create", userOrAnon: null }, + buildSnapshot: () => Promise.resolve({ id: reportId } as unknown as ReportDTO), + } +} + +function claimStatement(fake: FakeSqlControl): string { + const claim = fake.statements.find((s) => /UPDATE media_assets\s+SET report_id/.test(s.sql)) + if (!claim) throw new Error("no media claim statement was sent") + return squash(claim.sql) +} + +describe("authenticated report create: media claim predicate", () => { + it("only claims report-purpose media that no avatar, logo or event binds", async () => { + const fake = makeFakeSql([{ match: /INSERT INTO reference_counters/, rows: [{ next_val: 1 }] }]) + const repo = makeDrizzleReportRepository(fake.sql as unknown as Sql) + + await expect(repo.createReportTx(createArgs([randomUUID()]))).rejects.toMatchObject({ + httpStatus: 422, + fields: { mediaUploadIds: UNAVAILABLE }, + }) + + const claim = claimStatement(fake) + const boundElsewhere = await renderFragment((tag) => mediaBoundElsewhere(tag, null)) + expect(claim).toContain("purpose = 'report'") + expect(claim).toContain(`NOT (${boundElsewhere})`) + expect(claim).toContain("post_id IS NULL AND chat_message_id IS NULL") + }) + + it("keeps the claim when every requested upload is claimable", async () => { + const uploadIds = [randomUUID(), randomUUID()] + const fake = makeFakeSql([ + { match: /INSERT INTO reference_counters/, rows: [{ next_val: 1 }] }, + { + match: /UPDATE media_assets\s+SET report_id/, + rows: uploadIds.map((upload_id) => ({ upload_id })), + }, + { + match: /FROM reports WHERE id =/, + rows: [ + { + id: randomUUID(), + lng: -118.25, + lat: 34.05, + created_at: new Date(), + updated_at: new Date(), + }, + ], + }, + ]) + const repo = makeDrizzleReportRepository(fake.sql as unknown as Sql) + + const result = await repo.createReportTx(createArgs(uploadIds)) + + expect(result.kind).toBe("created") + expect(claimStatement(fake)).toContain("purpose = 'report'") + }) +}) From b5f3b865c9692da6b9a75c510d443572a56f5267 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:25:06 +0000 Subject: [PATCH 16/45] public media reads serve only a ready asset's re-encoded copy, never a validating upload --- services/api/src/services/affiliation.ts | 3 +- .../services/chat-group-repository.drizzle.ts | 4 +- .../services/cleanup-repository.drizzle.ts | 23 +++- services/api/src/services/cleanup-sql.ts | 6 +- .../host/announcement-repository.drizzle.ts | 6 +- .../host/host-portfolio-repository.drizzle.ts | 4 +- .../host/host-team-repository.drizzle.ts | 8 +- services/api/src/services/media-served-key.ts | 9 ++ .../src/services/social-repository.drizzle.ts | 10 +- .../volunteer-hours-repository.drizzle.ts | 4 +- services/api/test/unit/affiliation.test.ts | 7 +- .../test/unit/media-public-served-key.test.ts | 113 ++++++++++++++++++ 12 files changed, 167 insertions(+), 30 deletions(-) create mode 100644 services/api/test/unit/media-public-served-key.test.ts diff --git a/services/api/src/services/affiliation.ts b/services/api/src/services/affiliation.ts index 2ab270f9..b3e24e85 100644 --- a/services/api/src/services/affiliation.ts +++ b/services/api/src/services/affiliation.ts @@ -1,6 +1,7 @@ import type { OrganizationRefDTO, PersonDTO } from "@civfix/shared" import type { Sql } from "../db/client.js" import { blockedPairExpr } from "./hidden-identity.js" +import { publicServedKeyExpr } from "./media-served-key.js" import { PRESIGN_CONCURRENCY, mapWithLimit } from "./media-presign.js" import { presentIds } from "./present-ids.js" @@ -42,7 +43,7 @@ export async function loadPrimaryAffiliations( o.name, o.verified_status, o.verified_kind, - am.r2_key AS logo_key + ${publicServedKeyExpr(sql, "am")} AS logo_key FROM organization_members m JOIN organizations o ON o.id = m.organization_id JOIN users u ON u.id = m.user_id diff --git a/services/api/src/services/chat-group-repository.drizzle.ts b/services/api/src/services/chat-group-repository.drizzle.ts index c34b7fe8..e74df422 100644 --- a/services/api/src/services/chat-group-repository.drizzle.ts +++ b/services/api/src/services/chat-group-repository.drizzle.ts @@ -8,7 +8,7 @@ import { blockedPairExpr, hiddenIdentity } from "./hidden-identity.js" import { resolveAvatarMediaOrThrow } from "./avatar-media.js" import { monotonicReadWatermarkUpdate } from "./chat-read-state.drizzle.js" import { isUuid } from "../db/cursor-helpers.js" -import { servedKeyExpr } from "./media-served-key.js" +import { publicServedKeyExpr } from "./media-served-key.js" export type GroupMemberRole = (typeof GROUP_MEMBER_ROLE_VALUES)[number] @@ -240,7 +240,7 @@ export function makeChatGroupRepository(sql: Sql, presign?: PresignMedia): ChatG a.id AS avatar_id, a.kind AS avatar_kind, a.codec AS avatar_codec, - ${servedKeyExpr(sql, "a")} AS avatar_r2_key, + ${publicServedKeyExpr(sql, "a")} AS avatar_r2_key, a.thumb_key AS avatar_thumb_key, a.status AS avatar_status, a.width AS avatar_width, diff --git a/services/api/src/services/cleanup-repository.drizzle.ts b/services/api/src/services/cleanup-repository.drizzle.ts index 49e9a783..5a572d87 100644 --- a/services/api/src/services/cleanup-repository.drizzle.ts +++ b/services/api/src/services/cleanup-repository.drizzle.ts @@ -18,10 +18,11 @@ import { allocateEventReferenceCode } from "../db/reference-code.js" import { firstReadyStillLateral, publicReportFilter } from "./report-sql.js" import { hostStandingOf, hostStandingsOf, orgStandingOf } from "./host/host-standing.js" import { NO_HOST_STANDING } from "@civfix/shared/host" -import { servableMediaFilter, servedKeyExpr } from "./media-served-key.js" +import { publicServedKeyExpr } from "./media-served-key.js" import { MEDIA_CLAIM_WINDOW_SEC } from "./host/event-media.js" import { mediaBoundElsewhere, mediaBoundToCleanup } from "./media-bindings.js" import { isUniqueViolationOn } from "./host/registration-sql.js" +import { cancelWaitlistEntriesIn } from "./host/registration-repository.drizzle.js" import { deterministicUuid } from "./deterministic-uuid.js" import type { AttendeeView, @@ -730,12 +731,12 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { async galleryKeysFor(cleanupId: string): Promise { const rows = await sql<{ served_key: string | null }[]>` - SELECT ${servedKeyExpr(sql, "ma")} AS served_key + SELECT ${publicServedKeyExpr(sql, "ma")} AS served_key FROM cleanups c JOIN LATERAL unnest(c.gallery_media_ids) WITH ORDINALITY AS g(media_id, ord) ON true JOIN media_assets ma ON ma.id = g.media_id WHERE c.id = ${cleanupId} - AND ${servableMediaFilter(sql, "ma")} + AND ${publicServedKeyExpr(sql, "ma")} IS NOT NULL ORDER BY g.ord ` return rows.flatMap((r) => (r.served_key === null ? [] : [r.served_key])) @@ -755,7 +756,7 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { }[] >` SELECT o.id, o.slug, o.name, - ${servedKeyExpr(sql, "am")} AS logo_key, + ${publicServedKeyExpr(sql, "am")} AS logo_key, o.donation_url, o.verified_status, o.verified_kind, (o.suspended_at IS NOT NULL) AS suspended @@ -796,7 +797,7 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { }[] >` SELECT o.id, o.slug, o.name, - ${servedKeyExpr(sql, "am")} AS logo_key, + ${publicServedKeyExpr(sql, "am")} AS logo_key, o.donation_url, o.verified_status, o.verified_kind, (o.suspended_at IS NOT NULL) AS suspended, @@ -996,6 +997,7 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { const cleanup = locked[0] if (cleanup === undefined) return { kind: "not_found" } if (cleanup.status === "cancelled") return { kind: "closed" } + let releasedWaitlistTicketTypeIds: string[] = [] const deleted = await tx<{ user_id: string }[]>` DELETE FROM cleanup_members WHERE cleanup_id = ${cleanupId} AND user_id = ${userId} AND role <> 'organizer' @@ -1017,6 +1019,13 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { DELETE FROM cleanup_slot_claims WHERE cleanup_id = ${cleanupId} AND user_id = ${userId} ` + const cancelled = await cancelWaitlistEntriesIn(tx, { + cleanupId, + ticketTypeId: null, + subject: { kind: "user", userId }, + now: cleanup.now, + }) + releasedWaitlistTicketTypeIds = cancelled.releasedTicketTypeIds } const counted = await tx<{ count: number }[]>` SELECT @@ -1027,7 +1036,9 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { ) AS count ` const going = counted[0]?.count ?? 0 - return deleted.length > 0 ? { kind: "removed", going } : { kind: "not_member", going } + return deleted.length > 0 + ? { kind: "removed", going, releasedWaitlistTicketTypeIds } + : { kind: "not_member", going } }) }, diff --git a/services/api/src/services/cleanup-sql.ts b/services/api/src/services/cleanup-sql.ts index e1dba58d..5b1343b5 100644 --- a/services/api/src/services/cleanup-sql.ts +++ b/services/api/src/services/cleanup-sql.ts @@ -16,7 +16,7 @@ import type { OrgVerificationKind, OrgVerificationStatus, } from "@civfix/shared" -import { servedKeyExpr } from "./media-served-key.js" +import { publicServedKeyExpr } from "./media-served-key.js" export function cleanupStatusExpr(sql: Queryable) { return sql`CASE @@ -186,7 +186,7 @@ export function cleanupColumns(sql: Queryable, near: NearPoint | null) { c.timezone, c.visibility, c.cover_media_id, - ${servedKeyExpr(sql, "ma")} AS cover_key, + ${publicServedKeyExpr(sql, "ma")} AS cover_key, c.gallery_media_ids, c.donation_url, c.page_slug, @@ -198,7 +198,7 @@ export function cleanupColumns(sql: Queryable, near: NearPoint | null) { c.host_reply_to_verified_at, o.slug AS organization_slug, o.name AS organization_name, - ${servedKeyExpr(sql, "am")} AS organization_logo_key, + ${publicServedKeyExpr(sql, "am")} AS organization_logo_key, o.donation_url AS organization_donation_url, o.verified_status AS organization_verified_status, o.verified_kind AS organization_verified_kind, diff --git a/services/api/src/services/host/announcement-repository.drizzle.ts b/services/api/src/services/host/announcement-repository.drizzle.ts index c139f1ff..203dd0df 100644 --- a/services/api/src/services/host/announcement-repository.drizzle.ts +++ b/services/api/src/services/host/announcement-repository.drizzle.ts @@ -3,7 +3,7 @@ import type { Sql } from "../../db/client.js" import { PRESIGN_CONCURRENCY, mapWithLimit } from "../media-presign.js" import { presentIds } from "../present-ids.js" import { publicAuthorIdentity } from "../public-author.js" -import { servedKeyExpr } from "../media-served-key.js" +import { publicServedKeyExpr } from "../media-served-key.js" export type AnnouncementImagePresigner = (r2Key: string) => Promise @@ -42,7 +42,7 @@ export function makeDrizzleAnnouncementIdentityRepository( if (ids.length === 0) return out const rows = await sql` SELECT u.id, u.display_name, u.handle, u.bio, - ${servedKeyExpr(sql, "am")} AS avatar_r2_key, + ${publicServedKeyExpr(sql, "am")} AS avatar_r2_key, u.avatar_url, u.deleted_at FROM users u LEFT JOIN media_assets am ON am.id = u.avatar_media_id @@ -81,7 +81,7 @@ export function makeDrizzleAnnouncementIdentityRepository( async organizationFor(cleanupId) { const rows = await sql` SELECT o.id, o.slug, o.name, o.verified_status, o.verified_kind, - ${servedKeyExpr(sql, "am")} AS logo_key + ${publicServedKeyExpr(sql, "am")} AS logo_key FROM cleanups c JOIN organizations o ON o.id = c.organization_id LEFT JOIN media_assets am ON am.id = o.logo_media_id diff --git a/services/api/src/services/host/host-portfolio-repository.drizzle.ts b/services/api/src/services/host/host-portfolio-repository.drizzle.ts index 327682c1..c52bb6cf 100644 --- a/services/api/src/services/host/host-portfolio-repository.drizzle.ts +++ b/services/api/src/services/host/host-portfolio-repository.drizzle.ts @@ -6,7 +6,7 @@ import type { } from "@civfix/shared" import type { Sql } from "../../db/client.js" import { encodeTimeCursor, pageWith, parseTimeCursor } from "../../db/cursor-helpers.js" -import { servedKeyExpr } from "../media-served-key.js" +import { publicServedKeyExpr } from "../media-served-key.js" import { cleanupStatusExpr } from "../cleanup-sql.js" export interface HostedEventRecord { @@ -139,7 +139,7 @@ export function makeDrizzleHostPortfolioRepository(sql: Sql): HostPortfolioRepos c.timezone, ${cleanupStatusExpr(sql)} AS status, c.visibility, - ${servedKeyExpr(sql, "ma")} AS cover_key, + ${publicServedKeyExpr(sql, "ma")} AS cover_key, c.capacity, c.page_slug, ( diff --git a/services/api/src/services/host/host-team-repository.drizzle.ts b/services/api/src/services/host/host-team-repository.drizzle.ts index 189948d1..3102765c 100644 --- a/services/api/src/services/host/host-team-repository.drizzle.ts +++ b/services/api/src/services/host/host-team-repository.drizzle.ts @@ -8,7 +8,7 @@ import { } from "@civfix/shared" import type { Queryable, Sql } from "../../db/client.js" import { encodeTimeCursor, pageWith, parseTimeCursor } from "../../db/cursor-helpers.js" -import { servedKeyExpr } from "../media-served-key.js" +import { publicServedKeyExpr } from "../media-served-key.js" import { cleanupStatusExpr } from "../cleanup-sql.js" import { writeHostAudit } from "./host-audit.js" import { isUniqueViolationOn } from "./registration-sql.js" @@ -364,8 +364,10 @@ export function makeDrizzleHostTeamRepository(sql: Sql): HostTeamRepository { async resolveUserByHandle( handle: string, ): Promise<{ userId: string; email: string | null } | null> { + // A handle invite mails its accept link, so only an address the account proved it owns may + // receive it; the in-app notice still reaches the account either way. const rows = await sql<{ id: string; email: string | null }[]>` - SELECT id, email FROM users + SELECT id, CASE WHEN email_verified THEN email END AS email FROM users WHERE handle = ${handle} AND deleted_at IS NULL LIMIT 1 ` @@ -551,7 +553,7 @@ export function makeDrizzleHostTeamRepository(sql: Sql): HostTeamRepository { ${cleanupStatusExpr(sql)} AS event_status, c.visibility, c.address, - ${servedKeyExpr(sql, "ma")} AS cover_key, + ${publicServedKeyExpr(sql, "ma")} AS cover_key, bu.id AS inviter_id, bu.display_name AS inviter_name, bu.handle AS inviter_handle, diff --git a/services/api/src/services/media-served-key.ts b/services/api/src/services/media-served-key.ts index b8abeb83..11e5e9f1 100644 --- a/services/api/src/services/media-served-key.ts +++ b/services/api/src/services/media-served-key.ts @@ -5,10 +5,19 @@ type SqlFragment = postgres.Fragment export type MediaAlias = "m" | "am" | "ma" | "a" | "media_assets" +// Falls back to the uploaded original while the worker has not produced a served copy, so it is only +// for readers that are gated to the uploader and presign privately (a report owner, a chat member). export function servedKeyExpr(sql: Queryable, alias: MediaAlias): SqlFragment { return sql`COALESCE(${sql(alias)}.served_key, CASE WHEN ${sql(alias)}.status = 'validating' THEN ${sql(alias)}.r2_key END)` } +// Until the worker marks an asset ready, its r2_key holds the uploader's raw bytes: not re-encoded, EXIF +// GPS intact, unchecked. Every read shown to anyone but the uploader resolves only the ready served copy +// and yields no key otherwise, which the clients render as missing media. +export function publicServedKeyExpr(sql: Queryable, alias: MediaAlias): SqlFragment { + return sql`CASE WHEN ${sql(alias)}.status = 'ready' THEN ${sql(alias)}.served_key END` +} + export function servableMediaFilter(sql: Queryable, alias: MediaAlias): SqlFragment { return sql`(${sql(alias)}.status = 'validating' OR (${sql(alias)}.status = 'ready' AND ${sql(alias)}.served_key IS NOT NULL))` } diff --git a/services/api/src/services/social-repository.drizzle.ts b/services/api/src/services/social-repository.drizzle.ts index c349516a..35b1074d 100644 --- a/services/api/src/services/social-repository.drizzle.ts +++ b/services/api/src/services/social-repository.drizzle.ts @@ -27,7 +27,7 @@ import { } from "../db/cursor-helpers.js" import { escapeLike } from "./admin/like.js" import { cleanupStatusExpr, goingScalar } from "./cleanup-sql.js" -import { servedKeyExpr } from "./media-served-key.js" +import { publicServedKeyExpr } from "./media-served-key.js" export { searchByHandlePrefix, searchMentionable } from "./user-search.drizzle.js" export { @@ -280,7 +280,7 @@ async function connectionsPage( u.bio, u.follower_count AS followers, u.following_count AS following, - ${servedKeyExpr(sql, "am")} AS avatar_r2_key, + ${publicServedKeyExpr(sql, "am")} AS avatar_r2_key, u.avatar_url, u.show_volunteer_hours, u.edge_created_at, @@ -417,7 +417,7 @@ function suggestFollowsStatement( c.bio, c.followers, c.following, - ${servedKeyExpr(sql, "am")} AS avatar_r2_key, + ${publicServedKeyExpr(sql, "am")} AS avatar_r2_key, c.avatar_url, c.show_volunteer_hours, c.is_organizer @@ -453,7 +453,7 @@ export function makeDrizzleSocialRepository(sql: Sql): SocialRepository { u.bio, u.follower_count AS followers, u.following_count AS following, - ${servedKeyExpr(sql, "am")} AS avatar_r2_key, + ${publicServedKeyExpr(sql, "am")} AS avatar_r2_key, u.avatar_url, u.social_links, u.donation_url, @@ -510,7 +510,7 @@ export function makeDrizzleSocialRepository(sql: Sql): SocialRepository { u.bio, u.follower_count AS followers, u.following_count AS following, - ${servedKeyExpr(sql, "am")} AS avatar_r2_key, + ${publicServedKeyExpr(sql, "am")} AS avatar_r2_key, u.avatar_url, u.show_volunteer_hours, ${followingExpr} AS is_following diff --git a/services/api/src/services/volunteer-hours-repository.drizzle.ts b/services/api/src/services/volunteer-hours-repository.drizzle.ts index f02c1837..8ed7f516 100644 --- a/services/api/src/services/volunteer-hours-repository.drizzle.ts +++ b/services/api/src/services/volunteer-hours-repository.drizzle.ts @@ -8,7 +8,7 @@ import type { import type { Queryable, Sql } from "../db/client.js" import { encodeTimeCursor, pageWith } from "../db/cursor-helpers.js" import { blockedPairExpr, hiddenIdentity } from "./hidden-identity.js" -import { servedKeyExpr } from "./media-served-key.js" +import { publicServedKeyExpr } from "./media-served-key.js" import { DEFAULT_EVENT_TIME_ZONE } from "./host/event-fields.js" import { DAILY_HOURS_CAP, @@ -356,7 +356,7 @@ export function makeDrizzleVolunteerHoursRepository(sql: Sql): VolunteerHoursRep o.id, o.slug, o.name, - ${servedKeyExpr(sql, "am")} AS logo_key, + ${publicServedKeyExpr(sql, "am")} AS logo_key, o.verified_status, o.verified_kind, sum(vh.hours)::float8 AS hours diff --git a/services/api/test/unit/affiliation.test.ts b/services/api/test/unit/affiliation.test.ts index 3e982d4c..ac362fe5 100644 --- a/services/api/test/unit/affiliation.test.ts +++ b/services/api/test/unit/affiliation.test.ts @@ -29,7 +29,8 @@ interface FakeRow { function fakeSql(rows: FakeRow[]): { sql: Sql; calls: Capture[]; fragments: Capture[] } { const calls: Capture[] = [] const fragments: Capture[] = [] - const tag = (strings: TemplateStringsArray, ...args: unknown[]): Promise => { + const tag = (strings: TemplateStringsArray | string, ...args: unknown[]): unknown => { + if (typeof strings === "string") return strings const capture = { text: strings.join("?"), args } if (capture.text.includes("FROM organization_members")) calls.push(capture) else fragments.push(capture) @@ -66,7 +67,7 @@ describe("loadPrimaryAffiliations", () => { const { sql, calls } = fakeSql([]) await loadPrimaryAffiliations(sql, undefined, [ANN, BOB, ANN, BOB, CAROL], null) expect(calls).toHaveLength(1) - expect(calls[0]!.args[0]).toEqual([ANN, BOB, CAROL]) + expect(calls[0]!.args.find(Array.isArray)).toEqual([ANN, BOB, CAROL]) }) it("issues no query at all for an empty batch", async () => { @@ -80,7 +81,7 @@ describe("loadPrimaryAffiliations", () => { const { sql, calls } = fakeSql([]) await loadPrimaryAffiliations(sql, undefined, [ANN, null, BOB, undefined, ANN, null], null) expect(calls).toHaveLength(1) - expect(calls[0]!.args[0]).toEqual([ANN, BOB]) + expect(calls[0]!.args.find(Array.isArray)).toEqual([ANN, BOB]) }) it("issues no query at all when every id in the batch is missing", async () => { diff --git a/services/api/test/unit/media-public-served-key.test.ts b/services/api/test/unit/media-public-served-key.test.ts new file mode 100644 index 00000000..fc3bdf2e --- /dev/null +++ b/services/api/test/unit/media-public-served-key.test.ts @@ -0,0 +1,113 @@ +import { readdirSync, readFileSync } from "node:fs" +import { join, relative } from "node:path" +import { fileURLToPath } from "node:url" +import { describe, expect, it } from "vitest" +import type { Queryable, Sql } from "../../src/db/client.js" +import { loadPrimaryAffiliations } from "../../src/services/affiliation.js" +import { makeChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" +import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" +import { makeDrizzleAnnouncementIdentityRepository } from "../../src/services/host/announcement-repository.drizzle.js" +import { makeDrizzleOrganizationRepository } from "../../src/services/host/organization-repository.drizzle.js" +import { publicServedKeyExpr } from "../../src/services/media-served-key.js" +import { makeDrizzleSocialRepository } from "../../src/services/social-repository.drizzle.js" +import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" + +const ID = "11111111-1111-4111-8111-111111111111" +const SRC = fileURLToPath(new URL("../../src/", import.meta.url)) + +const UPLOADED_ORIGINAL_COLUMN = /\.r2_key\b/ + +const OWNER_GATED_SERVED_KEY_READERS = [ + "services/message-attachments.drizzle.ts", + "services/report-repository.drizzle.ts", +] + +function sqlOf(fake: FakeSqlControl): string { + return fake.statements.map((s) => s.sql.replace(/\s+/g, " ")).join("\n") +} + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return sourceFiles(path) + return entry.name.endsWith(".ts") ? [path] : [] + }) +} + +describe("publicServedKeyExpr", () => { + it("resolves only a ready asset's served copy, never the uploaded original", async () => { + const fake = makeFakeSql() + await fake.sql`${publicServedKeyExpr(fake.sql as unknown as Queryable, "ma")}` + + const text = sqlOf(fake) + expect(text).toContain("status = 'ready'") + expect(text).toContain("served_key") + expect(text).not.toMatch(UPLOADED_ORIGINAL_COLUMN) + expect(text).not.toContain("validating") + }) +}) + +describe("media read by people other than the uploader", () => { + const reads: [string, (fake: FakeSqlControl) => Promise][] = [ + [ + "an event's cover and host organization logo", + (fake) => makeDrizzleCleanupRepository(fake.sql as unknown as Sql).findCleanupById(ID, null), + ], + [ + "an event's gallery", + (fake) => makeDrizzleCleanupRepository(fake.sql as unknown as Sql).galleryKeysFor(ID), + ], + [ + "an event's organization card", + (fake) => makeDrizzleCleanupRepository(fake.sql as unknown as Sql).loadOrganizationRef(ID), + ], + [ + "an organization profile", + (fake) => + makeDrizzleOrganizationRepository(fake.sql as unknown as Sql).findOrganizationById( + ID, + null, + ), + ], + [ + "a person's profile avatar", + (fake) => makeDrizzleSocialRepository(fake.sql as unknown as Sql).findPersonById(ID), + ], + [ + "a chat group's avatar", + (fake) => makeChatGroupRepository(fake.sql as unknown as Sql).findById(ID), + ], + [ + "an announcement author's avatar", + (fake) => + makeDrizzleAnnouncementIdentityRepository(fake.sql as unknown as Sql, () => + Promise.resolve("u"), + ).authorsFor([ID]), + ], + [ + "an affiliation badge logo", + (fake) => + loadPrimaryAffiliations(fake.sql as unknown as Sql, () => Promise.resolve("u"), [ID], null), + ], + ] + + it.each(reads)("%s resolves only a ready served copy", async (_label, read) => { + const fake = makeFakeSql() + + await read(fake) + + const text = sqlOf(fake) + expect(text).toContain("served_key") + expect(text).not.toMatch(UPLOADED_ORIGINAL_COLUMN) + }) + + it("keeps the validating fallback to the owner-gated, privately presigned readers", () => { + const importers = sourceFiles(SRC) + .filter((path) => /\bservedKeyExpr\b/.test(readFileSync(path, "utf8"))) + .map((path) => relative(SRC, path).split("\\").join("/")) + .filter((path) => path !== "services/media-served-key.ts") + .sort() + + expect(importers).toEqual(OWNER_GATED_SERVED_KEY_READERS) + }) +}) From a6b86226665e05a843cdf8bcd8edd6addf371ae9 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:25:06 +0000 Subject: [PATCH 17/45] content reports of private events answer like unknown events; the contact suggestion is bounded --- services/api/src/routes/map.routes.ts | 33 +++++- .../src/services/content-report-subject.ts | 11 +- .../content-report-event-security-pg.test.ts | 90 +++++++++++++++ .../content-report-subject-security.test.ts | 84 ++++++++++++++ .../api/test/unit/map-routes-security.test.ts | 106 ++++++++++++++++++ 5 files changed, 318 insertions(+), 6 deletions(-) create mode 100644 services/api/test/integration/content-report-event-security-pg.test.ts create mode 100644 services/api/test/unit/content-report-subject-security.test.ts create mode 100644 services/api/test/unit/map-routes-security.test.ts diff --git a/services/api/src/routes/map.routes.ts b/services/api/src/routes/map.routes.ts index 9075208e..b13d5cb7 100644 --- a/services/api/src/routes/map.routes.ts +++ b/services/api/src/routes/map.routes.ts @@ -38,7 +38,35 @@ const CleanupsQuerySchema = z.object({ when: z.enum(["upcoming", "past"]).optional(), }) -const GeoidParamsSchema = z.object({ geoid: z.string().min(1) }).strict() +const GEOID_MAX_LENGTH = 64 + +const GeoidParamsSchema = z.object({ geoid: z.string().min(1).max(GEOID_MAX_LENGTH) }).strict() + +const CONTACT_EMAIL_MAX_LENGTH = 254 + +const CONTACT_FORM_URL_MAX_LENGTH = 2048 + +const SUGGEST_CONTACT_BODY_LIMIT = 16384 + +function isHttpUrl(value: string): boolean { + try { + const { protocol } = new URL(value) + return protocol === "https:" || protocol === "http:" + } catch { + return false + } +} + +// Anyone can write these fields into audit_log and the operator discovery notes, and the shared +// contract leaves email and formUrl unbounded, so the backend caps them after the contract parse. +const SuggestContactBoundsSchema = z.object({ + email: z.string().max(CONTACT_EMAIL_MAX_LENGTH, "That email address is too long.").optional(), + formUrl: z + .string() + .max(CONTACT_FORM_URL_MAX_LENGTH, "That link is too long.") + .refine(isHttpUrl, "Use a link that starts with http:// or https://.") + .optional(), +}) export const SuggestPlacesBodySchema = trimTextFields(SuggestPlacesRequestSchema, "q") @@ -179,13 +207,14 @@ export async function registerMapRoutes(app: FastifyInstance, container: Contain route( app, "suggestJurisdictionContact", - { config: { rateLimit: SUGGEST_CONTACT_RATE_LIMIT } }, + { bodyLimit: SUGGEST_CONTACT_BODY_LIMIT, config: { rateLimit: SUGGEST_CONTACT_RATE_LIMIT } }, async (request, reply) => { const { geoid } = parse(GeoidParamsSchema, request.params) const body = parse(SuggestContactRequestSchema, { ...(request.body as Record | undefined), geoid, }) + parse(SuggestContactBoundsSchema, { email: body.email, formUrl: body.formUrl }) if (!(await jurisdictionService().exists(geoid))) { throw AppError.notFound("Jurisdiction not found") diff --git a/services/api/src/services/content-report-subject.ts b/services/api/src/services/content-report-subject.ts index 7cf5be3e..cf5d70c1 100644 --- a/services/api/src/services/content-report-subject.ts +++ b/services/api/src/services/content-report-subject.ts @@ -8,6 +8,8 @@ import { } from "./media-authorization.js" import { makeDrizzleMediaRepository } from "./media-repository.drizzle.js" import { isPubliclyVisibleStatus } from "./report-visibility.js" +import { hasHostStanding, isEventPubliclyVisible } from "./host/authz.js" +import { hostStandingOf } from "./host/host-standing.js" export interface ContentSubjectGate { assertReportable( @@ -49,10 +51,11 @@ export function makeDrizzleContentSubjectGate(sql: Sql, db: Db): ContentSubjectG return (await mediaAuthorizer.authorize(asset, { userId: reporterUserId })).allowed } case "event": { - const rows = await sql<{ ok: number }[]>` - SELECT 1 AS ok FROM cleanups WHERE id = ${subjectId} LIMIT 1 - ` - return rows.length > 0 + // Same rule as the event detail read, so a private event's id is no existence oracle. A + // cancelled event stays reportable, or a host could cancel to escape a report. + const event = await hostStandingOf(sql, subjectId, reporterUserId) + if (event === null) return false + return hasHostStanding(event.standing) || isEventPubliclyVisible(event.visibility) } case "profile": { const rows = await sql<{ ok: number }[]>` diff --git a/services/api/test/integration/content-report-event-security-pg.test.ts b/services/api/test/integration/content-report-event-security-pg.test.ts new file mode 100644 index 00000000..c64dc388 --- /dev/null +++ b/services/api/test/integration/content-report-event-security-pg.test.ts @@ -0,0 +1,90 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import { withPg, testHandle, type PgHarness } from "../helpers/pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { + makeDrizzleContentSubjectGate, + type ContentSubjectGate, +} from "../../src/services/content-report-subject.js" + +const pg = await withPg() + +const NOT_FOUND = { httpStatus: 404, message: "Content not found" } + +describe.skipIf(!pg)("content-report subject gate (integration: event visibility)", () => { + let h: PgHarness + let gate: ContentSubjectGate + + beforeAll(() => { + h = pg as PgHarness + gate = makeDrizzleContentSubjectGate(h.sql, h.db) + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newUser(name: string): Promise { + const [u] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name, handle) VALUES (${name}, ${testHandle()}) RETURNING id + ` + return u!.id + } + + async function seedEvent(visibility: string, organizationId?: string): Promise { + const organizer = await newUser("organizer") + const id = await seedCleanup(h.sql, { + organizerUserId: organizer, + ...(organizationId !== undefined ? { organizationId } : {}), + }) + await h.sql`UPDATE cleanups SET visibility = ${visibility} WHERE id = ${id}` + return id + } + + it("hides a private event from a non-member and admits its members", async () => { + const eventId = await seedEvent("private") + const outsider = await newUser("outsider") + const attendee = await newUser("attendee") + await h.sql` + INSERT INTO cleanup_members (cleanup_id, user_id, role) VALUES (${eventId}, ${attendee}, 'member') + ` + + await expect(gate.assertReportable("event", eventId, outsider)).rejects.toMatchObject(NOT_FOUND) + await expect(gate.assertReportable("event", randomUUID(), outsider)).rejects.toMatchObject( + NOT_FOUND, + ) + await expect(gate.assertReportable("event", eventId, attendee)).resolves.toBeUndefined() + }) + + it("admits a member of the hosting organization, but not once the organization is deleted", async () => { + const slug = `org-${randomUUID().slice(0, 8)}` + const [org] = await h.sql<{ id: string }[]>` + INSERT INTO organizations (slug, name) VALUES (${slug}, ${slug}) RETURNING id + ` + const eventId = await seedEvent("private", org!.id) + const orgMember = await newUser("org member") + await h.sql` + INSERT INTO organization_members (organization_id, user_id, role) + VALUES (${org!.id}, ${orgMember}, 'member') + ` + + await expect(gate.assertReportable("event", eventId, orgMember)).resolves.toBeUndefined() + + await h.sql`UPDATE organizations SET deleted_at = now() WHERE id = ${org!.id}` + await expect(gate.assertReportable("event", eventId, orgMember)).rejects.toMatchObject( + NOT_FOUND, + ) + }) + + it("keeps public, unlisted and cancelled public events reportable by anyone signed in", async () => { + const reporter = await newUser("reporter") + const publicEvent = await seedEvent("public") + const unlistedEvent = await seedEvent("unlisted") + const cancelledEvent = await seedEvent("public") + await h.sql`UPDATE cleanups SET status = 'cancelled' WHERE id = ${cancelledEvent}` + + for (const eventId of [publicEvent, unlistedEvent, cancelledEvent]) { + await expect(gate.assertReportable("event", eventId, reporter)).resolves.toBeUndefined() + } + }) +}) diff --git a/services/api/test/unit/content-report-subject-security.test.ts b/services/api/test/unit/content-report-subject-security.test.ts new file mode 100644 index 00000000..9fb8dcd5 --- /dev/null +++ b/services/api/test/unit/content-report-subject-security.test.ts @@ -0,0 +1,84 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import type { Db, Sql } from "../../src/db/client.js" +import { makeDrizzleContentSubjectGate } from "../../src/services/content-report-subject.js" + +interface EventRow { + visibility: "public" | "unlisted" | "private" + status?: string + event_role?: string | null + org_role?: string | null +} + +function gateOver(event: EventRow | null) { + const fake = makeFakeSql([ + { + match: /FROM cleanups/, + rows: + event === null + ? [] + : [ + { + ok: 1, + cleanup_id: randomUUID(), + organizer_user_id: randomUUID(), + organization_id: null, + visibility: event.visibility, + status: event.status ?? "upcoming", + event_role: event.event_role ?? null, + org_role: event.org_role ?? null, + }, + ], + }, + ]) + return makeDrizzleContentSubjectGate(fake.sql as unknown as Sql, {} as Db) +} + +const NOT_FOUND = { httpStatus: 404, message: "Content not found" } + +describe("content-report subject gate: events", () => { + it("answers a non-member's report of a private event exactly like an unknown event", async () => { + const reporter = randomUUID() + const subject = randomUUID() + + await expect( + gateOver({ visibility: "private" }).assertReportable("event", subject, reporter), + ).rejects.toMatchObject(NOT_FOUND) + await expect(gateOver(null).assertReportable("event", subject, reporter)).rejects.toMatchObject( + NOT_FOUND, + ) + }) + + it("lets an event member report a private event", async () => { + await expect( + gateOver({ visibility: "private", event_role: "member" }).assertReportable( + "event", + randomUUID(), + randomUUID(), + ), + ).resolves.toBeUndefined() + }) + + it("lets a member of the hosting organization report a private event", async () => { + await expect( + gateOver({ visibility: "private", org_role: "member" }).assertReportable( + "event", + randomUUID(), + randomUUID(), + ), + ).resolves.toBeUndefined() + }) + + it("lets anyone signed in report a public or unlisted event, cancelled ones included", async () => { + for (const event of [ + { visibility: "public" }, + { visibility: "unlisted" }, + { visibility: "public", status: "cancelled" }, + ] as const) { + await expect( + gateOver(event).assertReportable("event", randomUUID(), randomUUID()), + ).resolves.toBeUndefined() + } + }) +}) diff --git a/services/api/test/unit/map-routes-security.test.ts b/services/api/test/unit/map-routes-security.test.ts new file mode 100644 index 00000000..6b92de78 --- /dev/null +++ b/services/api/test/unit/map-routes-security.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it } from "vitest" +import type { FastifyInstance } from "fastify" +import { buildServer } from "../../src/server.js" +import { buildContainer, type Container } from "../../src/di.js" +import { loadEnv } from "../../src/env.js" +import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" + +const SUGGEST_URL = "/v1/map/jurisdictions/0644000/suggest-contact" + +let app: FastifyInstance | undefined + +afterEach(async () => { + if (app) { + await app.close() + app = undefined + } +}) + +async function boot(): Promise { + const env = loadEnv({ NODE_ENV: "test" }) + const db = makeFakeSql([ + { match: /FROM jurisdictions/, rows: [{ "?column?": 1 }] }, + { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] }, + ]) + const container = { + ...buildContainer(env), + getDb: () => ({ sql: db.sql }), + } as unknown as Container + app = await buildServer({ env, container }) + return db +} + +function auditWrites(db: FakeSqlControl): number { + return db.statements.filter((s) => /INSERT INTO audit_log/.test(s.sql)).length +} + +describe("POST /map/jurisdictions/:geoid/suggest-contact: bounded fields", () => { + it("accepts a normal suggestion and writes one audit row", async () => { + const db = await boot() + const res = await app!.inject({ + method: "POST", + url: SUGGEST_URL, + payload: { email: "clerk@city.example.gov", formUrl: "https://city.example.gov/report" }, + }) + expect(res.statusCode).toBe(201) + expect(auditWrites(db)).toBe(1) + }) + + it("422s an email longer than an address can be, before any write", async () => { + const db = await boot() + const res = await app!.inject({ + method: "POST", + url: SUGGEST_URL, + payload: { email: `a@${"x".repeat(300)}.com` }, + }) + expect(res.statusCode).toBe(422) + expect(res.json().fields).toHaveProperty("email") + expect(auditWrites(db)).toBe(0) + }) + + it("422s an overlong form URL, before any write", async () => { + const db = await boot() + const res = await app!.inject({ + method: "POST", + url: SUGGEST_URL, + payload: { formUrl: `https://city.example.gov/${"y".repeat(3000)}` }, + }) + expect(res.statusCode).toBe(422) + expect(res.json().fields).toHaveProperty("formUrl") + expect(auditWrites(db)).toBe(0) + }) + + it("422s a form URL that is not an http(s) link", async () => { + const db = await boot() + const res = await app!.inject({ + method: "POST", + url: SUGGEST_URL, + payload: { formUrl: "javascript:alert(1)" }, + }) + expect(res.statusCode).toBe(422) + expect(res.json().fields).toHaveProperty("formUrl") + expect(auditWrites(db)).toBe(0) + }) + + it("422s an overlong geoid path segment", async () => { + const db = await boot() + const res = await app!.inject({ + method: "POST", + url: `/v1/map/jurisdictions/${"9".repeat(80)}/suggest-contact`, + payload: { email: "clerk@city.example.gov" }, + }) + expect(res.statusCode).toBe(422) + expect(auditWrites(db)).toBe(0) + }) + + it("refuses a body far larger than any valid suggestion", async () => { + const db = await boot() + const res = await app!.inject({ + method: "POST", + url: SUGGEST_URL, + payload: { email: "clerk@city.example.gov", padding: "z".repeat(100_000) }, + }) + expect(res.statusCode).toBe(413) + expect(auditWrites(db)).toBe(0) + }) +}) From bda12145a5226d3e0e68359d050f971552021c64 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:25:06 +0000 Subject: [PATCH 18/45] private events: guest rsvp, event hours and post deletion answer like unknown ids; claim nudge refuses foreign origins; resource budget checks the host first --- services/api/src/routes/claim.routes.ts | 24 +++- .../api/src/routes/volunteer-hours.routes.ts | 1 + .../src/services/cleanup-repository.types.ts | 2 +- services/api/src/services/cleanup-service.ts | 12 +- .../services/guest-rsvp-repository.drizzle.ts | 5 +- .../api/src/services/guest-rsvp-service.ts | 9 +- services/api/src/services/post-service.ts | 7 +- .../src/services/volunteer-hours-service.ts | 33 +++-- services/api/test/helpers/cleanups.ts | 6 +- services/api/test/helpers/guest-rsvp.ts | 1 + .../guest-rsvp-security-pg.test.ts | 36 +++++ .../test/unit/claim-routes-security.test.ts | 90 ++++++++++++ .../cleanup-remove-member-waitlist.test.ts | 117 +++++++++++++++ .../unit/cleanup-service-security.test.ts | 103 +++++++++++++ .../api/test/unit/guest-rsvp-security.test.ts | 135 ++++++++++++++++++ .../test/unit/post-service-security.test.ts | 94 ++++++++++++ .../test/unit/volunteer-hours-entries.test.ts | 1 + .../volunteer-hours-service-security.test.ts | 103 +++++++++++++ .../test/unit/volunteer-hours-service.test.ts | 4 + 19 files changed, 764 insertions(+), 19 deletions(-) create mode 100644 services/api/test/integration/guest-rsvp-security-pg.test.ts create mode 100644 services/api/test/unit/claim-routes-security.test.ts create mode 100644 services/api/test/unit/cleanup-remove-member-waitlist.test.ts create mode 100644 services/api/test/unit/cleanup-service-security.test.ts create mode 100644 services/api/test/unit/guest-rsvp-security.test.ts create mode 100644 services/api/test/unit/post-service-security.test.ts create mode 100644 services/api/test/unit/volunteer-hours-service-security.test.ts diff --git a/services/api/src/routes/claim.routes.ts b/services/api/src/routes/claim.routes.ts index d6f25e88..3761faf8 100644 --- a/services/api/src/routes/claim.routes.ts +++ b/services/api/src/routes/claim.routes.ts @@ -5,9 +5,10 @@ import { type ClaimNudgeResponse, type ClaimReportResponse, } from "@civfix/shared" -import type { FastifyInstance } from "fastify" +import type { FastifyInstance, FastifyRequest } from "fastify" import { perHost } from "../plugins/rate-limit.js" import type { Container } from "../di.js" +import { isProd } from "../env.js" import { requireAuth } from "../auth/context.js" import { ANON_COOKIE } from "../auth/transport.js" import { makeClaimService, type ClaimService } from "../services/claim-service.js" @@ -32,6 +33,21 @@ declare module "fastify" { export const CLAIM_RATE_LIMIT = perHost({ max: 20, timeWindow: "1 minute" }) +// A bodiless credentialed POST is a CORS simple request, so any same-site page could make the +// browser attach the Lax anon cookie and rotate the visitor's on-screen claim code. csrfProtect +// cannot help because anonymous reporters have no session cookie. Browsers always send Origin on a +// cross-origin POST, so an absent Origin is a non-browser caller that could send the token in the +// body anyway; only a present origin outside the web allowlist (including "null") is refused. +function isCookieNudgeOriginAllowed( + request: FastifyRequest, + webOrigins: readonly string[], +): boolean { + const origin = request.headers.origin + if (origin === undefined) return true + if (webOrigins.length === 0) return !isProd() + return webOrigins.includes(origin) +} + export async function registerClaimRoutes( app: FastifyInstance, container: Container, @@ -75,6 +91,12 @@ export async function registerClaimRoutes( if (!anonToken) { throw AppError.notFound("No pending report for this session") } + if ( + body.anonToken === undefined && + !isCookieNudgeOriginAllowed(request, container.env.WEB_ORIGINS) + ) { + throw AppError.forbidden("Origin not allowed.") + } const payload: ClaimNudgeResponse = await service().claimNudge(anonToken) reply.status(200).send(payload) }) diff --git a/services/api/src/routes/volunteer-hours.routes.ts b/services/api/src/routes/volunteer-hours.routes.ts index 08140ca4..6c67fe7a 100644 --- a/services/api/src/routes/volunteer-hours.routes.ts +++ b/services/api/src/routes/volunteer-hours.routes.ts @@ -101,6 +101,7 @@ export async function registerVolunteerHoursRoutes( return { organizerUserId: record.organizerUserId, status: record.status, + visibility: record.visibility, jurisdictionGeoid: record.jurisdictionGeoid, title: record.title, scheduledAt: record.scheduledAt, diff --git a/services/api/src/services/cleanup-repository.types.ts b/services/api/src/services/cleanup-repository.types.ts index cb5073e6..ecf05119 100644 --- a/services/api/src/services/cleanup-repository.types.ts +++ b/services/api/src/services/cleanup-repository.types.ts @@ -236,7 +236,7 @@ export type JoinCleanupOutcome = "joined" | "not_found" | "banned" | "closed" | export type LeaveCleanupOutcome = "left" | "not_found" | "closed" export type RemoveMemberOutcome = - | { kind: "removed"; going: number } + | { kind: "removed"; going: number; releasedWaitlistTicketTypeIds: string[] } | { kind: "not_member"; going: number } | { kind: "closed" } | { kind: "not_found" } diff --git a/services/api/src/services/cleanup-service.ts b/services/api/src/services/cleanup-service.ts index 3d48c5d1..f607c780 100644 --- a/services/api/src/services/cleanup-service.ts +++ b/services/api/src/services/cleanup-service.ts @@ -79,6 +79,7 @@ import { assertSlugAllowed } from "./host/slugs.js" import type { TicketTokenSigner } from "./host/ticket-token.js" import { NULL_HOST_AUDIT_SINK, type HostAuditSink } from "./host/host-audit.js" import type { InsightsInvalidator } from "./host/host-analytics-cache.js" +import { enqueueWaitlistPromotion } from "./host/waitlist-promotion.js" import { DEFAULT_EVENT_DURATION_MS, EVENT_NEEDS_A_SLOT_MESSAGE, @@ -1641,6 +1642,7 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { throw AppError.notFound("That person isn't attending this event.") } + await enqueueWaitlistPromotion(deps.jobs, outcome.releasedWaitlistTicketTypeIds, deps.logger) await deps.insightsInvalidator?.bumpInsightsGeneration(id) await audit.record({ @@ -1743,15 +1745,17 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { `cleanup:res-req:host:${input.actorId}`, RESOURCE_REQUEST_HOST_WINDOW_SEC, ) - const jurisdictionSends = await counters.incr( - `cleanup:res-req:jur:${record.jurisdictionGeoid ?? "unknown"}`, - RESOURCE_REQUEST_JURISDICTION_WINDOW_SEC, - ) + // The shared jurisdiction budget is charged only after the host's own cap passes, so one + // host hammering past its limit cannot exhaust the area for every other host. if (hostSends > RESOURCE_REQUEST_PER_HOST_PER_DAY) { throw AppError.rateLimited( "You've sent the maximum number of resource requests for today. Please try again tomorrow.", ) } + const jurisdictionSends = await counters.incr( + `cleanup:res-req:jur:${record.jurisdictionGeoid ?? "unknown"}`, + RESOURCE_REQUEST_JURISDICTION_WINDOW_SEC, + ) if (jurisdictionSends > RESOURCE_REQUEST_PER_JURISDICTION_PER_HOUR) { throw AppError.rateLimited( "This area has received too many resource requests in the past hour. Please try again later.", diff --git a/services/api/src/services/guest-rsvp-repository.drizzle.ts b/services/api/src/services/guest-rsvp-repository.drizzle.ts index 3a757dab..d4b2c181 100644 --- a/services/api/src/services/guest-rsvp-repository.drizzle.ts +++ b/services/api/src/services/guest-rsvp-repository.drizzle.ts @@ -1,4 +1,4 @@ -import type { CleanupStatus, GuestContactChannel } from "@civfix/shared" +import type { CleanupStatus, EventVisibility, GuestContactChannel } from "@civfix/shared" import type { Sql } from "../db/client.js" import { encodeTimeCursor, pageWith, type TimeCursor } from "../db/cursor-helpers.js" import type { @@ -43,6 +43,7 @@ export function makeDrizzleGuestRsvpRepository(sql: Sql): GuestRsvpRepository { id: string title: string status: CleanupStatus + visibility: EventVisibility scheduled_at: Date ends_at: Date | null address: string | null @@ -55,6 +56,7 @@ export function makeDrizzleGuestRsvpRepository(sql: Sql): GuestRsvpRepository { c.id, c.title, c.status, + c.visibility, c.scheduled_at, c.ends_at, c.address, @@ -71,6 +73,7 @@ export function makeDrizzleGuestRsvpRepository(sql: Sql): GuestRsvpRepository { id: row.id, title: row.title, status: row.status, + visibility: row.visibility, scheduledAt: row.scheduled_at, endsAt: row.ends_at, address: row.address, diff --git a/services/api/src/services/guest-rsvp-service.ts b/services/api/src/services/guest-rsvp-service.ts index a34ee7c1..92b62f1f 100644 --- a/services/api/src/services/guest-rsvp-service.ts +++ b/services/api/src/services/guest-rsvp-service.ts @@ -6,6 +6,7 @@ import { MAX_GUEST_NAME, type CleanupGuestDTO, type CleanupStatus, + type EventVisibility, type GetCleanupGuestsRequest, type GetCleanupGuestsResponse, type GuestContactChannel, @@ -47,6 +48,7 @@ import { mapWithLimit } from "./media-presign.js" import { renderMessage } from "../i18n/renderMessage.js" import { parseTimeCursor, type TimeCursor } from "../db/cursor-helpers.js" import { eventEndedError, eventWindowOf, hasEventEnded } from "./cleanup-rules.js" +import { isEventPubliclyVisible } from "./host/authz.js" import { enqueueWaitlistPromotion } from "./host/waitlist-promotion.js" import { formatEventWhen } from "./host/broadcast-render.js" import { DEFAULT_EVENT_TIME_ZONE } from "./host/event-fields.js" @@ -145,6 +147,7 @@ export interface GuestEventView { id: string title: string status: CleanupStatus + visibility: EventVisibility scheduledAt: Date endsAt: Date | null address: string | null @@ -485,7 +488,11 @@ export function makeGuestRsvpService(deps: GuestRsvpServiceDeps): GuestRsvpServi async function loadOpenEvent(cleanupId: string): Promise { const event = await deps.repo.findEvent(cleanupId) - if (event === null) throw AppError.notFound("Event not found") + // A guest never has standing on a private event, so it must be indistinguishable from an + // unknown id, and that check runs first so its cancelled or ended state does not leak either. + if (event === null || !isEventPubliclyVisible(event.visibility)) { + throw AppError.notFound("Event not found") + } if (event.status === "cancelled") throw eventClosedError() if (hasEventEnded(eventWindowOf(event), now())) throw eventEndedError() return event diff --git a/services/api/src/services/post-service.ts b/services/api/src/services/post-service.ts index a5ebb419..90ff2eda 100644 --- a/services/api/src/services/post-service.ts +++ b/services/api/src/services/post-service.ts @@ -488,8 +488,13 @@ export function makePostService(deps: PostServiceDeps): PostService { async deletePost(id: string, viewerId: string): Promise<{ ok: true }> { const brief = await deps.repo.getPostBrief(id) if (!brief || brief.deletedAt !== null) throw AppError.notFound("Post not found") - if (brief.authorId !== viewerId) + if (brief.authorId !== viewerId) { + // A post the caller cannot read must answer like a missing one, or 403 confirms it exists. + if (!isVisible(brief) || (await isBlocked(viewerId, brief.authorId))) { + throw AppError.notFound("Post not found") + } throw AppError.forbidden("You can only delete your own post.") + } await deps.repo.softDeletePost(id) return { ok: true } }, diff --git a/services/api/src/services/volunteer-hours-service.ts b/services/api/src/services/volunteer-hours-service.ts index 99c2b136..efa1438f 100644 --- a/services/api/src/services/volunteer-hours-service.ts +++ b/services/api/src/services/volunteer-hours-service.ts @@ -10,6 +10,7 @@ import type { CleanupStatus, EventHoursEntry, EventHoursResponse, + EventVisibility, LeaderboardEntryDTO, LeaderboardQuery, LeaderboardResponse, @@ -29,6 +30,7 @@ import { parseTimeCursor, type TimeCursor } from "../db/cursor-helpers.js" import { MIN_EVENT_DURATION_MS, eventWindowOf, hasEventEnded } from "./cleanup-rules.js" import { mapWithLimit, PRESIGN_CONCURRENCY } from "./media-presign.js" import type { AffiliationLoader } from "./affiliation.js" +import { hasHostStanding, isEventPubliclyVisible } from "./host/authz.js" import type { TopVolunteerRow } from "./host/analytics-repository.drizzle.js" import type { InsightsInvalidator } from "./host/host-analytics-cache.js" import type { NotificationService } from "./notification-service.js" @@ -229,6 +231,7 @@ export interface VolunteerHoursRepository { export interface CleanupHoursView { organizerUserId: string status: CleanupStatus + visibility: EventVisibility jurisdictionGeoid: string | null title: string scheduledAt: Date @@ -412,6 +415,21 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu return { eventRole: await deps.cleanups.roleOf(cleanupId, userId), orgRole: null } } + // Matches the event read's visibility rule: a private event the viewer has no standing on must + // answer exactly like an unknown id, or these endpoints become an existence oracle. + async function loadVisibleCleanup( + cleanupId: string, + viewerId: string, + ): Promise<{ cleanup: CleanupHoursView; standing: HostStanding }> { + const cleanup = await deps.cleanups.load(cleanupId) + if (cleanup === null) throw AppError.notFound("Event not found") + const standing = await standingFor(cleanupId, viewerId) + if (!hasHostStanding(standing) && !isEventPubliclyVisible(cleanup.visibility)) { + throw AppError.notFound("Event not found") + } + return { cleanup, standing } + } + async function notifyHoursLogged( cleanup: { id: string; title: string }, changed: LogEventHoursResult["changed"], @@ -535,11 +553,8 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu }, async getEventHours(cleanupId: string, viewerId: string): Promise { - const cleanup = await deps.cleanups.load(cleanupId) - if (cleanup === null) throw AppError.notFound("Event not found") - - const standing = await standingFor(cleanupId, viewerId) - if (standing.eventRole === null && standing.orgRole === null) { + const { standing } = await loadVisibleCleanup(cleanupId, viewerId) + if (!hasHostStanding(standing)) { return { scope: "self", entries: [] } } @@ -565,10 +580,10 @@ export function makeVolunteerHoursService(deps: VolunteerHoursServiceDeps): Volu actorId: string entries: EventHoursEntry[] }): Promise { - const cleanup = await deps.cleanups.load(input.cleanupId) - if (cleanup === null) throw AppError.notFound("Event not found") - - const actorStanding = await standingFor(input.cleanupId, input.actorId) + const { cleanup, standing: actorStanding } = await loadVisibleCleanup( + input.cleanupId, + input.actorId, + ) if (!can(actorStanding, "manage_event")) { throw AppError.forbidden("Only the event hosts can log volunteer hours.") } diff --git a/services/api/test/helpers/cleanups.ts b/services/api/test/helpers/cleanups.ts index 0bd0dc8e..3640c05b 100644 --- a/services/api/test/helpers/cleanups.ts +++ b/services/api/test/helpers/cleanups.ts @@ -1131,7 +1131,11 @@ export class InMemoryCleanupRepository implements CleanupRepository { this.deleteClaim(cleanupId, userId) } const going = this.goingOf(cleanupId) - return Promise.resolve(idx >= 0 ? { kind: "removed", going } : { kind: "not_member", going }) + return Promise.resolve( + idx >= 0 + ? { kind: "removed", going, releasedWaitlistTicketTypeIds: [] } + : { kind: "not_member", going }, + ) } isBanned(cleanupId: string, userId: string): Promise { diff --git a/services/api/test/helpers/guest-rsvp.ts b/services/api/test/helpers/guest-rsvp.ts index 4ac2f292..afaeb5d3 100644 --- a/services/api/test/helpers/guest-rsvp.ts +++ b/services/api/test/helpers/guest-rsvp.ts @@ -72,6 +72,7 @@ export class InMemoryGuestRsvpRepository implements GuestRsvpRepository, GuestCo id: event.id, title: event.title ?? "Beach cleanup", status: event.status ?? "upcoming", + visibility: event.visibility ?? "public", scheduledAt: event.scheduledAt ?? new Date(this.clock() + 86_400_000), endsAt: event.endsAt ?? null, address: event.address ?? "123 Ocean Ave", diff --git a/services/api/test/integration/guest-rsvp-security-pg.test.ts b/services/api/test/integration/guest-rsvp-security-pg.test.ts new file mode 100644 index 00000000..35e3e67c --- /dev/null +++ b/services/api/test/integration/guest-rsvp-security-pg.test.ts @@ -0,0 +1,36 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { makeDrizzleGuestRsvpRepository } from "../../src/services/guest-rsvp-repository.drizzle.js" +import type { GuestRsvpRepository } from "../../src/services/guest-rsvp-service.js" + +const pg = await withPg() + +describe.skipIf(!pg)("guest rsvp event visibility (integration)", () => { + let h: PgHarness + let repo: GuestRsvpRepository + + beforeAll(() => { + h = pg as PgHarness + repo = makeDrizzleGuestRsvpRepository(h.sql) + }) + + afterAll(async () => { + await h.teardown() + }) + + it("reads the event's visibility and still returns a private event to the notice paths", async () => { + const [host] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name) VALUES ('Host') RETURNING id + ` + const cleanupId = await seedCleanup(h.sql, { organizerUserId: host!.id }) + + await expect(repo.findEvent(cleanupId)).resolves.toMatchObject({ visibility: "public" }) + + await h.sql`UPDATE cleanups SET visibility = 'private' WHERE id = ${cleanupId}` + await expect(repo.findEvent(cleanupId)).resolves.toMatchObject({ + id: cleanupId, + visibility: "private", + }) + }) +}) diff --git a/services/api/test/unit/claim-routes-security.test.ts b/services/api/test/unit/claim-routes-security.test.ts new file mode 100644 index 00000000..3f74b281 --- /dev/null +++ b/services/api/test/unit/claim-routes-security.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it } from "vitest" +import type { FastifyInstance } from "fastify" +import { buildServer } from "../../src/server.js" +import { loadEnv } from "../../src/env.js" +import type { ClaimService } from "../../src/services/claim-service.js" + +const WEB_ORIGIN = "https://civfix.org" +const REPORT_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +const COOKIE_TOKEN = "cookie-anon-token" +const BODY_TOKEN = "body-anon-token" + +let app: FastifyInstance | undefined + +afterEach(async () => { + await app?.close() + app = undefined +}) + +async function serverWithNudges(): Promise<{ app: FastifyInstance; nudged: string[] }> { + const nudged: string[] = [] + const service: ClaimService = { + claimNudge: (anonToken) => { + nudged.push(anonToken) + return Promise.resolve({ reportId: REPORT_ID, claimCode: "fresh-code" }) + }, + claimReport: () => Promise.reject(new Error("unused")), + } + app = await buildServer({ + env: loadEnv({ NODE_ENV: "test", WEB_ORIGINS: WEB_ORIGIN }), + claimOverride: { service }, + }) + return { app, nudged } +} + +function nudge(target: FastifyInstance, headers: Record, payload?: object) { + return target.inject({ + method: "POST", + url: "/v1/claim/nudge", + headers: { cookie: `civfix_anon=${COOKIE_TOKEN}`, ...headers }, + ...(payload !== undefined ? { payload } : {}), + }) +} + +describe("POST /claim/nudge origin guard on the cookie path", () => { + it("refuses a cookie-borne nudge from an origin outside the web allowlist", async () => { + const h = await serverWithNudges() + + const res = await nudge(h.app, { origin: "https://evil.civfix.org" }) + + expect(res.statusCode).toBe(403) + expect(h.nudged).toEqual([]) + }) + + it("refuses a cookie-borne nudge from an opaque origin", async () => { + const h = await serverWithNudges() + + const res = await nudge(h.app, { origin: "null" }, {}) + + expect(res.statusCode).toBe(403) + expect(h.nudged).toEqual([]) + }) + + it("serves the first-party web app's cookie nudge", async () => { + const h = await serverWithNudges() + + const res = await nudge(h.app, { origin: WEB_ORIGIN }, {}) + + expect(res.statusCode).toBe(200) + expect(res.json()).toMatchObject({ reportId: REPORT_ID }) + expect(h.nudged).toEqual([COOKIE_TOKEN]) + }) + + it("serves a cookie nudge with no Origin, which no browser sends on a cross-site POST", async () => { + const h = await serverWithNudges() + + const res = await nudge(h.app, {}, {}) + + expect(res.statusCode).toBe(200) + expect(h.nudged).toEqual([COOKIE_TOKEN]) + }) + + it("leaves the body-token path untouched whatever the origin", async () => { + const h = await serverWithNudges() + + const res = await nudge(h.app, { origin: "https://evil.example" }, { anonToken: BODY_TOKEN }) + + expect(res.statusCode).toBe(200) + expect(h.nudged).toEqual([BODY_TOKEN]) + }) +}) diff --git a/services/api/test/unit/cleanup-remove-member-waitlist.test.ts b/services/api/test/unit/cleanup-remove-member-waitlist.test.ts new file mode 100644 index 00000000..bf58f855 --- /dev/null +++ b/services/api/test/unit/cleanup-remove-member-waitlist.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest" +import type { Jobs } from "@civfix/shared/interfaces" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import type { Sql } from "../../src/db/client.js" +import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" +import { makeCleanupService } from "../../src/services/cleanup-service.js" +import { WAITLIST_PROMOTE_JOB } from "../../src/services/host/registration-queues.js" +import { InMemoryCleanupRepository } from "../helpers/cleanups.js" +import { makeFakeSql } from "../helpers/fake-sql.js" +import { TEST_TICKET_SIGNER } from "../helpers/ticket-signer.js" + +const CLEANUP = "aaaaaaaa-0000-4000-8000-000000000001" +const ORGANIZER = "11111111-1111-4111-8111-111111111111" +const MEMBER = "33333333-3333-4333-8333-333333333333" +const HELD_TYPE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" +const NOW = new Date("2026-09-01T12:00:00.000Z") + +describe("removing an attendee bans them off the waitlist too", () => { + it("cancels their waiting and offered entries in the removal transaction", async () => { + const fake = makeFakeSql([ + { match: /FROM cleanups WHERE id = /, rows: [{ status: "upcoming", now: NOW }] }, + { match: /DELETE FROM cleanup_members/, rows: [{ user_id: MEMBER }] }, + { + match: /UPDATE cleanup_waitlist/, + rows: [ + { id: "w1", released_ticket_type_id: HELD_TYPE }, + { id: "w2", released_ticket_type_id: null }, + ], + }, + { match: /AS count/, rows: [{ count: 3 }] }, + ]) + let beganAt = -1 + let endedAt = -1 + const begin = fake.sql.begin + fake.sql.begin = async (cb) => { + beganAt = fake.statements.length + const result = await begin(cb) + endedAt = fake.statements.length + return result + } + + const outcome = await makeDrizzleCleanupRepository(fake.sql as unknown as Sql).removeMember( + CLEANUP, + MEMBER, + ORGANIZER, + ) + + expect(outcome).toEqual({ + kind: "removed", + going: 3, + releasedWaitlistTicketTypeIds: [HELD_TYPE], + }) + const ban = fake.statements.findIndex((s) => /INSERT INTO cleanup_bans/.test(s.sql)) + const waitlist = fake.statements.findIndex((s) => /UPDATE cleanup_waitlist/.test(s.sql)) + expect(ban).toBeGreaterThanOrEqual(0) + expect(waitlist).toBeGreaterThan(ban) + expect(beganAt).toBe(0) + expect(endedAt).toBe(fake.statements.length) + const cancel = fake.statements[waitlist]! + expect(cancel.sql.replace(/\s+/g, " ")).toContain("status IN ('waiting', 'offered')") + expect(cancel.values).toEqual(expect.arrayContaining([CLEANUP, MEMBER])) + }) + + it("leaves the waitlist alone when the person was not an attendee", async () => { + const fake = makeFakeSql([ + { match: /FROM cleanups WHERE id = /, rows: [{ status: "upcoming", now: NOW }] }, + { match: /AS count/, rows: [{ count: 1 }] }, + ]) + + const outcome = await makeDrizzleCleanupRepository(fake.sql as unknown as Sql).removeMember( + CLEANUP, + MEMBER, + ORGANIZER, + ) + + expect(outcome).toEqual({ kind: "not_member", going: 1 }) + expect(fake.statements.some((s) => /UPDATE cleanup_waitlist/.test(s.sql))).toBe(false) + }) + + it("offers the seats a removed attendee was holding to the next person", async () => { + const repo = new InMemoryCleanupRepository() + repo.seedCleanup({ + id: CLEANUP, + organizerUserId: ORGANIZER, + scheduledAt: new Date(Date.now() + 7 * 86_400_000), + withDefaultSlot: false, + }) + repo.seedMember(CLEANUP, MEMBER, "member") + const remove = repo.removeMember.bind(repo) + repo.removeMember = async (...args) => { + const outcome = await remove(...args) + return outcome.kind === "removed" + ? { ...outcome, releasedWaitlistTicketTypeIds: [HELD_TYPE] } + : outcome + } + const enqueued: { name: string; payload: unknown }[] = [] + const jobs = { + enqueue: (name: string, payload: unknown) => { + enqueued.push({ name, payload }) + return Promise.resolve("job") + }, + } as unknown as Jobs + const service = makeCleanupService({ + tickets: TEST_TICKET_SIGNER, + repo, + counters: new InMemoryCounterStore(), + jobs, + }) + + await service.removeMember(CLEANUP, ORGANIZER, MEMBER) + + expect(enqueued).toContainEqual({ + name: WAITLIST_PROMOTE_JOB, + payload: { ticketTypeId: HELD_TYPE }, + }) + }) +}) diff --git a/services/api/test/unit/cleanup-service-security.test.ts b/services/api/test/unit/cleanup-service-security.test.ts new file mode 100644 index 00000000..e882a2fe --- /dev/null +++ b/services/api/test/unit/cleanup-service-security.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest" +import type { CreateCleanupRequest } from "@civfix/shared" +import { TEST_TICKET_SIGNER } from "../helpers/ticket-signer.js" +import { InMemoryCleanupRepository } from "../helpers/cleanups.js" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { + makeCleanupService, + RESOURCE_REQUEST_PER_HOST_PER_DAY, + RESOURCE_REQUEST_PER_JURISDICTION_PER_HOUR, + type CleanupService, +} from "../../src/services/cleanup-service.js" + +const GEOID = "0644000" +const GREEDY_HOST = "11111111-1111-1111-1111-111111111111" +const NEIGHBOR_HOST = "22222222-2222-2222-2222-222222222222" + +type OutboundMail = NonNullable[0]["outboundMail"]> + +function eventInput(organizationId: string): CreateCleanupRequest { + return { + title: "Park Cleanup", + type: "site", + eventKind: "cleanup", + lat: 34.0, + lng: -118.49, + scheduledAt: new Date(Date.now() + 86_400_000).toISOString(), + organizationId, + slots: [{ title: "Volunteers" }], + } +} + +function harness() { + const repo = new InMemoryCleanupRepository() + const organization = repo.seedOrganization({ name: "Ballona Creek Trust" }) + for (const [id, handle] of [ + [GREEDY_HOST, "greedy"], + [NEIGHBOR_HOST, "neighbor"], + ] as const) { + repo.seedUser({ id, displayName: handle, handle }) + repo.seedOrgMember(organization.id, id, "member") + } + repo.jurisdictionContacts.set(GEOID, { contact: "events@lacity.gov", name: "City of LA" }) + + const sent: string[] = [] + const outboundMail = { + sendEventToJurisdiction: (input: { cleanupId: string }) => { + sent.push(input.cleanupId) + return Promise.resolve({ thread: { id: "thread-1" }, messageId: "" }) + }, + } as unknown as OutboundMail + + const svc = makeCleanupService({ + tickets: TEST_TICKET_SIGNER, + repo, + counters: new InMemoryCounterStore(), + outboundMail, + }) + const creator = makeCleanupService({ + tickets: TEST_TICKET_SIGNER, + repo, + counters: { incr: () => Promise.resolve(1), incrBy: () => Promise.resolve(1) }, + }) + + async function eventHostedBy(hostId: string): Promise { + const dto = await creator.createCleanup(eventInput(organization.id), hostId) + const stored = repo.cleanups.get(dto.id) + if (stored) stored.jurisdictionGeoid = GEOID + return dto.id + } + + return { svc: svc as CleanupService, sent, eventHostedBy } +} + +describe("resource request budgets", () => { + it("a host already over its daily cap does not spend the jurisdiction's hourly budget", async () => { + const { svc, sent, eventHostedBy } = harness() + const greedyEvent = await eventHostedBy(GREEDY_HOST) + + for (let i = 0; i < RESOURCE_REQUEST_PER_HOST_PER_DAY; i += 1) { + await svc.requestResources({ + cleanupId: greedyEvent, + message: "Need bags.", + actorId: GREEDY_HOST, + }) + } + for (let i = 0; i < RESOURCE_REQUEST_PER_JURISDICTION_PER_HOUR; i += 1) { + await expect( + svc.requestResources({ cleanupId: greedyEvent, message: "Again.", actorId: GREEDY_HOST }), + ).rejects.toMatchObject({ code: "RATE_LIMITED" }) + } + expect(sent).toHaveLength(RESOURCE_REQUEST_PER_HOST_PER_DAY) + + const neighborEvent = await eventHostedBy(NEIGHBOR_HOST) + await expect( + svc.requestResources({ + cleanupId: neighborEvent, + message: "Need a dumpster.", + actorId: NEIGHBOR_HOST, + }), + ).resolves.toEqual({ ok: true }) + expect(sent).toHaveLength(RESOURCE_REQUEST_PER_HOST_PER_DAY + 1) + }) +}) diff --git a/services/api/test/unit/guest-rsvp-security.test.ts b/services/api/test/unit/guest-rsvp-security.test.ts new file mode 100644 index 00000000..3d176eeb --- /dev/null +++ b/services/api/test/unit/guest-rsvp-security.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import { FakeAbuseChecks, FakeMailer, FakeSmsSender } from "@civfix/shared/fakes" +import type { GuestRsvpRequestRequest, GuestRsvpVerifyRequest } from "@civfix/shared" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { InMemoryGuestRsvpRepository } from "../helpers/guest-rsvp.js" +import { + makeGuestRsvpService, + type GuestRsvpService, +} from "../../src/services/guest-rsvp-service.js" + +const EVENT_ID = "11111111-1111-1111-1111-111111111111" +const CODE = "424242" +const ctx = { ip: "203.0.113.10" } + +interface Harness { + service: GuestRsvpService + repo: InMemoryGuestRsvpRepository + mailer: FakeMailer +} + +function build(): Harness { + const clock = Date.parse("2026-08-25T12:00:00.000Z") + const now = (): number => clock + const repo = new InMemoryGuestRsvpRepository({ now }) + repo.seedEvent({ id: EVENT_ID, title: "Quiet private cleanup" }) + const mailer = new FakeMailer() + const service = makeGuestRsvpService({ + repo, + mailer, + smsSender: new FakeSmsSender(), + abuseChecks: new FakeAbuseChecks(), + cache: new InMemoryCacheClient(now), + counters: new InMemoryCounterStore(now), + requireGuestContact: () => Promise.resolve(), + smsGuestEnabled: false, + smsDailyCap: 50, + manageLinkBase: "https://civfix.org", + now, + newCode: () => CODE, + newToken: () => `manage-token-${randomUUID()}`, + }) + return { service, repo, mailer } +} + +function emailRequest(id: string): GuestRsvpRequestRequest { + return { + id, + name: "Ada Lovelace", + channel: "email", + email: "ada@example.org", + turnstileToken: "ok", + } as GuestRsvpRequestRequest +} + +function emailVerify(id: string): GuestRsvpVerifyRequest { + return { id, channel: "email", email: "ada@example.org", code: CODE } as GuestRsvpVerifyRequest +} + +async function rejectionOf(promise: Promise): Promise<{ code: string; message: string }> { + try { + await promise + } catch (err) { + const { code, message } = err as { code: string; message: string } + return { code, message } + } + throw new Error("expected the call to reject") +} + +describe("guest rsvp on a private event", () => { + let h: Harness + beforeEach(() => { + h = build() + }) + + it("answers a code request exactly like an unknown event and sends nothing", async () => { + const unknown = await rejectionOf(h.service.requestCode(emailRequest(randomUUID()), ctx)) + h.repo.seedEvent({ id: EVENT_ID, visibility: "private" }) + + const hidden = await rejectionOf(h.service.requestCode(emailRequest(EVENT_ID), ctx)) + + expect(unknown.code).toBe("NOT_FOUND") + expect(hidden).toEqual(unknown) + expect(h.mailer.sent).toHaveLength(0) + expect(h.repo.otps).toHaveLength(0) + }) + + it("does not reveal that a private event was cancelled or has ended", async () => { + const unknown = await rejectionOf(h.service.requestCode(emailRequest(randomUUID()), ctx)) + + h.repo.seedEvent({ id: EVENT_ID, visibility: "private", status: "cancelled" }) + await expect(rejectionOf(h.service.requestCode(emailRequest(EVENT_ID), ctx))).resolves.toEqual( + unknown, + ) + + h.repo.seedEvent({ + id: EVENT_ID, + visibility: "private", + scheduledAt: new Date(Date.parse("2026-08-24T09:00:00.000Z")), + endsAt: new Date(Date.parse("2026-08-24T12:00:00.000Z")), + }) + await expect(rejectionOf(h.service.verifyCode(emailVerify(EVENT_ID), ctx))).resolves.toEqual( + unknown, + ) + }) + + it("refuses to verify a code once the event has gone private since the request", async () => { + await h.service.requestCode(emailRequest(EVENT_ID), ctx) + h.repo.seedEvent({ id: EVENT_ID, visibility: "private" }) + + const unknown = await rejectionOf(h.service.verifyCode(emailVerify(randomUUID()), ctx)) + const hidden = await rejectionOf(h.service.verifyCode(emailVerify(EVENT_ID), ctx)) + + expect(hidden).toEqual(unknown) + expect(h.repo.guests).toHaveLength(0) + }) + + it("still lets an existing guest cancel after the event went private", async () => { + await h.service.requestCode(emailRequest(EVENT_ID), ctx) + const { manageToken } = await h.service.verifyCode(emailVerify(EVENT_ID), ctx) + h.repo.seedEvent({ id: EVENT_ID, visibility: "private" }) + + await expect(h.service.cancelRsvp(manageToken)).resolves.toEqual({ ok: true }) + expect(h.repo.guests[0]?.cancelledAt).not.toBeNull() + }) + + it("keeps unlisted events joinable by link", async () => { + h.repo.seedEvent({ id: EVENT_ID, visibility: "unlisted" }) + + await expect(h.service.requestCode(emailRequest(EVENT_ID), ctx)).resolves.toMatchObject({ + sent: true, + }) + }) +}) diff --git a/services/api/test/unit/post-service-security.test.ts b/services/api/test/unit/post-service-security.test.ts new file mode 100644 index 00000000..c8bb9050 --- /dev/null +++ b/services/api/test/unit/post-service-security.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest" +import type { Sql } from "../../src/db/client.js" +import { makePostService } from "../../src/services/post-service.js" +import type { PostBrief, PostRepository } from "../../src/services/post-repository.drizzle.js" + +const AUTHOR = "author" +const STRANGER = "stranger" +const POST_ID = "p1" +const UNKNOWN_ID = "p-unknown" + +const unusedSql = (() => { + throw new Error("sql must not be called in these unit paths") +}) as unknown as Sql + +function brief(over: Partial = {}): PostBrief { + return { + id: POST_ID, + authorId: AUTHOR, + kind: "post", + replyToId: null, + repostOfId: null, + deletedAt: null, + visibility: "public", + ...over, + } +} + +function harness(post: PostBrief, blockedPairs: Array<[string, string]> = []) { + const deleted: string[] = [] + const repo = { + getPostBrief: (id: string) => Promise.resolve(id === post.id ? post : null), + softDeletePost: (id: string) => { + deleted.push(id) + return Promise.resolve() + }, + } as unknown as PostRepository + const svc = makePostService({ + repo, + sql: unusedSql, + isBlockedEitherWay: (a, b) => + Promise.resolve(blockedPairs.some(([x, y]) => (x === a && y === b) || (x === b && y === a))), + }) + return { svc, deleted } +} + +async function rejectionOf(promise: Promise): Promise<{ code: string; message: string }> { + try { + await promise + } catch (err) { + const { code, message } = err as { code: string; message: string } + return { code, message } + } + throw new Error("expected the call to reject") +} + +describe("deletePost does not reveal posts the caller cannot read", () => { + it("answers a hidden post of someone else exactly like an unknown id", async () => { + const { svc, deleted } = harness(brief({ visibility: "hidden" })) + + const unknown = await rejectionOf(svc.deletePost(UNKNOWN_ID, STRANGER)) + const hidden = await rejectionOf(svc.deletePost(POST_ID, STRANGER)) + + expect(unknown.code).toBe("NOT_FOUND") + expect(hidden).toEqual(unknown) + expect(deleted).toEqual([]) + }) + + it("answers a post whose author blocked the caller exactly like an unknown id", async () => { + const { svc, deleted } = harness(brief(), [[AUTHOR, STRANGER]]) + + const unknown = await rejectionOf(svc.deletePost(UNKNOWN_ID, STRANGER)) + const blocked = await rejectionOf(svc.deletePost(POST_ID, STRANGER)) + + expect(blocked).toEqual(unknown) + expect(deleted).toEqual([]) + }) + + it("keeps 403 for a visible post owned by someone else", async () => { + const { svc, deleted } = harness(brief()) + + await expect(svc.deletePost(POST_ID, STRANGER)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "You can only delete your own post.", + }) + expect(deleted).toEqual([]) + }) + + it("still lets the author delete their own hidden post", async () => { + const { svc, deleted } = harness(brief({ visibility: "hidden" }), [[AUTHOR, STRANGER]]) + + await expect(svc.deletePost(POST_ID, AUTHOR)).resolves.toEqual({ ok: true }) + expect(deleted).toEqual([POST_ID]) + }) +}) diff --git a/services/api/test/unit/volunteer-hours-entries.test.ts b/services/api/test/unit/volunteer-hours-entries.test.ts index e019ec79..ac30eee1 100644 --- a/services/api/test/unit/volunteer-hours-entries.test.ts +++ b/services/api/test/unit/volunteer-hours-entries.test.ts @@ -29,6 +29,7 @@ function makeRepo(opts?: { frozenClock?: boolean }): InMemoryVolunteerHoursRepos const doneEvent = (title: string): CleanupHoursView => ({ organizerUserId: HOST, status: "done", + visibility: "public", jurisdictionGeoid: GEOID_A, title, scheduledAt: new Date("2026-07-04T08:00:00.000Z"), diff --git a/services/api/test/unit/volunteer-hours-service-security.test.ts b/services/api/test/unit/volunteer-hours-service-security.test.ts new file mode 100644 index 00000000..99d0c7b4 --- /dev/null +++ b/services/api/test/unit/volunteer-hours-service-security.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest" +import type { EventVisibility } from "@civfix/shared" +import { + makeVolunteerHoursService, + type CleanupHoursView, + type VolunteerHoursService, +} from "../../src/services/volunteer-hours-service.js" +import { InMemoryVolunteerHoursRepository } from "../../src/services/volunteer-hours-repository.memory.js" + +const HOST = "11111111-1111-1111-1111-111111111111" +const MEMBER = "22222222-2222-2222-2222-222222222222" +const STRANGER = "33333333-3333-3333-3333-333333333333" +const CLEANUP = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + +function endedEvent(visibility: EventVisibility): CleanupHoursView { + return { + organizerUserId: HOST, + status: "done", + visibility, + jurisdictionGeoid: "0644000", + title: "Quiet sweep", + scheduledAt: new Date("2026-07-04T08:00:00.000Z"), + endsAt: new Date("2026-07-04T12:00:00.000Z"), + completedAt: new Date("2026-07-04T12:00:00.000Z"), + timezone: null, + } +} + +function serviceFor(view: CleanupHoursView | null): VolunteerHoursService { + return makeVolunteerHoursService({ + repo: new InMemoryVolunteerHoursRepository(), + cleanups: { + load: () => Promise.resolve(view), + listMemberIds: () => Promise.resolve([HOST, MEMBER]), + roleOf: (_cleanupId: string, userId: string) => { + if (userId === HOST) return Promise.resolve("organizer" as const) + if (userId === MEMBER) return Promise.resolve("member" as const) + return Promise.resolve(null) + }, + }, + }) +} + +async function rejectionOf(promise: Promise): Promise<{ code: string; message: string }> { + try { + await promise + } catch (err) { + const { code, message } = err as { code: string; message: string } + return { code, message } + } + throw new Error("expected the call to reject") +} + +const logBy = (actorId: string) => ({ + cleanupId: CLEANUP, + actorId, + entries: [{ userId: MEMBER, hours: 2 }], +}) + +describe("event hours on a private event", () => { + it("reads to a stranger exactly like an unknown event", async () => { + const unknown = await rejectionOf(serviceFor(null).getEventHours(CLEANUP, STRANGER)) + + const hidden = await rejectionOf( + serviceFor(endedEvent("private")).getEventHours(CLEANUP, STRANGER), + ) + + expect(unknown.code).toBe("NOT_FOUND") + expect(hidden).toEqual(unknown) + }) + + it("answers a stranger's hours log exactly like an unknown event", async () => { + const unknown = await rejectionOf(serviceFor(null).logEventHours(logBy(STRANGER))) + + const hidden = await rejectionOf( + serviceFor(endedEvent("private")).logEventHours(logBy(STRANGER)), + ) + + expect(hidden).toEqual(unknown) + }) + + it("keeps serving members and hosts of the private event", async () => { + const service = serviceFor(endedEvent("private")) + + await expect(service.getEventHours(CLEANUP, MEMBER)).resolves.toMatchObject({ scope: "self" }) + await expect(service.getEventHours(CLEANUP, HOST)).resolves.toMatchObject({ scope: "all" }) + await expect(service.logEventHours(logBy(MEMBER))).rejects.toMatchObject({ code: "FORBIDDEN" }) + await expect(service.logEventHours(logBy(HOST))).resolves.toMatchObject({ credited: 1 }) + }) + + it("leaves public and unlisted events answering a stranger as before", async () => { + for (const visibility of ["public", "unlisted"] as const) { + const service = serviceFor(endedEvent(visibility)) + await expect(service.getEventHours(CLEANUP, STRANGER)).resolves.toEqual({ + scope: "self", + entries: [], + }) + await expect(service.logEventHours(logBy(STRANGER))).rejects.toMatchObject({ + code: "FORBIDDEN", + }) + } + }) +}) diff --git a/services/api/test/unit/volunteer-hours-service.test.ts b/services/api/test/unit/volunteer-hours-service.test.ts index 2e2a6ec4..35dd8206 100644 --- a/services/api/test/unit/volunteer-hours-service.test.ts +++ b/services/api/test/unit/volunteer-hours-service.test.ts @@ -125,6 +125,7 @@ function eventOfLength(hours: number): CleanupHoursView { return { organizerUserId: HOST, status: "done", + visibility: "public", jurisdictionGeoid: GEOID_A, title: "Ocean Beach sweep", scheduledAt: SCHEDULED_AT, @@ -175,6 +176,7 @@ describe("volunteer hours: logEventHours (service gating + crediting)", () => { const doneEvent: CleanupHoursView = { organizerUserId: HOST, status: "done", + visibility: "public", jurisdictionGeoid: GEOID_A, title: "Ocean Beach sweep", scheduledAt: SCHEDULED_AT, @@ -405,6 +407,7 @@ describe("volunteer hours: logEventHours (service gating + crediting)", () => { view: { organizerUserId: HOST, status: "upcoming", + visibility: "public", jurisdictionGeoid: GEOID_A, title: "Ocean Beach sweep", scheduledAt: FUTURE_SCHEDULED_AT, @@ -825,6 +828,7 @@ describe("volunteer hours: hours_logged notifications", () => { const doneEvent: CleanupHoursView = { organizerUserId: HOST, status: "done", + visibility: "public", jurisdictionGeoid: GEOID_A, title: "Ocean Beach sweep", scheduledAt: SCHEDULED_AT, From ea1546ca1e1ee6964b6d8ff0b078a6a1612e0f4b Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:25:06 +0000 Subject: [PATCH 19/45] host registration: hidden ticket types are host-only, bans reach the waitlist, guests cannot self-register on private events, roster search escapes wildcards --- .../api/src/routes/host/questions.routes.ts | 8 +- .../api/src/services/host/question-service.ts | 23 +- .../host/registration-repository.drizzle.ts | 321 ++++++---- .../host/registration-repository.memory.ts | 46 +- .../host/registration-repository.types.ts | 8 +- .../src/services/host/registration-service.ts | 9 +- .../api/src/services/host/waitlist-service.ts | 2 + ...egistration-repository-security-pg.test.ts | 233 +++++++ .../registration-repository-security.test.ts | 583 ++++++++++++++++++ 9 files changed, 1090 insertions(+), 143 deletions(-) create mode 100644 services/api/test/integration/registration-repository-security-pg.test.ts create mode 100644 services/api/test/unit/host/registration-repository-security.test.ts diff --git a/services/api/src/routes/host/questions.routes.ts b/services/api/src/routes/host/questions.routes.ts index 35862e5e..90fb3cb3 100644 --- a/services/api/src/routes/host/questions.routes.ts +++ b/services/api/src/routes/host/questions.routes.ts @@ -32,9 +32,13 @@ export function registerHostQuestionRoutes( async (request, reply) => { const { id } = parse(CleanupIdParamsSchema, request.params) const auth = await resolveAuthContext(request) - await ctx.guards().requireVisible(id, auth.userId ?? null) + const userId = auth.userId ?? null + await ctx.guards().requireVisible(id, userId) const query = parse(ListEventQuestionsRequestSchema, queryWith(request, { id })) - const payload: ListEventQuestionsResponse = await ctx.services().questions.list(query) + const canManage = await ctx.guards().canManage(id, userId, "manage_tickets") + const payload: ListEventQuestionsResponse = await ctx + .services() + .questions.list(query, { canManage }) reply.status(200).send(payload) }, ) diff --git a/services/api/src/services/host/question-service.ts b/services/api/src/services/host/question-service.ts index ae25b243..aeb0321a 100644 --- a/services/api/src/services/host/question-service.ts +++ b/services/api/src/services/host/question-service.ts @@ -17,8 +17,15 @@ export interface QuestionServiceDeps { now?: () => Date } +export interface QuestionViewer { + canManage: boolean +} + export interface QuestionService { - list(query: ListEventQuestionsRequest): Promise + list( + query: ListEventQuestionsRequest, + viewer: QuestionViewer, + ): Promise save(input: SaveEventQuestionsRequest): Promise } @@ -26,11 +33,21 @@ export function makeQuestionService(deps: QuestionServiceDeps): QuestionService const now = deps.now ?? (() => new Date()) return { - async list(query): Promise { + async list(query, viewer): Promise { const records = await deps.repo.listQuestions(query.id, { ...(query.ticketTypeId !== undefined ? { ticketTypeId: query.ticketTypeId } : {}), }) - return { items: records.map(toEventQuestionDTO) } + if (viewer.canManage || records.every((record) => record.ticketTypeId === null)) { + return { items: records.map(toEventQuestionDTO) } + } + // A hidden type is host-assigned only; its questions would publish its id to anyone. + const types = await deps.repo.listTicketTypes(query.id) + const hidden = new Set(types.filter((t) => t.visibility === "hidden").map((t) => t.id)) + return { + items: records + .filter((record) => record.ticketTypeId === null || !hidden.has(record.ticketTypeId)) + .map(toEventQuestionDTO), + } }, async save(input): Promise { diff --git a/services/api/src/services/host/registration-repository.drizzle.ts b/services/api/src/services/host/registration-repository.drizzle.ts index 49bd007f..fbe881d7 100644 --- a/services/api/src/services/host/registration-repository.drizzle.ts +++ b/services/api/src/services/host/registration-repository.drizzle.ts @@ -1,5 +1,5 @@ import { AppError } from "@civfix/shared" -import type { CheckinMethod, RegistrationRosterSort } from "@civfix/shared" +import type { CheckinMethod, EventVisibility, RegistrationRosterSort } from "@civfix/shared" import type { Queryable, Sql, TransactionSql } from "../../db/client.js" import { constantTimeStringEqual } from "../../auth/crypto.js" import { @@ -12,6 +12,7 @@ import { import { eventWindowOfRow, hasEventEnded } from "../cleanup-rules.js" import { cleanupStatusExpr } from "../cleanup-sql.js" import { mediaBoundElsewhere, mediaBoundToCleanup } from "../media-bindings.js" +import { likeContains } from "../admin/like.js" import { MEDIA_CLAIM_WINDOW_SEC } from "./event-media.js" import { deterministicUuid } from "../deterministic-uuid.js" import { @@ -45,6 +46,7 @@ import { import type { AnswerRecord, CancelRegistrationOutcome, + RemoveRegistrationOutcome, CheckinCountersRecord, CheckinResultRecord, ClaimWaitlistOutcome, @@ -99,6 +101,70 @@ const ROSTER_SORTS = Object.freeze({ tag`COALESCE(ci.first_at, 'epoch'::timestamptz) DESC, r.registered_at DESC, r.id DESC`, }) satisfies Readonly unknown>> +async function isBannedIn(tag: Queryable, cleanupId: string, userId: string): Promise { + const banned = await tag<{ one: number }[]>` + SELECT 1 AS one FROM cleanup_bans + WHERE cleanup_id = ${cleanupId} AND user_id = ${userId} + LIMIT 1 + ` + return banned.length > 0 +} + +/** Backstop for a waiting row whose user was banned by a path that did not cancel it. */ +function waitlistEntryNotBanned(tag: Queryable) { + return tag`NOT EXISTS ( + SELECT 1 FROM cleanup_bans b + WHERE b.cleanup_id = w.cleanup_id AND b.user_id = w.user_id + )` +} + +export async function cancelWaitlistEntriesIn( + tag: Queryable, + args: { + cleanupId: string + ticketTypeId: string | null + subject: RegistrationSubject + now: Date + }, +): Promise<{ left: number; releasedTicketTypeIds: string[] }> { + const typeFilter = + args.ticketTypeId === null ? tag`` : tag`AND ticket_type_id = ${args.ticketTypeId}` + const rows = await tag<{ id: string; released_ticket_type_id: string | null }[]>` + WITH left_entries AS ( + UPDATE cleanup_waitlist + SET status = 'cancelled' + WHERE cleanup_id = ${args.cleanupId} + AND status IN ('waiting', 'offered') + ${typeFilter} + AND ${ + args.subject.kind === "user" + ? tag`user_id = ${args.subject.userId}` + : tag`guest_id = ${args.subject.guestId}` + } + RETURNING id, ticket_type_id, party_size, offered_at + ), released AS ( + UPDATE cleanup_ticket_types t + SET reserved_seats = GREATEST(t.reserved_seats - e.party_size, 0), + updated_at = ${args.now} + FROM left_entries e + WHERE t.id = e.ticket_type_id AND e.offered_at IS NOT NULL + RETURNING t.id + ) + SELECT id, + CASE WHEN offered_at IS NULL THEN NULL ELSE ticket_type_id END + AS released_ticket_type_id + FROM left_entries + ` + return { + left: rows.length, + releasedTicketTypeIds: [ + ...new Set( + rows.map((row) => row.released_ticket_type_id).filter((id): id is string => id !== null), + ), + ], + } +} + class RegistrationRefusal extends Error { readonly outcome: RegisterTxOutcome @@ -162,6 +228,16 @@ export function registrationIdempotencyOwner(subject: RegistrationSubject): stri return subjectOwner(subject) } +// A guest has no standing that could open a private event, so a guest's own sign-up answers exactly as an +// unknown event does. A host seating a walk-up, and a guest already waiting when the event went private, +// act on standing the event granted earlier and keep working. +export function guestSelfRegistrationOnPrivateEvent( + args: Pick, + visibility: EventVisibility, +): boolean { + return args.subject.kind === "guest" && args.source === "self" && visibility === "private" +} + function withinSalesWindow(now: Date, opensAt: Date | null, closesAt: Date | null): boolean { if (opensAt !== null && now < opensAt) return false if (closesAt !== null && now >= closesAt) return false @@ -608,28 +684,30 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio const locked = await tx< { status: EventRegistrationContext["status"] + visibility: EventRegistrationContext["visibility"] registration_opens_at: Date | null registration_closes_at: Date | null capacity: number | null }[] >` - SELECT status, registration_opens_at, registration_closes_at, capacity + SELECT status, visibility, registration_opens_at, registration_closes_at, capacity FROM cleanups WHERE id = ${args.cleanupId} LIMIT 1 FOR SHARE ` const event = locked[0] if (event === undefined) return { kind: "not_found" as const } + if (guestSelfRegistrationOnPrivateEvent(args, event.visibility)) { + return { kind: "not_found" as const } + } if (event.status === "cancelled") return { kind: "closed" as const } if (!withinSalesWindow(args.now, event.registration_opens_at, event.registration_closes_at)) { return { kind: "registration_closed" as const } } - if (args.subject.kind === "user") { - const banned = await tx<{ one: number }[]>` - SELECT 1 AS one FROM cleanup_bans - WHERE cleanup_id = ${args.cleanupId} AND user_id = ${args.subject.userId} - LIMIT 1 - ` - if (banned.length > 0) return { kind: "banned" as const } + if ( + args.subject.kind === "user" && + (await isBannedIn(tx, args.cleanupId, args.subject.userId)) + ) { + return { kind: "banned" as const } } const replay = await findRegisterSnapshot(tx, args) @@ -677,6 +755,10 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio return { kind: "ticket_type_not_found" as const } } + if (ticketType !== null && ticketType.visibility === "hidden" && args.source === "self") { + return { kind: "ticket_type_not_found" as const } + } + if (ticketType !== null) { if (ticketType.visibility === "access_code" && args.waitlistId === null) { if (args.accessCodeHash === null) return { kind: "access_code_required" as const } @@ -866,6 +948,44 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio return { kind: "registered" as const, registration } } + async function cancelRegistrationIn( + tag: Queryable, + args: { cleanupId: string; registrationId: string; actorId: string | null; now: Date }, + ): Promise { + const cancelled = await tag<{ id: string; ticket_type_id: string | null }[]>` + WITH cancelled AS ( + UPDATE cleanup_registrations + SET status = 'cancelled', cancelled_at = ${args.now}, cancelled_by = ${args.actorId} + WHERE id = ${args.registrationId} + AND cleanup_id = ${args.cleanupId} + AND status = 'registered' + RETURNING id, ticket_type_id, party_size + ), seats AS ( + UPDATE cleanup_registration_seats s + SET status = 'cancelled' + FROM cancelled c + WHERE s.registration_id = c.id AND s.status = 'active' + RETURNING s.id + ), released AS ( + UPDATE cleanup_ticket_types t + SET reserved_seats = GREATEST(t.reserved_seats - c.party_size, 0), + updated_at = ${args.now} + FROM cancelled c + WHERE t.id = c.ticket_type_id + RETURNING t.id + ) + SELECT id, ticket_type_id FROM cancelled + ` + const row = cancelled[0] + if (row === undefined) { + const existing = await loadRegistrationById(tag, args.cleanupId, args.registrationId) + return existing === null ? { kind: "not_found" } : { kind: "already_cancelled" } + } + const registration = await loadRegistrationById(tag, args.cleanupId, row.id) + if (registration === null) return { kind: "not_found" } + return { kind: "cancelled", registration, ticketTypeId: row.ticket_type_id } + } + async function runRegisterTx(args: RegisterTxArgs): Promise { try { return await sql.begin((tx) => registerIn(tx, args)) @@ -1364,18 +1484,19 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio const typeFilter = query.ticketTypeId === null ? sql`` : sql`AND r.ticket_type_id = ${query.ticketTypeId}` const slotFilter = query.slotId === null ? sql`` : sql`AND sc.slot_id = ${query.slotId}` - const search = - query.q === null || query.q.length === 0 - ? sql`` - : sql`AND ( - u.display_name ILIKE ${`%${query.q}%`} - OR u.handle ILIKE ${`%${query.q}%`} - OR g.name ILIKE ${`%${query.q}%`} - OR EXISTS ( - SELECT 1 FROM cleanup_registration_seats s - WHERE s.registration_id = r.id AND s.attendee_name ILIKE ${`%${query.q}%`} - ) - )` + const search = (() => { + if (query.q === null || query.q.length === 0) return sql`` + const pattern = likeContains(query.q) + return sql`AND ( + u.display_name ILIKE ${pattern} ESCAPE '\\' + OR u.handle ILIKE ${pattern} ESCAPE '\\' + OR g.name ILIKE ${pattern} ESCAPE '\\' + OR EXISTS ( + SELECT 1 FROM cleanup_registration_seats s + WHERE s.registration_id = r.id AND s.attendee_name ILIKE ${pattern} ESCAPE '\\' + ) + )` + })() const cursorFilter = (() => { if (query.cursor === null) return sql`` @@ -1464,38 +1585,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio actorId: string | null now: Date }): Promise { - const cancelled = await sql<{ id: string; ticket_type_id: string | null }[]>` - WITH cancelled AS ( - UPDATE cleanup_registrations - SET status = 'cancelled', cancelled_at = ${args.now}, cancelled_by = ${args.actorId} - WHERE id = ${args.registrationId} - AND cleanup_id = ${args.cleanupId} - AND status = 'registered' - RETURNING id, ticket_type_id, party_size - ), seats AS ( - UPDATE cleanup_registration_seats s - SET status = 'cancelled' - FROM cancelled c - WHERE s.registration_id = c.id AND s.status = 'active' - RETURNING s.id - ), released AS ( - UPDATE cleanup_ticket_types t - SET reserved_seats = GREATEST(t.reserved_seats - c.party_size, 0), - updated_at = ${args.now} - FROM cancelled c - WHERE t.id = c.ticket_type_id - RETURNING t.id - ) - SELECT id, ticket_type_id FROM cancelled - ` - const row = cancelled[0] - if (row === undefined) { - const existing = await loadRegistrationById(sql, args.cleanupId, args.registrationId) - return existing === null ? { kind: "not_found" } : { kind: "already_cancelled" } - } - const registration = await loadRegistrationById(sql, args.cleanupId, row.id) - if (registration === null) return { kind: "not_found" } - return { kind: "cancelled", registration, ticketTypeId: row.ticket_type_id } + return cancelRegistrationIn(sql, args) }, async removeRegistration(args: { @@ -1504,31 +1594,39 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio actorId: string ban: boolean now: Date - }): Promise { - const outcome = await repo.cancelRegistration({ - cleanupId: args.cleanupId, - registrationId: args.registrationId, - actorId: args.actorId, - now: args.now, - }) - if (outcome.kind === "not_found") return outcome - const registration = - outcome.kind === "cancelled" - ? outcome.registration - : await loadRegistrationById(sql, args.cleanupId, args.registrationId) - const targetUserId = registration?.userId ?? null - if (args.ban && targetUserId !== null) { - await sql` - INSERT INTO cleanup_bans (cleanup_id, user_id, banned_by_user_id) - VALUES (${args.cleanupId}, ${targetUserId}, ${args.actorId}) - ON CONFLICT (cleanup_id, user_id) DO NOTHING - ` - await sql` - DELETE FROM cleanup_members - WHERE cleanup_id = ${args.cleanupId} AND user_id = ${targetUserId} AND role = 'member' + }): Promise { + return sql.begin(async (tx): Promise => { + const target = await tx<{ user_id: string | null }[]>` + SELECT user_id FROM cleanup_registrations + WHERE id = ${args.registrationId} AND cleanup_id = ${args.cleanupId} + LIMIT 1 ` - } - return outcome + if (target.length === 0) return { kind: "not_found", releasedWaitlistTicketTypeIds: [] } + const bannedUserId = args.ban ? (target[0]?.user_id ?? null) : null + let releasedWaitlistTicketTypeIds: string[] = [] + if (bannedUserId !== null) { + await tx` + INSERT INTO cleanup_bans (cleanup_id, user_id, banned_by_user_id) + VALUES (${args.cleanupId}, ${bannedUserId}, ${args.actorId}) + ON CONFLICT (cleanup_id, user_id) DO NOTHING + ` + await tx` + DELETE FROM cleanup_members + WHERE cleanup_id = ${args.cleanupId} AND user_id = ${bannedUserId} AND role = 'member' + ` + // Waitlist rows before the registration: the same cleanup_waitlist -> cleanup_ticket_types + // order the expiry sweep and leaveWaitlist take, so the two cannot deadlock. + const cancelled = await cancelWaitlistEntriesIn(tx, { + cleanupId: args.cleanupId, + ticketTypeId: null, + subject: { kind: "user", userId: bannedUserId }, + now: args.now, + }) + releasedWaitlistTicketTypeIds = cancelled.releasedTicketTypeIds + } + const outcome = await cancelRegistrationIn(tx, args) + return { ...outcome, releasedWaitlistTicketTypeIds } + }) }, async transferRegistration(args: { @@ -1630,6 +1728,12 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio if (hasEventEnded(eventWindowOfRow(event), event.now.getTime())) { return { kind: "ended" as const } } + if ( + args.subject.kind === "user" && + (await isBannedIn(tx, args.cleanupId, args.subject.userId)) + ) { + return { kind: "banned" as const } + } const typeRows = await tx< { @@ -1645,7 +1749,9 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio LIMIT 1 ` const type = typeRows[0] - if (type === undefined) return { kind: "ticket_type_not_found" as const } + if (type === undefined || type.visibility === "hidden") { + return { kind: "ticket_type_not_found" as const } + } if (!type.waitlist_enabled) return { kind: "waitlist_disabled" as const } if (type.visibility === "access_code") { if (args.accessCodeHash === null) return { kind: "access_code_required" as const } @@ -1742,44 +1848,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio subject: RegistrationSubject now: Date }): Promise<{ left: number; releasedTicketTypeIds: string[] }> { - const typeFilter = - args.ticketTypeId === null ? sql`` : sql`AND ticket_type_id = ${args.ticketTypeId}` - const rows = await sql<{ id: string; released_ticket_type_id: string | null }[]>` - WITH left_entries AS ( - UPDATE cleanup_waitlist - SET status = 'cancelled' - WHERE cleanup_id = ${args.cleanupId} - AND status IN ('waiting', 'offered') - ${typeFilter} - AND ${ - args.subject.kind === "user" - ? sql`user_id = ${args.subject.userId}` - : sql`guest_id = ${args.subject.guestId}` - } - RETURNING id, ticket_type_id, party_size, offered_at - ), released AS ( - UPDATE cleanup_ticket_types t - SET reserved_seats = GREATEST(t.reserved_seats - e.party_size, 0), - updated_at = ${args.now} - FROM left_entries e - WHERE t.id = e.ticket_type_id AND e.offered_at IS NOT NULL - RETURNING t.id - ) - SELECT id, - CASE WHEN offered_at IS NULL THEN NULL ELSE ticket_type_id END - AS released_ticket_type_id - FROM left_entries - ` - return { - left: rows.length, - releasedTicketTypeIds: [ - ...new Set( - rows - .map((row) => row.released_ticket_type_id) - .filter((id): id is string => id !== null), - ), - ], - } + return cancelWaitlistEntriesIn(sql, args) }, async listWaitlist(args: { @@ -1821,10 +1890,11 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio party_size: number }[] >` - SELECT id, cleanup_id, user_id, guest_id, party_size - FROM cleanup_waitlist - WHERE ticket_type_id = ${args.ticketTypeId} AND status = 'waiting' - ORDER BY created_at, id + SELECT w.id, w.cleanup_id, w.user_id, w.guest_id, w.party_size + FROM cleanup_waitlist w + WHERE w.ticket_type_id = ${args.ticketTypeId} AND w.status = 'waiting' + AND ${waitlistEntryNotBanned(tx)} + ORDER BY w.created_at, w.id LIMIT 1 FOR UPDATE SKIP LOCKED ` @@ -1874,11 +1944,12 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio party_size: number }[] >` - SELECT id, ticket_type_id, user_id, guest_id, party_size - FROM cleanup_waitlist - WHERE id = ${args.waitlistId} - AND cleanup_id = ${args.cleanupId} - AND status = 'waiting' + SELECT w.id, w.ticket_type_id, w.user_id, w.guest_id, w.party_size + FROM cleanup_waitlist w + WHERE w.id = ${args.waitlistId} + AND w.cleanup_id = ${args.cleanupId} + AND w.status = 'waiting' + AND ${waitlistEntryNotBanned(tx)} LIMIT 1 FOR UPDATE ` diff --git a/services/api/src/services/host/registration-repository.memory.ts b/services/api/src/services/host/registration-repository.memory.ts index a9e387d1..6c1fa341 100644 --- a/services/api/src/services/host/registration-repository.memory.ts +++ b/services/api/src/services/host/registration-repository.memory.ts @@ -12,11 +12,13 @@ import { ARRIVAL_BUCKET_MINUTES, buildCheckinResult, emptyCheckinResult, + guestSelfRegistrationOnPrivateEvent, waitlistEntryAsRegistration, } from "./registration-repository.drizzle.js" import type { AnswerRecord, CancelRegistrationOutcome, + RemoveRegistrationOutcome, CheckinCountersRecord, CheckinResultRecord, ClaimWaitlistOutcome, @@ -505,11 +507,12 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos async registerTx(args: RegisterTxArgs): Promise { const event = this.events.get(args.cleanupId) if (event === undefined) return { kind: "not_found" } + if (guestSelfRegistrationOnPrivateEvent(args, event.visibility)) return { kind: "not_found" } if (event.status === "cancelled") return { kind: "closed" } if (!withinWindow(args.now, event.registrationOpensAt, event.registrationClosesAt)) { return { kind: "registration_closed" } } - if (args.subject.kind === "user" && this.bans.has(`${args.cleanupId}:${args.subject.userId}`)) { + if (args.subject.kind === "user" && this.isBanned(args.cleanupId, args.subject.userId)) { return { kind: "banned" } } @@ -546,6 +549,9 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos } else if (types.length > 1) { return { kind: "ticket_type_not_found" } } + if (type !== null && type.visibility === "hidden" && args.source === "self") { + return { kind: "ticket_type_not_found" } + } const partySize = args.seats.length if (type !== null) { @@ -791,15 +797,24 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos actorId: string ban: boolean now: Date - }): Promise { + }): Promise { + const record = this.registrations.get(args.registrationId) + if (record === undefined || record.cleanupId !== args.cleanupId) { + return { kind: "not_found", releasedWaitlistTicketTypeIds: [] } + } + let releasedWaitlistTicketTypeIds: string[] = [] + if (args.ban && record.userId !== null) { + this.bans.add(`${args.cleanupId}:${record.userId}`) + const cancelled = await this.leaveWaitlist({ + cleanupId: args.cleanupId, + ticketTypeId: null, + subject: { kind: "user", userId: record.userId }, + now: args.now, + }) + releasedWaitlistTicketTypeIds = cancelled.releasedTicketTypeIds + } const outcome = await this.cancelRegistration(args) - if (outcome.kind === "not_found") return outcome - const targetUserId = - outcome.kind === "cancelled" - ? outcome.registration.userId - : (this.registrations.get(args.registrationId)?.userId ?? null) - if (args.ban && targetUserId !== null) this.bans.add(`${args.cleanupId}:${targetUserId}`) - return outcome + return { ...outcome, releasedWaitlistTicketTypeIds } } async transferRegistration(args: { @@ -839,6 +854,10 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos return { kind: "transferred", registration: this.toRecord(record) } } + private isBanned(cleanupId: string, userId: string): boolean { + return this.bans.has(`${cleanupId}:${userId}`) + } + private positionOf(entry: WaitlistRecord): number | null { if (entry.status !== "waiting") return null const ahead = [...this.waitlist.values()].filter( @@ -867,8 +886,11 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos if (event === undefined) return { kind: "not_found" } if (event.status === "cancelled") return { kind: "closed" } if (hasEventEnded(eventWindowOf(event), args.now.getTime())) return { kind: "ended" } + if (args.subject.kind === "user" && this.isBanned(args.cleanupId, args.subject.userId)) { + return { kind: "banned" } + } const type = this.ticketTypes.get(args.ticketTypeId) - if (type === undefined || type.cleanupId !== args.cleanupId) { + if (type === undefined || type.cleanupId !== args.cleanupId || type.visibility === "hidden") { return { kind: "ticket_type_not_found" } } if (!type.waitlistEnabled) return { kind: "waitlist_disabled" } @@ -985,6 +1007,7 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos }): Promise { const candidates = [...this.waitlist.values()] .filter((w) => w.ticketTypeId === args.ticketTypeId && w.status === "waiting") + .filter((w) => w.userId === null || !this.isBanned(w.cleanupId, w.userId)) .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id)) const candidate = candidates[0] if (candidate === undefined) return null @@ -1017,6 +1040,9 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos const candidate = this.waitlist.get(args.waitlistId) if (candidate === undefined || candidate.cleanupId !== args.cleanupId) return null if (candidate.status !== "waiting") return null + if (candidate.userId !== null && this.isBanned(candidate.cleanupId, candidate.userId)) { + return null + } const type = this.ticketTypes.get(candidate.ticketTypeId) if (type === undefined) return null if (type.capacity !== null && type.reservedSeats + candidate.partySize > type.capacity) { diff --git a/services/api/src/services/host/registration-repository.types.ts b/services/api/src/services/host/registration-repository.types.ts index 645f1a26..82b42144 100644 --- a/services/api/src/services/host/registration-repository.types.ts +++ b/services/api/src/services/host/registration-repository.types.ts @@ -271,6 +271,11 @@ export type CancelRegistrationOutcome = | { kind: "already_cancelled" } | { kind: "not_found" } +/** A ban also withdraws the user's waitlist places; these types had offered seats released. */ +export type RemoveRegistrationOutcome = CancelRegistrationOutcome & { + releasedWaitlistTicketTypeIds: string[] +} + export type TransferRegistrationOutcome = | { kind: "transferred"; registration: RegistrationRecord } | { kind: "full" } @@ -304,6 +309,7 @@ export type JoinWaitlistOutcome = | { kind: "access_code_invalid" } | { kind: "waitlist_disabled" } | { kind: "ticket_type_not_found" } + | { kind: "banned" } | { kind: "closed" } | { kind: "ended" } | { kind: "not_found" } @@ -455,7 +461,7 @@ export interface HostRegistrationRepository { actorId: string ban: boolean now: Date - }): Promise + }): Promise transferRegistration(args: { cleanupId: string registrationId: string diff --git a/services/api/src/services/host/registration-service.ts b/services/api/src/services/host/registration-service.ts index 921058c5..98dab70d 100644 --- a/services/api/src/services/host/registration-service.ts +++ b/services/api/src/services/host/registration-service.ts @@ -520,8 +520,13 @@ export function makeRegistrationService(deps: RegistrationServiceDeps): Registra now: now(), }) if (outcome.kind === "not_found") throw AppError.notFound("Registration not found") - if (outcome.kind === "cancelled") { - await enqueueWaitlistPromotion(deps.jobs, [outcome.ticketTypeId], deps.logger) + const cancelledTicketTypeId = outcome.kind === "cancelled" ? outcome.ticketTypeId : null + await enqueueWaitlistPromotion( + deps.jobs, + [cancelledTicketTypeId, ...outcome.releasedWaitlistTicketTypeIds], + deps.logger, + ) + if (outcome.kind === "cancelled" || outcome.releasedWaitlistTicketTypeIds.length > 0) { await eventChanged(input.id) } await deps.audit?.({ diff --git a/services/api/src/services/host/waitlist-service.ts b/services/api/src/services/host/waitlist-service.ts index 82558223..1b82b853 100644 --- a/services/api/src/services/host/waitlist-service.ts +++ b/services/api/src/services/host/waitlist-service.ts @@ -122,6 +122,8 @@ export function makeWaitlistService(deps: WaitlistServiceDeps): WaitlistService throw AppError.validation({ accessCode: "that code is not valid for this ticket type" }) case "ticket_type_not_found": throw AppError.notFound("Ticket type not found") + case "banned": + throw AppError.forbidden("A host removed you from this event.") case "closed": throw AppError.conflict("This event is closed.") case "ended": diff --git a/services/api/test/integration/registration-repository-security-pg.test.ts b/services/api/test/integration/registration-repository-security-pg.test.ts new file mode 100644 index 00000000..f5f8d2c3 --- /dev/null +++ b/services/api/test/integration/registration-repository-security-pg.test.ts @@ -0,0 +1,233 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { makeDrizzleHostRegistrationRepository } from "../../src/services/host/registration-repository.drizzle.js" +import { makeTicketTokenSigner } from "../../src/services/host/ticket-token.js" +import type { + HostRegistrationRepository, + RegisterTxArgs, + SeatDraft, +} from "../../src/services/host/registration-repository.types.js" + +const pg = await withPg() +const tokens = makeTicketTokenSigner("integration-registration-security-secret") +const FUTURE = new Date(Date.now() + 7 * 86_400_000) +const CLAIM_WINDOW_MS = 60 * 60 * 1000 + +describe.skipIf(!pg)("registration security (integration)", () => { + let h: PgHarness + let repo: HostRegistrationRepository + + beforeAll(() => { + h = pg as PgHarness + repo = makeDrizzleHostRegistrationRepository(h.sql) + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newUser(name: string): Promise { + const [u] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name) VALUES (${name}) RETURNING id + ` + return (u as { id: string }).id + } + + async function newCleanup(): Promise { + return await seedCleanup(h.sql, { + organizerUserId: await newUser("Organizer"), + title: "Registration security", + lng: -118.25, + lat: 34.05, + scheduledAt: FUTURE, + }) + } + + async function newTicketType( + cleanupId: string, + over: { capacity?: number; visibility?: string } = {}, + ): Promise { + const [row] = await h.sql<{ id: string }[]>` + INSERT INTO cleanup_ticket_types ( + cleanup_id, name, capacity, max_party_size, waitlist_enabled, visibility + ) VALUES ( + ${cleanupId}, ${`Type ${randomUUID().slice(0, 8)}`}, ${over.capacity ?? 5}, 4, true, + ${over.visibility ?? "public"} + ) + RETURNING id + ` + return (row as { id: string }).id + } + + function seats(partySize: number): SeatDraft[] { + return Array.from({ length: partySize }, () => { + const id = randomUUID() + return { id, attendeeName: null, tokenHash: tokens.hashFor(id) } + }) + } + + function selfRegistration( + cleanupId: string, + userId: string, + ticketTypeId: string | null, + partySize = 1, + ): RegisterTxArgs { + return { + cleanupId, + subject: { kind: "user", userId }, + ticketTypeId, + seats: seats(partySize), + accessCodeHash: null, + answers: [], + consent: null, + slotId: null, + source: "self", + idempotencyKey: `self-${randomUUID()}`, + waitlistId: null, + now: new Date(), + } + } + + async function reservedSeats(ticketTypeId: string): Promise { + const rows = await h.sql<{ reserved_seats: number }[]>` + SELECT reserved_seats FROM cleanup_ticket_types WHERE id = ${ticketTypeId} + ` + return (rows[0] as { reserved_seats: number }).reserved_seats + } + + async function waitlistStatuses(userId: string): Promise { + const rows = await h.sql<{ status: string }[]>` + SELECT status FROM cleanup_waitlist WHERE user_id = ${userId} ORDER BY created_at, id + ` + return rows.map((r) => r.status) + } + + it("refuses a self registration on a sole hidden type but seats a host walk-up on it", async () => { + const cleanupId = await newCleanup() + const hidden = await newTicketType(cleanupId, { visibility: "hidden" }) + const userId = await newUser("Self") + + await expect(repo.registerTx(selfRegistration(cleanupId, userId, null))).resolves.toEqual({ + kind: "ticket_type_not_found", + }) + await expect(repo.registerTx(selfRegistration(cleanupId, userId, hidden))).resolves.toEqual({ + kind: "ticket_type_not_found", + }) + expect(await reservedSeats(hidden)).toBe(0) + + const walkup = await repo.registerWalkupTx({ + cleanupId, + name: "Walk In", + manageTokenHash: `walkup-${randomUUID()}`, + ticketTypeId: hidden, + seats: seats(1), + idempotencyKey: `walkup-${randomUUID()}`, + idempotencyOwner: `user:${userId}`, + now: new Date(), + }) + expect(walkup.kind).toBe("registered") + expect(await reservedSeats(hidden)).toBe(1) + }) + + it("refuses a waitlist join on a hidden type, and a banned user's join", async () => { + const cleanupId = await newCleanup() + const hidden = await newTicketType(cleanupId, { visibility: "hidden" }) + const open = await newTicketType(cleanupId) + const banned = await newUser("Banned") + await h.sql`INSERT INTO cleanup_bans (cleanup_id, user_id) VALUES (${cleanupId}, ${banned})` + + await expect( + repo.joinWaitlist({ + cleanupId, + ticketTypeId: hidden, + subject: { kind: "user", userId: await newUser("Joiner") }, + partySize: 1, + accessCodeHash: null, + now: new Date(), + }), + ).resolves.toEqual({ kind: "ticket_type_not_found" }) + await expect( + repo.joinWaitlist({ + cleanupId, + ticketTypeId: open, + subject: { kind: "user", userId: banned }, + partySize: 1, + accessCodeHash: null, + now: new Date(), + }), + ).resolves.toEqual({ kind: "banned" }) + expect(await waitlistStatuses(banned)).toEqual([]) + }) + + it("skips a banned user who is still waiting when offering a place", async () => { + const cleanupId = await newCleanup() + const type = await newTicketType(cleanupId, { capacity: 1 }) + const banned = await newUser("Banned waiter") + const next = await newUser("Next waiter") + const now = new Date() + for (const [index, userId] of [banned, next].entries()) { + await repo.joinWaitlist({ + cleanupId, + ticketTypeId: type, + subject: { kind: "user", userId }, + partySize: 1, + accessCodeHash: null, + now: new Date(now.getTime() + index * 1000), + }) + } + await h.sql`INSERT INTO cleanup_bans (cleanup_id, user_id) VALUES (${cleanupId}, ${banned})` + + const offer = await repo.offerNextWaitlistEntry({ + ticketTypeId: type, + now, + claimWindowMs: CLAIM_WINDOW_MS, + }) + + expect(offer?.userId).toBe(next) + expect(await waitlistStatuses(banned)).toEqual(["waiting"]) + }) + + it("a ban withdraws the user's waiting and offered places and releases the held seats", async () => { + const cleanupId = await newCleanup() + const main = await newTicketType(cleanupId) + const vip = await newTicketType(cleanupId, { capacity: 3 }) + const extra = await newTicketType(cleanupId, { capacity: 3 }) + const userId = await newUser("To be banned") + const now = new Date() + for (const ticketTypeId of [vip, extra]) { + await repo.joinWaitlist({ + cleanupId, + ticketTypeId, + subject: { kind: "user", userId }, + partySize: 3, + accessCodeHash: null, + now, + }) + } + await repo.offerNextWaitlistEntry({ ticketTypeId: vip, now, claimWindowMs: CLAIM_WINDOW_MS }) + expect(await reservedSeats(vip)).toBe(3) + const registered = await repo.registerTx(selfRegistration(cleanupId, userId, main)) + if (registered.kind !== "registered") throw new Error("expected registered") + + const outcome = await repo.removeRegistration({ + cleanupId, + registrationId: registered.registration.id, + actorId: await newUser("Host"), + ban: true, + now, + }) + + expect(outcome.kind).toBe("cancelled") + expect(outcome.releasedWaitlistTicketTypeIds).toEqual([vip]) + expect(await waitlistStatuses(userId)).toEqual(["cancelled", "cancelled"]) + expect(await reservedSeats(vip)).toBe(0) + expect(await reservedSeats(main)).toBe(0) + const bans = await h.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM cleanup_bans + WHERE cleanup_id = ${cleanupId} AND user_id = ${userId} + ` + expect(bans[0]?.n).toBe(1) + }) +}) diff --git a/services/api/test/unit/host/registration-repository-security.test.ts b/services/api/test/unit/host/registration-repository-security.test.ts new file mode 100644 index 00000000..fe04bd9a --- /dev/null +++ b/services/api/test/unit/host/registration-repository-security.test.ts @@ -0,0 +1,583 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import type { RegisterForEventRequest } from "@civfix/shared" +import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" +import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { makeDrizzleHostRegistrationRepository } from "../../../src/services/host/registration-repository.drizzle.js" +import type { + QuestionRecord, + RegisterTxArgs, + SeatDraft, +} from "../../../src/services/host/registration-repository.types.js" +import { + makeRegistrationService, + type RegistrationService, +} from "../../../src/services/host/registration-service.js" +import { makeQuestionService } from "../../../src/services/host/question-service.js" +import { + makeWaitlistService, + WAITLIST_CLAIM_WINDOW_MS, + type WaitlistService, +} from "../../../src/services/host/waitlist-service.js" +import { makeTicketTokenSigner } from "../../../src/services/host/ticket-token.js" +import { makeFakeSql, type FakeSqlControl, type SqlHandler } from "../../helpers/fake-sql.js" +import type { Sql } from "../../../src/db/client.js" + +const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +const USER = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" +const BANNED = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" +const HOST = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" +const HIDDEN_TYPE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" +const REGISTRATION = "ffffffff-ffff-4fff-8fff-ffffffffffff" +const NOW = new Date("2026-01-01T12:00:00.000Z") + +const tokens = makeTicketTokenSigner("registration-security-test-secret-long-enough") + +interface Harness { + repo: InMemoryHostRegistrationRepository + registrations: RegistrationService + waitlist: WaitlistService + enqueued: string[] +} + +function seatsFor(partySize: number): SeatDraft[] { + return Array.from({ length: partySize }, () => { + const id = randomUUID() + return { id, attendeeName: null, tokenHash: tokens.hashFor(id) } + }) +} + +function build(): Harness { + const repo = new InMemoryHostRegistrationRepository() + repo.seedEvent({ cleanupId: EVENT }) + const enqueued: string[] = [] + const jobs = { + enqueue: (_name: string, payload: unknown) => { + enqueued.push((payload as { ticketTypeId: string }).ticketTypeId) + return Promise.resolve("job") + }, + schedule: () => Promise.resolve(), + work: () => Promise.resolve(), + complete: () => Promise.resolve(), + fail: () => Promise.resolve(), + } + const registrations = makeRegistrationService({ + repo, + tokens, + jobs, + counters: new InMemoryCounterStore(() => NOW.getTime()), + now: () => NOW, + }) + const waitlist = makeWaitlistService({ + repo, + registrations: { + buildSeatDrafts: seatsFor, + eventChanged: () => Promise.resolve(), + }, + jobs, + now: () => NOW, + }) + return { repo, registrations, waitlist, enqueued } +} + +function request(over: Partial = {}): RegisterForEventRequest { + return { + id: EVENT, + idempotencyKey: `key-${randomUUID()}`, + partySize: 1, + joinWaitlistIfFull: false, + ...over, + } +} + +function question(over: Partial): QuestionRecord { + return { + id: randomUUID(), + cleanupId: EVENT, + ticketTypeId: null, + kind: "short_text", + prompt: "Any access needs?", + helpText: null, + required: false, + options: [], + maxSelections: null, + consentText: null, + showIf: null, + sortOrder: 0, + archivedAt: null, + ...over, + } +} + +describe("hidden ticket types are host-assigned only", () => { + let h: Harness + + beforeEach(() => { + h = build() + }) + + it("refuses a self registration that names a hidden type", async () => { + h.repo.seedTicketType({ cleanupId: EVENT, name: "General" }) + const hidden = h.repo.seedTicketType({ cleanupId: EVENT, name: "Staff", visibility: "hidden" }) + + const res = await h.registrations.register(request({ ticketTypeId: hidden.id }), { + kind: "user", + userId: USER, + }) + + expect(res.outcome).toBe("ticket_type_not_found") + expect(h.repo.ticketTypes.get(hidden.id)?.reservedSeats).toBe(0) + }) + + it("never auto-selects a sole hidden type for a self registration", async () => { + const hidden = h.repo.seedTicketType({ cleanupId: EVENT, name: "Staff", visibility: "hidden" }) + + const res = await h.registrations.register(request(), { kind: "user", userId: USER }) + + expect(res.outcome).toBe("ticket_type_not_found") + expect(h.repo.ticketTypes.get(hidden.id)?.reservedSeats).toBe(0) + }) + + it("keeps refusing an unnamed type when a public type sits beside hidden ones", async () => { + h.repo.seedTicketType({ cleanupId: EVENT, name: "General" }) + h.repo.seedTicketType({ cleanupId: EVENT, name: "Staff", visibility: "hidden" }) + + const res = await h.registrations.register(request(), { kind: "user", userId: USER }) + + expect(res.outcome).toBe("ticket_type_not_found") + }) + + it("still lets a host seat a walk-up on a hidden type", async () => { + const hidden = h.repo.seedTicketType({ cleanupId: EVENT, name: "Staff", visibility: "hidden" }) + + const res = await h.registrations.walkup( + { id: EVENT, name: "Walk In", partySize: 1, ticketTypeId: hidden.id, checkInNow: false }, + HOST, + ) + + expect(res.outcome).toBe("registered") + }) + + it("refuses a waitlist join on a hidden type as if the type did not exist", async () => { + const hidden = h.repo.seedTicketType({ + cleanupId: EVENT, + name: "Staff", + visibility: "hidden", + capacity: 1, + waitlistEnabled: true, + }) + + await expect( + h.waitlist.join( + { id: EVENT, ticketTypeId: hidden.id, partySize: 1 }, + { kind: "user", userId: USER }, + ), + ).rejects.toMatchObject({ code: "NOT_FOUND", message: "Ticket type not found" }) + expect([...h.repo.waitlist.values()]).toHaveLength(0) + }) + + it("lists a hidden type's questions only to someone who can manage tickets", async () => { + const hidden = h.repo.seedTicketType({ cleanupId: EVENT, name: "Staff", visibility: "hidden" }) + const general = h.repo.seedTicketType({ cleanupId: EVENT, name: "General" }) + const everyone = question({ prompt: "Everyone", sortOrder: 0 }) + const publicScoped = question({ prompt: "General", ticketTypeId: general.id, sortOrder: 1 }) + const hiddenScoped = question({ prompt: "Staff only", ticketTypeId: hidden.id, sortOrder: 2 }) + for (const q of [everyone, publicScoped, hiddenScoped]) h.repo.questions.set(q.id, q) + const questions = makeQuestionService({ repo: h.repo }) + + const asPublic = await questions.list({ id: EVENT }, { canManage: false }) + const asPublicScoped = await questions.list( + { id: EVENT, ticketTypeId: hidden.id }, + { canManage: false }, + ) + const asHost = await questions.list({ id: EVENT }, { canManage: true }) + + expect(asPublic.items.map((q) => q.prompt)).toEqual(["Everyone", "General"]) + expect(asPublicScoped.items.map((q) => q.prompt)).toEqual(["Everyone"]) + expect(asHost.items.map((q) => q.prompt)).toEqual(["Everyone", "General", "Staff only"]) + }) +}) + +describe("bans reach the waitlist", () => { + let h: Harness + + beforeEach(() => { + h = build() + }) + + it("refuses a banned user's waitlist join", async () => { + const type = h.repo.seedTicketType({ cleanupId: EVENT, capacity: 1, waitlistEnabled: true }) + h.repo.bans.add(`${EVENT}:${BANNED}`) + + await expect( + h.waitlist.join( + { id: EVENT, ticketTypeId: type.id, partySize: 1 }, + { kind: "user", userId: BANNED }, + ), + ).rejects.toMatchObject({ code: "FORBIDDEN", message: "A host removed you from this event." }) + expect([...h.repo.waitlist.values()]).toHaveLength(0) + }) + + it("never offers a place to a banned user who is still waiting", async () => { + const type = h.repo.seedTicketType({ cleanupId: EVENT, capacity: 1, waitlistEnabled: true }) + const banned = await h.repo.joinWaitlist({ + cleanupId: EVENT, + ticketTypeId: type.id, + subject: { kind: "user", userId: BANNED }, + partySize: 1, + accessCodeHash: null, + now: NOW, + }) + const next = await h.repo.joinWaitlist({ + cleanupId: EVENT, + ticketTypeId: type.id, + subject: { kind: "user", userId: USER }, + partySize: 1, + accessCodeHash: null, + now: new Date(NOW.getTime() + 1), + }) + h.repo.bans.add(`${EVENT}:${BANNED}`) + + const offer = await h.repo.offerNextWaitlistEntry({ + ticketTypeId: type.id, + now: NOW, + claimWindowMs: WAITLIST_CLAIM_WINDOW_MS, + }) + const manual = await h.repo.offerWaitlistEntry({ + cleanupId: EVENT, + waitlistId: banned.kind === "joined" ? banned.entry.id : "", + now: NOW, + claimWindowMs: WAITLIST_CLAIM_WINDOW_MS, + }) + + expect(offer?.waitlistId).toBe(next.kind === "joined" ? next.entry.id : "") + expect(manual).toBeNull() + }) + + it("cancels the banned user's waiting and offered entries, releases the held seats and promotes the next person", async () => { + const main = h.repo.seedTicketType({ cleanupId: EVENT, capacity: 5 }) + const vip = h.repo.seedTicketType({ cleanupId: EVENT, capacity: 3, waitlistEnabled: true }) + const extra = h.repo.seedTicketType({ cleanupId: EVENT, capacity: 3, waitlistEnabled: true }) + for (const type of [vip, extra]) { + await h.repo.joinWaitlist({ + cleanupId: EVENT, + ticketTypeId: type.id, + subject: { kind: "user", userId: BANNED }, + partySize: 3, + accessCodeHash: null, + now: NOW, + }) + } + await h.repo.offerNextWaitlistEntry({ + ticketTypeId: vip.id, + now: NOW, + claimWindowMs: WAITLIST_CLAIM_WINDOW_MS, + }) + expect(h.repo.ticketTypes.get(vip.id)?.reservedSeats).toBe(3) + const registered = await h.registrations.register(request({ ticketTypeId: main.id }), { + kind: "user", + userId: BANNED, + }) + h.enqueued.length = 0 + + await h.registrations.remove( + { id: EVENT, registrationId: registered.registration?.id ?? "", ban: true }, + HOST, + ) + + const entries = [...h.repo.waitlist.values()].filter((w) => w.userId === BANNED) + expect(entries.map((w) => w.status)).toEqual(["cancelled", "cancelled"]) + expect(h.repo.ticketTypes.get(vip.id)?.reservedSeats).toBe(0) + expect(h.repo.ticketTypes.get(extra.id)?.reservedSeats).toBe(0) + expect(h.enqueued.sort()).toEqual([main.id, vip.id].sort()) + }) + + it("leaves waitlist entries alone when the host removes without banning", async () => { + const main = h.repo.seedTicketType({ cleanupId: EVENT, capacity: 5 }) + const vip = h.repo.seedTicketType({ cleanupId: EVENT, capacity: 3, waitlistEnabled: true }) + await h.repo.joinWaitlist({ + cleanupId: EVENT, + ticketTypeId: vip.id, + subject: { kind: "user", userId: USER }, + partySize: 1, + accessCodeHash: null, + now: NOW, + }) + const registered = await h.registrations.register(request({ ticketTypeId: main.id }), { + kind: "user", + userId: USER, + }) + + await h.registrations.remove( + { id: EVENT, registrationId: registered.registration?.id ?? "", ban: false }, + HOST, + ) + + expect([...h.repo.waitlist.values()].map((w) => w.status)).toEqual(["waiting"]) + }) +}) + +describe("registration SQL guards", () => { + function typeRow(visibility: string) { + return { + id: HIDDEN_TYPE, + capacity: null, + reserved_seats: 0, + sales_opens_at: null, + sales_closes_at: null, + visibility, + access_code_hash: null, + max_party_size: 4, + waitlist_enabled: true, + } + } + + function eventHandlers(visibility: string): SqlHandler[] { + return [ + { + match: /FROM cleanups\s+WHERE id = \?/, + rows: [ + { + status: "upcoming", + registration_opens_at: null, + registration_closes_at: null, + capacity: null, + scheduled_at: new Date(NOW.getTime() + 86_400_000), + ends_at: null, + now: NOW, + }, + ], + }, + { match: /FROM cleanup_ticket_types/, rows: [typeRow(visibility)] }, + ] + } + + function registerArgs(over: Partial): RegisterTxArgs { + return { + cleanupId: EVENT, + subject: { kind: "user", userId: USER }, + ticketTypeId: null, + seats: seatsFor(1), + accessCodeHash: null, + answers: [], + consent: null, + slotId: null, + source: "self", + idempotencyKey: "k1", + waitlistId: null, + now: NOW, + ...over, + } + } + + function repoOver(fake: FakeSqlControl) { + return makeDrizzleHostRegistrationRepository(fake.sql as unknown as Sql) + } + + it("refuses a self registration on a sole hidden type, named or not", async () => { + for (const ticketTypeId of [null, HIDDEN_TYPE]) { + const fake = makeFakeSql(eventHandlers("hidden")) + const outcome = await repoOver(fake).registerTx(registerArgs({ ticketTypeId })) + expect(outcome).toEqual({ kind: "ticket_type_not_found" }) + expect(fake.statements.some((s) => /UPDATE cleanup_ticket_types/.test(s.sql))).toBe(false) + } + }) + + it("refuses a waitlist join on a hidden type, and a banned user's join", async () => { + const hidden = makeFakeSql(eventHandlers("hidden")) + await expect( + repoOver(hidden).joinWaitlist({ + cleanupId: EVENT, + ticketTypeId: HIDDEN_TYPE, + subject: { kind: "user", userId: USER }, + partySize: 1, + accessCodeHash: null, + now: NOW, + }), + ).resolves.toEqual({ kind: "ticket_type_not_found" }) + + const banned = makeFakeSql([ + ...eventHandlers("public"), + { match: /FROM cleanup_bans/, rows: [{ one: 1 }] }, + ]) + await expect( + repoOver(banned).joinWaitlist({ + cleanupId: EVENT, + ticketTypeId: HIDDEN_TYPE, + subject: { kind: "user", userId: BANNED }, + partySize: 1, + accessCodeHash: null, + now: NOW, + }), + ).resolves.toEqual({ kind: "banned" }) + expect(banned.statements.some((s) => /INSERT INTO cleanup_waitlist/.test(s.sql))).toBe(false) + }) + + it("skips banned users when choosing whom to offer a place", async () => { + for (const offer of [ + (fake: FakeSqlControl) => + repoOver(fake).offerNextWaitlistEntry({ + ticketTypeId: HIDDEN_TYPE, + now: NOW, + claimWindowMs: WAITLIST_CLAIM_WINDOW_MS, + }), + (fake: FakeSqlControl) => + repoOver(fake).offerWaitlistEntry({ + cleanupId: EVENT, + waitlistId: REGISTRATION, + now: NOW, + claimWindowMs: WAITLIST_CLAIM_WINDOW_MS, + }), + ]) { + const fake = makeFakeSql() + await offer(fake) + const select = fake.statements[0]!.sql.replace(/\s+/g, " ") + expect(select).toMatch(/FROM cleanup_waitlist w/) + expect(select).toContain( + "NOT EXISTS ( SELECT 1 FROM cleanup_bans b WHERE b.cleanup_id = w.cleanup_id AND b.user_id = w.user_id )", + ) + } + }) + + it("bans, cancels the waitlist entries and cancels the registration in one transaction", async () => { + const fake = makeFakeSql([ + { match: /SELECT user_id FROM cleanup_registrations/, rows: [{ user_id: BANNED }] }, + { + match: /UPDATE cleanup_waitlist/, + rows: [{ id: "w1", released_ticket_type_id: HIDDEN_TYPE }], + }, + { match: /WITH cancelled AS/, rows: [] }, + ]) + let began = -1 + let ended = -1 + const begin = fake.sql.begin + fake.sql.begin = async (cb) => { + began = fake.statements.length + const result = await begin(cb) + ended = fake.statements.length + return result + } + + const outcome = await repoOver(fake).removeRegistration({ + cleanupId: EVENT, + registrationId: REGISTRATION, + actorId: HOST, + ban: true, + now: NOW, + }) + + expect(began).toBe(0) + expect(ended).toBe(fake.statements.length) + const order = [ + /INSERT INTO cleanup_bans/, + /DELETE FROM cleanup_members/, + /UPDATE cleanup_waitlist/, + /WITH cancelled AS/, + ].map((pattern) => fake.statements.findIndex((s) => pattern.test(s.sql))) + expect(order.every((index) => index >= 0)).toBe(true) + expect([...order].sort((a, b) => a - b)).toEqual(order) + expect(outcome.releasedWaitlistTicketTypeIds).toEqual([HIDDEN_TYPE]) + }) +}) + +describe("guest self-registration on a private event", () => { + const GUEST = "12121212-1212-4212-8212-121212121212" + const WAITLIST_ENTRY = "34343434-3434-4434-8434-343434343434" + + function privateEvent(): SqlHandler[] { + return [ + { + match: /FROM cleanups\s+WHERE id = \?/, + rows: [ + { + status: "upcoming", + visibility: "private", + registration_opens_at: null, + registration_closes_at: null, + capacity: null, + }, + ], + }, + ] + } + + function guestArgs(over: Partial): RegisterTxArgs { + return { + cleanupId: EVENT, + subject: { kind: "guest", guestId: GUEST }, + ticketTypeId: null, + seats: seatsFor(1), + accessCodeHash: null, + answers: [], + consent: null, + slotId: null, + source: "self", + idempotencyKey: "guest-key", + waitlistId: null, + now: NOW, + ...over, + } + } + + const seatsWritten = (fake: FakeSqlControl) => + fake.statements.some((s) => /INSERT INTO cleanup_registrations/.test(s.sql)) + + it("answers not found and writes nothing", async () => { + const fake = makeFakeSql(privateEvent()) + + const outcome = await makeDrizzleHostRegistrationRepository( + fake.sql as unknown as Sql, + ).registerTx(guestArgs({})) + + expect(outcome).toEqual({ kind: "not_found" }) + expect(seatsWritten(fake)).toBe(false) + }) + + it("still seats a guest the host adds at the door, and a guest already on the waitlist", async () => { + for (const over of [ + { source: "walkup" as const }, + { source: "waitlist" as const, waitlistId: WAITLIST_ENTRY }, + ]) { + const fake = makeFakeSql(privateEvent()) + + await makeDrizzleHostRegistrationRepository(fake.sql as unknown as Sql) + .registerTx(guestArgs(over)) + .catch(() => undefined) + + expect(seatsWritten(fake), over.source).toBe(true) + } + }) + + it("is refused the same way by the in-memory twin", async () => { + const repo = new InMemoryHostRegistrationRepository() + repo.seedEvent({ cleanupId: EVENT, visibility: "private" }) + + await expect(repo.registerTx(guestArgs({}))).resolves.toEqual({ kind: "not_found" }) + await expect(repo.registerTx(guestArgs({ source: "walkup" }))).resolves.toMatchObject({ + kind: "registered", + }) + }) +}) + +describe("roster search", () => { + it("matches the host's text literally, wildcards included", async () => { + const fake = makeFakeSql() + + await makeDrizzleHostRegistrationRepository(fake.sql as unknown as Sql).listRoster({ + cleanupId: EVENT, + filter: "all", + ticketTypeId: null, + slotId: null, + sort: "registered_at_desc", + q: "50%_off\\", + cursor: null, + limit: 20, + withTotal: false, + }) + + const roster = fake.statements.find((s) => /FROM cleanup_registrations r/.test(s.sql))! + const patterns = roster.values.filter((v) => typeof v === "string" && v.includes("50")) + expect(patterns).toEqual(Array(4).fill("%50\\%\\_off\\\\%")) + expect(roster.sql.match(/ILIKE \? ESCAPE '\\'/g)).toHaveLength(4) + }) +}) From 0da176e1fd3e6ef66a7ec043e495dc6fd6723ef7 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:25:06 +0000 Subject: [PATCH 20/45] orgs and teams: removal or demotion revokes pending invites, verified-only invite email, generic suspension copy, escaped page search --- .../host/admin-pages-repository.drizzle.ts | 8 +- .../host/host-team-repository.memory.ts | 9 +- .../src/services/host/host-team-service.ts | 3 +- .../host/organization-repository.drizzle.ts | 58 +++- .../host/organization-repository.memory.ts | 40 +++ .../host/organization-repository.types.ts | 16 + .../src/services/host/organization-service.ts | 12 +- ...rganization-repository-security-pg.test.ts | 135 +++++++++ .../admin-pages-repository-security.test.ts | 46 +++ .../host/host-team-service-security.test.ts | 112 +++++++ .../organization-service-security.test.ts | 285 ++++++++++++++++++ 11 files changed, 710 insertions(+), 14 deletions(-) create mode 100644 services/api/test/integration/organization-repository-security-pg.test.ts create mode 100644 services/api/test/unit/host/admin-pages-repository-security.test.ts create mode 100644 services/api/test/unit/host/host-team-service-security.test.ts create mode 100644 services/api/test/unit/host/organization-service-security.test.ts diff --git a/services/api/src/services/host/admin-pages-repository.drizzle.ts b/services/api/src/services/host/admin-pages-repository.drizzle.ts index 62f2196e..5f59f23d 100644 --- a/services/api/src/services/host/admin-pages-repository.drizzle.ts +++ b/services/api/src/services/host/admin-pages-repository.drizzle.ts @@ -1,5 +1,6 @@ import type { EventPageStatus, EventVisibility } from "@civfix/shared" -import type { Sql } from "../../db/client.js" +import type { Queryable } from "../../db/client.js" +import { likeContains } from "../admin/like.js" export interface AdminEventPageRow { cleanupId: string @@ -90,7 +91,7 @@ function toRow(row: PageRowSelect): AdminEventPageRow { } } -export function makeDrizzleAdminEventPageRepository(sql: Sql): AdminEventPageRepository { +export function makeDrizzleAdminEventPageRepository(sql: Queryable): AdminEventPageRepository { const selection = sql` p.id AS page_id, p.cleanup_id, c.page_slug AS slug, c.title, p.status, c.visibility, u.id AS organizer_id, u.display_name AS organizer_name, u.handle AS organizer_handle, @@ -127,7 +128,8 @@ export function makeDrizzleAdminEventPageRepository(sql: Sql): AdminEventPageRep : sql`AND p.flagged_at IS NULL` const search = params.q !== undefined && params.q.length > 0 - ? sql`AND (c.title ILIKE ${`%${params.q}%`} OR c.page_slug::text ILIKE ${`%${params.q}%`})` + ? sql`AND (c.title ILIKE ${likeContains(params.q)} ESCAPE '\\' + OR c.page_slug::text ILIKE ${likeContains(params.q)} ESCAPE '\\')` : sql`` const cursorFilter = params.cursor !== null diff --git a/services/api/src/services/host/host-team-repository.memory.ts b/services/api/src/services/host/host-team-repository.memory.ts index ecfcc12f..8b8c83c9 100644 --- a/services/api/src/services/host/host-team-repository.memory.ts +++ b/services/api/src/services/host/host-team-repository.memory.ts @@ -45,6 +45,8 @@ interface StoredPerson { displayName: string handle: string | null email: string | null + /** Mirrors users.email_verified. */ + emailVerified: boolean avatarUrl: string | null } @@ -97,6 +99,7 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { displayName: over.displayName ?? "Member", handle: over.handle ?? null, email: over.email ?? null, + emailVerified: over.emailVerified ?? true, avatarUrl: over.avatarUrl ?? null, } this.users.set(person.id, person) @@ -224,7 +227,11 @@ export class InMemoryHostTeamRepository implements HostTeamRepository { resolveUserByHandle(handle: string): Promise<{ userId: string; email: string | null } | null> { const match = [...this.users.values()].find((u) => u.handle === handle) - return Promise.resolve(match === undefined ? null : { userId: match.id, email: match.email }) + return Promise.resolve( + match === undefined + ? null + : { userId: match.id, email: match.emailVerified ? match.email : null }, + ) } private openInviteFor(args: OpenTeamInviteQuery): StoredInvite | undefined { diff --git a/services/api/src/services/host/host-team-service.ts b/services/api/src/services/host/host-team-service.ts index 5903b2b4..c200cba7 100644 --- a/services/api/src/services/host/host-team-service.ts +++ b/services/api/src/services/host/host-team-service.ts @@ -37,6 +37,7 @@ import type { HostTeamRepository, PendingInviteForUserRecord, } from "./host-team-repository.types.js" +import { webBaseUrlOf } from "../../lib/base-url.js" export const TEAM_INVITES_PER_EVENT_PER_DAY = 30 const TEAM_INVITE_WINDOW_SEC = 24 * 60 * 60 @@ -227,7 +228,7 @@ export function makeHostTeamService(deps: HostTeamServiceDeps): HostTeamService token: string, ): Promise { if (deps.mailer === undefined) return - const base = deps.webOrigin ?? "https://civfix.org" + const base = deps.webOrigin ?? webBaseUrlOf({}) const link = `${base}/cleanups/${cleanupId}#teamInvite=${encodeURIComponent(token)}` try { await deps.mailer.sendTransactional( diff --git a/services/api/src/services/host/organization-repository.drizzle.ts b/services/api/src/services/host/organization-repository.drizzle.ts index 69502105..699f411c 100644 --- a/services/api/src/services/host/organization-repository.drizzle.ts +++ b/services/api/src/services/host/organization-repository.drizzle.ts @@ -11,7 +11,7 @@ import type postgres from "postgres" import type { Queryable, Sql } from "../../db/client.js" import { encodeTimeCursor, isUuid, pageWith, parseTimeCursor } from "../../db/cursor-helpers.js" import { likeContains } from "../admin/like.js" -import { servedKeyExpr } from "../media-served-key.js" +import { publicServedKeyExpr } from "../media-served-key.js" import { MEDIA_CLAIM_WINDOW_SEC } from "./event-media.js" import { mediaBoundElsewhere } from "../media-bindings.js" import { writeHostAudit } from "./host-audit.js" @@ -54,7 +54,9 @@ import type { UpdateOrganizationAudit, UpdateOrganizationOutcome, UpdateOrganizationPatch, + InviterRevocationReason, } from "./organization-repository.types.js" +import { roleChangeWithdrawsInvites } from "./organization-repository.types.js" const PG_UNIQUE_VIOLATION = "23505" @@ -224,7 +226,7 @@ function organizationColumns(sql: Queryable, viewerId: string | null) { o.website_url, o.donation_url, o.logo_media_id, - ${servedKeyExpr(sql, "am")} AS logo_key, + ${publicServedKeyExpr(sql, "am")} AS logo_key, o.social_links, o.verified_status, o.verified_kind, @@ -760,6 +762,14 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit target: `organization:${args.organizationId}`, meta: { targetUserId: args.userId, from: existing.role, to: args.role }, }) + if (roleChangeWithdrawsInvites(existing.role, args.role)) { + await revokeInvitesByInviterInTx(tx, { + organizationId: args.organizationId, + inviterId: args.userId, + actorId: args.actorId, + reason: "inviter_demoted", + }) + } return "updated" }) }, @@ -816,6 +826,12 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit ...(args.reason !== undefined ? { reason: args.reason } : {}), }, }) + await revokeInvitesByInviterInTx(tx, { + organizationId: args.organizationId, + inviterId: args.userId, + actorId: args.actorId, + reason: "inviter_removed", + }) return "removed" }) }, @@ -1202,6 +1218,14 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit reason: args.reason, }, }) + if (roleChangeWithdrawsInvites(existing.role, args.role)) { + await revokeInvitesByInviterInTx(tx, { + organizationId: args.organizationId, + inviterId: args.userId, + actorId: args.actorId, + reason: "inviter_demoted", + }) + } if (args.role === "owner") { await writeHostAudit(tx, { actorId: args.actorId, @@ -1358,7 +1382,7 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit o.id AS organization_id, o.slug AS organization_slug, o.name AS organization_name, - ${servedKeyExpr(sql, "am")} AS organization_logo_key, + ${publicServedKeyExpr(sql, "am")} AS organization_logo_key, o.verified_status AS organization_verified_status, o.verified_kind AS organization_verified_kind FROM organization_invites i @@ -1725,6 +1749,34 @@ function toAdminVerificationRecord(row: AdminVerificationRowSelect): AdminOrgVer } } +/** The caller already holds the organizations row lock, so this keeps the documented lock order. */ +async function revokeInvitesByInviterInTx( + tx: Queryable, + args: { + organizationId: string + inviterId: string + actorId: string + reason: InviterRevocationReason + }, +): Promise { + const revoked = await tx<{ id: string }[]>` + UPDATE organization_invites + SET status = 'revoked', revoked_at = now() + WHERE organization_id = ${args.organizationId} + AND invited_by = ${args.inviterId} + AND status = 'pending' + RETURNING id + ` + for (const row of revoked) { + await writeHostAudit(tx, { + actorId: args.actorId, + action: "org.invite_revoked", + target: `organization:${args.organizationId}`, + meta: { inviteId: row.id, reason: args.reason }, + }) + } +} + async function expireInvitesInTx(tag: Queryable, organizationId: string, now: Date): Promise { await tag` UPDATE organization_invites SET status = 'expired' diff --git a/services/api/src/services/host/organization-repository.memory.ts b/services/api/src/services/host/organization-repository.memory.ts index 4c036d4b..3a403ed3 100644 --- a/services/api/src/services/host/organization-repository.memory.ts +++ b/services/api/src/services/host/organization-repository.memory.ts @@ -44,7 +44,9 @@ import type { UpdateOrganizationAudit, UpdateOrganizationOutcome, UpdateOrganizationPatch, + InviterRevocationReason, } from "./organization-repository.types.js" +import { roleChangeWithdrawsInvites } from "./organization-repository.types.js" interface StoredOrganization { id: string @@ -479,12 +481,21 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { if (this.countAdminSeats(args.organizationId) <= 1) return Promise.resolve("last_admin") } if (member.role !== args.role) { + const from = member.role member.role = args.role this.audits.push({ actorId: args.actorId, action: "org.member_role_changed", target: `organization:${args.organizationId}`, }) + if (roleChangeWithdrawsInvites(from, args.role)) { + this.revokeInvitesByInviter( + args.organizationId, + args.userId, + args.actorId, + "inviter_demoted", + ) + } } return Promise.resolve("updated") } @@ -516,6 +527,7 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { ...(args.reason !== undefined ? { reason: args.reason } : {}), }, }) + this.revokeInvitesByInviter(args.organizationId, args.userId, args.actorId, "inviter_removed") return Promise.resolve("removed") } @@ -817,6 +829,9 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { target: `organization:${args.organizationId}`, meta: { targetUserId: args.userId, from, to: args.role, reason: args.reason }, }) + if (roleChangeWithdrawsInvites(from, args.role)) { + this.revokeInvitesByInviter(args.organizationId, args.userId, args.actorId, "inviter_demoted") + } if (args.role === "owner") { this.audits.push({ actorId: args.actorId, @@ -828,6 +843,31 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { return Promise.resolve("updated") } + private revokeInvitesByInviter( + organizationId: string, + inviterId: string, + actorId: string, + reason: InviterRevocationReason, + ): void { + for (const invite of this.invites) { + if ( + invite.organizationId !== organizationId || + invite.invitedBy !== inviterId || + invite.status !== "pending" + ) { + continue + } + invite.status = "revoked" + invite.revokedAt = new Date() + this.audits.push({ + actorId, + action: "org.invite_revoked", + target: `organization:${organizationId}`, + meta: { inviteId: invite.id, reason }, + }) + } + } + private expireInvites(organizationId: string, now: Date): void { for (const invite of this.invites) { if ( diff --git a/services/api/src/services/host/organization-repository.types.ts b/services/api/src/services/host/organization-repository.types.ts index d1194e4c..2bcfb135 100644 --- a/services/api/src/services/host/organization-repository.types.ts +++ b/services/api/src/services/host/organization-repository.types.ts @@ -6,8 +6,24 @@ import type { OrgVerificationStatus, SocialLinks, } from "@civfix/shared" +import { can } from "@civfix/shared/host" import type { CleanupOrganizationView, CleanupPersonView } from "../cleanup-repository.types.js" +export type InviterRevocationReason = "inviter_removed" | "inviter_demoted" + +/** + * An accepted invite seats `invite.role` without re-checking the inviter, so a role change that takes + * away the power to invite must also withdraw the invites already sent with it. + */ +export function roleChangeWithdrawsInvites( + from: OrganizationMemberRole, + to: OrganizationMemberRole, +): boolean { + const canInvite = (role: OrganizationMemberRole): boolean => + can({ eventRole: null, orgRole: role }, "manage_org_members") + return canInvite(from) && !canInvite(to) +} + export interface OrganizationBaseRecord { id: string slug: string diff --git a/services/api/src/services/host/organization-service.ts b/services/api/src/services/host/organization-service.ts index 087ec956..c4851fd3 100644 --- a/services/api/src/services/host/organization-service.ts +++ b/services/api/src/services/host/organization-service.ts @@ -47,6 +47,7 @@ import type { OrgVerificationRecord, UpdateOrganizationPatch, } from "./organization-repository.types.js" +import { webBaseUrlOf } from "../../lib/base-url.js" export const ORGS_CREATED_PER_DAY = 5 const ORG_CREATE_WINDOW_SEC = 24 * 60 * 60 @@ -341,14 +342,13 @@ function toInviteDTO(record: OrganizationInviteRecord): OrganizationInviteDTO { /** * The org-scoped write gate for an operator-suspended org (DECISIONS §32): members keep reading, the * admin plane keeps working, but self-service settings, team changes, verification applications and - * invite acceptance are refused until an operator lifts the flag. + * invite acceptance are refused until an operator lifts the flag. The operator's reason is internal + * (the admin console records it for the audit log), so members only ever see the generic sentence. */ function assertNotSuspended(record: OrganizationBaseRecord): void { if (record.suspendedAt === null) return throw AppError.forbidden( - record.suspendedReason === null || record.suspendedReason.length === 0 - ? "This organization has been suspended, so it can't be changed right now." - : `This organization has been suspended (${record.suspendedReason}), so it can't be changed right now.`, + "This organization has been suspended, so it can't be changed right now.", ) } @@ -398,7 +398,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza const now = deps.now ?? (() => new Date()) const newId = deps.newId ?? (() => randomUUID()) const newToken = deps.newToken ?? (() => generateToken(ORG_INVITE_TOKEN_BYTES)) - const webBase = () => (deps.webOrigin ?? "https://civfix.org").replace(/\/+$/, "") + const webBase = () => (deps.webOrigin ?? webBaseUrlOf({})).replace(/\/+$/, "") async function logoUrlOf(record: OrganizationBaseRecord): Promise { if (record.logoKey === null || deps.presignLogo === undefined) return null @@ -643,7 +643,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza return } if (owner === null) return - const base = (deps.webOrigin ?? "https://civfix.org").replace(/\/+$/, "") + const base = webBase() const orgPath = `/orgs/${org.slug}` const verifyPath = `/manage/orgs/${org.id}/verification` const kindLabel = verificationKindLabel(org.verifiedKind ?? null) diff --git a/services/api/test/integration/organization-repository-security-pg.test.ts b/services/api/test/integration/organization-repository-security-pg.test.ts new file mode 100644 index 00000000..618008d4 --- /dev/null +++ b/services/api/test/integration/organization-repository-security-pg.test.ts @@ -0,0 +1,135 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import { testHandle, withPg, type PgHarness } from "../helpers/pg.js" +import { makeDrizzleOrganizationRepository } from "../../src/services/host/organization-repository.drizzle.js" +import type { OrganizationRepository } from "../../src/services/host/organization-repository.types.js" + +const pg = await withPg() +const INVITE_TTL_MS = 14 * 86_400_000 + +describe.skipIf(!pg)("organization invite revocation (integration)", () => { + let h: PgHarness + let orgs: OrganizationRepository + + beforeAll(() => { + h = pg as PgHarness + orgs = makeDrizzleOrganizationRepository(h.sql) + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newUser(name: string): Promise { + const [u] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name, handle) VALUES (${name}, ${testHandle()}) RETURNING id + ` + return (u as { id: string }).id + } + + async function newOrg(ownerId: string): Promise { + const slug = `sec-${randomUUID().slice(0, 8)}` + const created = await orgs.createOrganizationTx({ + organizationId: randomUUID(), + slug, + name: `Org ${slug}`, + description: null, + websiteUrl: null, + logoMediaId: null, + socialLinks: null, + createdBy: ownerId, + now: new Date(), + }) + if (created === "slug_taken") throw new Error(`slug ${slug} unexpectedly taken`) + return created.id + } + + async function seat(organizationId: string, userId: string, role: "admin" | "member") { + await h.sql` + INSERT INTO organization_members (organization_id, user_id, role) + VALUES (${organizationId}, ${userId}, ${role}) + ` + } + + async function invite(organizationId: string, invitedBy: string): Promise { + const now = new Date() + const outcome = await orgs.createInviteTx({ + inviteId: randomUUID(), + organizationId, + email: `${randomUUID().slice(0, 8)}@example.test`, + userId: null, + role: "admin", + tokenHash: randomUUID().replace(/-/g, ""), + invitedBy, + expiresAt: new Date(now.getTime() + INVITE_TTL_MS), + now, + }) + return outcome.invite.id + } + + async function statusOf(inviteId: string): Promise<{ status: string; revoked: boolean }> { + const rows = await h.sql<{ status: string; revoked_at: Date | null }[]>` + SELECT status, revoked_at FROM organization_invites WHERE id = ${inviteId} + ` + const row = rows[0] as { status: string; revoked_at: Date | null } + return { status: row.status, revoked: row.revoked_at !== null } + } + + it("revokes a removed admin's pending invites and audits each one", async () => { + const owner = await newUser("Owner") + const admin = await newUser("Admin") + const orgId = await newOrg(owner) + await seat(orgId, admin, "admin") + const byAdmin = await invite(orgId, admin) + const byOwner = await invite(orgId, owner) + + await expect( + orgs.removeMemberTx({ organizationId: orgId, userId: admin, actorId: owner }), + ).resolves.toBe("removed") + + expect(await statusOf(byAdmin)).toEqual({ status: "revoked", revoked: true }) + expect(await statusOf(byOwner)).toEqual({ status: "pending", revoked: false }) + const audits = await h.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM audit_log + WHERE action = 'org.invite_revoked' + AND meta->>'inviteId' = ${byAdmin} + AND meta->>'reason' = 'inviter_removed' + ` + expect(audits[0]?.n).toBe(1) + }) + + it("revokes on an admin-to-member demotion, but not when the demotion is refused", async () => { + const owner = await newUser("Owner") + const admin = await newUser("Admin") + const orgId = await newOrg(owner) + await seat(orgId, admin, "admin") + const byAdmin = await invite(orgId, admin) + + await expect( + orgs.setMemberRoleTx({ + organizationId: orgId, + userId: admin, + role: "member", + actorId: owner, + }), + ).resolves.toBe("updated") + expect((await statusOf(byAdmin)).status).toBe("revoked") + + const orphan = await newOrg(owner) + const lastAdmin = await newUser("Last admin") + await seat(orphan, lastAdmin, "admin") + const kept = await invite(orphan, lastAdmin) + await h.sql` + DELETE FROM organization_members WHERE organization_id = ${orphan} AND role = 'owner' + ` + await expect( + orgs.setMemberRoleTx({ + organizationId: orphan, + userId: lastAdmin, + role: "member", + actorId: lastAdmin, + }), + ).resolves.toBe("last_admin") + expect((await statusOf(kept)).status).toBe("pending") + }) +}) diff --git a/services/api/test/unit/host/admin-pages-repository-security.test.ts b/services/api/test/unit/host/admin-pages-repository-security.test.ts new file mode 100644 index 00000000..0c417072 --- /dev/null +++ b/services/api/test/unit/host/admin-pages-repository-security.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest" +import { makeFakeSql } from "../../helpers/fake-sql.js" +import { makeDrizzleAdminEventPageRepository } from "../../../src/services/host/admin-pages-repository.drizzle.js" +import type { Sql, TransactionSql } from "../../../src/db/client.js" + +const PAGE_LIMIT = 25 + +describe("operator event-page search", () => { + it("matches LIKE metacharacters in the term literally", async () => { + const fake = makeFakeSql() + const repo = makeDrizzleAdminEventPageRepository(fake.sql as unknown as Sql) + + await repo.list({ q: "50%_off\\", cursor: null, limit: PAGE_LIMIT }) + + const stmt = fake.statements[0]! + const flat = stmt.sql.replace(/\s+/g, " ") + expect(flat).toContain("c.title ILIKE ? ESCAPE '\\'") + expect(flat).toContain("c.page_slug::text ILIKE ? ESCAPE '\\'") + const patterns = stmt.values.filter((v) => typeof v === "string" && v.startsWith("%")) + expect(patterns).toEqual(["%50\\%\\_off\\\\%", "%50\\%\\_off\\\\%"]) + }) + + it("adds no search predicate for an empty term", async () => { + const fake = makeFakeSql() + const repo = makeDrizzleAdminEventPageRepository(fake.sql as unknown as Sql) + + await repo.list({ q: "", cursor: null, limit: PAGE_LIMIT }) + + expect(fake.statements[0]!.sql).not.toContain("ILIKE") + }) +}) + +describe("operator event-page repository inside a transaction", () => { + it("runs on the transaction handle it is given", async () => { + const fake = makeFakeSql() + + await fake.sql.begin((tx) => + makeDrizzleAdminEventPageRepository(tx as unknown as TransactionSql).list({ + cursor: null, + limit: PAGE_LIMIT, + }), + ) + + expect(fake.statements).toHaveLength(1) + }) +}) diff --git a/services/api/test/unit/host/host-team-service-security.test.ts b/services/api/test/unit/host/host-team-service-security.test.ts new file mode 100644 index 00000000..5692032b --- /dev/null +++ b/services/api/test/unit/host/host-team-service-security.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it } from "vitest" +import type { HostCapability } from "@civfix/shared" +import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" +import type { HostStandingResolution } from "../../../src/services/host/host-standing.js" +import { InMemoryHostTeamRepository } from "../../../src/services/host/host-team-repository.memory.js" +import { makeDrizzleHostTeamRepository } from "../../../src/services/host/host-team-repository.drizzle.js" +import { + makeHostTeamService, + type HostTeamService, +} from "../../../src/services/host/host-team-service.js" +import type { CreateNotificationInput } from "../../../src/services/notification-service.js" +import { fakeCleanupDTO } from "../../helpers/host-team.js" +import { makeFakeSql } from "../../helpers/fake-sql.js" +import type { Sql } from "../../../src/db/client.js" + +const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +const ORGANIZER = "11111111-1111-4111-8111-111111111111" +const UNVERIFIED = "22222222-2222-4222-8222-222222222222" +const VERIFIED = "33333333-3333-4333-8333-333333333333" + +let repo: InMemoryHostTeamRepository +let service: HostTeamService +let sentMail: string[] +let bells: string[] +let tokenSeq: number + +beforeEach(() => { + repo = new InMemoryHostTeamRepository() + sentMail = [] + bells = [] + tokenSeq = 0 + const clock = new Date("2026-09-06T12:00:00.000Z") + repo.seedUser({ id: ORGANIZER, displayName: "Olive Organizer", handle: "olive" }) + repo.seedUser({ + id: UNVERIFIED, + displayName: "Una Unverified", + handle: "una", + email: "victim@x.org", + emailVerified: false, + }) + repo.seedUser({ id: VERIFIED, displayName: "Vera Verified", handle: "vera", email: "vera@x.org" }) + repo.seedMember(EVENT, ORGANIZER, "organizer") + service = makeHostTeamService({ + repo, + standing: (cleanupId: string, _userId: string, _capability: HostCapability) => { + const resolution: HostStandingResolution = { + cleanupId, + standing: { eventRole: "organizer", orgRole: null }, + organizerUserId: ORGANIZER, + organizationId: null, + visibility: "public", + } + return Promise.resolve(resolution) + }, + counters: new InMemoryCounterStore(() => clock.getTime()), + mailer: { + sendTransactional: (to) => { + sentMail.push(to) + return Promise.resolve() + }, + }, + loadEvent: (cleanupId: string) => Promise.resolve(fakeCleanupDTO(cleanupId)), + notifier: { + createNotification: (userId: string, _input: CreateNotificationInput) => { + bells.push(userId) + return Promise.resolve(null) + }, + }, + eventTitleOf: () => Promise.resolve("Beach cleanup"), + webOrigin: "https://civfix.test", + now: () => clock, + newToken: () => `token-${++tokenSeq}-aaaaaaaaaaaaaaaaaaaaaaaa`, + newId: () => `cccccccc-cccc-4ccc-8ccc-${String(tokenSeq).padStart(12, "0")}`, + }) +}) + +describe("handle team invites", () => { + it("never emails an address the account has not verified, but still tells the account in-app", async () => { + await service.inviteMember(EVENT, ORGANIZER, { + identifierKind: "handle", + identifier: "una", + role: "staff", + }) + + expect(sentMail).toEqual([]) + expect(bells).toEqual([UNVERIFIED]) + }) + + it("emails a verified address as before", async () => { + await service.inviteMember(EVENT, ORGANIZER, { + identifierKind: "handle", + identifier: "vera", + role: "staff", + }) + + expect(sentMail).toEqual(["vera@x.org"]) + expect(bells).toEqual([VERIFIED]) + }) + + it("reads the address only when it is verified", async () => { + const fake = makeFakeSql([{ match: /FROM users/, rows: [{ id: UNVERIFIED, email: null }] }]) + + const resolved = await makeDrizzleHostTeamRepository( + fake.sql as unknown as Sql, + ).resolveUserByHandle("una") + + expect(resolved).toEqual({ userId: UNVERIFIED, email: null }) + expect(fake.statements[0]!.sql.replace(/\s+/g, " ")).toContain( + "CASE WHEN email_verified THEN email END AS email", + ) + }) +}) diff --git a/services/api/test/unit/host/organization-service-security.test.ts b/services/api/test/unit/host/organization-service-security.test.ts new file mode 100644 index 00000000..c838a8bd --- /dev/null +++ b/services/api/test/unit/host/organization-service-security.test.ts @@ -0,0 +1,285 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import { InMemoryCounterStore } from "../../../src/abuse/counter-store.js" +import { InMemoryOrganizationRepository } from "../../../src/services/host/organization-repository.memory.js" +import { makeDrizzleOrganizationRepository } from "../../../src/services/host/organization-repository.drizzle.js" +import { + makeOrganizationService, + type OrganizationService, +} from "../../../src/services/host/organization-service.js" +import { makeFakeSql, type SqlHandler } from "../../helpers/fake-sql.js" +import type { Sql } from "../../../src/db/client.js" + +const OWNER = "11111111-1111-4111-8111-111111111111" +const ADMIN = "22222222-2222-4222-8222-222222222222" +const MEMBER = "33333333-3333-4333-8333-333333333333" +const SOCK = "44444444-4444-4444-8444-444444444444" +const OPERATOR = "55555555-5555-4555-8555-555555555555" +const ORG = "77777777-7777-4777-8777-777777777777" + +const SOCK_EMAIL = "sock@x.org" +const TOKEN_PREFIX = "org-invite-security-token-0123456789abcdefghij" +const INTERNAL_REASON = "fraud report from Jane Reporter" +const SUSPENDED_COPY = "This organization has been suspended, so it can't be changed right now." + +let repo: InMemoryOrganizationRepository +let service: OrganizationService +let clock: Date +let minted: number + +beforeEach(() => { + repo = new InMemoryOrganizationRepository() + repo.seedUser({ id: OWNER, displayName: "Olive Owner", handle: "olive", email: "olive@x.org" }) + repo.seedUser({ id: ADMIN, displayName: "Adam Admin", handle: "adam", email: "adam@x.org" }) + repo.seedUser({ id: MEMBER, displayName: "Mel Member", handle: "mel", email: "mel@x.org" }) + repo.seedUser({ id: SOCK, displayName: "Sock Puppet", handle: "sock", email: SOCK_EMAIL }) + clock = new Date("2026-09-06T12:00:00.000Z") + minted = 0 + service = makeOrganizationService({ + repo, + counters: new InMemoryCounterStore(() => clock.getTime()), + now: () => clock, + newId: () => randomUUID(), + newToken: () => { + minted += 1 + return `${TOKEN_PREFIX}-${minted}` + }, + webOrigin: "https://civfix.test/", + presignLogo: (key) => Promise.resolve(`https://cdn.test/${key}`), + }) +}) + +async function orgWithAdmin(slug = "ballona-creek-trust"): Promise { + const dto = await service.createOrganization( + { name: `Org ${slug}`, slug } as Parameters[0], + OWNER, + ) + await service.inviteMember(dto.id, OWNER, { + identifierKind: "handle", + identifier: "adam", + role: "admin", + }) + await service.inviteMember(dto.id, OWNER, { + identifierKind: "handle", + identifier: "mel", + role: "member", + }) + return dto.id +} + +async function inviteAs(orgId: string, inviterId: string, email: string): Promise { + const result = await service.inviteMember(orgId, inviterId, { + identifierKind: "email", + identifier: email, + role: "admin", + }) + return result.invite?.id ?? "" +} + +function statusOf(inviteId: string): string | undefined { + return repo.invites.find((i) => i.id === inviteId)?.status +} + +describe("suspension copy", () => { + it("never shows members the operator's internal suspension reason", async () => { + const id = await orgWithAdmin() + await service.adminSetSuspended(id, OPERATOR, { suspended: true, reason: INTERNAL_REASON }) + + const refusals = [ + () => service.updateOrganization(id, { name: "New name" }, OWNER), + () => + service.inviteMember(id, ADMIN, { + identifierKind: "handle", + identifier: "sock", + role: "member", + }), + ] + + for (const refused of refusals) { + await expect(refused()).rejects.toMatchObject({ + code: "FORBIDDEN", + message: SUSPENDED_COPY, + }) + } + }) +}) + +describe("pending invites of a departing or demoted inviter", () => { + it("revokes an admin's pending invites when the owner removes them, so the link no longer seats anyone", async () => { + const id = await orgWithAdmin() + const inviteId = await inviteAs(id, ADMIN, SOCK_EMAIL) + + await service.removeMember(id, OWNER, ADMIN) + + expect(statusOf(inviteId)).toBe("revoked") + expect(repo.invites.find((i) => i.id === inviteId)?.revokedAt).toBeInstanceOf(Date) + expect(repo.audits).toContainEqual({ + actorId: OWNER, + action: "org.invite_revoked", + target: `organization:${id}`, + meta: { inviteId, reason: "inviter_removed" }, + }) + await expect(service.acceptMyInvite(SOCK, inviteId)).rejects.toMatchObject({ + code: "NOT_FOUND", + }) + expect(repo.members.some((m) => m.organizationId === id && m.userId === SOCK)).toBe(false) + }) + + it("revokes them when the admin leaves on their own", async () => { + const id = await orgWithAdmin() + await service.setMemberRole(id, OWNER, MEMBER, "admin") + const inviteId = await inviteAs(id, ADMIN, SOCK_EMAIL) + + await service.removeMember(id, ADMIN, ADMIN) + + expect(statusOf(inviteId)).toBe("revoked") + }) + + it("revokes them when the owner demotes the admin to member", async () => { + const id = await orgWithAdmin() + const inviteId = await inviteAs(id, ADMIN, SOCK_EMAIL) + + await service.setMemberRole(id, OWNER, ADMIN, "member") + + expect(statusOf(inviteId)).toBe("revoked") + expect(repo.audits).toContainEqual({ + actorId: OWNER, + action: "org.invite_revoked", + target: `organization:${id}`, + meta: { inviteId, reason: "inviter_demoted" }, + }) + }) + + it("revokes them on an operator demotion and an operator removal", async () => { + const id = await orgWithAdmin() + await service.setMemberRole(id, OWNER, MEMBER, "admin") + const demotedInvite = await inviteAs(id, ADMIN, SOCK_EMAIL) + const removedInvite = await inviteAs(id, MEMBER, "other@x.org") + + await service.adminSetMemberRole(id, OPERATOR, { + userId: ADMIN, + role: "member", + reason: "policy", + }) + await service.adminRemoveMember(id, OPERATOR, { userId: MEMBER, reason: "policy" }) + + expect(statusOf(demotedInvite)).toBe("revoked") + expect(statusOf(removedInvite)).toBe("revoked") + }) + + it("leaves other inviters' invites, the same inviter's invites in another org, and closed invites alone", async () => { + const id = await orgWithAdmin() + const other = await orgWithAdmin("second-trust") + const ownerInvite = await inviteAs(id, OWNER, "owner-pick@x.org") + const elsewhere = await inviteAs(other, ADMIN, "elsewhere@x.org") + const accepted = await inviteAs(id, ADMIN, SOCK_EMAIL) + await service.acceptMyInvite(SOCK, accepted) + + await service.removeMember(id, OWNER, ADMIN) + + expect(statusOf(ownerInvite)).toBe("pending") + expect(statusOf(elsewhere)).toBe("pending") + expect(statusOf(accepted)).toBe("accepted") + }) + + it("keeps the invites of a member promoted to admin, and of an admin whose demotion is refused", async () => { + const id = await orgWithAdmin() + const inviteId = await inviteAs(id, ADMIN, SOCK_EMAIL) + const ownerSeat = repo.members.findIndex((m) => m.organizationId === id && m.role === "owner") + repo.members.splice(ownerSeat, 1) + + await expect( + repo.setMemberRoleTx({ organizationId: id, userId: ADMIN, role: "member", actorId: ADMIN }), + ).resolves.toBe("last_admin") + await expect(service.removeMember(id, ADMIN, ADMIN)).rejects.toMatchObject({ + code: "VALIDATION", + }) + await repo.setMemberRoleTx({ + organizationId: id, + userId: MEMBER, + role: "admin", + actorId: ADMIN, + }) + + expect(statusOf(inviteId)).toBe("pending") + }) +}) + +describe("inviter-revocation SQL", () => { + function handlers(role: string, seats: number): SqlHandler[] { + return [ + { match: /SELECT role FROM organization_members/, rows: [{ role }] }, + { match: /count\(\*\)::int AS n\s+FROM organization_members/, rows: [{ n: seats }] }, + { match: /DELETE FROM organization_members/, rows: [{ role }] }, + { match: /UPDATE organization_invites/, rows: [{ id: "invite-1" }] }, + { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] }, + ] + } + + function indexOf(statements: { sql: string }[], pattern: RegExp): number { + return statements.findIndex((s) => pattern.test(s.sql)) + } + + const REVOKE = /UPDATE organization_invites\s+SET status = 'revoked'/ + const ORG_LOCK = /FROM organizations WHERE id = \?.*FOR UPDATE/s + + it("revokes the removed member's pending invites in the same transaction, after the org lock", async () => { + const fake = makeFakeSql(handlers("admin", 2)) + + const outcome = await makeDrizzleOrganizationRepository( + fake.sql as unknown as Sql, + ).removeMemberTx({ organizationId: ORG, userId: ADMIN, actorId: OWNER }) + + expect(outcome).toBe("removed") + const revoke = indexOf(fake.statements, REVOKE) + expect(revoke).toBeGreaterThan(indexOf(fake.statements, ORG_LOCK)) + expect(revoke).toBeGreaterThan(indexOf(fake.statements, /DELETE FROM organization_members/)) + const stmt = fake.statements[revoke]! + expect(stmt.sql).toMatch(/organization_id = \?/) + expect(stmt.sql).toMatch(/invited_by = \?/) + expect(stmt.sql).toMatch(/status = 'pending'/) + expect(stmt.values).toEqual(expect.arrayContaining([ORG, ADMIN])) + }) + + it("revokes on an admin-to-member demotion and not when the demotion is refused", async () => { + const allowed = makeFakeSql(handlers("admin", 2)) + await makeDrizzleOrganizationRepository(allowed.sql as unknown as Sql).setMemberRoleTx({ + organizationId: ORG, + userId: ADMIN, + role: "member", + actorId: OWNER, + }) + expect(indexOf(allowed.statements, REVOKE)).toBeGreaterThan(0) + + const refused = makeFakeSql(handlers("admin", 1)) + const outcome = await makeDrizzleOrganizationRepository( + refused.sql as unknown as Sql, + ).setMemberRoleTx({ organizationId: ORG, userId: ADMIN, role: "member", actorId: OWNER }) + expect(outcome).toBe("last_admin") + expect(indexOf(refused.statements, REVOKE)).toBe(-1) + }) + + it("revokes on an operator demotion but not on a promotion", async () => { + const demoted = makeFakeSql(handlers("admin", 2)) + await makeDrizzleOrganizationRepository(demoted.sql as unknown as Sql).adminSetMemberRoleTx({ + organizationId: ORG, + userId: ADMIN, + role: "member", + actorId: OPERATOR, + reason: "policy", + now: clock, + }) + expect(indexOf(demoted.statements, REVOKE)).toBeGreaterThan(0) + + const promoted = makeFakeSql(handlers("member", 2)) + await makeDrizzleOrganizationRepository(promoted.sql as unknown as Sql).adminSetMemberRoleTx({ + organizationId: ORG, + userId: MEMBER, + role: "admin", + actorId: OPERATOR, + reason: "policy", + now: clock, + }) + expect(indexOf(promoted.statements, REVOKE)).toBe(-1) + }) +}) From 2759c758ee82097c7c0f00f6a13e10627dbb8673 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:25:06 +0000 Subject: [PATCH 21/45] host comms: quoted csv cells, suspended orgs stop scheduled sends, critical notices cannot be cancelled, unsubscribe failures answer 503, audits are logged or atomic --- .../api/src/routes/admin/broadcasts.routes.ts | 6 +- services/api/src/routes/admin/pages.routes.ts | 63 ++++-- .../src/routes/host/announcements.routes.ts | 36 +-- .../api/src/routes/host/broadcasts.routes.ts | 62 +++--- .../api/src/routes/host/exports.routes.ts | 19 +- .../api/src/routes/host/unsubscribe.routes.ts | 17 +- .../src/services/host/broadcast-pipeline.ts | 47 +++- .../host/broadcast-repository.drizzle.ts | 28 ++- .../host/broadcast-repository.memory.ts | 9 +- .../src/services/host/broadcast-repository.ts | 8 +- .../src/services/host/broadcast-service.ts | 47 ++-- .../api/src/services/host/comms-wiring.ts | 47 ++-- services/api/src/services/host/export-csv.ts | 5 +- ...host-messaging-suspension-audit-pg.test.ts | 110 ++++++++++ .../unit/admin-host-platform-routes.test.ts | 12 +- .../admin-moderation-audit-security.test.ts | 205 ++++++++++++++++++ .../unit/broadcast-pipeline-security.test.ts | 200 +++++++++++++++++ .../broadcast-repository-security.test.ts | 24 ++ .../unit/broadcast-service-security.test.ts | 159 ++++++++++++++ .../test/unit/comms-wiring-security.test.ts | 82 +++++++ .../api/test/unit/export-csv-security.test.ts | 24 ++ .../api/test/unit/host-export-csv.test.ts | 16 +- .../unit/host-route-audit-security.test.ts | 160 ++++++++++++++ .../unit/unsubscribe-routes-security.test.ts | 155 +++++++++++++ 24 files changed, 1394 insertions(+), 147 deletions(-) create mode 100644 services/api/test/integration/host-messaging-suspension-audit-pg.test.ts create mode 100644 services/api/test/unit/admin-moderation-audit-security.test.ts create mode 100644 services/api/test/unit/broadcast-pipeline-security.test.ts create mode 100644 services/api/test/unit/broadcast-repository-security.test.ts create mode 100644 services/api/test/unit/broadcast-service-security.test.ts create mode 100644 services/api/test/unit/comms-wiring-security.test.ts create mode 100644 services/api/test/unit/export-csv-security.test.ts create mode 100644 services/api/test/unit/host-route-audit-security.test.ts create mode 100644 services/api/test/unit/unsubscribe-routes-security.test.ts diff --git a/services/api/src/routes/admin/broadcasts.routes.ts b/services/api/src/routes/admin/broadcasts.routes.ts index e1fd36f3..0ea38e36 100644 --- a/services/api/src/routes/admin/broadcasts.routes.ts +++ b/services/api/src/routes/admin/broadcasts.routes.ts @@ -1,4 +1,5 @@ import { + AppError, AdminBroadcastListQuerySchema, AdminHostListQuerySchema, SetHostMessagingSuspendedRequestSchema, @@ -12,7 +13,6 @@ import type { Container } from "../../di.js" import { requireAuth } from "../../auth/context.js" import { route } from "../../versioning/route.js" import { parse } from "../_validate.js" -import { writeAudit } from "../../services/admin/audit.js" import { encodeTimeCursor, parseTimeCursor } from "../../db/cursor-helpers.js" import { makeDrizzleBroadcastRepository } from "../../services/host/broadcast-repository.drizzle.js" import type { BroadcastRepository } from "../../services/host/broadcast-repository.js" @@ -158,13 +158,13 @@ export async function registerAdminBroadcastRoutes( async (request, reply) => { const operatorId = requireAuth(request) const body = parse(SetHostMessagingSuspendedRequestSchema, mergeParams(request)) - await broadcastRepo().setHostMessagingSuspended(body.id, body.suspended) - await writeAudit(container.getDb().sql, { + const found = await broadcastRepo().setHostMessagingSuspended(body.id, body.suspended, { action: body.suspended ? "host.messaging_suspended" : "host.messaging_restored", actorId: operatorId, target: `user:${body.id}`, meta: { reason: body.reason }, }) + if (!found) throw AppError.notFound("User not found.") const payload: SetHostMessagingSuspendedResponse = { ok: true, suspended: body.suspended } reply.status(200).send(payload) }, diff --git a/services/api/src/routes/admin/pages.routes.ts b/services/api/src/routes/admin/pages.routes.ts index 3ce5afd9..93b17890 100644 --- a/services/api/src/routes/admin/pages.routes.ts +++ b/services/api/src/routes/admin/pages.routes.ts @@ -13,7 +13,7 @@ import type { Container } from "../../di.js" import { requireAuth } from "../../auth/context.js" import { route } from "../../versioning/route.js" import { parse } from "../_validate.js" -import { writeAudit } from "../../services/admin/audit.js" +import { writeAudit, type WriteAuditInput } from "../../services/admin/audit.js" import { encodeTimeCursor, parseTimeCursor } from "../../db/cursor-helpers.js" import { makeDrizzleAdminEventPageRepository, @@ -26,8 +26,11 @@ import { makeEventMediaPresigner } from "../../services/host/event-media.js" export const ADMIN_EVENT_PAGE_DEFAULT_LIMIT = 50 +type RecordAudit = (entry: WriteAuditInput) => Promise + export interface AdminEventPageOverrides { repo: AdminEventPageRepository + audit?: RecordAudit } declare module "fastify" { @@ -83,6 +86,22 @@ export async function registerAdminEventPageRoutes( const repo = (): AdminEventPageRepository => app.adminEventPageOverrides?.repo ?? makeDrizzleAdminEventPageRepository(container.getDb().sql) + /** + * A moderation effect and its audit row commit together: a failed audit rolls the flag or unpublish + * back instead of leaving an operator action applied with no record of who took it. + */ + function moderate( + fn: (pages: AdminEventPageRepository, recordAudit: RecordAudit) => Promise, + ): Promise { + const overrides = app.adminEventPageOverrides + if (overrides) return fn(overrides.repo, overrides.audit ?? (() => Promise.resolve())) + return container + .getDb() + .sql.begin((tx) => + fn(makeDrizzleAdminEventPageRepository(tx), (entry) => writeAudit(tx, entry)), + ) as Promise + } + route(app, "adminListEventPages", async (request, reply) => { requireAuth(request) const query = parse(AdminEventPageListQuerySchema, request.query) @@ -123,32 +142,40 @@ export async function registerAdminEventPageRoutes( route(app, "adminFlagEventPage", { preHandler: csrfProtect }, async (request, reply) => { const operatorId = requireAuth(request) const body = parse(FlagEventPageRequestSchema, mergeParams(request)) - const row = await repo().setFlagged(body.id, { - flagged: body.flagged, - reason: body.reason ?? null, - operatorId, + const row = await moderate(async (pages, recordAudit) => { + const flagged = await pages.setFlagged(body.id, { + flagged: body.flagged, + reason: body.reason ?? null, + operatorId, + }) + if (flagged === null) return null + await recordAudit({ + action: body.flagged ? "event_page.flagged" : "event_page.unflagged", + actorId: operatorId, + target: `cleanup:${body.id}`, + meta: { reason: body.reason ?? null }, + }) + return flagged }) if (row === null) throw AppError.notFound("Signup page not found.") - await writeAudit(container.getDb().sql, { - action: body.flagged ? "event_page.flagged" : "event_page.unflagged", - actorId: operatorId, - target: `cleanup:${body.id}`, - meta: { reason: body.reason ?? null }, - }) reply.status(200).send(toDTO(row)) }) route(app, "adminUnpublishEventPage", { preHandler: csrfProtect }, async (request, reply) => { const operatorId = requireAuth(request) const body = parse(UnpublishEventPageRequestSchema, mergeParams(request)) - const row = await repo().unpublish(body.id) - if (row === null) throw AppError.notFound("Signup page not found.") - await writeAudit(container.getDb().sql, { - action: "event_page.unpublished", - actorId: operatorId, - target: `cleanup:${body.id}`, - meta: { reason: body.reason }, + const row = await moderate(async (pages, recordAudit) => { + const unpublished = await pages.unpublish(body.id) + if (unpublished === null) return null + await recordAudit({ + action: "event_page.unpublished", + actorId: operatorId, + target: `cleanup:${body.id}`, + meta: { reason: body.reason }, + }) + return unpublished }) + if (row === null) throw AppError.notFound("Signup page not found.") reply.status(200).send(toDTO(row)) }) } diff --git a/services/api/src/routes/host/announcements.routes.ts b/services/api/src/routes/host/announcements.routes.ts index 3d328c0f..9e330074 100644 --- a/services/api/src/routes/host/announcements.routes.ts +++ b/services/api/src/routes/host/announcements.routes.ts @@ -13,9 +13,8 @@ import { perIdentity } from "../../plugins/rate-limit.js" import { route } from "../../versioning/route.js" import { parse, trimTextFields } from "../_validate.js" import { requireCapability, resolveVisibleStanding } from "../../services/host/authz.js" -import { writeAudit } from "../../services/admin/audit.js" import { BroadcastCapError, capError } from "../../services/host/broadcast-service.js" -import { makeCommsRuntime } from "../../services/host/comms-wiring.js" +import { auditBestEffort, makeCommsRuntime } from "../../services/host/comms-wiring.js" import type { CommsRuntime } from "../../services/host/comms-wiring.js" import type { AnnouncementProjection, @@ -78,10 +77,16 @@ export async function registerHostAnnouncementRoutes( if (err instanceof BroadcastCapError) throw capError(err.kind) throw err } - await audit(container, "event.announcement_sent", userId, payload.id, { - cleanupId: body.id, - audience: body.audience.kind, - }) + await auditBestEffort( + container.getDb().sql, + { + action: "event.announcement_sent", + actorId: userId, + target: `broadcast:${payload.id}`, + meta: { cleanupId: body.id, audience: body.audience.kind }, + }, + request.log, + ) reply.status(200).send(payload) }, ) @@ -120,22 +125,3 @@ export async function registerHostAnnouncementRoutes( }, ) } - -async function audit( - container: Container, - action: string, - actorId: string, - announcementId: string, - meta: Record, -): Promise { - try { - await writeAudit(container.getDb().sql, { - action, - actorId, - target: `broadcast:${announcementId}`, - meta, - }) - } catch { - return - } -} diff --git a/services/api/src/routes/host/broadcasts.routes.ts b/services/api/src/routes/host/broadcasts.routes.ts index 0635276a..268307f0 100644 --- a/services/api/src/routes/host/broadcasts.routes.ts +++ b/services/api/src/routes/host/broadcasts.routes.ts @@ -24,14 +24,13 @@ import { perIdentity } from "../../plugins/rate-limit.js" import { route } from "../../versioning/route.js" import { parse } from "../_validate.js" import { requireCapability, resolveVisibleStanding } from "../../services/host/authz.js" -import { writeAudit } from "../../services/admin/audit.js" import { BroadcastCapError, capError, emailHashOf, type BroadcastService, } from "../../services/host/broadcast-service.js" -import { makeCommsRuntime } from "../../services/host/comms-wiring.js" +import { auditBestEffort, makeCommsRuntime } from "../../services/host/comms-wiring.js" import type { CommsRuntime } from "../../services/host/comms-wiring.js" export const BROADCAST_WRITE_RATE_LIMIT = perIdentity({ max: 30, timeWindow: "1 minute" }) @@ -176,9 +175,16 @@ export async function registerHostBroadcastRoutes( const payload = await withCaps(() => service().testSend(params.id, userId, params.broadcastId), ) - await audit(container, "event.broadcast_test_sent", userId, params.broadcastId, { - cleanupId: params.id, - }) + await auditBestEffort( + container.getDb().sql, + { + action: "event.broadcast_test_sent", + actorId: userId, + target: `broadcast:${params.broadcastId}`, + meta: { cleanupId: params.id }, + }, + request.log, + ) reply.status(200).send(payload) }, ) @@ -192,15 +198,24 @@ export async function registerHostBroadcastRoutes( const params = parse(SendEventBroadcastRequestSchema, mergeParams(request)) await requireCapability(container.getDb().sql, params.id, userId, "broadcast") const payload = await withCaps(() => service().send(params.id, userId, params.broadcastId)) - await audit(container, "event.broadcast_sent", userId, params.broadcastId, { - cleanupId: params.id, - segment: payload.segment?.kind ?? null, - channels: payload.channels, - subjectHash: - payload.subject === null || payload.subject === undefined - ? null - : emailHashOf(payload.subject), - }) + await auditBestEffort( + container.getDb().sql, + { + action: "event.broadcast_sent", + actorId: userId, + target: `broadcast:${params.broadcastId}`, + meta: { + cleanupId: params.id, + segment: payload.segment?.kind ?? null, + channels: payload.channels, + subjectHash: + payload.subject === null || payload.subject === undefined + ? null + : emailHashOf(payload.subject), + }, + }, + request.log, + ) reply.status(200).send(payload) }, ) @@ -265,22 +280,3 @@ export async function registerHostBroadcastRoutes( }, ) } - -async function audit( - container: Container, - action: string, - actorId: string, - broadcastId: string, - meta: Record, -): Promise { - try { - await writeAudit(container.getDb().sql, { - action, - actorId, - target: `broadcast:${broadcastId}`, - meta, - }) - } catch { - return - } -} diff --git a/services/api/src/routes/host/exports.routes.ts b/services/api/src/routes/host/exports.routes.ts index 1d70334e..9854e84e 100644 --- a/services/api/src/routes/host/exports.routes.ts +++ b/services/api/src/routes/host/exports.routes.ts @@ -15,9 +15,8 @@ import { perIdentity } from "../../plugins/rate-limit.js" import { route } from "../../versioning/route.js" import { parse } from "../_validate.js" import { requireCapability } from "../../services/host/authz.js" -import { writeAudit } from "../../services/admin/audit.js" import { HOST_EXPORT_JOB } from "../../services/host/broadcast-queues.js" -import { makeCommsRuntime } from "../../services/host/comms-wiring.js" +import { auditBestEffort, makeCommsRuntime } from "../../services/host/comms-wiring.js" import type { CommsRuntime } from "../../services/host/comms-wiring.js" import { toHostExportDTO, type HostExportService } from "../../services/host/export-service.js" @@ -80,12 +79,16 @@ export async function registerHostExportRoutes( { exportId: payload.id }, { singletonKey: `export:${payload.id}`, retryLimit: 2 }, ) - await writeAudit(container.getDb().sql, { - action: "event.roster_exported", - actorId: userId, - target: `cleanup:${body.id}`, - meta: { exportId: payload.id, kind: body.kind }, - }) + await auditBestEffort( + container.getDb().sql, + { + action: "event.roster_exported", + actorId: userId, + target: `cleanup:${body.id}`, + meta: { exportId: payload.id, kind: body.kind }, + }, + request.log, + ) reply.status(200).send(payload) }, ) diff --git a/services/api/src/routes/host/unsubscribe.routes.ts b/services/api/src/routes/host/unsubscribe.routes.ts index c7c28428..41af97a6 100644 --- a/services/api/src/routes/host/unsubscribe.routes.ts +++ b/services/api/src/routes/host/unsubscribe.routes.ts @@ -1,4 +1,6 @@ import { + AppError, + ErrorCode, OpenUnsubscribeBroadcastsRequestSchema, UnsubscribeBroadcastsRequestSchema, type UnsubscribeBroadcastsResponse, @@ -7,6 +9,7 @@ import type { FastifyInstance, FastifyRequest } from "fastify" import type { Container } from "../../di.js" import { perHost } from "../../plugins/rate-limit.js" import { route } from "../../versioning/route.js" +import { exposeMessage } from "../../errors/exposed-message.js" import { makeCommsRuntime, webBaseUrlOf } from "../../services/host/comms-wiring.js" import type { CommsRuntime } from "../../services/host/comms-wiring.js" @@ -14,6 +17,8 @@ export const UNSUBSCRIBE_RATE_LIMIT = perHost({ max: 60, timeWindow: "1 minute" const OK: UnsubscribeBroadcastsResponse = { ok: true } +const UNSUBSCRIBE_UNAVAILABLE_COPY = "We couldn't save your unsubscribe. Please try again." + function tokenFrom(request: FastifyRequest): string | null { const body = (request.body ?? {}) as Record if (typeof body.token === "string" && body.token.length > 0) return body.token @@ -35,7 +40,7 @@ export async function registerUnsubscribeRoutes( } function webBaseUrl(): string { - return webBaseUrlOf(container.env?.WEB_ORIGINS ?? []) + return webBaseUrlOf(container.env ?? {}) } await app.register(async (unsubscribeScope) => { @@ -69,7 +74,15 @@ export async function registerUnsubscribeRoutes( try { await runtime().broadcasts.unsubscribe(parsed.data.token) } catch (err) { - request.log.error({ err }, "unsubscribe: write failed (answering 200 regardless)") + // Only a verified token reaches the write, so a failure here reveals nothing about token + // validity. 5xx is the retry signal RFC 8058 senders and the web confirmation page act on; + // a 200 would drop the opt-out for good. + throw exposeMessage( + new AppError(ErrorCode.INTERNAL, UNSUBSCRIBE_UNAVAILABLE_COPY, { + httpStatus: 503, + cause: err, + }), + ) } reply.status(200).send(OK) }, diff --git a/services/api/src/services/host/broadcast-pipeline.ts b/services/api/src/services/host/broadcast-pipeline.ts index e647d4bc..a744ae41 100644 --- a/services/api/src/services/host/broadcast-pipeline.ts +++ b/services/api/src/services/host/broadcast-pipeline.ts @@ -18,7 +18,7 @@ import type { DeliveryRowInput, EventBroadcastContext, } from "./broadcast-types.js" -import { CRITICAL_BROADCAST_KINDS } from "./broadcast-types.js" +import { CRITICAL_BROADCAST_KINDS, HOST_COMPOSED_BROADCAST_KINDS } from "./broadcast-types.js" import { eventManageUrl, eventPath, @@ -140,7 +140,7 @@ export class TokenBucket { } export interface PlanOutcome { - kind: "planned" | "skipped" | "killed" | "too_many" | "over_budget" | "empty" + kind: "planned" | "skipped" | "killed" | "org_suspended" | "too_many" | "over_budget" | "empty" recipients?: number chunks?: number } @@ -204,6 +204,39 @@ export function makeBroadcastPipeline(deps: BroadcastPipelineDeps) { ) } + /** + * DECISIONS §32: an operator-suspended organization's events send no host-composed message, and a + * release or a queued plan is a send just as much as the compose-time call was. Critical automated + * notices (a cancellation, a changed time) stay deliverable: attendees must still hear about them. + */ + async function organizationSuspendedFor(record: BroadcastRecord): Promise { + if (!HOST_COMPOSED_BROADCAST_KINDS.has(record.kind)) return false + const event = await repo.eventContext(record.cleanupId) + return event?.organizationSuspended === true + } + + async function failForSuspendedOrganization( + record: BroadcastRecord, + from: "scheduled" | "sending", + at: Date, + ): Promise { + const moved = await repo.transition(record.id, [from], "failed", { finishedAt: at }) + if (moved === null) return false + await repo.suppressRemaining(record.id, "kill_switch") + await repo.refreshCounts(record.id) + await insights.bumpInsightsGeneration(record.cleanupId) + deps.logger?.warn( + { + evt: "broadcast.failed", + broadcastId: record.id, + cleanupId: record.cleanupId, + reason: "org_suspended", + }, + "broadcast refused: the event's organization is suspended", + ) + return true + } + async function plan(broadcastId: string): Promise { const record = await repo.findById(broadcastId) if (record === null || record.status !== "sending") return { kind: "skipped" } @@ -211,6 +244,10 @@ export function makeBroadcastPipeline(deps: BroadcastPipelineDeps) { await killBroadcast(record, "kill_switch") return { kind: "killed" } } + if (await organizationSuspendedFor(record)) { + const failed = await failForSuspendedOrganization(record, "sending", now()) + return { kind: failed ? "org_suspended" : "skipped" } + } const segment = record.segment ?? { kind: "all_registered" as const } const memberIds: string[] = [] @@ -760,6 +797,12 @@ export function makeBroadcastPipeline(deps: BroadcastPipelineDeps) { } async function releaseScheduled(id: string, at: Date): Promise { + const due = await repo.findById(id) + if (due === null || due.status !== "scheduled") return false + if (await organizationSuspendedFor(due)) { + await failForSuspendedOrganization(due, "scheduled", at) + return false + } const moved = await repo.transition(id, ["scheduled"], "sending", { startedAt: at }) if (moved === null) return false if (moved.createdBy !== null && moved.kind === "host_broadcast") { diff --git a/services/api/src/services/host/broadcast-repository.drizzle.ts b/services/api/src/services/host/broadcast-repository.drizzle.ts index d48545ae..52aac804 100644 --- a/services/api/src/services/host/broadcast-repository.drizzle.ts +++ b/services/api/src/services/host/broadcast-repository.drizzle.ts @@ -7,6 +7,8 @@ import type { DeliveryStatus, } from "@civfix/shared" import type { Queryable, Sql } from "../../db/client.js" +import { writeAudit, type WriteAuditInput } from "../admin/audit.js" +import { likeContains } from "../admin/like.js" import { listGuestAudiencePage, listMemberAudiencePage } from "./broadcast-audience-sql.js" import type { AdminBroadcastListQuery, @@ -310,7 +312,7 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { : sql`AND NOT COALESCE(m.host_messaging_suspended, false)` const search = params.q !== undefined && params.q.length > 0 - ? sql`AND (u.display_name ILIKE ${`%${params.q}%`} OR u.handle ILIKE ${`%${params.q}%`})` + ? sql`AND (u.display_name ILIKE ${likeContains(params.q)} ESCAPE '\\' OR u.handle ILIKE ${likeContains(params.q)} ESCAPE '\\')` : sql`` const cursorFilter = params.cursor !== null @@ -873,14 +875,22 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { } }, - async setHostMessagingSuspended(userId: string, suspended: boolean): Promise { - const rows = await sql<{ user_id: string }[]>` - INSERT INTO user_moderation (user_id, host_messaging_suspended) - VALUES (${userId}, ${suspended}) - ON CONFLICT (user_id) DO UPDATE - SET host_messaging_suspended = ${suspended}, updated_at = now() - RETURNING user_id` - return rows.length > 0 + setHostMessagingSuspended( + userId: string, + suspended: boolean, + audit: WriteAuditInput, + ): Promise { + return sql.begin(async (tx) => { + const rows = await tx<{ user_id: string }[]>` + INSERT INTO user_moderation (user_id, host_messaging_suspended) + SELECT u.id, ${suspended} FROM users u WHERE u.id = ${userId} + ON CONFLICT (user_id) DO UPDATE + SET host_messaging_suspended = ${suspended}, updated_at = now() + RETURNING user_id` + if (rows.length === 0) return false + await writeAudit(tx, audit) + return true + }) as Promise }, async isEmailSuppressed(emailHash: string): Promise { diff --git a/services/api/src/services/host/broadcast-repository.memory.ts b/services/api/src/services/host/broadcast-repository.memory.ts index 82c85eb1..43acc79d 100644 --- a/services/api/src/services/host/broadcast-repository.memory.ts +++ b/services/api/src/services/host/broadcast-repository.memory.ts @@ -13,6 +13,7 @@ import type { } from "./broadcast-repository.js" import { DEFAULT_PREFS } from "../notification-helpers.js" import type { NotificationPrefsRecord } from "../notification-service.js" +import type { WriteAuditInput } from "../admin/audit.js" import type { AdminBroadcastRow, AdminHostListParams, @@ -89,6 +90,7 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { private readonly events = new Map() private readonly hosts = new Map() private dueReminders: DueReminder[] = [] + readonly audits: WriteAuditInput[] = [] seedEvent( context: Omit & { @@ -672,7 +674,12 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { return Promise.resolve(this.hosts.get(userId) ?? null) } - setHostMessagingSuspended(userId: string, suspended: boolean): Promise { + setHostMessagingSuspended( + userId: string, + suspended: boolean, + audit: WriteAuditInput, + ): Promise { + this.audits.push(audit) const existing = this.hosts.get(userId) ?? { suspended: false, emailVerified: true, diff --git a/services/api/src/services/host/broadcast-repository.ts b/services/api/src/services/host/broadcast-repository.ts index 4987dbd6..e2d8ed77 100644 --- a/services/api/src/services/host/broadcast-repository.ts +++ b/services/api/src/services/host/broadcast-repository.ts @@ -5,6 +5,7 @@ import type { DeliveryStatus, } from "@civfix/shared" import type { NotificationPrefsRecord } from "../notification-service.js" +import type { WriteAuditInput } from "../admin/audit.js" import type { AdminBroadcastRow, AdminHostListParams, @@ -152,7 +153,12 @@ export interface BroadcastRepository { eventContext(cleanupId: string): Promise hostMessagingState(userId: string): Promise - setHostMessagingSuspended(userId: string, suspended: boolean): Promise + /** Writes `audit` in the same transaction as the flag; false (and no audit row) for an unknown user. */ + setHostMessagingSuspended( + userId: string, + suspended: boolean, + audit: WriteAuditInput, + ): Promise isEmailSuppressed(emailHash: string): Promise suppressedEmailHashes(emailHashes: readonly string[]): Promise> diff --git a/services/api/src/services/host/broadcast-service.ts b/services/api/src/services/host/broadcast-service.ts index 3d54a89b..94812e8b 100644 --- a/services/api/src/services/host/broadcast-service.ts +++ b/services/api/src/services/host/broadcast-service.ts @@ -21,6 +21,7 @@ import type { CounterStore } from "../../abuse/counter-store.js" import { encodeTimeCursor, parseTimeCursor } from "../../db/cursor-helpers.js" import type { BroadcastRepository } from "./broadcast-repository.js" import type { BroadcastRecord, EventBroadcastContext } from "./broadcast-types.js" +import { CRITICAL_BROADCAST_KINDS } from "./broadcast-types.js" import { assertBroadcastLinkPolicy, broadcastLinkWarnings, @@ -265,6 +266,21 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi return record } + /** + * A critical automated notice (the event was cancelled or changed) is the platform telling attendees, + * not a host message: anyone holding `broadcast`, a coordinator included, must not be able to stop, + * rewrite or delete it. + */ + async function requireHostControllable( + cleanupId: string, + broadcastId: string, + refusal: string, + ): Promise { + const record = await requireDraft(cleanupId, broadcastId) + if (CRITICAL_BROADCAST_KINDS.has(record.kind)) throw AppError.conflict(refusal) + return record + } + /** * The org-suspension gate (DECISIONS §32): an event linked to an operator-suspended organization * cannot compose, send or schedule host broadcasts. Same code + wording as the org service's own @@ -356,7 +372,11 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi async update(cleanupId, actorId, body) { await guardHost(actorId) - const current = await requireDraft(cleanupId, body.broadcastId) + const current = await requireHostControllable( + cleanupId, + body.broadcastId, + "Automatic messages can't be edited.", + ) const subject = body.subject ?? current.subject ?? "" const bodyMd = body.bodyMd ?? current.bodyMd ?? "" const ctaUrl = "ctaUrl" in body ? (body.ctaUrl ?? null) : current.ctaUrl @@ -377,6 +397,7 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi }, async remove(cleanupId, broadcastId) { + await requireHostControllable(cleanupId, broadcastId, "Automatic messages can't be deleted.") const deleted = await repo.deleteDraft(cleanupId, broadcastId) if (!deleted) throw AppError.conflict("That message can no longer be deleted.") return { ok: true } @@ -500,7 +521,11 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi }, async cancel(cleanupId, broadcastId) { - await requireDraft(cleanupId, broadcastId) + await requireHostControllable( + cleanupId, + broadcastId, + "Automatic messages can't be cancelled.", + ) const moved = await repo.transition( broadcastId, ["draft", "scheduled", "sending"], @@ -559,17 +584,13 @@ export function makeBroadcastService(deps: BroadcastServiceDeps): BroadcastServi now().getTime(), ) if (capability !== null) { - try { - await repo.recordUnsubscribe({ - scope: "event", - cleanupId: capability.cleanupId, - subjectKind: capability.subjectKind, - subjectId: capability.subjectId, - reason: "one_click", - }) - } catch (err) { - deps.logger?.error({ err }, "broadcast: one-click unsubscribe write failed") - } + await repo.recordUnsubscribe({ + scope: "event", + cleanupId: capability.cleanupId, + subjectKind: capability.subjectKind, + subjectId: capability.subjectId, + reason: "one_click", + }) } return { ok: true } }, diff --git a/services/api/src/services/host/comms-wiring.ts b/services/api/src/services/host/comms-wiring.ts index 0acaf90d..4f814567 100644 --- a/services/api/src/services/host/comms-wiring.ts +++ b/services/api/src/services/host/comms-wiring.ts @@ -1,8 +1,10 @@ import { AppError } from "@civfix/shared" import type { FastifyBaseLogger } from "fastify" import type { Container } from "../../di.js" +import type { Queryable } from "../../db/client.js" import { domainOf } from "../../adapters/mail-text.js" -import { writeAudit } from "../admin/audit.js" +import { apiBaseUrlOf, webBaseUrlOf } from "../../lib/base-url.js" +import { writeAudit, type WriteAuditInput } from "../admin/audit.js" import { makeRouteNotificationService } from "../route-notifier.js" import { makeDrizzleAnalyticsRepository } from "./analytics-repository.drizzle.js" import { makeAnalyticsService, type AnalyticsService } from "./analytics-service.js" @@ -54,16 +56,26 @@ export interface CommsRuntime { exports: HostExportService } -export function webBaseUrlOf(webOrigins: readonly string[]): string { - const origin = webOrigins[0] - return origin !== undefined && origin.length > 0 - ? origin.replace(/\/+$/, "") - : "https://civfix.org" -} - -export function apiBaseUrlOf(publicApiUrl: string): string { - const trimmed = publicApiUrl.trim().replace(/\/+$/, "") - return trimmed.length > 0 ? trimmed : "https://api.civfix.org" +export { apiBaseUrlOf, webBaseUrlOf } from "../../lib/base-url.js" + +/** + * The effect this row records has already happened (a send, an export enqueue), so a failed audit + * write must not turn it into an error the caller would retry. It is logged instead of dropped so the + * gap in the trail is visible. + */ +export async function auditBestEffort( + sql: Queryable, + entry: WriteAuditInput, + logger: Pick | undefined, +): Promise { + try { + await writeAudit(sql, entry) + } catch (err) { + logger?.warn( + { err, action: entry.action, target: entry.target ?? null }, + "audit write failed (suppressed)", + ) + } } function selfHostsOf(webOrigins: readonly string[]): string[] { @@ -93,8 +105,8 @@ export function broadcastConfigOf(container: Container): BroadcastConfig { linkAllowedHosts: env.BROADCAST_LINK_ALLOWED_HOSTS, mailFromEvents: env.MAIL_FROM_EVENTS, unsubscribeSigningKey: env.UNSUBSCRIBE_SIGNING_KEY, - webBaseUrl: webBaseUrlOf(container.env.WEB_ORIGINS), - apiBaseUrl: apiBaseUrlOf(container.env.PUBLIC_API_URL), + webBaseUrl: webBaseUrlOf(env), + apiBaseUrl: apiBaseUrlOf(env), eventUpdatePerEventPerHour: env.HOST_EVENT_UPDATE_PER_EVENT_PER_HOUR, } } @@ -157,13 +169,8 @@ export function makeCommsRuntime(container: Container, logger?: CommsLogger): Co }, ) }, - audit: async (action, actorId, target, meta) => { - try { - await writeAudit(sql, { action, actorId, target, meta }) - } catch (err) { - logger?.warn({ err, action }, "broadcast audit write failed (suppressed)") - } - }, + audit: (action, actorId, target, meta) => + auditBestEffort(sql, { action, actorId, target, meta }, logger), ...(logger !== undefined ? { logger } : {}), }) diff --git a/services/api/src/services/host/export-csv.ts b/services/api/src/services/host/export-csv.ts index 4731546f..c092f92b 100644 --- a/services/api/src/services/host/export-csv.ts +++ b/services/api/src/services/host/export-csv.ts @@ -4,8 +4,9 @@ export function csvCell(value: string | number | null | undefined): string { if (value === null || value === undefined) return "" const raw = typeof value === "number" ? String(value) : value const guarded = INJECTION_PREFIX_RE.test(raw) ? `'${raw}` : raw - if (/[",\n\r]/.test(guarded)) return `"${guarded.replace(/"/g, '""')}"` - return guarded + // Quoting every cell, not only those holding a comma, keeps a ';' inside the value from opening a + // new field (and a formula) when a list-separator ';' locale of Excel opens the file. + return `"${guarded.replace(/"/g, '""')}"` } export function csvRow(cells: readonly (string | number | null | undefined)[]): string { diff --git a/services/api/test/integration/host-messaging-suspension-audit-pg.test.ts b/services/api/test/integration/host-messaging-suspension-audit-pg.test.ts new file mode 100644 index 00000000..9e2555b3 --- /dev/null +++ b/services/api/test/integration/host-messaging-suspension-audit-pg.test.ts @@ -0,0 +1,110 @@ +import { randomUUID } from "node:crypto" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { makeDrizzleBroadcastRepository } from "../../src/services/host/broadcast-repository.drizzle.js" +import type { BroadcastRepository } from "../../src/services/host/broadcast-repository.js" + +const pg = await withPg() + +describe.skipIf(!pg)("host messaging suspension and operator host search (integration)", () => { + let h: PgHarness + let repo: BroadcastRepository + + beforeAll(() => { + h = pg as PgHarness + repo = makeDrizzleBroadcastRepository(h.sql) + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newUser(displayName: string): Promise { + const handle = `u${randomUUID().replace(/-/g, "").slice(0, 15)}` + const [row] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name, handle) VALUES (${displayName}, ${handle}) RETURNING id` + return row!.id + } + + async function suspendedFlag(userId: string): Promise { + const rows = await h.sql<{ suspended: boolean }[]>` + SELECT host_messaging_suspended AS suspended FROM user_moderation WHERE user_id = ${userId}` + return rows[0]?.suspended ?? null + } + + async function auditCount(userId: string): Promise { + const [row] = await h.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM audit_log WHERE target = ${`user:${userId}`}` + return row!.n + } + + it("commits the suspension and its audit row together", async () => { + const operator = await newUser("Operator") + const host = await newUser("Ada Host") + + const found = await repo.setHostMessagingSuspended(host, true, { + action: "host.messaging_suspended", + actorId: operator, + target: `user:${host}`, + meta: { reason: "spam" }, + }) + + expect(found).toBe(true) + expect(await suspendedFlag(host)).toBe(true) + expect(await auditCount(host)).toBe(1) + }) + + it("rolls the suspension back when the audit row cannot be written", async () => { + const host = await newUser("Bea Host") + + await expect( + repo.setHostMessagingSuspended(host, true, { + action: "host.messaging_suspended", + actorId: randomUUID(), + target: `user:${host}`, + meta: { reason: "spam" }, + }), + ).rejects.toThrow() + + expect(await suspendedFlag(host)).toBeNull() + expect(await auditCount(host)).toBe(0) + }) + + it("reports an unknown user as not found and audits nothing", async () => { + const operator = await newUser("Operator Two") + const ghost = randomUUID() + + const found = await repo.setHostMessagingSuspended(ghost, true, { + action: "host.messaging_suspended", + actorId: operator, + target: `user:${ghost}`, + meta: { reason: "spam" }, + }) + + expect(found).toBe(false) + expect(await suspendedFlag(ghost)).toBeNull() + expect(await auditCount(ghost)).toBe(0) + }) + + it("treats % and _ in the host search as literal characters", async () => { + const operator = await newUser("Operator Three") + const host = await newUser("Cy Literal") + await repo.setHostMessagingSuspended(host, true, { + action: "host.messaging_suspended", + actorId: operator, + target: `user:${host}`, + }) + const search = (q: string) => + repo.listAdminHosts({ + q, + suspended: true, + windowStart: new Date(Date.now() - 24 * 60 * 60 * 1000), + cursor: null, + limit: 50, + }) + + expect((await search("Cy Lit")).map((row) => row.userId)).toContain(host) + expect(await search("%")).toEqual([]) + expect(await search("C_ Literal")).toEqual([]) + }) +}) 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 66ae3d74..b3732dee 100644 --- a/services/api/test/unit/admin-host-platform-routes.test.ts +++ b/services/api/test/unit/admin-host-platform-routes.test.ts @@ -394,7 +394,11 @@ describe("admin org verification decision", () => { describe("admin host list", () => { it("returns a suspended host even with no broadcast activity in the window", async () => { const h = await harness() - await h.broadcasts.setHostMessagingSuspended(HOST, true) + await h.broadcasts.setHostMessagingSuspended(HOST, true, { + action: "host.messaging_suspended", + actorId: OPERATOR, + target: `user:${HOST}`, + }) const res = await h.app.inject({ method: "GET", url: "/v1/admin/hosts" }) expect(res.statusCode).toBe(200) @@ -415,7 +419,11 @@ describe("admin host list", () => { it("filters to the suspended set when asked", async () => { const h = await harness() - await h.broadcasts.setHostMessagingSuspended(HOST, true) + await h.broadcasts.setHostMessagingSuspended(HOST, true, { + action: "host.messaging_suspended", + actorId: OPERATOR, + target: `user:${HOST}`, + }) const suspended = await h.app.inject({ method: "GET", url: "/v1/admin/hosts?suspended=true" }) expect((suspended.json() as { items: unknown[] }).items).toHaveLength(1) diff --git a/services/api/test/unit/admin-moderation-audit-security.test.ts b/services/api/test/unit/admin-moderation-audit-security.test.ts new file mode 100644 index 00000000..0e1e8b2c --- /dev/null +++ b/services/api/test/unit/admin-moderation-audit-security.test.ts @@ -0,0 +1,205 @@ +import Fastify, { type FastifyInstance } from "fastify" +import { afterEach, describe, expect, it } from "vitest" +import { FakeStorage } from "@civfix/shared/fakes" +import { makeErrorHandler, makeNotFoundHandler } from "../../src/errors/http-mapper.js" +import type { Container } from "../../src/di.js" +import { registerAdminEventPageRoutes } from "../../src/routes/admin/pages.routes.js" +import { registerAdminBroadcastRoutes } from "../../src/routes/admin/broadcasts.routes.js" +import { makeFakeSql, type FakeSqlControl, type SqlHandler } from "../helpers/fake-sql.js" + +const OPERATOR = "11111111-1111-4111-8111-111111111111" +const CLEANUP = "22222222-2222-4222-8222-222222222222" +const HOST = "44444444-4444-4444-8444-444444444444" +const AUDIT_ID = "77777777-7777-4777-8777-777777777777" + +const PAGE_ROW = { + page_id: "55555555-5555-4555-8555-555555555555", + cleanup_id: CLEANUP, + slug: "beach-sweep", + title: "Beach sweep", + status: "unpublished", + visibility: "public", + organizer_id: HOST, + organizer_name: "Ada", + organizer_handle: "ada", + organizer_joined: new Date("2026-01-01T00:00:00.000Z"), + org_name: null, + view_count: "12", + published_at: new Date("2026-08-01T00:00:00.000Z"), + flagged_at: new Date("2026-09-01T00:00:00.000Z"), + flag_reason: "spam", + flagged_by_id: OPERATOR, + flagged_by_name: "Op", + flagged_by_handle: "op", + flagged_by_joined: new Date("2026-01-01T00:00:00.000Z"), + sort_at: new Date("2026-08-01T00:00:00.000Z"), +} + +const auditOk: SqlHandler = { match: /INSERT INTO audit_log/, rows: [{ id: AUDIT_ID }] } +const auditDown: SqlHandler = { + match: /INSERT INTO audit_log/, + rows: () => { + throw new Error("audit_log unavailable") + }, +} + +interface Harness { + app: FastifyInstance + outer: FakeSqlControl + tx: FakeSqlControl +} + +let app: FastifyInstance | undefined + +afterEach(async () => { + await app?.close() + app = undefined +}) + +async function harness(txHandlers: SqlHandler[]): Promise { + const outer = makeFakeSql() + const tx = makeFakeSql(txHandlers) + outer.sql.begin = (cb) => cb(tx.sql) + const container = { + env: { NODE_ENV: "test", WEB_ORIGINS: ["http://localhost:3000"] }, + storage: new FakeStorage(), + csrf: { protect: (_req: unknown, _reply: unknown, done: () => void) => done() }, + getDb: () => ({ sql: outer.sql }), + } as unknown as Container + + const instance = Fastify({ logger: false }) + instance.setErrorHandler(makeErrorHandler()) + instance.setNotFoundHandler(makeNotFoundHandler()) + ;(instance.decorateRequest as (name: string, value: unknown) => void)("auth", null) + instance.addHook("onRequest", (request, _reply, done) => { + ;(request as { auth?: unknown }).auth = { userId: OPERATOR, roles: ["operator"] } + done() + }) + await registerAdminEventPageRoutes(instance, container) + await registerAdminBroadcastRoutes(instance, container) + await instance.ready() + app = instance + return { app: instance, outer, tx } +} + +function statementsMatching(control: FakeSqlControl, pattern: RegExp): string[] { + return control.statements.map((s) => s.sql).filter((sql) => pattern.test(sql)) +} + +describe("operator page moderation writes its audit row in the effect's transaction", () => { + it("flags a page and audits it inside one transaction", async () => { + const h = await harness([ + { match: /UPDATE cleanup_pages/, rows: [{ cleanup_id: CLEANUP }] }, + { match: /FROM cleanup_pages p/, rows: [PAGE_ROW] }, + auditOk, + ]) + + const res = await h.app.inject({ + method: "POST", + url: `/v1/admin/pages/${CLEANUP}/flag`, + payload: { flagged: true, reason: "spam" }, + }) + + expect(res.statusCode).toBe(200) + expect(h.outer.statements).toEqual([]) + expect(statementsMatching(h.tx, /UPDATE cleanup_pages/)).toHaveLength(1) + expect(statementsMatching(h.tx, /INSERT INTO audit_log/)).toHaveLength(1) + const audit = h.tx.statements.find((s) => /INSERT INTO audit_log/.test(s.sql)) + expect(audit?.values).toEqual([ + OPERATOR, + "event_page.flagged", + `cleanup:${CLEANUP}`, + { reason: "spam" }, + ]) + }) + + it("rolls the flag back with the failed audit: both run in the transaction that rejects", async () => { + const h = await harness([ + { match: /UPDATE cleanup_pages/, rows: [{ cleanup_id: CLEANUP }] }, + { match: /FROM cleanup_pages p/, rows: [PAGE_ROW] }, + auditDown, + ]) + + const res = await h.app.inject({ + method: "POST", + url: `/v1/admin/pages/${CLEANUP}/flag`, + payload: { flagged: true, reason: "spam" }, + }) + + expect(res.statusCode).toBe(500) + expect(h.outer.statements).toEqual([]) + expect(statementsMatching(h.tx, /UPDATE cleanup_pages/)).toHaveLength(1) + }) + + it("unpublishes a page and audits it inside one transaction", async () => { + const h = await harness([ + { match: /UPDATE cleanup_pages/, rows: [{ cleanup_id: CLEANUP }] }, + { match: /FROM cleanup_pages p/, rows: [PAGE_ROW] }, + auditOk, + ]) + + const res = await h.app.inject({ + method: "POST", + url: `/v1/admin/pages/${CLEANUP}/unpublish`, + payload: { reason: "off-platform payment link" }, + }) + + expect(res.statusCode).toBe(200) + expect(h.outer.statements).toEqual([]) + expect(statementsMatching(h.tx, /INSERT INTO audit_log/)).toHaveLength(1) + }) + + it("404s a missing page before writing any audit row", async () => { + const h = await harness([{ match: /UPDATE cleanup_pages/, rows: [] }, auditOk]) + + const res = await h.app.inject({ + method: "POST", + url: `/v1/admin/pages/${CLEANUP}/unpublish`, + payload: { reason: "spam" }, + }) + + expect(res.statusCode).toBe(404) + expect(statementsMatching(h.tx, /INSERT INTO audit_log/)).toHaveLength(0) + expect(statementsMatching(h.outer, /INSERT INTO audit_log/)).toHaveLength(0) + }) +}) + +describe("host messaging suspension writes its audit row in the effect's transaction", () => { + it("suspends and audits inside one transaction", async () => { + const h = await harness([ + { match: /INSERT INTO user_moderation/, rows: [{ user_id: HOST }] }, + auditOk, + ]) + + const res = await h.app.inject({ + method: "POST", + url: `/v1/admin/hosts/${HOST}/messaging`, + payload: { suspended: true, reason: "spam blasts" }, + }) + + expect(res.statusCode).toBe(200) + expect(h.outer.statements).toEqual([]) + expect(statementsMatching(h.tx, /INSERT INTO user_moderation/)).toHaveLength(1) + const audit = h.tx.statements.find((s) => /INSERT INTO audit_log/.test(s.sql)) + expect(audit?.values).toEqual([ + OPERATOR, + "host.messaging_suspended", + `user:${HOST}`, + { reason: "spam blasts" }, + ]) + }) + + it("404s an unknown user instead of auditing a suspension that never happened", async () => { + const h = await harness([{ match: /INSERT INTO user_moderation/, rows: [] }, auditOk]) + + const res = await h.app.inject({ + method: "POST", + url: `/v1/admin/hosts/${HOST}/messaging`, + payload: { suspended: true, reason: "spam blasts" }, + }) + + expect(res.statusCode).toBe(404) + expect(statementsMatching(h.tx, /INSERT INTO audit_log/)).toHaveLength(0) + expect(statementsMatching(h.outer, /INSERT INTO audit_log/)).toHaveLength(0) + }) +}) diff --git a/services/api/test/unit/broadcast-pipeline-security.test.ts b/services/api/test/unit/broadcast-pipeline-security.test.ts new file mode 100644 index 00000000..ddf882c2 --- /dev/null +++ b/services/api/test/unit/broadcast-pipeline-security.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest" +import type { BroadcastKind } from "@civfix/shared" +import { FakeMailer } from "@civfix/shared/fakes" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { insightsGenerationKey } from "../../src/services/host/host-analytics-cache.js" +import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { + makeBroadcastService, + type BroadcastConfig, +} from "../../src/services/host/broadcast-service.js" +import { makeBroadcastPipeline } from "../../src/services/host/broadcast-pipeline.js" +import type { NotificationService } from "../../src/services/notification-service.js" + +const EVENT = "00000000-0000-0000-0000-0000000000ee" +const HOST = "00000000-0000-0000-0000-0000000000aa" +const MEMBER_COUNT = 3 + +const CONFIG: BroadcastConfig = { + killSwitch: false, + perEventPerDay: 3, + recipientsPerDay: 2000, + cooldownSec: 900, + minAccountAgeHours: 24, + maxRecipients: 5000, + chunkSize: 2, + emailConcurrency: 2, + emailRatePerSec: 1000, + linkAllowedHosts: [], + mailFromEvents: "events@civfix.org", + unsubscribeSigningKey: "unsubscribe-signing-key-for-tests-0123456789", + webBaseUrl: "http://localhost:3000", + apiBaseUrl: "http://localhost:8080", + eventUpdatePerEventPerHour: 3, +} + +function memberId(n: number): string { + return `00000000-0000-0000-0000-${String(n).padStart(12, "0")}` +} + +function harness(options: { organizationSuspended: boolean }) { + const repo = new InMemoryBroadcastRepository() + repo.seedEvent({ + cleanupId: EVENT, + title: "Beach Cleanup", + pageSlug: "beach-cleanup", + scheduledAt: new Date("2026-02-01T17:00:00Z"), + endsAt: null, + timezone: "America/Los_Angeles", + address: "Ocean Ave", + status: "upcoming", + organizerUserId: HOST, + organizationSuspended: options.organizationSuspended, + replyTo: null, + replyToVerified: false, + }) + repo.seedHost(HOST, { accountCreatedAt: new Date("2020-01-01T00:00:00Z") }) + repo.seedMembers( + EVENT, + Array.from({ length: MEMBER_COUNT }, (_, i) => ({ userId: memberId(i + 1) })), + ) + repo.seedGuests(EVENT, []) + const cache = new InMemoryCacheClient() + const counters = new InMemoryCounterStore() + const mailer = new FakeMailer() + const counterKeys: string[] = [] + const incr = counters.incr.bind(counters) + counters.incr = (key, ttl) => { + counterKeys.push(key) + return incr(key, ttl) + } + const service = makeBroadcastService({ + repo, + counters, + config: CONFIG, + mailer, + enqueuePlan: () => Promise.resolve(), + }) + const chunks: number[] = [] + const pipeline = makeBroadcastPipeline({ + repo, + service, + notifications: { + createNotifications: () => Promise.resolve(), + } as unknown as NotificationService, + mailer, + cache, + config: CONFIG, + mailDomain: "civfix.org", + enqueueChunk: (_id, chunkNo) => { + chunks.push(chunkNo) + return Promise.resolve() + }, + audit: () => Promise.resolve(), + }) + return { repo, cache, pipeline, chunks, counterKeys } +} + +type Harness = ReturnType + +async function seedBroadcast( + h: Harness, + kind: BroadcastKind, + status: "scheduled" | "sending", +): Promise { + const record = await h.repo.create({ + cleanupId: EVENT, + createdBy: kind === "event_cancelled" || kind === "event_updated" ? null : HOST, + kind, + subject: "Bring gloves", + bodyMd: "See you there.", + segment: { kind: "all_registered" }, + channels: ["email"], + status: status === "scheduled" ? "scheduled" : "draft", + ...(status === "scheduled" ? { scheduledAt: new Date(Date.now() - 1000) } : {}), + }) + if (status === "sending") { + await h.repo.transition(record.id, ["draft"], "sending", { startedAt: new Date() }) + } + return record.id +} + +async function generation(h: Harness): Promise { + return Number((await h.cache.get(insightsGenerationKey(EVENT))) ?? 0) +} + +describe("scheduled release under a suspended organization", () => { + it("fails a due host broadcast instead of sending it, and burns no send slot", async () => { + const h = harness({ organizationSuspended: true }) + const id = await seedBroadcast(h, "host_broadcast", "scheduled") + const before = await generation(h) + + const result = await h.pipeline.sweep() + + expect(result.released).toBe(0) + const after = await h.repo.findById(id) + expect(after?.status).toBe("failed") + expect(after?.finishedAt).not.toBeNull() + expect(after?.plannedAt).toBeNull() + expect(h.repo.allDeliveries()).toHaveLength(0) + expect(h.chunks).toHaveLength(0) + expect(h.counterKeys).toHaveLength(0) + expect(await generation(h)).toBeGreaterThan(before) + }) + + it("fails a due host-composed thank-you the same way", async () => { + const h = harness({ organizationSuspended: true }) + const id = await seedBroadcast(h, "thank_you", "scheduled") + + await h.pipeline.sweep() + + expect((await h.repo.findById(id))?.status).toBe("failed") + expect(h.repo.allDeliveries()).toHaveLength(0) + }) + + it("still releases a due host broadcast when the organization is in good standing", async () => { + const h = harness({ organizationSuspended: false }) + const id = await seedBroadcast(h, "host_broadcast", "scheduled") + + const result = await h.pipeline.sweep() + + expect(result.released).toBe(1) + expect((await h.repo.findById(id))?.plannedAt).not.toBeNull() + expect(h.repo.allDeliveries()).toHaveLength(MEMBER_COUNT) + }) +}) + +describe("plan under a suspended organization", () => { + it("stops an already-queued plan of a host broadcast", async () => { + const h = harness({ organizationSuspended: true }) + const id = await seedBroadcast(h, "host_broadcast", "sending") + const before = await generation(h) + + const outcome = await h.pipeline.plan(id) + + expect(outcome.kind).toBe("org_suspended") + expect((await h.repo.findById(id))?.status).toBe("failed") + expect(h.repo.allDeliveries()).toHaveLength(0) + expect(h.chunks).toHaveLength(0) + expect(await generation(h)).toBeGreaterThan(before) + }) + + it("stops an announcement too", async () => { + const h = harness({ organizationSuspended: true }) + const id = await seedBroadcast(h, "announcement", "sending") + + expect((await h.pipeline.plan(id)).kind).toBe("org_suspended") + expect((await h.repo.findById(id))?.status).toBe("failed") + }) + + it("never blocks a critical automated notice: attendees still learn the event is off", async () => { + const h = harness({ organizationSuspended: true }) + const id = await seedBroadcast(h, "event_cancelled", "sending") + + const outcome = await h.pipeline.plan(id) + + expect(outcome.kind).toBe("planned") + expect(h.repo.allDeliveries()).toHaveLength(MEMBER_COUNT) + }) +}) diff --git a/services/api/test/unit/broadcast-repository-security.test.ts b/services/api/test/unit/broadcast-repository-security.test.ts new file mode 100644 index 00000000..11ec4aca --- /dev/null +++ b/services/api/test/unit/broadcast-repository-security.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest" +import type { Sql } from "../../src/db/client.js" +import { makeDrizzleBroadcastRepository } from "../../src/services/host/broadcast-repository.drizzle.js" +import { makeFakeSql } from "../helpers/fake-sql.js" + +describe("operator host search", () => { + it("matches % and _ literally instead of as wildcards", async () => { + const fake = makeFakeSql() + const repo = makeDrizzleBroadcastRepository(fake.sql as unknown as Sql) + + await repo.listAdminHosts({ + q: "50%_off\\", + windowStart: new Date("2026-09-01T00:00:00Z"), + cursor: null, + limit: 10, + }) + + const statement = fake.statements[0] + expect(statement?.sql).toMatch(/u\.display_name ILIKE \? ESCAPE '\\'/) + expect(statement?.sql).toMatch(/u\.handle ILIKE \? ESCAPE '\\'/) + expect(statement?.values).toContain("%50\\%\\_off\\\\%") + expect(statement?.values).not.toContain("%50%_off\\%") + }) +}) diff --git a/services/api/test/unit/broadcast-service-security.test.ts b/services/api/test/unit/broadcast-service-security.test.ts new file mode 100644 index 00000000..7aa4bcd3 --- /dev/null +++ b/services/api/test/unit/broadcast-service-security.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest" +import type { BroadcastKind } from "@civfix/shared" +import { FakeMailer } from "@civfix/shared/fakes" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { + makeBroadcastService, + type BroadcastConfig, +} from "../../src/services/host/broadcast-service.js" +import { + mintUnsubscribeToken, + unsubscribeExpiryFrom, +} from "../../src/services/host/broadcast-capability-token.js" + +const EVENT = "00000000-0000-0000-0000-0000000000ee" +const HOST = "00000000-0000-0000-0000-0000000000aa" +const ATTENDEE = "00000000-0000-0000-0000-0000000000bb" +const KEY = "unsubscribe-signing-key-for-tests-0123456789" + +const CONFIG: BroadcastConfig = { + killSwitch: false, + perEventPerDay: 3, + recipientsPerDay: 2000, + cooldownSec: 900, + minAccountAgeHours: 24, + maxRecipients: 5000, + chunkSize: 200, + emailConcurrency: 4, + emailRatePerSec: 10, + linkAllowedHosts: [], + mailFromEvents: "events@civfix.org", + unsubscribeSigningKey: KEY, + webBaseUrl: "http://localhost:3000", + apiBaseUrl: "http://localhost:8080", + eventUpdatePerEventPerHour: 3, +} + +function build() { + const repo = new InMemoryBroadcastRepository() + repo.seedEvent({ + cleanupId: EVENT, + title: "Beach Cleanup", + pageSlug: "beach-cleanup", + scheduledAt: new Date("2026-02-01T17:00:00Z"), + endsAt: null, + timezone: "UTC", + address: null, + status: "cancelled", + organizerUserId: HOST, + replyTo: null, + replyToVerified: false, + }) + repo.seedHost(HOST, { accountCreatedAt: new Date("2020-01-01T00:00:00Z") }) + const service = makeBroadcastService({ + repo, + counters: new InMemoryCounterStore(), + config: CONFIG, + mailer: new FakeMailer(), + enqueuePlan: () => Promise.resolve(), + }) + return { repo, service } +} + +async function seed( + repo: InMemoryBroadcastRepository, + kind: BroadcastKind, + status: "draft" | "sending", +): Promise { + const automated = kind === "event_cancelled" || kind === "event_updated" + const record = await repo.create({ + cleanupId: EVENT, + createdBy: automated ? null : HOST, + kind, + subject: "The cleanup is off", + bodyMd: "Sorry, we had to cancel.", + segment: { kind: "all_registered" }, + channels: ["email"], + }) + if (status === "sending") { + await repo.transition(record.id, ["draft"], "sending", { startedAt: new Date() }) + } + return record.id +} + +describe("critical automated notices cannot be stopped by an event team member", () => { + it("refuses to cancel an event_cancelled notice mid-send with a 409", async () => { + const { repo, service } = build() + const id = await seed(repo, "event_cancelled", "sending") + + await expect(service.cancel(EVENT, id)).rejects.toMatchObject({ + code: "CONFLICT", + httpStatus: 409, + message: "Automatic messages can't be cancelled.", + }) + expect((await repo.findById(id))?.status).toBe("sending") + }) + + it("refuses to cancel an event_updated notice", async () => { + const { repo, service } = build() + const id = await seed(repo, "event_updated", "sending") + + await expect(service.cancel(EVENT, id)).rejects.toMatchObject({ httpStatus: 409 }) + expect((await repo.findById(id))?.status).toBe("sending") + }) + + it("refuses to edit or delete a critical notice even while it is a draft", async () => { + const { repo, service } = build() + const id = await seed(repo, "event_cancelled", "draft") + + await expect( + service.update(EVENT, HOST, { id: EVENT, broadcastId: id, subject: "Never mind" }), + ).rejects.toMatchObject({ httpStatus: 409, message: "Automatic messages can't be edited." }) + await expect(service.remove(EVENT, id)).rejects.toMatchObject({ + httpStatus: 409, + message: "Automatic messages can't be deleted.", + }) + const after = await repo.findById(id) + expect(after?.subject).toBe("The cleanup is off") + }) + + it("still lets a host cancel their own message mid-send", async () => { + const { repo, service } = build() + const id = await seed(repo, "host_broadcast", "sending") + + const cancelled = await service.cancel(EVENT, id) + + expect(cancelled.status).toBe("cancelled") + }) +}) + +describe("one-click unsubscribe write failures", () => { + it("surfaces the failure for a verified token instead of reporting success", async () => { + const { repo, service } = build() + repo.recordUnsubscribe = () => Promise.reject(new Error("db down")) + const token = mintUnsubscribeToken( + { + subjectKind: "user", + subjectId: ATTENDEE, + cleanupId: EVENT, + expiresAtMs: unsubscribeExpiryFrom(Date.now()), + }, + KEY, + ) + + await expect(service.unsubscribe(token)).rejects.toThrow("db down") + }) + + it("never touches the store for a token it cannot verify", async () => { + const { repo, service } = build() + let writes = 0 + repo.recordUnsubscribe = () => { + writes += 1 + return Promise.reject(new Error("db down")) + } + + await expect(service.unsubscribe("v1.forged.token")).resolves.toEqual({ ok: true }) + expect(writes).toBe(0) + }) +}) diff --git a/services/api/test/unit/comms-wiring-security.test.ts b/services/api/test/unit/comms-wiring-security.test.ts new file mode 100644 index 00000000..022e2287 --- /dev/null +++ b/services/api/test/unit/comms-wiring-security.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest" +import type { Container } from "../../src/di.js" +import type { Queryable } from "../../src/db/client.js" +import { + apiBaseUrlOf, + auditBestEffort, + broadcastConfigOf, + webBaseUrlOf, +} from "../../src/services/host/comms-wiring.js" + +const LOCAL_API_PORT = 8181 + +function containerWith(env: Record): Container { + return { env } as unknown as Container +} + +describe("broadcast base URLs never fall back to a production host", () => { + it("links an unconfigured dev or test runtime to localhost", () => { + const config = broadcastConfigOf( + containerWith({ + NODE_ENV: "development", + PORT: LOCAL_API_PORT, + WEB_ORIGINS: [], + PUBLIC_API_URL: "", + }), + ) + + expect(config.webBaseUrl).toBe("http://localhost:3000") + expect(config.apiBaseUrl).toBe(`http://localhost:${LOCAL_API_PORT}`) + }) + + it("uses the configured origins, trimmed of trailing slashes", () => { + const env = { + NODE_ENV: "production", + PORT: 8080, + WEB_ORIGINS: ["https://civfix.dev/", "https://other.example"], + PUBLIC_API_URL: " https://api.civfix.dev// ", + } + + expect(webBaseUrlOf(env)).toBe("https://civfix.dev") + expect(apiBaseUrlOf(env)).toBe("https://api.civfix.dev") + }) + + it("refuses to invent a base URL in production instead of guessing one", () => { + const env = { NODE_ENV: "production", PORT: 8080, WEB_ORIGINS: [], PUBLIC_API_URL: "" } + + expect(() => webBaseUrlOf(env)).toThrow(/WEB_ORIGINS/) + expect(() => apiBaseUrlOf(env)).toThrow(/PUBLIC_API_URL/) + }) +}) + +describe("best-effort route audit", () => { + it("logs the lost row at warn with its action and target instead of dropping it silently", async () => { + const warnings: Array<{ obj: Record; msg: string | undefined }> = [] + const failing = Object.assign(() => Promise.reject(new Error("audit_log unavailable")), { + json: (value: unknown) => value, + }) as unknown as Queryable + + await expect( + auditBestEffort( + failing, + { + action: "event.broadcast_sent", + actorId: "00000000-0000-0000-0000-0000000000aa", + target: "broadcast:00000000-0000-0000-0000-0000000000b1", + meta: {}, + }, + { + warn: (obj: unknown, msg?: string) => { + warnings.push({ obj: obj as Record, msg }) + }, + }, + ), + ).resolves.toBeUndefined() + + expect(warnings).toHaveLength(1) + expect(warnings[0]?.obj).toMatchObject({ + action: "event.broadcast_sent", + target: "broadcast:00000000-0000-0000-0000-0000000000b1", + }) + }) +}) diff --git a/services/api/test/unit/export-csv-security.test.ts b/services/api/test/unit/export-csv-security.test.ts new file mode 100644 index 00000000..ac1ff698 --- /dev/null +++ b/services/api/test/unit/export-csv-security.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest" +import { csvCell, csvProvenanceRow, csvRow } from "../../src/services/host/export-csv.js" + +describe("csv cells in a semicolon-separator locale", () => { + it("quotes a cell whose second field would start a formula after ';'", () => { + expect(csvRow(["a;=1+1"])).toBe('"a;=1+1"\n') + expect(csvRow(["id", "x;=cmd|' /C calc'!A0"])).toBe('"id","x;=cmd|\' /C calc\'!A0"\n') + }) + + it("keeps the leading-character guard inside the quotes", () => { + expect(csvCell("=1+1")).toBe('"\'=1+1"') + expect(csvCell("@SUM(A1)")).toBe('"\'@SUM(A1)"') + }) + + it("quotes cells a spreadsheet would otherwise re-split: leading space and full-width equals", () => { + expect(csvCell(" =1")).toBe('" =1"') + expect(csvCell("=1+1")).toBe('"=1+1"') + }) + + it("quotes numbers and provenance lines too, so no cell is ever bare", () => { + expect(csvRow([42, "x"])).toBe('"42","x"\n') + expect(csvProvenanceRow("note;=1")).toBe('"# note;=1"\n') + }) +}) diff --git a/services/api/test/unit/host-export-csv.test.ts b/services/api/test/unit/host-export-csv.test.ts index dba8cc48..2dca4168 100644 --- a/services/api/test/unit/host-export-csv.test.ts +++ b/services/api/test/unit/host-export-csv.test.ts @@ -23,10 +23,10 @@ describe("csv cells", () => { it("prefixes formula-injection cells", () => { expect(csvCell('=HYPERLINK("https://evil.example")')).toContain("'=") - expect(csvCell("+1")).toBe("'+1") - expect(csvCell("-1")).toBe("'-1") - expect(csvCell("@x")).toBe("'@x") - expect(csvCell("\tx")).toBe("'\tx") + expect(csvCell("+1")).toBe(`"'+1"`) + expect(csvCell("-1")).toBe(`"'-1"`) + expect(csvCell("@x")).toBe(`"'@x"`) + expect(csvCell("\tx")).toBe(`"'\tx"`) }) it("renders empty for null and undefined", () => { @@ -35,8 +35,8 @@ describe("csv cells", () => { }) it("writes rows and provenance lines", () => { - expect(csvRow(["a", "b"])).toBe("a,b\n") - expect(csvProvenanceRow("note")).toBe("# note\n") + expect(csvRow(["a", "b"])).toBe('"a","b"\n') + expect(csvProvenanceRow("note")).toBe('"# note"\n') }) }) @@ -168,8 +168,8 @@ describe("host export build", () => { const text = put.body.toString("utf8") expect(text).toContain("# member email is never included") expect(text).toContain("# k=5 note") - expect(text).toContain("a,b\n") - expect(text).toContain("1,Alex\n") + expect(text).toContain('"a","b"\n') + expect(text).toContain('"1","Alex"\n') expect(h.current().rowCount).toBe(2) expect(h.current().truncated).toBe(false) }) diff --git a/services/api/test/unit/host-route-audit-security.test.ts b/services/api/test/unit/host-route-audit-security.test.ts new file mode 100644 index 00000000..e49b6c9e --- /dev/null +++ b/services/api/test/unit/host-route-audit-security.test.ts @@ -0,0 +1,160 @@ +import { Writable } from "node:stream" +import Fastify, { type FastifyInstance } from "fastify" +import { afterEach, describe, expect, it } from "vitest" +import type { BroadcastDTO, HostExportDTO } from "@civfix/shared" +import { FakeStorage } from "@civfix/shared/fakes" +import { makeErrorHandler, makeNotFoundHandler } from "../../src/errors/http-mapper.js" +import type { Container } from "../../src/di.js" +import { registerHostExportRoutes } from "../../src/routes/host/exports.routes.js" +import { registerHostBroadcastRoutes } from "../../src/routes/host/broadcasts.routes.js" +import type { HostExportService } from "../../src/services/host/export-service.js" +import type { CommsRuntime } from "../../src/services/host/comms-wiring.js" +import { makeFakeSql } from "../helpers/fake-sql.js" + +const USER = "11111111-1111-4111-8111-111111111111" +const EVENT = "22222222-2222-4222-8222-222222222222" +const EXPORT_ID = "44444444-4444-4444-8444-444444444444" +const BROADCAST_ID = "55555555-5555-4555-8555-555555555555" +const WARN_LEVEL = 40 + +interface LogLine { + level: number + msg?: string + action?: string + target?: string +} + +let app: FastifyInstance | undefined + +afterEach(async () => { + await app?.close() + app = undefined +}) + +function organizerStandingSql() { + const fake = makeFakeSql([ + { + match: /FROM cleanups c/, + rows: [ + { + cleanup_id: EVENT, + organizer_user_id: USER, + organization_id: null, + visibility: "public", + event_role: "organizer", + org_role: null, + }, + ], + }, + { + match: /INSERT INTO audit_log/, + rows: () => { + throw new Error("audit_log unavailable") + }, + }, + ]) + return fake +} + +async function build( + register: (instance: FastifyInstance, container: Container) => Promise, + decorate: (instance: FastifyInstance) => void, +): Promise<{ app: FastifyInstance; logs: LogLine[]; enqueued: string[] }> { + const logs: LogLine[] = [] + const stream = new Writable({ + write(chunk: Buffer, _enc, done) { + logs.push(JSON.parse(chunk.toString("utf8")) as LogLine) + done() + }, + }) + const enqueued: string[] = [] + const fake = organizerStandingSql() + const container = { + env: { NODE_ENV: "test", WEB_ORIGINS: ["http://localhost:3000"] }, + storage: new FakeStorage(), + csrf: { protect: (_r: unknown, _p: unknown, done: () => void) => done() }, + getDb: () => ({ sql: fake.sql }), + jobs: { + enqueue: (name: string) => { + enqueued.push(name) + return Promise.resolve("job-1") + }, + }, + } as unknown as Container + + const instance = Fastify({ logger: { level: "warn", stream } }) + instance.setErrorHandler(makeErrorHandler()) + instance.setNotFoundHandler(makeNotFoundHandler()) + const allowed = () => () => + Promise.resolve({ isAllowed: true, isExceeded: false, max: 1, remaining: 1, ttlInSeconds: 0 }) + ;(instance.decorate as (name: string, value: unknown) => void)("createRateLimit", allowed) + ;(instance.decorateRequest as (name: string, value: unknown) => void)("auth", null) + instance.addHook("onRequest", (request, _reply, done) => { + ;(request as { auth?: unknown }).auth = { userId: USER, roles: ["citizen"] } + done() + }) + decorate(instance) + await register(instance, container) + await instance.ready() + app = instance + return { app: instance, logs, enqueued } +} + +function auditWarnings(logs: LogLine[]): LogLine[] { + return logs.filter((line) => line.level === WARN_LEVEL && line.action !== undefined) +} + +describe("host export request when the audit write fails", () => { + it("answers the export it already queued instead of a 500, and logs the lost audit row", async () => { + const exports = { + request: () => Promise.resolve({ id: EXPORT_ID, kind: "roster" } as HostExportDTO), + } as unknown as HostExportService + const h = await build(registerHostExportRoutes, (instance) => + instance.decorate("hostExportOverrides", { exports }), + ) + + const res = await h.app.inject({ + method: "POST", + url: `/v1/cleanups/${EVENT}/exports`, + payload: { kind: "roster" }, + }) + + expect(res.statusCode).toBe(200) + expect(h.enqueued).toHaveLength(1) + expect(auditWarnings(h.logs)).toEqual([ + expect.objectContaining({ action: "event.roster_exported", target: `cleanup:${EVENT}` }), + ]) + }) +}) + +describe("host broadcast send when the audit write fails", () => { + it("still answers the send and logs the lost audit row at warn", async () => { + const sent = { + id: BROADCAST_ID, + cleanupId: EVENT, + segment: { kind: "all_registered" }, + channels: ["email"], + subject: "Bring gloves", + } as unknown as BroadcastDTO + const runtime = { + broadcasts: { send: () => Promise.resolve(sent) }, + } as unknown as CommsRuntime + const h = await build(registerHostBroadcastRoutes, (instance) => + instance.decorate("broadcastOverrides", { runtime }), + ) + + const res = await h.app.inject({ + method: "POST", + url: `/v1/cleanups/${EVENT}/broadcasts/${BROADCAST_ID}/send`, + payload: {}, + }) + + expect(res.statusCode).toBe(200) + expect(auditWarnings(h.logs)).toEqual([ + expect.objectContaining({ + action: "event.broadcast_sent", + target: `broadcast:${BROADCAST_ID}`, + }), + ]) + }) +}) diff --git a/services/api/test/unit/unsubscribe-routes-security.test.ts b/services/api/test/unit/unsubscribe-routes-security.test.ts new file mode 100644 index 00000000..c710040e --- /dev/null +++ b/services/api/test/unit/unsubscribe-routes-security.test.ts @@ -0,0 +1,155 @@ +import Fastify, { type FastifyInstance } from "fastify" +import { afterEach, describe, expect, it } from "vitest" +import { FakeMailer } from "@civfix/shared/fakes" +import type { Container } from "../../src/di.js" +import { makeErrorHandler, makeNotFoundHandler } from "../../src/errors/http-mapper.js" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { registerUnsubscribeRoutes } from "../../src/routes/host/unsubscribe.routes.js" +import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { + makeBroadcastService, + type BroadcastConfig, +} from "../../src/services/host/broadcast-service.js" +import { + mintUnsubscribeToken, + unsubscribeExpiryFrom, +} from "../../src/services/host/broadcast-capability-token.js" +import type { CommsRuntime } from "../../src/services/host/comms-wiring.js" + +const EVENT = "00000000-0000-0000-0000-0000000000ee" +const USER = "00000000-0000-0000-0000-0000000000aa" +const KEY = "unsubscribe-signing-key-for-tests-0123456789" + +const CONFIG: BroadcastConfig = { + killSwitch: false, + perEventPerDay: 3, + recipientsPerDay: 2000, + cooldownSec: 900, + minAccountAgeHours: 24, + maxRecipients: 5000, + chunkSize: 200, + emailConcurrency: 4, + emailRatePerSec: 10, + linkAllowedHosts: [], + mailFromEvents: "events@civfix.org", + unsubscribeSigningKey: KEY, + webBaseUrl: "http://localhost:3000", + apiBaseUrl: "http://localhost:8080", + eventUpdatePerEventPerHour: 3, +} + +let app: FastifyInstance | undefined + +afterEach(async () => { + await app?.close() + app = undefined +}) + +async function build(): Promise<{ app: FastifyInstance; writes: () => number }> { + const repo = new InMemoryBroadcastRepository() + let writes = 0 + repo.recordUnsubscribe = () => { + writes += 1 + return Promise.reject(new Error("connection terminated")) + } + const broadcasts = makeBroadcastService({ + repo, + counters: new InMemoryCounterStore(), + config: CONFIG, + mailer: new FakeMailer(), + enqueuePlan: () => Promise.resolve(), + }) + const instance = Fastify({ logger: false }) + instance.setErrorHandler(makeErrorHandler()) + instance.setNotFoundHandler(makeNotFoundHandler()) + instance.decorate("broadcastOverrides", { runtime: { broadcasts } as unknown as CommsRuntime }) + await registerUnsubscribeRoutes(instance, { + env: { NODE_ENV: "test", WEB_ORIGINS: ["http://localhost:3000"] }, + } as unknown as Container) + await instance.ready() + app = instance + return { app: instance, writes: () => writes } +} + +function validToken(): string { + return mintUnsubscribeToken( + { + subjectKind: "user", + subjectId: USER, + cleanupId: EVENT, + expiresAtMs: unsubscribeExpiryFrom(Date.now()), + }, + KEY, + ) +} + +describe("one-click unsubscribe when the opt-out cannot be stored", () => { + it("answers a verified token with a retryable 503, not a false success", async () => { + const { app: instance, writes } = await build() + + const res = await instance.inject({ + method: "POST", + url: "/v1/broadcasts/unsubscribe", + payload: { token: validToken() }, + }) + + expect(writes()).toBe(1) + expect(res.statusCode).toBe(503) + expect(res.json()).toMatchObject({ + message: "We couldn't save your unsubscribe. Please try again.", + }) + }) + + it("keeps answering 200 for tokens it cannot verify, so the failure is no validity oracle", async () => { + const { app: instance, writes } = await build() + const forged = mintUnsubscribeToken( + { + subjectKind: "user", + subjectId: USER, + cleanupId: EVENT, + expiresAtMs: unsubscribeExpiryFrom(Date.now()), + }, + "a-completely-different-signing-key-1234567890", + ) + + for (const payload of [ + { token: forged }, + { token: "not-a-token-at-all-but-long-enough" }, + {}, + ]) { + const res = await instance.inject({ + method: "POST", + url: "/v1/broadcasts/unsubscribe", + payload, + }) + expect(res.statusCode).toBe(200) + expect(res.body).toBe('{"ok":true}') + } + expect(writes()).toBe(0) + }) +}) + +describe("the mail-client link redirect without a configured web origin", () => { + it("lands on localhost, never on the production site", async () => { + const repo = new InMemoryBroadcastRepository() + const broadcasts = makeBroadcastService({ + repo, + counters: new InMemoryCounterStore(), + config: CONFIG, + mailer: new FakeMailer(), + enqueuePlan: () => Promise.resolve(), + }) + const instance = Fastify({ logger: false }) + instance.decorate("broadcastOverrides", { runtime: { broadcasts } as unknown as CommsRuntime }) + await registerUnsubscribeRoutes(instance, { + env: { NODE_ENV: "development", WEB_ORIGINS: [] }, + } as unknown as Container) + await instance.ready() + app = instance + + const res = await instance.inject({ method: "GET", url: "/v1/broadcasts/unsubscribe" }) + + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe("http://localhost:3000/unsubscribe") + }) +}) From 4922ff0b0071877d910d0d58a8a716435fadee96 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 07:25:06 +0000 Subject: [PATCH 22/45] no production url as a fallback for web or api links --- services/api/src/lib/base-url.ts | 34 +++++ services/api/src/routes/cleanups.routes.ts | 3 +- services/api/src/routes/host/orgs.routes.ts | 5 +- services/api/src/routes/host/team.routes.ts | 5 +- services/api/src/services/guest-notify.ts | 7 - .../api/src/services/guest-rsvp-wiring.ts | 4 +- .../src/services/host/registration-wiring.ts | 5 +- .../api/test/unit/cleanups-routes.test.ts | 10 ++ .../test/unit/web-base-url-fallback.test.ts | 130 ++++++++++++++++++ 9 files changed, 185 insertions(+), 18 deletions(-) create mode 100644 services/api/src/lib/base-url.ts create mode 100644 services/api/test/unit/web-base-url-fallback.test.ts diff --git a/services/api/src/lib/base-url.ts b/services/api/src/lib/base-url.ts new file mode 100644 index 00000000..6cb3213a --- /dev/null +++ b/services/api/src/lib/base-url.ts @@ -0,0 +1,34 @@ +export const LOCAL_WEB_PORT = 3000 +export const DEFAULT_API_PORT = 8080 + +export interface BaseUrlEnv { + NODE_ENV?: string + PORT?: number + WEB_ORIGINS?: readonly string[] + PUBLIC_API_URL?: string +} + +/** + * A missing origin must never resolve to a production host: a dev or test runtime would otherwise mail + * real people links (and dev-signed unsubscribe tokens) that point at production. loadEnv refuses to + * boot production without both values, so the production branch only guards that invariant. + */ +function baseUrlOr( + configured: string | undefined, + variable: string, + env: BaseUrlEnv, + port: number, +): string { + const trimmed = (configured ?? "").trim().replace(/\/+$/, "") + if (trimmed.length > 0) return trimmed + if (env.NODE_ENV === "production") throw new Error(`${variable} is required in production`) + return `http://localhost:${port}` +} + +export function webBaseUrlOf(env: BaseUrlEnv): string { + return baseUrlOr(env.WEB_ORIGINS?.[0], "WEB_ORIGINS", env, LOCAL_WEB_PORT) +} + +export function apiBaseUrlOf(env: BaseUrlEnv): string { + return baseUrlOr(env.PUBLIC_API_URL, "PUBLIC_API_URL", env, env.PORT ?? DEFAULT_API_PORT) +} diff --git a/services/api/src/routes/cleanups.routes.ts b/services/api/src/routes/cleanups.routes.ts index 4d40f997..1c0e2096 100644 --- a/services/api/src/routes/cleanups.routes.ts +++ b/services/api/src/routes/cleanups.routes.ts @@ -41,6 +41,7 @@ import { makeDrizzleCleanupRepository } from "../services/cleanup-repository.dri import { makeHostAuditSink } from "../services/host/host-audit.js" import { enrichCleanupDTOs } from "../services/cleanup-enrichment.js" import { makeCommsRuntime } from "../services/host/comms-wiring.js" +import { webBaseUrlOf } from "../lib/base-url.js" import { makeInsightsGeneration } from "../services/host/host-analytics-cache.js" import { makeEventMediaPresigner } from "../services/host/event-media.js" import { SCHEDULE_MAX_AHEAD_MS, SCHEDULE_MAX_BACKDATE_MS } from "../services/cleanup-rules.js" @@ -234,7 +235,7 @@ export async function registerCleanupRoutes( container: Container, ): Promise { const csrfProtect = container.csrf.protect - const webOrigin = (container.env.WEB_ORIGINS[0] ?? "https://civfix.org").replace(/\/+$/, "") + const webOrigin = webBaseUrlOf(container.env) function repo(): CleanupRepository { const overrides = app.cleanupOverrides diff --git a/services/api/src/routes/host/orgs.routes.ts b/services/api/src/routes/host/orgs.routes.ts index 9fd3fa28..07105b41 100644 --- a/services/api/src/routes/host/orgs.routes.ts +++ b/services/api/src/routes/host/orgs.routes.ts @@ -50,6 +50,7 @@ import { makeContainerCleanupService } from "../cleanups.routes.js" import { CLEANUPS_DEFAULT_LIMIT } from "../../services/cleanup-service.js" import { makeDrizzleOrganizationRepository } from "../../services/host/organization-repository.drizzle.js" import type { OrganizationRepository } from "../../services/host/organization-repository.types.js" +import { webBaseUrlOf } from "../../lib/base-url.js" export interface OrganizationOverrides { repo: OrganizationRepository @@ -144,9 +145,7 @@ export function makeContainerOrganizationService( createNotification: (userId, input) => container.getNotificationService(app.log).createNotification(userId, input), }, - ...(container.env.WEB_ORIGINS[0] !== undefined - ? { webOrigin: container.env.WEB_ORIGINS[0] } - : {}), + webOrigin: webBaseUrlOf(container.env), logger: app.log, }) } diff --git a/services/api/src/routes/host/team.routes.ts b/services/api/src/routes/host/team.routes.ts index 8bed24f6..ba4a3938 100644 --- a/services/api/src/routes/host/team.routes.ts +++ b/services/api/src/routes/host/team.routes.ts @@ -34,6 +34,7 @@ import type { HostTeamRepository } from "../../services/host/host-team-repositor import { makeEventMediaPresigner } from "../../services/host/event-media.js" import { makeRouteNotificationService } from "../../services/route-notifier.js" import { makeRouteCleanupReader } from "../../services/route-cleanup-reader.js" +import { webBaseUrlOf } from "../../lib/base-url.js" export interface HostTeamOverrides { repo: HostTeamRepository @@ -114,9 +115,7 @@ export async function registerHostTeamRoutes( ` return rows[0]?.title ?? null }, - ...(container.env.WEB_ORIGINS[0] !== undefined - ? { webOrigin: container.env.WEB_ORIGINS[0] } - : {}), + webOrigin: webBaseUrlOf(container.env), logger: app.log, }) } diff --git a/services/api/src/services/guest-notify.ts b/services/api/src/services/guest-notify.ts index 86dd0a16..37314315 100644 --- a/services/api/src/services/guest-notify.ts +++ b/services/api/src/services/guest-notify.ts @@ -3,13 +3,6 @@ import { formatEventWhen } from "./host/broadcast-render.js" import { DEFAULT_EVENT_TIME_ZONE } from "./host/event-fields.js" import type { GuestRsvpRepository } from "./guest-rsvp-service.js" -export function guestManageLinkBase(webOrigins: readonly string[]): string { - const origin = webOrigins[0] - return origin !== undefined && origin.length > 0 - ? origin.replace(/\/+$/, "") - : "https://civfix.org" -} - export function guestEventLink(linkBase: string, cleanupId: string): string { return `${linkBase}/cleanups/${cleanupId}` } diff --git a/services/api/src/services/guest-rsvp-wiring.ts b/services/api/src/services/guest-rsvp-wiring.ts index 75b85799..c7a8ec0f 100644 --- a/services/api/src/services/guest-rsvp-wiring.ts +++ b/services/api/src/services/guest-rsvp-wiring.ts @@ -3,7 +3,7 @@ import { REVIEWER_OTP_EMAIL } from "../auth/otp.js" import { writeAudit } from "./admin/audit.js" import { requireCapability } from "./host/authz.js" import { makeContainerRegistrationServices } from "./host/registration-wiring.js" -import { guestManageLinkBase } from "./guest-notify.js" +import { webBaseUrlOf } from "../lib/base-url.js" import { makeDrizzleGuestRsvpRepository } from "./guest-rsvp-repository.drizzle.js" import { makeGuestRsvpService, @@ -86,7 +86,7 @@ export function makeContainerGuestRsvpService( counters: overrides?.counters ?? container.getCounterStore(), smsGuestEnabled: container.env.SMS_GUEST_ENABLED, smsDailyCap: container.env.SMS_DAILY_CAP, - manageLinkBase: guestManageLinkBase(container.env.WEB_ORIGINS), + manageLinkBase: webBaseUrlOf(container.env), ...(reviewer !== undefined ? { reviewer } : {}), ...(overrides?.now !== undefined ? { now: overrides.now } : {}), ...(logger !== undefined ? { logger } : {}), diff --git a/services/api/src/services/host/registration-wiring.ts b/services/api/src/services/host/registration-wiring.ts index ad174adc..a019bc1a 100644 --- a/services/api/src/services/host/registration-wiring.ts +++ b/services/api/src/services/host/registration-wiring.ts @@ -2,7 +2,8 @@ import type { CounterStore } from "../../abuse/counter-store.js" import type { Container } from "../../di.js" import type { Sql } from "../../db/client.js" import { makeMediaPresigner } from "../media-presign.js" -import { guestManageLinkBase, makeGuestPromotionNotifier } from "../guest-notify.js" +import { makeGuestPromotionNotifier } from "../guest-notify.js" +import { webBaseUrlOf } from "../../lib/base-url.js" import { makeDrizzleGuestRsvpRepository } from "../guest-rsvp-repository.drizzle.js" import { writeAudit } from "../admin/audit.js" import type { HostCapability } from "@civfix/shared" @@ -140,7 +141,7 @@ export function makeContainerRegistrationServices( : makeGuestPromotionNotifier({ repo: makeDrizzleGuestRsvpRepository(sql), mailer: container.mailer, - linkBase: guestManageLinkBase(container.env.WEB_ORIGINS), + linkBase: webBaseUrlOf(container.env), }) const counters = overrides?.counters ?? container.getCounterStore() const insightsInvalidator: InsightsInvalidator = diff --git a/services/api/test/unit/cleanups-routes.test.ts b/services/api/test/unit/cleanups-routes.test.ts index 27aebe5f..449cc09d 100644 --- a/services/api/test/unit/cleanups-routes.test.ts +++ b/services/api/test/unit/cleanups-routes.test.ts @@ -1261,6 +1261,16 @@ describe("GET /cleanups/:id/ics", () => { expect(body.ics.trimEnd().endsWith("END:VCALENDAR")).toBe(true) }) + it("never links a production page when no web origin is configured", async () => { + const { app, token } = await makeHarness() + const id = await createCleanup(app, token) + + const res = await app.inject({ method: "GET", url: `/v1/cleanups/${id}/ics` }) + + expect(res.statusCode).toBe(200) + expect((res.json() as { ics: string }).ics).not.toContain("https://civfix.org/events/") + }) + it("marks a cancelled event CANCELLED so a calendar client withdraws it", async () => { const { app, token } = await makeHarness() const id = await createCleanup(app, token) diff --git a/services/api/test/unit/web-base-url-fallback.test.ts b/services/api/test/unit/web-base-url-fallback.test.ts new file mode 100644 index 00000000..0554adcd --- /dev/null +++ b/services/api/test/unit/web-base-url-fallback.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import type { HostCapability } from "@civfix/shared" +import { FakeAbuseChecks, FakeMailer, FakeSmsSender } from "@civfix/shared/fakes" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { buildContainer } from "../../src/di.js" +import { loadEnv } from "../../src/env.js" +import { buildServer } from "../../src/server.js" +import type { HostStandingResolution } from "../../src/services/host/host-standing.js" +import { InMemoryHostTeamRepository } from "../../src/services/host/host-team-repository.memory.js" +import { makeHostTeamService } from "../../src/services/host/host-team-service.js" +import { InMemoryOrganizationRepository } from "../../src/services/host/organization-repository.memory.js" +import { makeOrganizationService } from "../../src/services/host/organization-service.js" +import { InMemoryGuestRsvpRepository } from "../helpers/guest-rsvp.js" +import { fakeCleanupDTO } from "../helpers/host-team.js" + +const LOCAL_WEB = "http://localhost:3000" +const PRODUCTION_WEB = "https://civfix.org" +const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +const ORGANIZER = "11111111-1111-4111-8111-111111111111" +const OWNER = "22222222-2222-4222-8222-222222222222" + +function mailText(mailer: FakeMailer): string { + return JSON.stringify(mailer.sent.map((m) => m.vars)) +} + +describe("links mailed from a runtime with no web origin configured", () => { + it("point an event team invite at the local web app", async () => { + const mailer = new FakeMailer() + const repo = new InMemoryHostTeamRepository() + repo.seedUser({ id: ORGANIZER, displayName: "Olive Organizer", handle: "olive" }) + repo.seedMember(EVENT, ORGANIZER, "organizer") + const service = makeHostTeamService({ + repo, + standing: (cleanupId: string, _userId: string, _capability: HostCapability) => { + const resolution: HostStandingResolution = { + cleanupId, + standing: { eventRole: "organizer", orgRole: null }, + organizerUserId: ORGANIZER, + organizationId: null, + visibility: "public", + } + return Promise.resolve(resolution) + }, + counters: new InMemoryCounterStore(), + mailer, + loadEvent: (cleanupId: string) => Promise.resolve(fakeCleanupDTO(cleanupId)), + notifier: { createNotification: () => Promise.resolve(null) }, + eventTitleOf: () => Promise.resolve("Beach cleanup"), + }) + + await service.inviteMember(EVENT, ORGANIZER, { + identifierKind: "email", + identifier: "helper@x.org", + role: "cohost", + }) + + expect(mailText(mailer)).toContain(`${LOCAL_WEB}/cleanups/${EVENT}#teamInvite=`) + expect(mailText(mailer)).not.toContain(PRODUCTION_WEB) + }) + + it("point an organization invite at the local web app", async () => { + const mailer = new FakeMailer() + const repo = new InMemoryOrganizationRepository() + repo.seedUser({ id: OWNER, displayName: "Olive Owner", handle: "olive", email: "o@x.org" }) + const service = makeOrganizationService({ + repo, + counters: new InMemoryCounterStore(), + newId: () => randomUUID(), + mailer, + }) + const org = await service.createOrganization( + { name: "Creek Trust", slug: "creek-trust" } as Parameters< + typeof service.createOrganization + >[0], + OWNER, + ) + + await service.inviteMember(org.id, OWNER, { + identifierKind: "email", + identifier: "helper@x.org", + role: "member", + }) + + expect(mailText(mailer)).toContain(`${LOCAL_WEB}/manage/org-invites/accept#token=`) + expect(mailText(mailer)).not.toContain(PRODUCTION_WEB) + }) + + it("point a guest's cancel link at the local web app", async () => { + const env = loadEnv({ NODE_ENV: "test" }) + const mailer = new FakeMailer() + const repo = new InMemoryGuestRsvpRepository() + repo.seedEvent({ id: EVENT, title: "Beach cleanup" }) + const app = await buildServer({ + env, + container: buildContainer(env), + guestRsvpOverrides: { + repo, + requireGuestContact: () => Promise.resolve(), + cache: new InMemoryCacheClient(), + counters: new InMemoryCounterStore(), + mailer, + smsSender: new FakeSmsSender(), + abuseChecks: new FakeAbuseChecks(), + }, + }) + try { + const contact = { channel: "email", email: "ada@example.org" } + const requested = await app.inject({ + method: "POST", + url: `/v1/cleanups/${EVENT}/guest-rsvp/request`, + payload: { name: "Ada", ...contact, turnstileToken: "ok" }, + }) + expect(requested.statusCode, requested.body).toBe(200) + const code = mailer.sent.find((m) => m.template === "guest_otp")?.vars?.code + const verified = await app.inject({ + method: "POST", + url: `/v1/cleanups/${EVENT}/guest-rsvp/verify`, + payload: { ...contact, code }, + }) + expect(verified.statusCode, verified.body).toBe(200) + + const confirmed = mailer.sent.find((m) => m.template === "guest_confirmed") + expect(String(confirmed?.vars?.cancelUrl)).toMatch(new RegExp(`^${LOCAL_WEB}/guest\\?token=`)) + } finally { + await app.close() + } + }) +}) From efb2fa6b4f3f5a4225233d2080623d6bdf412a9a Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:02:56 +0000 Subject: [PATCH 23/45] account erasure sets the session ban before it runs and fails closed --- docs/erasure-behavior.md | 22 ++- services/api/src/auth/session-service.ts | 16 +- services/api/src/auth/stores.ts | 21 ++- services/api/src/routes/users.routes.ts | 37 ++++- services/api/test/helpers/auth.ts | 7 + .../test/unit/users-routes-security.test.ts | 150 ++++++++++++++++++ 6 files changed, 232 insertions(+), 21 deletions(-) diff --git a/docs/erasure-behavior.md b/docs/erasure-behavior.md index 8921aafa..e19c53d6 100644 --- a/docs/erasure-behavior.md +++ b/docs/erasure-behavior.md @@ -20,7 +20,15 @@ response can be answered truthfully. It is the source of record for the `[auth][csrf]`) performs a **soft delete**: 0. An **email-OTP gate**: the caller must re-prove control of the account email - before any destructive work runs. + before any destructive work runs. Then **the session ban marker is set** + (`SessionService.markBanned`), before anything is erased. A cached session + projection is checked against that marker and the session epoch, never against the `sessions` table, so deleting the rows alone would + leave a cached bearer (and a websocket re-auth) working, and sliding its + expiry forward, up to the 90-day absolute session cap. If the marker cannot be written the request fails with 503 and + nothing is erased; if the erasure transaction below then fails, the marker is + 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 `services/api/src/auth/pg-stores.ts`. **One transaction**, so a partial erasure is not a reachable state: @@ -56,16 +64,18 @@ response can be answered truthfully. It is the source of record for the 2. Clears the session + CSRF cookies on the response. 3. Three independent best-effort steps after the commit (`allSettled`, each logged on failure, none of them able to fail a deletion that already happened): - `SessionStore.banUser(userId)` sets the ban/veto marker, bumps the session - epoch and drops the write-through cache entries, so a cached session - projection is rejected on its next request; unlink the OAuth identities; and - write the audit-log row (`account.deleted`, actor = the user). + `SessionService.banUser(userId)` refreshes the ban marker, bumps the session + epoch and drops any write-through cache entries it can still find; unlink the + OAuth identities; and write the audit-log row (`account.deleted`, actor = the + user). Revocation does not depend on this step: by the time it runs every + session row is gone and the marker set in step 0 already rejects every + cached projection. ## What is scrubbed vs. kept | Data | After `DELETE /me` | |---|---| -| Live sessions / login | **Revoked**: every session row deleted inside the erasure transaction; afterwards (best-effort) the ban marker is set and the cached sessions evicted; cookies cleared. | +| 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. | | 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. | diff --git a/services/api/src/auth/session-service.ts b/services/api/src/auth/session-service.ts index 1e0973fc..c9dbbd9c 100644 --- a/services/api/src/auth/session-service.ts +++ b/services/api/src/auth/session-service.ts @@ -244,7 +244,7 @@ export class SessionService { } async banUser(userId: string): Promise { - await this.cache.set(bannedKey(userId), "1", this.ttlSeconds + BANNED_MARKER_GRACE_SECONDS) + await this.markBanned(userId) await this.bumpEpoch(userId) const ids = await this.store.deleteAllForUser(userId) await this.evictSessionCaches(ids, userId) @@ -275,6 +275,12 @@ export class SessionService { }) } + // Outlives every cached projection (their TTL never exceeds ttlSeconds), so the marker alone is enough + // to reject a session whose durable row is already gone. + async markBanned(userId: string): Promise { + await this.cache.set(bannedKey(userId), "1", this.ttlSeconds + BANNED_MARKER_GRACE_SECONDS) + } + async clearBan(userId: string): Promise { await this.cache.del(bannedKey(userId)) } @@ -306,11 +312,9 @@ export class SessionService { } private async enforceRevokedStatus(userId: string, hash: string): Promise { - await this.cache - .set(bannedKey(userId), "1", this.ttlSeconds + BANNED_MARKER_GRACE_SECONDS) - .catch((err: unknown) => { - this.logger?.error({ userId, err }, "ban marker refresh failed during resolve") - }) + await this.markBanned(userId).catch((err: unknown) => { + this.logger?.error({ userId, err }, "ban marker refresh failed during resolve") + }) await this.expireSession(hash) } diff --git a/services/api/src/auth/stores.ts b/services/api/src/auth/stores.ts index 3afc90e4..c60e573b 100644 --- a/services/api/src/auth/stores.ts +++ b/services/api/src/auth/stores.ts @@ -189,10 +189,13 @@ export interface UpdateSettingsInput { export const PRIMARY_ORGANIZATION_NOT_A_MEMBER = "Pick an organization you belong to, or clear the selection." +export type ErasureCascade = (userId: string) => Promise + export class InMemoryUserStore implements UserStore { private readonly byId = new Map() private readonly statuses = new Map() private readonly memberships = new Map>() + private readonly erasureCascades: ErasureCascade[] = [] private readonly now: () => Date constructor(opts: { now?: () => Date } = {}) { @@ -339,9 +342,16 @@ export class InMemoryUserStore implements UserStore { this.memberships.set(userId, set) } - softDeleteAndAnonymize(id: string): Promise { + // The Postgres erasure transaction also deletes the user's sessions, push tokens and notifications. + // Here those rows live in other in-memory stores, so each one registers how to drop them. + cascadeErasureTo(cascade: ErasureCascade): void { + this.erasureCascades.push(cascade) + } + + async softDeleteAndAnonymize(id: string): Promise { const row = this.byId.get(id) if (!row) throw new Error("InMemoryUserStore.softDeleteAndAnonymize: user not found") + for (const cascade of this.erasureCascades) await cascade(id) const next: UserRecord = { ...row, deletedAt: row.deletedAt ?? new Date(), @@ -354,7 +364,7 @@ export class InMemoryUserStore implements UserStore { primaryOrganizationId: null, } this.byId.set(id, next) - return Promise.resolve({ ...next }) + return { ...next } } seed(_email: string | null, row: UserRecord): void { @@ -495,9 +505,12 @@ export function makeInMemoryStores(): AuthStores & { oauth: InMemoryOAuthIdentityStore otps: InMemoryOtpStore } { + const users = new InMemoryUserStore() + const sessions = new InMemorySessionStore() + users.cascadeErasureTo((userId) => sessions.deleteAllForUser(userId)) return { - users: new InMemoryUserStore(), - sessions: new InMemorySessionStore(), + users, + sessions, oauth: new InMemoryOAuthIdentityStore(), otps: new InMemoryOtpStore(), } diff --git a/services/api/src/routes/users.routes.ts b/services/api/src/routes/users.routes.ts index d06111cd..465919cd 100644 --- a/services/api/src/routes/users.routes.ts +++ b/services/api/src/routes/users.routes.ts @@ -6,6 +6,7 @@ import { PaginationQuerySchema, IdSchema, AppError, + ErrorCode, type SearchUsersResponse, type BlockUserResponse, type ListBlocksResponse, @@ -22,6 +23,7 @@ import { clearCsrfCookie } from "../auth/csrf.js" import { clearSessionCookie } from "../auth/transport.js" import { searchByHandlePrefix, searchMentionable } from "../services/social-repository.drizzle.js" import { toUserDTO } from "../auth/auth-services.js" +import { exposeMessage } from "../errors/exposed-message.js" import { writeAudit } from "../services/admin/audit.js" import { DATA_EXPORT_JOB, dataExportSupportEmail } from "../services/data-export-jobs.js" import type { BlocksRepository } from "../services/blocks-repository.drizzle.js" @@ -52,6 +54,9 @@ export const BLOCK_RATE_LIMIT = { max: 60, timeWindow: "1 minute" } as const export const UNBLOCKABLE_MESSAGE = "User not found" +export const ACCOUNT_DELETION_UNAVAILABLE_MESSAGE = + "We couldn't delete your account right now. Nothing was changed. Please try again in a few minutes." + export async function registerUsersRoutes( app: FastifyInstance, container: Container, @@ -201,10 +206,32 @@ export async function registerUsersRoutes( } } - // The erasure transaction also deletes the durable sessions, push tokens and notifications. The - // steps below run after it commits: the ban marker and epoch bump retire cached session projections, - // and a failure is logged rather than failing a deletion that already happened. - await store.softDeleteAndAnonymize(userId) + // A cached session projection is checked against the ban marker and the epoch, never the sessions + // table, so deleting the rows alone would leave every cached bearer working, and sliding forward, + // up to the absolute session cap. The marker goes up before anything is erased: if it cannot be + // written the deletion is refused, and if the erasure then fails the marker comes down again so the + // account keeps working. + try { + await sessions.markBanned(userId) + } catch (err) { + throw exposeMessage( + new AppError(ErrorCode.INTERNAL, ACCOUNT_DELETION_UNAVAILABLE_MESSAGE, { + httpStatus: 503, + cause: err, + }), + ) + } + try { + await store.softDeleteAndAnonymize(userId) + } catch (err) { + await sessions.clearBan(userId).catch((clearErr: unknown) => { + request.log.error( + { err: clearErr, userId }, + "account deletion: erasure failed and the pre-set ban marker could not be cleared; the live account stays locked out until the marker expires or an operator restores its status", + ) + }) + throw err + } clearSessionCookie(reply) clearCsrfCookie(reply) @@ -226,7 +253,7 @@ export async function registerUsersRoutes( if (outcome.status === "rejected") { request.log.error( { err: outcome.reason, userId, step: cleanups[i]![0] }, - "account deletion: post-revocation cleanup step failed (the account IS deleted and every session revoked)", + "account deletion: post-commit cleanup step failed (the account IS deleted, its session rows went with the erasure and the ban marker set before it still rejects cached sessions)", ) } }) diff --git a/services/api/test/helpers/auth.ts b/services/api/test/helpers/auth.ts index b46af963..eb6dfbdd 100644 --- a/services/api/test/helpers/auth.ts +++ b/services/api/test/helpers/auth.ts @@ -188,6 +188,13 @@ export async function makeAuthHarness(opts: MakeAuthHarnessOptions = {}): Promis const now = (): number => nowMs.value const stores = makeInMemoryStores() + const notificationRepo = opts.server?.notificationOverrides?.repo + if (notificationRepo) { + stores.users.cascadeErasureTo(async (userId) => { + await notificationRepo.deletePushTokensForUser(userId) + await notificationRepo.deleteAllNotificationsForUser(userId) + }) + } const cache = new InMemoryCacheClient(now) const mailer = new FakeMailer() const verifier = new StubJwksVerifier() diff --git a/services/api/test/unit/users-routes-security.test.ts b/services/api/test/unit/users-routes-security.test.ts index b3d86a44..547c7114 100644 --- a/services/api/test/unit/users-routes-security.test.ts +++ b/services/api/test/unit/users-routes-security.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it } from "vitest" import { makeAuthHarness, type AuthHarness } from "../helpers/auth.js" +import { InMemoryNotificationRepository } from "../helpers/notifications.js" const EMAIL = "leaving@example.com" +const BAN_MARKER_PREFIX = "banned:" let harness: AuthHarness | undefined afterEach(async () => { @@ -9,6 +11,33 @@ afterEach(async () => { harness = undefined }) +async function requestDeletionCode(h: AuthHarness): Promise { + await h.app.inject({ + method: "POST", + url: "/v1/auth/otp/request", + payload: { email: EMAIL }, + }) + return h.mailer.lastOtpFor(EMAIL)! +} + +function deleteMe(h: AuthHarness, token: string, emailOtp: string) { + return h.app.inject({ + method: "DELETE", + url: "/v1/me", + headers: { authorization: `Bearer ${token}`, "x-client": "mobile" }, + payload: { emailOtp }, + }) +} + +async function sessionUserId(h: AuthHarness, token: string): Promise { + const res = await h.app.inject({ + method: "GET", + url: "/v1/auth/session", + headers: { authorization: `Bearer ${token}`, "x-client": "mobile" }, + }) + return (res.json() as { user: { id: string } | null }).user?.id ?? null +} + describe("DELETE /v1/me after the erasure commits", () => { it("reports the deletion as done when the session ban marker cannot be written", async () => { harness = await makeAuthHarness() @@ -37,4 +66,125 @@ describe("DELETE /v1/me after the erasure commits", () => { expect(banAttempts).toBe(1) expect((await harness.stores.users.findById(userId))?.deletedAt ?? null).not.toBeNull() }) + + it("stops the deleted account's bearer from resolving", async () => { + harness = await makeAuthHarness() + const { token, userId } = await harness.signIn(EMAIL) + expect(await sessionUserId(harness, token)).toBe(userId) + + const res = await deleteMe(harness, token, await requestDeletionCode(harness)) + + expect(res.statusCode).toBe(200) + expect(await harness.services.sessions.resolveSession(token)).toBeNull() + expect(await sessionUserId(harness, token)).toBeNull() + expect(harness.stores.sessions.count()).toBe(0) + }) + + it("keeps a cached bearer dead when the post-commit ban step fails", async () => { + harness = await makeAuthHarness() + const { token } = await harness.signIn(EMAIL) + harness.services.sessions.banUser = () => Promise.reject(new Error("redis unavailable")) + + const res = await deleteMe(harness, token, await requestDeletionCode(harness)) + + expect(res.statusCode).toBe(200) + expect(await sessionUserId(harness, token)).toBeNull() + expect(await harness.services.sessions.resolveSession(token)).toBeNull() + expect(harness.stores.sessions.count()).toBe(0) + }) + + it("purges the user's push tokens and notifications with the erasure", async () => { + const notificationRepo = new InMemoryNotificationRepository() + harness = await makeAuthHarness({ + server: { notificationOverrides: { repo: notificationRepo } }, + }) + const { token, userId } = await harness.signIn(EMAIL) + await notificationRepo.upsertPushToken({ + userId, + platform: "ios", + token: "device-token-leaving", + deviceId: null, + }) + await notificationRepo.upsertPushToken({ + userId: "someone-else", + platform: "ios", + token: "device-token-staying", + deviceId: null, + }) + await notificationRepo.insertNotification({ + userId, + type: "report_update", + title: "t", + body: "b", + link: null, + }) + + const res = await deleteMe(harness, token, await requestDeletionCode(harness)) + + expect(res.statusCode).toBe(200) + expect(notificationRepo.pushTokens.filter((t) => t.userId === userId)).toEqual([]) + expect(notificationRepo.notifications.filter((n) => n.userId === userId)).toEqual([]) + expect(notificationRepo.pushTokens.map((t) => t.token)).toEqual(["device-token-staying"]) + }) +}) + +describe("DELETE /v1/me revocation is fail-closed", () => { + it("answers 503 and deletes nothing when the ban marker cannot be set first", async () => { + harness = await makeAuthHarness() + const { token, userId } = await harness.signIn(EMAIL) + const code = await requestDeletionCode(harness) + const cache = harness.cache + const realSet = cache.set.bind(cache) + cache.set = (key, value, ttlSeconds) => + key.startsWith(BAN_MARKER_PREFIX) + ? Promise.reject(new Error("redis unavailable")) + : realSet(key, value, ttlSeconds) + + const res = await deleteMe(harness, token, code) + + expect(res.statusCode).toBe(503) + expect(res.json().message).toBe( + "We couldn't delete your account right now. Nothing was changed. Please try again in a few minutes.", + ) + const after = await harness.stores.users.findById(userId) + expect(after?.deletedAt ?? null).toBeNull() + expect(after?.email).toBe(EMAIL) + expect(await sessionUserId(harness, token)).toBe(userId) + }) + + it("holds the ban marker while the erasure transaction runs", async () => { + harness = await makeAuthHarness() + const { token, userId } = await harness.signIn(EMAIL) + const users = harness.stores.users + const realErase = users.softDeleteAndAnonymize.bind(users) + let activeDuringErasure: boolean | undefined + users.softDeleteAndAnonymize = async (id) => { + activeDuringErasure = await harness!.services.sessions.isUserActive(id) + return realErase(id) + } + + const res = await deleteMe(harness, token, await requestDeletionCode(harness)) + + expect(res.statusCode).toBe(200) + expect(activeDuringErasure).toBe(false) + expect(await harness.services.sessions.isUserActive(userId)).toBe(false) + }) + + it("clears the ban marker when the erasure transaction fails", async () => { + harness = await makeAuthHarness() + const { token, userId } = await harness.signIn(EMAIL) + const code = await requestDeletionCode(harness) + let activeDuringErasure: boolean | undefined + harness.stores.users.softDeleteAndAnonymize = async (id) => { + activeDuringErasure = await harness!.services.sessions.isUserActive(id) + throw new Error("serialization failure") + } + + const res = await deleteMe(harness, token, code) + + expect(res.statusCode).toBe(500) + expect(activeDuringErasure).toBe(false) + expect(await harness.services.sessions.isUserActive(userId)).toBe(true) + expect(await sessionUserId(harness, token)).toBe(userId) + }) }) From efdd21373934debb3eccc3a958880c8a80839503 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:02:56 +0000 Subject: [PATCH 24/45] sign-in refuses to adopt an account whose email was never verified --- services/api/src/auth/oauth.ts | 6 +++ services/api/src/routes/admin/auth.routes.ts | 34 ++++++++---- .../unit/admin-auth-routes-security.test.ts | 29 +++++++++++ .../api/test/unit/auth-oauth-security.test.ts | 52 +++++++++++++++++++ 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/services/api/src/auth/oauth.ts b/services/api/src/auth/oauth.ts index 4b840ebe..ab958783 100644 --- a/services/api/src/auth/oauth.ts +++ b/services/api/src/auth/oauth.ts @@ -18,6 +18,9 @@ export const APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys" export const PROVIDER_GOOGLE = "google" export const PROVIDER_APPLE = "apple" +export const UNVERIFIED_ACCOUNT_EXISTS_MESSAGE = + "An account already uses this email address. Sign in the way you did before, for example with a code sent to your email." + export interface OAuthConfig { google?: { clientId: string @@ -209,6 +212,9 @@ export class OAuthService { ): Promise { const byEmail = await this.users.findByEmail(verifiedEmail) if (!byEmail || byEmail.deletedAt !== null) return null + // A row holding an address nobody proved could have been planted by whoever presented it, so a + // verified identity is never attached to it. The row is left exactly as it is. + if (!byEmail.emailVerified) throw AppError.conflict(UNVERIFIED_ACCOUNT_EXISTS_MESSAGE) await this.oauthStore.linkIdentity(byEmail.id, provider, providerUserId) return byEmail } diff --git a/services/api/src/routes/admin/auth.routes.ts b/services/api/src/routes/admin/auth.routes.ts index f4052c60..10de9613 100644 --- a/services/api/src/routes/admin/auth.routes.ts +++ b/services/api/src/routes/admin/auth.routes.ts @@ -32,6 +32,7 @@ import { route } from "../../versioning/route.js" const ADMIN_AUTH_RATE_LIMIT = { max: 10, timeWindow: "1 minute" } as const const UNNAMED_OPERATOR_DISPLAY_NAME = "Operator" +const UNAUTHORIZED_OPERATOR_MESSAGE = "This account is not authorized for the operator dashboard." export interface AdminAuthOverrides { auditSink(input: WriteAuditInput): Promise @@ -58,6 +59,21 @@ export async function registerAdminAuthRoutes( const defaultVerify: VerifyAccessJwt | null = teamDomain && aud ? createAccessVerifier({ teamDomain, aud }) : null + async function auditLoginDenied( + user: UserRecord, + request: FastifyRequest, + detail: Record, + ): Promise { + await auditOperatorAuth(app, container, { + actorId: user.id, + action: "operator.login_denied", + target: `user:${user.id}`, + meta: { email: user.email, ...detail, via: "cf-access" }, + }).catch((err: unknown) => { + request.log.warn({ err }, "operator.login_denied audit write failed") + }) + } + async function provisionOperator(email: string, request: FastifyRequest): Promise { let user = await services.users.findByEmail(email) if (!user) { @@ -70,19 +86,19 @@ export async function registerAdminAuthRoutes( emailVerified: true, }) } + // Cloudflare Access proves the operator owns the address, but a row that never verified it may + // have been planted by someone else, and promoting it would hand them the operator role. The row is + // refused and left untouched. + if (!user.emailVerified) { + await auditLoginDenied(user, request, { reason: "email_unverified" }) + throw AppError.forbidden(UNAUTHORIZED_OPERATOR_MESSAGE) + } // The status gate runs before the role grant: a restricted account must leave no operator role and // no successful-login audit behind, even though establishOperatorSession would refuse it anyway. const accountStatus = await services.users.accountStatus(user.id) const refusal = restrictedAccountRefusal(accountStatus) if (refusal) { - await auditOperatorAuth(app, container, { - actorId: user.id, - action: "operator.login_denied", - target: `user:${user.id}`, - meta: { email: user.email, status: accountStatus, via: "cf-access" }, - }).catch((err: unknown) => { - request.log.warn({ err }, "operator.login_denied audit write failed") - }) + await auditLoginDenied(user, request, { status: accountStatus }) throw refusal } const operator = @@ -112,7 +128,7 @@ export async function registerAdminAuthRoutes( const identity = await verifyHeader(verify, request) const email = identity.email?.toLowerCase() if (!email || !isAdminEmail(env, email)) { - throw AppError.forbidden("This account is not authorized for the operator dashboard.") + throw AppError.forbidden(UNAUTHORIZED_OPERATOR_MESSAGE) } const operator = await provisionOperator(email, request) const payload = await establishOperatorSession(services, csrf, request, reply, operator) diff --git a/services/api/test/unit/admin-auth-routes-security.test.ts b/services/api/test/unit/admin-auth-routes-security.test.ts index 7090afcb..ae4b314e 100644 --- a/services/api/test/unit/admin-auth-routes-security.test.ts +++ b/services/api/test/unit/admin-auth-routes-security.test.ts @@ -98,6 +98,35 @@ describe("admin Cloudflare Access exchange for a restricted account", () => { }) }) +describe("admin Cloudflare Access exchange onto an account whose email was never verified", () => { + it("refuses to adopt the row, leaves it untouched and audits the denial", async () => { + const h = await harness() + const planted = await h.stores.users.create(ALLOWED, { + displayName: "Planted", + role: "citizen", + emailVerified: false, + }) + + const res = await h.app.inject({ method: "POST", url: EXCHANGE_URL, headers: ACCESS_HEADER }) + + expect(res.statusCode).toBe(403) + expect(res.json().message).toBe("This account is not authorized for the operator dashboard.") + expect(await h.stores.users.findById(planted.id)).toMatchObject({ + role: "citizen", + email: ALLOWED, + emailVerified: false, + displayName: "Planted", + }) + expect(h.audits.map((a) => a.action)).toEqual(["operator.login_denied"]) + expect(h.audits[0]).toMatchObject({ + actorId: planted.id, + target: `user:${planted.id}`, + meta: { email: ALLOWED, reason: "email_unverified", via: "cf-access" }, + }) + expect(res.headers["set-cookie"]).toBeUndefined() + }) +}) + describe("admin Cloudflare Access exchange without Access configured", () => { it("tells the operator in production that Access is not configured", async () => { const h = await harness({ accessConfigured: false }) diff --git a/services/api/test/unit/auth-oauth-security.test.ts b/services/api/test/unit/auth-oauth-security.test.ts index 85b7f15a..0d48d8da 100644 --- a/services/api/test/unit/auth-oauth-security.test.ts +++ b/services/api/test/unit/auth-oauth-security.test.ts @@ -114,6 +114,58 @@ describe("provider sign-in with an unverified email", () => { }) }) +describe("provider sign-in onto an account whose email was never verified", () => { + const ACCOUNT_EXISTS_MESSAGE = + "An account already uses this email address. Sign in the way you did before, for example with a code sent to your email." + + async function plantedAccount(users: InMemoryUserStore) { + return users.create(VICTIM_EMAIL, { + displayName: "Planted", + role: "citizen", + emailVerified: false, + }) + } + + it("refuses to adopt the row instead of linking the verified identity to it", async () => { + const { oauth, users, oauthStore, verifier } = makeServices() + const planted = await plantedAccount(users) + verifier.register("t", claims("owner-sub", VICTIM_EMAIL)) + + const attempt = oauth.signInWithGoogleIdToken("t") + + await expect(attempt).rejects.toMatchObject({ + code: "CONFLICT", + message: ACCOUNT_EXISTS_MESSAGE, + }) + expect(await oauthStore.findByProvider("google", "owner-sub")).toBeNull() + const after = await users.findById(planted.id) + expect(after).toMatchObject({ + email: VICTIM_EMAIL, + emailVerified: false, + displayName: "Planted", + deletedAt: null, + }) + }) + + it("refuses the same row when a concurrent insert surfaces it", async () => { + const { oauth, users, oauthStore, verifier } = makeServices() + const planted = await plantedAccount(users) + verifier.register("t", claims("owner-sub", VICTIM_EMAIL)) + const findByEmail = users.findByEmail.bind(users) + let calls = 0 + users.findByEmail = (email: string) => { + calls += 1 + return calls === 1 ? Promise.resolve(null) : findByEmail(email) + } + + await expect(oauth.signInWithAppleIdToken("t", undefined)).rejects.toMatchObject({ + code: "CONFLICT", + }) + expect(await oauthStore.findByProvider("apple", "owner-sub")).toBeNull() + expect((await users.findById(planted.id))?.emailVerified).toBe(false) + }) +}) + describe("strict user creation for provider sign-in", () => { it("rejects an email that another account holds instead of returning that account", async () => { const users = new InMemoryUserStore() From dd77bf26400d4268611600db9acacc85477b0395 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:02:56 +0000 Subject: [PATCH 25/45] websocket backlog cap covers a full frame burst --- services/api/src/ws/types.ts | 6 ++++-- .../unit/socket-lifecycle-security.test.ts | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/services/api/src/ws/types.ts b/services/api/src/ws/types.ts index e8feda23..b2a80c9c 100644 --- a/services/api/src/ws/types.ts +++ b/services/api/src/ws/types.ts @@ -33,8 +33,10 @@ export const WS_HANDSHAKE_FRAME_BUFFER = 32 export const WS_HANDSHAKE_BUFFER_BYTES = 64 * 1024 -// Counts the frame in flight, so a client may pipeline this many frames behind one slow handler. -export const WS_MAX_QUEUED_FRAMES = 32 +// Counts the frame in flight. A full token-bucket burst, or a reconnect re-joining every room, has to +// fit behind one slow handler without closing the socket (a close makes the client reconnect and +// replay the same burst); the bucket still rejects the excess as each frame is dequeued. +export const WS_MAX_QUEUED_FRAMES = Math.max(WS_FRAME_LIMIT.capacity, WS_MAX_JOINED_ROOMS) export const WS_MAX_QUEUED_BYTES = 256 * 1024 diff --git a/services/api/test/unit/socket-lifecycle-security.test.ts b/services/api/test/unit/socket-lifecycle-security.test.ts index 9dc6d95e..e9fbdb5f 100644 --- a/services/api/test/unit/socket-lifecycle-security.test.ts +++ b/services/api/test/unit/socket-lifecycle-security.test.ts @@ -7,6 +7,8 @@ import { InMemoryChatPubSub } from "../../src/adapters/chat-pubsub.js" import { InMemoryChatRepository } from "../helpers/chat.js" import { WS_CLOSE_POLICY_VIOLATION, + WS_FRAME_LIMIT, + WS_MAX_JOINED_ROOMS, WS_MAX_QUEUED_BYTES, WS_MAX_QUEUED_FRAMES, } from "../../src/ws/types.js" @@ -180,7 +182,22 @@ describe("post-handshake inbound frame backlog is bounded", () => { for (let i = 0; i < 10; i += 1) await flush() expect(socket.closes).toHaveLength(0) - expect(membership.calls).toEqual(Array.from({ length: queued + 1 }, (_, i) => roomN(i))) + const admittedByBucket = Math.min(queued + 1, WS_FRAME_LIMIT.capacity) + expect(membership.calls).toEqual(Array.from({ length: admittedByBucket }, (_, i) => roomN(i))) + }) + + it("keeps a full token-bucket burst or a full room re-join open behind one slow handler", async () => { + const socket = await openLiveSocket(membership) + const burst = Math.max(WS_FRAME_LIMIT.capacity, WS_MAX_JOINED_ROOMS) + socket.emit("message", joinFrame(0)) + await flush() + for (let i = 1; i < burst; i += 1) socket.emit("message", joinFrame(i)) + + expect(socket.closes).toHaveLength(0) + membership.release() + for (let i = 0; i < 10; i += 1) await flush() + expect(socket.closes).toHaveLength(0) + expect(membership.calls.length).toBeGreaterThanOrEqual(WS_FRAME_LIMIT.capacity) }) it("frees backlog room as frames finish, so a drained socket takes a new burst", async () => { From 3e068e66d9da6019dd2bdfc90eec0d8b866486bf Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:02:56 +0000 Subject: [PATCH 26/45] index pending org invites by inviter --- ...2026-07-24-full-backend-security-review.md | 3 +- ...82_organization_invites_invited_by_idx.sql | 31 +++++++++++++++ .../api/src/db/schema/organization_invites.ts | 5 ++- ...rganization-invites-inviter-idx-pg.test.ts | 29 ++++++++++++++ .../organization-invites-inviter-idx.test.ts | 38 +++++++++++++++++++ 5 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 services/api/drizzle/0182_organization_invites_invited_by_idx.sql create mode 100644 services/api/test/integration/organization-invites-inviter-idx-pg.test.ts create mode 100644 services/api/test/unit/organization-invites-inviter-idx.test.ts 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 475c1eea..f668989d 100644 --- a/docs/security/2026-07-24-full-backend-security-review.md +++ b/docs/security/2026-07-24-full-backend-security-review.md @@ -322,7 +322,7 @@ Everything an operator has to do by hand, in order, plus the two infra facts and node dist/db/migrate.js # == pnpm --filter @civfix/api db:migrate ``` -`drizzle/` holds **163 files**, `0000_extensions.sql` … `0180_civfix_official_account.sql`. The nine rows +`drizzle/` holds **165 files**, `0000_extensions.sql` … `0182_organization_invites_invited_by_idx.sql`. The nine rows 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 @@ -570,6 +570,7 @@ foreign key into `organizations` from `0105`. | `0178_event_announcements.sql` | widens `broadcasts_kind_check` to accept the new `announcement` kind (event announcements ride the existing host-broadcast pipeline rather than a parallel table), adds the partial `broadcasts_announcement_public_idx (cleanup_id, created_at DESC, id DESC) WHERE kind = 'announcement'` that backs the public per-event announcements list, and re-creates `broadcasts_scrub_idx` with `kind <> 'announcement'` so the retention scrub can never NULL an announcement body - an announcement is permanent public event content rendered on the event page, not a one-shot email. `broadcasts` is NOT on the hot-table list in `docs/out-of-band-indexes.md`, so both indexes build inline; `IF NOT EXISTS` makes an out-of-band CONCURRENTLY build a no-op | Every `createEventAnnouncement` fails on the CHECK constraint. Applying the CHECK but not the scrub-index swap still works (the repository query carries the same predicate), it just scans more rows | | `0179_address_resolution.sql` | the address-resolution overhaul: creates `geocode_cache` (a read-through cache of reverse geocodes keyed by the shared 5-decimal point key, with its `geocode_cache_resolved_at_idx`; derived public data only, no user/report/event reference, TTL applied on read rather than by a cron), adds `cleanups.address_source` (resolved | edited | manual) and backfills it to `manual` for every event that already has an address, and adds `reports.addr_source` + `reports.addr_precision`. All three CHECK constraints are `NOT VALID` (new columns, so they hold by construction) and are listed in the outstanding-VALIDATE set below. No new index on a hot table: both `reports` columns are plain row columns | `POST /map/resolve-address` and every create path still answer, but uncached (the cache swallows its own errors) and with no provenance persisted; a new client's event create fails on the missing `cleanups.address_source` column | | `0180_civfix_official_account.sql` | DATA only, no DDL (the Drizzle mirror is unchanged): creates the official `@civfix` account (`users.id` `00000000-0000-4000-8000-00000000c1f1`, display name `CivFix`, role `citizen`, no email, DMs closed) that admin-panel chat posts are authored by. First it frees the handle: any OTHER row holding `civfix` (citext, so any case) gets its auto-placeholder handle `user` + the first 12 hex digits of its id, and nothing else about that row changes - not its display name, and not `handle_changed_at`, so its owner may pick a new handle at once. The INSERT conflicts on the id only, so a placeholder or handle clash fails the file instead of skipping it. At most two `users` rows are touched: milliseconds, row locks only. `SessionService` refuses to mint or resolve a session for the id, and with no email and no OAuth identity no sign-in path can reach it | Admin-panel report and event chat posts fail on the `chat_messages.sender_id` foreign key once the code that authors them as the official account is deployed; nothing else reads the row | +| `0182_organization_invites_invited_by_idx.sql` | adds the partial index `organization_invites_inviter_pending_idx (invited_by) WHERE status = 'pending'`, so the account-erasure statement that revokes the pending organization invites a user sent or received (`invited_by = $1 OR user_id = $1`) can BitmapOr this index with `organization_invites_invitee_pending_idx` instead of scanning the table. `organization_invites` is not on the hot-table list, so it builds inline under the migration transaction (milliseconds at current size); if the table has grown, build it `CONCURRENTLY` by hand first and the `IF NOT EXISTS` guard makes the file a no-op | Account deletion still works, with a sequential scan of `organization_invites` inside the erasure transaction | **Deferred to the NEXT release** (expand/contract, `docs/migrations-expand-contract.md`): 0.43.0 diff --git a/services/api/drizzle/0182_organization_invites_invited_by_idx.sql b/services/api/drizzle/0182_organization_invites_invited_by_idx.sql new file mode 100644 index 00000000..b5bcffda --- /dev/null +++ b/services/api/drizzle/0182_organization_invites_invited_by_idx.sql @@ -0,0 +1,31 @@ +-- ============================================================================= +-- 0182_organization_invites_invited_by_idx.sql +-- ----------------------------------------------------------------------------- +-- Account erasure (PgUserStore.softDeleteAndAnonymize) revokes every pending +-- organization invite the user sent or received: +-- +-- UPDATE organization_invites ... WHERE status = 'pending' +-- AND (invited_by = $1 OR user_id = $1) +-- +-- The user_id branch is served by organization_invites_invitee_pending_idx +-- (0165); the invited_by branch had no index, so the OR fell back to a +-- sequential scan inside the erasure transaction. This partial index lets the +-- planner BitmapOr the two branches. +-- +-- NOT A HOT TABLE: organization_invites is absent from the hot-table list in +-- docs/out-of-band-indexes.md, so this builds inline. If the table has grown +-- large by the time this deploys, build it with CREATE INDEX CONCURRENTLY first +-- and the IF NOT EXISTS guard turns this into a no-op. +-- +-- CANONICAL DDL: hand-authored source of truth. Mirror: +-- schema/organization_invites.ts. +-- +-- Conventions: one concern per file; one transaction per file. Forward-only, +-- no down. +-- +-- Ordering rules: requires 0162_org_suspension_and_invites.sql. +-- ============================================================================= + +CREATE INDEX IF NOT EXISTS organization_invites_inviter_pending_idx + ON organization_invites (invited_by) + WHERE status = 'pending'; diff --git a/services/api/src/db/schema/organization_invites.ts b/services/api/src/db/schema/organization_invites.ts index 79c06e2e..945a3e42 100644 --- a/services/api/src/db/schema/organization_invites.ts +++ b/services/api/src/db/schema/organization_invites.ts @@ -11,7 +11,7 @@ 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 (the SQL is canonical). */ +/** Mirror of drizzle/0162_org_suspension_and_invites.sql, 0165 and 0182 (the SQL is canonical). */ export const organizationInvites = pgTable( "organization_invites", { @@ -51,6 +51,9 @@ export const organizationInvites = pgTable( index("organization_invites_expiry_idx") .on(t.expiresAt) .where(sql`${t.status} = 'pending'`), + index("organization_invites_inviter_pending_idx") + .on(t.invitedBy) + .where(sql`${t.status} = 'pending'`), ], ) diff --git a/services/api/test/integration/organization-invites-inviter-idx-pg.test.ts b/services/api/test/integration/organization-invites-inviter-idx-pg.test.ts new file mode 100644 index 00000000..6136bde2 --- /dev/null +++ b/services/api/test/integration/organization-invites-inviter-idx-pg.test.ts @@ -0,0 +1,29 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness } from "../helpers/pg.js" + +const pg = await withPg() + +describe.skipIf(!pg)("organization_invites inviter index (0182, integration)", () => { + let h: PgHarness + + beforeAll(() => { + h = pg as PgHarness + }) + + afterAll(async () => { + await h.teardown() + }) + + it("builds a partial index on invited_by over pending invites", async () => { + const rows = await h.sql<{ indexdef: string }[]>` + SELECT indexdef FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'organization_invites' + AND indexname = 'organization_invites_inviter_pending_idx' + ` + + expect(rows).toHaveLength(1) + expect(rows[0]!.indexdef).toMatch(/\(invited_by\)/) + expect(rows[0]!.indexdef).toMatch(/WHERE \(status = 'pending'::text\)/) + }) +}) diff --git a/services/api/test/unit/organization-invites-inviter-idx.test.ts b/services/api/test/unit/organization-invites-inviter-idx.test.ts new file mode 100644 index 00000000..1cbce570 --- /dev/null +++ b/services/api/test/unit/organization-invites-inviter-idx.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { describe, expect, it } from "vitest" +import { getTableConfig } from "drizzle-orm/pg-core" +import { PgDialect } from "drizzle-orm/pg-core" +import { organizationInvites } from "../../src/db/schema/organization_invites.js" + +const INDEX_NAME = "organization_invites_inviter_pending_idx" +const MIGRATION = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "drizzle", + "0182_organization_invites_invited_by_idx.sql", +) + +describe("organization_invites inviter index for account erasure", () => { + it("ships an idempotent partial index on invited_by over pending rows", () => { + const ddl = readFileSync(MIGRATION, "utf8").replace(/--.*$/gm, "").replace(/\s+/g, " ") + + expect(ddl).toContain( + `CREATE INDEX IF NOT EXISTS ${INDEX_NAME} ON organization_invites (invited_by) WHERE status = 'pending';`, + ) + expect(ddl).not.toMatch(/CONCURRENTLY/i) + }) + + it("is mirrored in the Drizzle schema with the same column and predicate", () => { + const index = getTableConfig(organizationInvites).indexes.find( + (i) => i.config.name === INDEX_NAME, + ) + + expect(index).toBeDefined() + expect(index!.config.columns.map((c) => ("name" in c ? c.name : null))).toEqual(["invited_by"]) + const where = new PgDialect().sqlToQuery(index!.config.where!).sql + expect(where).toBe(`"organization_invites"."status" = 'pending'`) + }) +}) From c9466f4d26975539b9fb04e5388c57b2c52fe296 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:02:56 +0000 Subject: [PATCH 27/45] chat integration tests use the production operator gate --- services/api/src/routes/chat-powers-wiring.ts | 11 ----------- .../api/test/integration/chat-groups-pg.test.ts | 4 ++-- .../api/test/integration/chat-pins-pg.test.ts | 17 ++++++++++++----- .../api/test/integration/chat-polls-pg.test.ts | 4 ++-- .../test/integration/messages-edit-pg.test.ts | 4 ++-- 5 files changed, 18 insertions(+), 22 deletions(-) diff --git a/services/api/src/routes/chat-powers-wiring.ts b/services/api/src/routes/chat-powers-wiring.ts index 04f6ec26..d4d5d5fa 100644 --- a/services/api/src/routes/chat-powers-wiring.ts +++ b/services/api/src/routes/chat-powers-wiring.ts @@ -62,17 +62,6 @@ export function makeIsDmBlocked( } } -/** The user's global users.role, or null when the row is missing. */ -export async function globalRoleOf( - sql: ReturnType["sql"], - userId: string, -): Promise { - const rows = await sql<{ role: GlobalRole }[]>` - SELECT role FROM users WHERE id = ${userId} LIMIT 1 - ` - return rows[0]?.role ?? null -} - /** * The global role that counts for chat powers. users.role is never demoted when an operator is * off-boarded, so the operator role only carries authority while the row's current email passes the same diff --git a/services/api/test/integration/chat-groups-pg.test.ts b/services/api/test/integration/chat-groups-pg.test.ts index ffcc0689..3a75313e 100644 --- a/services/api/test/integration/chat-groups-pg.test.ts +++ b/services/api/test/integration/chat-groups-pg.test.ts @@ -47,7 +47,7 @@ import { makeReportChatRepository } from "../../src/services/report-chat-reposit import { makeChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" import { makeConversationMutesRepository } from "../../src/services/conversation-mutes-repository.drizzle.js" import { makeChatPowersResolver } from "../../src/services/chat-room-roles.js" -import { globalRoleOf } from "../../src/routes/chat-powers-wiring.js" +import { chatAuthorityRoleOf } from "../../src/routes/chat-powers-wiring.js" import type { PresignMedia } from "../../src/services/media-presign.js" const pg = await withPg() @@ -94,7 +94,7 @@ describe.skipIf(!pg)("chat groups service + routes (integration)", () => { isDmParticipant: (threadId, userId) => dm.isParticipant(threadId, userId), cleanupRoleOf: (cleanupId, userId) => cleanups.roleOf(cleanupId, userId), reportChatRoleOf: (reportId, userId) => reportChat.roleOf(reportId, userId), - globalRoleOf: (userId) => globalRoleOf(h.sql, userId), + globalRoleOf: (userId) => chatAuthorityRoleOf(h.sql, env, userId), groupRoleOf: (groupId, userId) => groups.roleOf(groupId, userId), }), } diff --git a/services/api/test/integration/chat-pins-pg.test.ts b/services/api/test/integration/chat-pins-pg.test.ts index ec48b6f9..fbc1040d 100644 --- a/services/api/test/integration/chat-pins-pg.test.ts +++ b/services/api/test/integration/chat-pins-pg.test.ts @@ -50,10 +50,14 @@ import { makeDrizzleDiscussionRepository } from "../../src/services/discussion-r import { makeCleanupService } from "../../src/services/cleanup-service.js" import { makeChatPowersResolver } from "../../src/services/chat-room-roles.js" import { makeChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" -import { globalRoleOf } from "../../src/routes/chat-powers-wiring.js" +import { chatAuthorityRoleOf } from "../../src/routes/chat-powers-wiring.js" const pg = await withPg() +// Operator chat powers only count while the account's email is on ADMIN_EMAILS, as in production. +const PIN_OPERATOR_EMAIL = "pin-operator@civfix.test" +const DELETE_OPERATOR_EMAIL = "delete-operator@civfix.test" + describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { let h: PgHarness let app: FastifyInstance @@ -63,7 +67,10 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { beforeAll(async () => { h = pg as PgHarness - const env = loadEnv({ NODE_ENV: "test" }) + const env = loadEnv({ + NODE_ENV: "test", + ADMIN_EMAILS: [PIN_OPERATOR_EMAIL, DELETE_OPERATOR_EMAIL].join(","), + }) authServices = buildAuthServices({ stores: makeInMemoryStores(), cache: new InMemoryCacheClient(() => Date.now()), @@ -88,7 +95,7 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { isDmParticipant: (threadId, userId) => dm.isParticipant(threadId, userId), cleanupRoleOf: (cleanupId, userId) => cleanups.roleOf(cleanupId, userId), reportChatRoleOf: (reportId, userId) => reportChat.roleOf(reportId, userId), - globalRoleOf: (userId) => globalRoleOf(h.sql, userId), + globalRoleOf: (userId) => chatAuthorityRoleOf(h.sql, env, userId), // P4 group lane, wired to the real repo for parity (this file exercises no group rooms). groupRoleOf: (groupId, userId) => makeChatGroupRepository(h.sql).roleOf(groupId, userId), }), @@ -268,7 +275,7 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { it("a global OPERATOR pins in a report room WITHOUT a membership row", async () => { const posterId = await newUser("Pin Poster") const operatorId = await newUser("Pin Operator") - await h.sql`UPDATE users SET role = 'operator' WHERE id = ${operatorId}` + await h.sql`UPDATE users SET role = 'operator', email = ${PIN_OPERATOR_EMAIL} WHERE id = ${operatorId}` const reportId = await newReport() await makeReportChatRepository(h.sql).join(reportId, posterId) const msg = await chat().insertMessage( @@ -566,7 +573,7 @@ describe.skipIf(!pg)("chat pins + moderator delete (integration)", () => { it("an OPERATOR deletes a report message without a membership row (200)", async () => { const posterId = await newUser("Del Poster") const operatorId = await newUser("Del Operator") - await h.sql`UPDATE users SET role = 'operator' WHERE id = ${operatorId}` + await h.sql`UPDATE users SET role = 'operator', email = ${DELETE_OPERATOR_EMAIL} WHERE id = ${operatorId}` const reportId = await newReport() await makeReportChatRepository(h.sql).join(reportId, posterId) const msg = await chat().insertMessage( diff --git a/services/api/test/integration/chat-polls-pg.test.ts b/services/api/test/integration/chat-polls-pg.test.ts index f767790c..4ed5e96f 100644 --- a/services/api/test/integration/chat-polls-pg.test.ts +++ b/services/api/test/integration/chat-polls-pg.test.ts @@ -50,7 +50,7 @@ import { makeReportChatRepository } from "../../src/services/report-chat-reposit import { makeChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" import { makeChatPollRepository } from "../../src/services/chat-poll-repository.drizzle.js" import { makeChatPowersResolver } from "../../src/services/chat-room-roles.js" -import { globalRoleOf } from "../../src/routes/chat-powers-wiring.js" +import { chatAuthorityRoleOf } from "../../src/routes/chat-powers-wiring.js" import { makeConversationMutesRepository } from "../../src/services/conversation-mutes-repository.drizzle.js" import { makeContainerPollNotifier } from "../../src/services/chat-poll-notifier.js" import { makeChatPollService } from "../../src/services/chat-poll-service.js" @@ -84,7 +84,7 @@ describe.skipIf(!pg)("chat polls: create / vote / close + hydration (integration isDmParticipant: (t, u) => dmRepo.isParticipant(t, u), cleanupRoleOf: (c, u) => cleanups.roleOf(c, u), reportChatRoleOf: (r, u) => reportChat.roleOf(r, u), - globalRoleOf: (u) => globalRoleOf(h.sql, u), + globalRoleOf: (u) => chatAuthorityRoleOf(h.sql, env, u), groupRoleOf: (g, u) => groups.roleOf(g, u), }) const overrides: ChatGatewayOverrides = { diff --git a/services/api/test/integration/messages-edit-pg.test.ts b/services/api/test/integration/messages-edit-pg.test.ts index d757f239..8fc69896 100644 --- a/services/api/test/integration/messages-edit-pg.test.ts +++ b/services/api/test/integration/messages-edit-pg.test.ts @@ -46,7 +46,7 @@ import { makeDrizzleDiscussionRepository } from "../../src/services/discussion-r import { makeChatPollRepository } from "../../src/services/chat-poll-repository.drizzle.js" import { makeChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" import { makeChatPowersResolver } from "../../src/services/chat-room-roles.js" -import { globalRoleOf } from "../../src/routes/chat-powers-wiring.js" +import { chatAuthorityRoleOf } from "../../src/routes/chat-powers-wiring.js" const pg = await withPg() @@ -641,7 +641,7 @@ describe.skipIf(!pg)("chat message edit (integration)", () => { isDmParticipant: (threadId, userId) => dm.isParticipant(threadId, userId), cleanupRoleOf: (cleanupId, userId) => cleanups.roleOf(cleanupId, userId), reportChatRoleOf: (reportId, userId) => reportChat.roleOf(reportId, userId), - globalRoleOf: (userId) => globalRoleOf(h.sql, userId), + globalRoleOf: (userId) => chatAuthorityRoleOf(h.sql, env, userId), groupRoleOf: (groupId, userId) => groups.roleOf(groupId, userId), }), } From c4c24407771eadf66c0116fe308af7ffac0bc6e4 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:09:56 +0000 Subject: [PATCH 28/45] media claims bind only the caller's own upload; chat readers other than the sender never get an unprocessed upload; event pages serve processed media --- docs/erasure-behavior.md | 1 + ...2026-07-24-full-backend-security-review.md | 1 + services/api/drizzle/0181_media_uploader.sql | 21 ++ services/api/src/auth/pg-stores.ts | 40 ++- services/api/src/db/schema/media.ts | 1 + .../src/services/anon-repository.drizzle.ts | 5 +- services/api/src/services/anon-service.ts | 17 + services/api/src/services/avatar-media.ts | 43 ++- .../src/services/chat-attachments.drizzle.ts | 12 +- .../services/chat-group-repository.drizzle.ts | 15 +- .../api/src/services/chat-group-service.ts | 16 +- .../src/services/chat-repository.drizzle.ts | 8 +- .../api/src/services/chat-viewer-fields.ts | 6 + .../api/src/services/dm-repository.drizzle.ts | 15 +- .../host/registration-repository.drizzle.ts | 17 +- services/api/src/services/media-bindings.ts | 31 +- .../api/src/services/media-intake-service.ts | 3 + .../src/services/media-repository.drizzle.ts | 1 + services/api/src/services/media-served-key.ts | 24 +- services/api/src/services/media-uploader.ts | 20 ++ .../services/message-attachments.drizzle.ts | 26 +- .../src/services/post-repository.drizzle.ts | 6 +- .../src/services/report-repository.drizzle.ts | 6 +- services/api/test/helpers/media-pg.ts | 5 +- .../test/integration/avatar-media-pg.test.ts | 152 ++++++--- .../media-attach-claim-security-pg.test.ts | 41 ++- .../report-media-claim-security-pg.test.ts | 34 +- .../unit/anon-repository-security.test.ts | 2 + .../test/unit/chat-attachment-viewer.test.ts | 86 +++++ .../unit/chat-attachments-servable.test.ts | 15 +- .../unit/media-attach-claim-security.test.ts | 13 +- .../test/unit/media-claim-uploader.test.ts | 302 ++++++++++++++++++ .../test/unit/media-public-served-key.test.ts | 134 +++++++- 33 files changed, 987 insertions(+), 132 deletions(-) create mode 100644 services/api/drizzle/0181_media_uploader.sql create mode 100644 services/api/src/services/media-uploader.ts create mode 100644 services/api/test/unit/chat-attachment-viewer.test.ts create mode 100644 services/api/test/unit/media-claim-uploader.test.ts diff --git a/docs/erasure-behavior.md b/docs/erasure-behavior.md index e19c53d6..f21732d2 100644 --- a/docs/erasure-behavior.md +++ b/docs/erasure-behavior.md @@ -95,6 +95,7 @@ response can be answered truthfully. It is the source of record for the | `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. | +| `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. | ### The host-transfer ladder 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 f668989d..4c31a685 100644 --- a/docs/security/2026-07-24-full-backend-security-review.md +++ b/docs/security/2026-07-24-full-backend-security-review.md @@ -570,6 +570,7 @@ foreign key into `organizations` from `0105`. | `0178_event_announcements.sql` | widens `broadcasts_kind_check` to accept the new `announcement` kind (event announcements ride the existing host-broadcast pipeline rather than a parallel table), adds the partial `broadcasts_announcement_public_idx (cleanup_id, created_at DESC, id DESC) WHERE kind = 'announcement'` that backs the public per-event announcements list, and re-creates `broadcasts_scrub_idx` with `kind <> 'announcement'` so the retention scrub can never NULL an announcement body - an announcement is permanent public event content rendered on the event page, not a one-shot email. `broadcasts` is NOT on the hot-table list in `docs/out-of-band-indexes.md`, so both indexes build inline; `IF NOT EXISTS` makes an out-of-band CONCURRENTLY build a no-op | Every `createEventAnnouncement` fails on the CHECK constraint. Applying the CHECK but not the scrub-index swap still works (the repository query carries the same predicate), it just scans more rows | | `0179_address_resolution.sql` | the address-resolution overhaul: creates `geocode_cache` (a read-through cache of reverse geocodes keyed by the shared 5-decimal point key, with its `geocode_cache_resolved_at_idx`; derived public data only, no user/report/event reference, TTL applied on read rather than by a cron), adds `cleanups.address_source` (resolved | edited | manual) and backfills it to `manual` for every event that already has an address, and adds `reports.addr_source` + `reports.addr_precision`. All three CHECK constraints are `NOT VALID` (new columns, so they hold by construction) and are listed in the outstanding-VALIDATE set below. No new index on a hot table: both `reports` columns are plain row columns | `POST /map/resolve-address` and every create path still answer, but uncached (the cache swallows its own errors) and with no provenance persisted; a new client's event create fails on the missing `cleanups.address_source` column | | `0180_civfix_official_account.sql` | DATA only, no DDL (the Drizzle mirror is unchanged): creates the official `@civfix` account (`users.id` `00000000-0000-4000-8000-00000000c1f1`, display name `CivFix`, role `citizen`, no email, DMs closed) that admin-panel chat posts are authored by. First it frees the handle: any OTHER row holding `civfix` (citext, so any case) gets its auto-placeholder handle `user` + the first 12 hex digits of its id, and nothing else about that row changes - not its display name, and not `handle_changed_at`, so its owner may pick a new handle at once. The INSERT conflicts on the id only, so a placeholder or handle clash fails the file instead of skipping it. At most two `users` rows are touched: milliseconds, row locks only. `SessionService` refuses to mint or resolve a session for the id, and with no email and no OAuth identity no sign-in path can reach it | Admin-panel report and event chat posts fail on the `chat_messages.sender_id` foreign key once the code that authors them as the official account is deployed; nothing else reads the row | +| `0181_media_uploader.sql` | adds the nullable `media_assets.uploader` column (`u:`, `a:` or `anon`), written by upload create, so a report, post, chat or avatar claim by uploadId binds only the caller's own upload. Nullable with no default and no backfill: a catalog-only change on a hot table, lock held for milliseconds, no index (claims find the row through the unique `upload_id`). Rows from before the deploy stay NULL and are claimable only inside the claim window | Upload create fails writing a column that does not exist; claims fail on the missing column | | `0182_organization_invites_invited_by_idx.sql` | adds the partial index `organization_invites_inviter_pending_idx (invited_by) WHERE status = 'pending'`, so the account-erasure statement that revokes the pending organization invites a user sent or received (`invited_by = $1 OR user_id = $1`) can BitmapOr this index with `organization_invites_invitee_pending_idx` instead of scanning the table. `organization_invites` is not on the hot-table list, so it builds inline under the migration transaction (milliseconds at current size); if the table has grown, build it `CONCURRENTLY` by hand first and the `IF NOT EXISTS` guard makes the file a no-op | Account deletion still works, with a sequential scan of `organization_invites` inside the erasure transaction | diff --git a/services/api/drizzle/0181_media_uploader.sql b/services/api/drizzle/0181_media_uploader.sql new file mode 100644 index 00000000..ce246c44 --- /dev/null +++ b/services/api/drizzle/0181_media_uploader.sql @@ -0,0 +1,21 @@ +-- ============================================================================= +-- 0181_media_uploader.sql +-- ----------------------------------------------------------------------------- +-- Records who created each upload, so a claim by uploadId can require the +-- caller to be that uploader. The uploadId is readable from every served URL, +-- so knowing it proves nothing. +-- +-- Values: 'u:', 'a:', or 'anon' for a caller with no +-- session. NULL marks a row written before this column existed; the claim paths +-- accept those only inside the claim window, so they age out on their own. +-- +-- media_assets is a hot table. A nullable column with no default is a catalog +-- change only: no rewrite, no backfill, and the lock is held for milliseconds. +-- No index: every claim finds its row through the unique upload_id first. +-- +-- Erasure keeps the value, like reports.reporter_user_id: it names a users row +-- that survives as an anonymized tombstone, and nulling it would turn an erased +-- user's uploads back into rows any caller may claim inside the window. +-- ============================================================================= + +ALTER TABLE media_assets ADD COLUMN IF NOT EXISTS uploader text; diff --git a/services/api/src/auth/pg-stores.ts b/services/api/src/auth/pg-stores.ts index 02433174..8d02f404 100644 --- a/services/api/src/auth/pg-stores.ts +++ b/services/api/src/auth/pg-stores.ts @@ -23,7 +23,12 @@ import { } from "@civfix/shared" import type { Jobs } from "@civfix/shared/interfaces" import { decideHandleWrite, handleChanged } from "./handle-policy.js" -import { resolveAvatarMediaOrThrow } from "../services/avatar-media.js" +import { + avatarClaimQuery, + avatarMediaRefOrThrow, + type AvatarMediaRow, +} from "../services/avatar-media.js" +import { userUploader } from "../services/media-uploader.js" import { enqueueWaitlistPromotion } from "../services/host/waitlist-promotion.js" import type { NotificationService } from "../services/notification-service.js" import { @@ -238,15 +243,6 @@ export class PgUserStore implements UserStore { if (input.donationUrl !== undefined) { set.donationUrl = input.donationUrl === "" ? null : input.donationUrl } - if (input.avatarUploadId !== undefined) { - const media = await resolveAvatarMediaOrThrow(this.db.$client, input.avatarUploadId, { - userId: id, - }) - set.avatarMediaId = media.id - if (input.presignAvatar && media.servedKey !== null) { - set.avatarUrl = await input.presignAvatar(media.servedKey) - } - } if (input.socialLinks !== undefined) { const links = input.socialLinks const clean: SocialLinks = {} @@ -258,13 +254,27 @@ export class PgUserStore implements UserStore { } set.socialLinks = Object.keys(clean).length > 0 ? clean : null } + const avatarUploadId = input.avatarUploadId let updated: (typeof users.$inferSelect)[] try { - updated = await this.db - .update(users) - .set(set) - .where(and(eq(users.id, id), isNull(users.deletedAt))) - .returning() + updated = await this.db.transaction(async (tx) => { + if (avatarUploadId !== undefined) { + const media = avatarMediaRefOrThrow( + await tx.execute( + avatarClaimQuery(sql, avatarUploadId, { uploader: userUploader(id), userId: id }), + ), + ) + set.avatarMediaId = media.id + if (input.presignAvatar && media.servedKey !== null) { + set.avatarUrl = await input.presignAvatar(media.servedKey) + } + } + return tx + .update(users) + .set(set) + .where(and(eq(users.id, id), isNull(users.deletedAt))) + .returning() + }) } catch (err) { if (isUniqueViolation(err)) throw AppError.conflict("That username is taken.") throw err diff --git a/services/api/src/db/schema/media.ts b/services/api/src/db/schema/media.ts index 73d0da61..9c69086a 100644 --- a/services/api/src/db/schema/media.ts +++ b/services/api/src/db/schema/media.ts @@ -36,6 +36,7 @@ export const mediaAssets = pgTable( thumbKey: text("thumb_key"), status: text("status").$type().notNull(), purpose: text("purpose").$type().notNull().default("report"), + uploader: text("uploader"), width: integer("width"), height: integer("height"), byteSize: bigint("byte_size", { mode: "number" }), diff --git a/services/api/src/services/anon-repository.drizzle.ts b/services/api/src/services/anon-repository.drizzle.ts index 15aa3d1e..924838ac 100644 --- a/services/api/src/services/anon-repository.drizzle.ts +++ b/services/api/src/services/anon-repository.drizzle.ts @@ -58,7 +58,7 @@ import type { ClaimRepository, PendingAnonReport } from "./claim-service.js" import { AppError } from "@civfix/shared" import type { AnonReportResponse, ReportStatus } from "@civfix/shared" import { insertModerationItem } from "./admin/moderation-repository.drizzle.js" -import { claimableAsReportMedia } from "./media-bindings.js" +import { claimableAsReportMedia, lockUploadsForClaim } from "./media-bindings.js" /** Postgres unique-violation SQLSTATE; surfaced on the idempotency-key race. */ const PG_UNIQUE_VIOLATION = "23505" @@ -242,13 +242,14 @@ export function makeDrizzleAnonReportRepository( // 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. if (args.mediaUploadIds.length > 0) { + await lockUploadsForClaim(tx, args.mediaUploadIds) const claimed = await tx<{ upload_id: string }[]>` UPDATE media_assets SET report_id = ${args.reportId} WHERE upload_id IN ${tx(args.mediaUploadIds)} AND (report_id IS NULL OR report_id = ${args.reportId}) AND post_id IS NULL AND chat_message_id IS NULL - AND ${claimableAsReportMedia(tx)} + AND ${claimableAsReportMedia(tx, args.mediaUploaders)} AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) RETURNING upload_id ` diff --git a/services/api/src/services/anon-service.ts b/services/api/src/services/anon-service.ts index f639ca1e..6a999e41 100644 --- a/services/api/src/services/anon-service.ts +++ b/services/api/src/services/anon-service.ts @@ -28,6 +28,7 @@ import { } from "../abuse/anon-token.js" import { generateToken, constantTimeStringEqual, sha256Hex } from "../auth/crypto.js" import { UNKNOWN_JURCODE } from "../db/reference-code.js" +import { UNSESSIONED_UPLOADER, anonUploader } from "./media-uploader.js" import { addressProvenance, resolveAddressOrNull, @@ -61,6 +62,7 @@ export interface CreateAnonReportTxArgs { addrPrecision: AddressPrecision | null h3Cell: string mediaUploadIds: string[] + mediaUploaders: readonly string[] claimCodeHash: string reportCap: number responseSnapshot: AnonReportResponse @@ -241,6 +243,7 @@ export function makeAnonService(deps: AnonServiceDeps): AnonService { addrPrecision: addressWrite.addrPrecision, h3Cell, mediaUploadIds: input.mediaUploadIds, + mediaUploaders: anonMediaUploaders(tokenRow.id, input.anonToken, deps.anonTokenSigningKey), claimCodeHash, reportCap: ANON_TOKEN_REPORT_CAP, responseSnapshot, @@ -279,6 +282,20 @@ export function makeAnonService(deps: AnonServiceDeps): AnonService { } } +// Guest uploads carry the anon cookie the upload request presented, and a first report's uploads carry no +// session at all. The presented token counts by signature alone: an expired one is re-issued at submit, +// yet the uploads made under it are still this guest's. +function anonMediaUploaders( + tokenId: string, + presentedToken: string | undefined, + signingKey: string, +): string[] { + const presentedId = presentedToken ? verifyAnonTokenSignature(presentedToken, signingKey) : null + const sessions = + presentedId !== null && presentedId !== tokenId ? [tokenId, presentedId] : [tokenId] + return [...sessions.map(anonUploader), UNSESSIONED_UPLOADER] +} + function resolveTrustedCfGeo( ctx: AnonSubmitContext, log: (line: string, extra?: Record) => void, diff --git a/services/api/src/services/avatar-media.ts b/services/api/src/services/avatar-media.ts index a47ac46d..7fbf0f92 100644 --- a/services/api/src/services/avatar-media.ts +++ b/services/api/src/services/avatar-media.ts @@ -1,5 +1,5 @@ import { AppError } from "@civfix/shared" -import type { Sql } from "../db/client.js" +import type { Queryable } from "../db/client.js" import { UNBOUND_GRACE_MS } from "./media-authorization.js" export interface AvatarMediaRef { @@ -9,20 +9,33 @@ export interface AvatarMediaRef { } export interface AvatarClaimant { + uploader: string userId?: string | undefined groupId?: string | undefined } +export interface AvatarMediaRow extends Record { + id: string + r2_key: string + served_key: string | null +} + export const AVATAR_CLAIM_WINDOW_SECONDS = UNBOUND_GRACE_MS / 1000 -export async function resolveAvatarMediaOrThrow( - sql: Sql, +type SqlTemplateTag = (strings: TemplateStringsArray, ...values: (string | number | null)[]) => Q + +// Written against a bare template tag so the profile update can run it through drizzle's sql inside the +// same transaction that writes users.avatar_media_id. FOR UPDATE holds the media row until that write +// commits, so a report, post or chat claim waiting on the row then sees the avatar binding, and a claim +// that committed first leaves a row this query no longer matches. +export function avatarClaimQuery( + tag: SqlTemplateTag, uploadId: string, - claimant: AvatarClaimant = {}, -): Promise { + claimant: AvatarClaimant, +): Q { const claimantUserId = claimant.userId ?? null const claimantGroupId = claimant.groupId ?? null - const rows = await sql<{ id: string; r2_key: string; served_key: string | null }[]>` + return tag` SELECT m.id, COALESCE(m.served_key, m.r2_key) AS r2_key, m.served_key FROM media_assets m WHERE m.upload_id = ${uploadId} @@ -36,6 +49,7 @@ export async function resolveAvatarMediaOrThrow( AND m.post_id IS NULL AND m.chat_message_id IS NULL AND m.created_at > now() - make_interval(secs => ${AVATAR_CLAIM_WINDOW_SECONDS}) + AND (m.uploader = ${claimant.uploader} OR m.uploader IS NULL) AND NOT EXISTS ( SELECT 1 FROM users u WHERE u.avatar_media_id = m.id @@ -47,10 +61,27 @@ export async function resolveAvatarMediaOrThrow( AND (${claimantGroupId}::uuid IS NULL OR g.id <> ${claimantGroupId}::uuid) ) LIMIT 1 + FOR UPDATE OF m ` +} + +export function avatarMediaRefOrThrow(rows: readonly AvatarMediaRow[]): AvatarMediaRef { const row = rows[0] if (!row) { throw AppError.validation({ avatarUploadId: "That image is unavailable." }) } return { id: row.id, r2Key: row.r2_key, servedKey: row.served_key } } + +export async function resolveAvatarMediaOrThrow( + sql: Queryable, + uploadId: string, + claimant: AvatarClaimant, +): Promise { + const rows = await avatarClaimQuery( + (strings, ...values) => sql(strings, ...values), + uploadId, + claimant, + ) + return avatarMediaRefOrThrow(rows) +} diff --git a/services/api/src/services/chat-attachments.drizzle.ts b/services/api/src/services/chat-attachments.drizzle.ts index 7b33ebd4..ca9a5a17 100644 --- a/services/api/src/services/chat-attachments.drizzle.ts +++ b/services/api/src/services/chat-attachments.drizzle.ts @@ -8,14 +8,22 @@ export function attachChatMedia( messageId: string, uploadIds: string[], messageCreatedAt: Date, + senderId: string, ): Promise { - return makeAttachmentRepo("chat_message_id").attach(sql, messageId, uploadIds, messageCreatedAt) + return makeAttachmentRepo("chat_message_id").attach( + sql, + messageId, + uploadIds, + messageCreatedAt, + senderId, + ) } export function loadChatAttachments( sql: Queryable, messageIds: string[], presign: PresignMedia, + viewerUserId: string | null, ): Promise> { - return loadServableAttachmentsFor(sql, "chat_message_id", messageIds, presign) + return loadServableAttachmentsFor(sql, "chat_message_id", messageIds, presign, viewerUserId) } diff --git a/services/api/src/services/chat-group-repository.drizzle.ts b/services/api/src/services/chat-group-repository.drizzle.ts index e74df422..0ebc119a 100644 --- a/services/api/src/services/chat-group-repository.drizzle.ts +++ b/services/api/src/services/chat-group-repository.drizzle.ts @@ -6,6 +6,7 @@ import type { PresignMedia } from "./media-presign.js" import { publicAuthorIdentity } from "./public-author.js" import { blockedPairExpr, hiddenIdentity } from "./hidden-identity.js" import { resolveAvatarMediaOrThrow } from "./avatar-media.js" +import { userUploader } from "./media-uploader.js" import { monotonicReadWatermarkUpdate } from "./chat-read-state.drizzle.js" import { isUuid } from "../db/cursor-helpers.js" import { publicServedKeyExpr } from "./media-served-key.js" @@ -62,6 +63,11 @@ export interface UpdateChatGroupPatch { visibility?: ChatGroupVisibility } +export interface GroupAvatarClaimant { + uploaderUserId: string + groupId?: string | undefined +} + export interface ChatGroupRepository { create(input: CreateChatGroupInput, memberIds: string[]): Promise findById(id: string): Promise @@ -84,7 +90,7 @@ export interface ChatGroupRepository { cursor: string | null, limit: number, ): Promise<{ members: GroupMemberView[]; nextCursor: string | null }> - findMediaIdByUploadId(uploadId: string, groupId?: string): Promise + findMediaIdByUploadId(uploadId: string, claimant: GroupAvatarClaimant): Promise invitableIdsOf(actorId: string, candidateIds: string[]): Promise blockedPairsAmong(userIds: string[]): Promise> listMemberIds(groupId: string, limit?: number): Promise @@ -400,8 +406,11 @@ export function makeChatGroupRepository(sql: Sql, presign?: PresignMedia): ChatG } }, - async findMediaIdByUploadId(uploadId: string, groupId?: string): Promise { - const media = await resolveAvatarMediaOrThrow(sql, uploadId, { groupId }) + async findMediaIdByUploadId(uploadId: string, claimant: GroupAvatarClaimant): Promise { + const media = await resolveAvatarMediaOrThrow(sql, uploadId, { + uploader: userUploader(claimant.uploaderUserId), + groupId: claimant.groupId, + }) return media.id }, diff --git a/services/api/src/services/chat-group-service.ts b/services/api/src/services/chat-group-service.ts index 35910cd7..b3606ca5 100644 --- a/services/api/src/services/chat-group-service.ts +++ b/services/api/src/services/chat-group-service.ts @@ -90,9 +90,12 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi } } - async function resolveAvatar(uploadId: string | undefined): Promise { + async function resolveAvatar( + uploadId: string | undefined, + uploaderUserId: string, + ): Promise { if (uploadId === undefined) return null - return groups.findMediaIdByUploadId(uploadId) + return groups.findMediaIdByUploadId(uploadId, { uploaderUserId }) } async function filterInvitees(actorId: string, memberIds: string[]): Promise { @@ -171,7 +174,7 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi assertNoSlur(req.name, "name") assertNoSlur(req.description ?? null, "description") const [avatarMediaId, actorFiltered] = await Promise.all([ - resolveAvatar(req.avatarUploadId), + resolveAvatar(req.avatarUploadId, ownerId), filterInvitees(ownerId, req.memberIds), ]) const memberIds = await filterPairwise(actorFiltered) @@ -219,7 +222,12 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi ? { description: req.description === "" ? null : req.description } : {}), ...(req.avatarUploadId !== undefined - ? { avatarMediaId: await groups.findMediaIdByUploadId(req.avatarUploadId, req.id) } + ? { + avatarMediaId: await groups.findMediaIdByUploadId(req.avatarUploadId, { + uploaderUserId: userId, + groupId: req.id, + }), + } : {}), ...(req.visibility !== undefined ? { visibility: req.visibility } : {}), }) diff --git a/services/api/src/services/chat-repository.drizzle.ts b/services/api/src/services/chat-repository.drizzle.ts index 9672694f..45876067 100644 --- a/services/api/src/services/chat-repository.drizzle.ts +++ b/services/api/src/services/chat-repository.drizzle.ts @@ -403,7 +403,7 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha pollsByMessage, ] = await Promise.all([ presign - ? loadChatAttachments(sql, ids, presign) + ? loadChatAttachments(sql, ids, presign, viewerUserId) : Promise.resolve(new Map()), loadChatReactionsFor(sql, ids, viewerUserId), loadChatMentionsFor(sql, ids), @@ -437,7 +437,7 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha liveIds.length > 0 ? loadChatReactions(sql, row.id, viewerUserId) : Promise.resolve([]), liveIds.length > 0 ? loadChatMentions(sql, row.id) : Promise.resolve([]), presign - ? loadChatAttachments(sql, liveIds, presign) + ? loadChatAttachments(sql, liveIds, presign, viewerUserId) : Promise.resolve(new Map()), resolveReportCity(scope), replyMapForRows(sql, "chat_messages", [row]), @@ -616,7 +616,7 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha ? sql.begin(async (tx) => { const inserted = await run(tx) const createdAt = inserted[0]!.created_at - if (wantsMedia) await attachChatMedia(tx, id, uploadIds, createdAt) + if (wantsMedia) await attachChatMedia(tx, id, uploadIds, createdAt, input.userId) if (inTx) await inTx(tx, { id, createdAt }) return inserted }) @@ -626,7 +626,7 @@ export function makeDrizzleChatRepository(sql: Sql, presign?: PresignMedia): Cha : Promise.resolve(null), ]) const attachments = wantsMedia - ? ((await loadChatAttachments(sql, [id], presign!)).get(id) ?? []) + ? ((await loadChatAttachments(sql, [id], presign!, input.userId)).get(id) ?? []) : [] return toMessageDTO( rows[0]!, diff --git a/services/api/src/services/chat-viewer-fields.ts b/services/api/src/services/chat-viewer-fields.ts index b24390f5..30906ff9 100644 --- a/services/api/src/services/chat-viewer-fields.ts +++ b/services/api/src/services/chat-viewer-fields.ts @@ -6,6 +6,12 @@ export function neutralizeChatViewerFields(dto: ChatMessageDTO): ChatMessageDTO mine: false, reactions: (dto.reactions ?? []).map((r) => (r.mine ? { ...r, mine: false } : r)), } + // The sender's copy links a still-validating attachment to its raw upload (unscanned, EXIF intact) so + // they see their own photo at once. The contract has no url-less attachment, so the copy everyone else + // gets leaves it out until a later read finds it ready. + if (dto.attachments != null) { + next.attachments = dto.attachments.filter((a) => a.status === "ready") + } if (dto.poll != null) { next.poll = { ...dto.poll, diff --git a/services/api/src/services/dm-repository.drizzle.ts b/services/api/src/services/dm-repository.drizzle.ts index 3cf19657..3b3edff7 100644 --- a/services/api/src/services/dm-repository.drizzle.ts +++ b/services/api/src/services/dm-repository.drizzle.ts @@ -252,7 +252,7 @@ export function makeDrizzleDmRepository(sql: Sql, presign?: PresignMedia): DmRep const [attachmentsByMessage, reactionsByMessage, mentionsByMessage, replyByTarget] = await Promise.all([ presign - ? loadChatAttachments(sql, ids, presign) + ? loadChatAttachments(sql, ids, presign, viewerUserId) : Promise.resolve(new Map()), loadChatReactionsFor(sql, ids, viewerUserId), loadChatMentionsFor(sql, ids), @@ -280,7 +280,7 @@ export function makeDrizzleDmRepository(sql: Sql, presign?: PresignMedia): DmRep liveIds.length > 0 ? loadChatReactions(sql, row.id, viewerUserId) : Promise.resolve([]), liveIds.length > 0 ? loadChatMentions(sql, row.id) : Promise.resolve([]), presign - ? loadChatAttachments(sql, liveIds, presign) + ? loadChatAttachments(sql, liveIds, presign, viewerUserId) : Promise.resolve(new Map()), replyMapForRows(sql, "dm_messages", [row]), ]) @@ -390,13 +390,20 @@ export function makeDrizzleDmRepository(sql: Sql, presign?: PresignMedia): DmRep const rows = wantsMedia ? await sql.begin(async (tx) => { const inserted = await run(tx) - await attachChatMedia(tx, inserted[0]!.id, uploadIds, inserted[0]!.created_at) + await attachChatMedia( + tx, + inserted[0]!.id, + uploadIds, + inserted[0]!.created_at, + input.senderId, + ) return inserted }) : await run(sql) const messageId = rows[0]!.id const attachments = wantsMedia - ? ((await loadChatAttachments(sql, [messageId], presign!)).get(messageId) ?? []) + ? ((await loadChatAttachments(sql, [messageId], presign!, input.senderId)).get(messageId) ?? + []) : [] return toMessageDTO(rows[0]!, [], [], input.senderId, input.clientId, attachments, replyTo) }, diff --git a/services/api/src/services/host/registration-repository.drizzle.ts b/services/api/src/services/host/registration-repository.drizzle.ts index fbe881d7..6f75e3ab 100644 --- a/services/api/src/services/host/registration-repository.drizzle.ts +++ b/services/api/src/services/host/registration-repository.drizzle.ts @@ -14,6 +14,7 @@ import { cleanupStatusExpr } from "../cleanup-sql.js" import { mediaBoundElsewhere, mediaBoundToCleanup } from "../media-bindings.js" import { likeContains } from "../admin/like.js" import { MEDIA_CLAIM_WINDOW_SEC } from "./event-media.js" +import { publicServedKeyExpr } from "../media-served-key.js" import { deterministicUuid } from "../deterministic-uuid.js" import { isCheckViolationOn, @@ -602,7 +603,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio p.blocks, p.seo, c.cover_media_id, - m.r2_key AS cover_key, + ${publicServedKeyExpr(tag, "m")} AS cover_key, c.visibility, p.published_at, p.updated_at, @@ -2352,7 +2353,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio p.blocks, p.seo, c.cover_media_id, - m.r2_key AS cover_key, + ${publicServedKeyExpr(sql, "m")} AS cover_key, c.visibility, p.published_at, p.updated_at, @@ -2375,13 +2376,15 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio ): Promise> { const out = new Map() if (mediaIds.length === 0) return out - const rows = await sql<{ id: string; r2_key: string }[]>` - SELECT media_assets.id, media_assets.r2_key FROM media_assets + const rows = await sql<{ id: string; served_key: string }[]>` + SELECT media_assets.id, ${publicServedKeyExpr(sql, "media_assets")} AS served_key + FROM media_assets WHERE media_assets.id = ANY(${[...new Set(mediaIds)]}::uuid[]) AND media_assets.status = 'ready' + AND media_assets.served_key IS NOT NULL AND (${mediaBoundToCleanup(sql, cleanupId)}) ` - for (const row of rows) out.set(row.id, row.r2_key) + for (const row of rows) out.set(row.id, row.served_key) return out }, @@ -2504,7 +2507,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio p.blocks, p.seo, c.cover_media_id, - m.r2_key AS cover_key, + ${publicServedKeyExpr(sql, "m")} AS cover_key, c.visibility, p.published_at, p.updated_at, @@ -2512,7 +2515,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio p.flag_reason, COALESCE(p.view_count, 0) AS view_count, c.donation_url, - lm.r2_key AS logo_key + ${publicServedKeyExpr(sql, "lm")} AS logo_key FROM cleanups c LEFT JOIN cleanup_pages p ON p.cleanup_id = c.id LEFT JOIN media_assets m ON m.id = c.cover_media_id AND m.status = 'ready' diff --git a/services/api/src/services/media-bindings.ts b/services/api/src/services/media-bindings.ts index 61540f2e..f86d232d 100644 --- a/services/api/src/services/media-bindings.ts +++ b/services/api/src/services/media-bindings.ts @@ -1,4 +1,5 @@ import type { Queryable } from "../db/client.js" +import { MEDIA_CLAIM_WINDOW_SEC } from "./host/event-media.js" export const MEDIA_BINDING_RELATIONS = [ "users.avatar_media_id", @@ -34,16 +35,36 @@ export function mediaBoundElsewhere(tag: Queryable, exceptCleanupId: string | nu // Only report-purpose rows can be attached by uploadId, and the purpose check alone is not enough: // avatars keep purpose 'report' while users.avatar_media_id and chat_groups.avatar_media_id bind them. -// The uploadId is readable from every served URL, so it is no proof of ownership. -export function claimableAsReportMedia(tag: Queryable) { - return tag`purpose = 'report' AND NOT (${mediaBoundElsewhere(tag, null)})` +export function claimableAsReportMedia(tag: Queryable, uploaders: readonly string[]) { + return tag`purpose = 'report' AND NOT (${mediaBoundElsewhere(tag, null)}) AND (${uploadedByClaimant(tag, uploaders)})` } // createUpload stores every upload with the default purpose 'report' and only a binding re-purposes it, // so an author's fresh post or chat upload sits in exactly the pool a report may claim. An UPDATE's // WHERE reads the row before its SET, so the post path's purpose = 'post' cannot satisfy this check. -export function claimableAsAttachment(tag: Queryable) { - return claimableAsReportMedia(tag) +export function claimableAsAttachment(tag: Queryable, uploaders: readonly string[]) { + return claimableAsReportMedia(tag, uploaders) +} + +// The uploadId is readable from every served URL, so it proves nothing: only the account or anon session +// that created the upload may bind it. A NULL uploader predates attribution; the window every claim +// shares with the event and logo claims is what ages those rows out. +export function uploadedByClaimant(tag: Queryable, uploaders: readonly string[]) { + return tag`media_assets.created_at > now() - make_interval(secs => ${MEDIA_CLAIM_WINDOW_SEC}) + AND (media_assets.uploader IN ${tag([...uploaders])} OR media_assets.uploader IS NULL)` +} + +// A claim's bound-elsewhere test reads users and chat_groups under its statement snapshot, and waiting on +// a row lock does not refresh that snapshot. Taking the lock in an earlier statement means the claim +// runs after any avatar bind holding it has committed, and sees that bind. Ordered by id so two claims +// over overlapping uploads lock in the same order. +export async function lockUploadsForClaim(tx: Queryable, uploadIds: readonly string[]) { + await tx` + SELECT id FROM media_assets + WHERE upload_id IN ${tx([...uploadIds])} + ORDER BY id + FOR UPDATE + ` } export function eventsBindingMedia(tag: Queryable, mediaId: string) { diff --git a/services/api/src/services/media-intake-service.ts b/services/api/src/services/media-intake-service.ts index e3ea2426..d1aa56bf 100644 --- a/services/api/src/services/media-intake-service.ts +++ b/services/api/src/services/media-intake-service.ts @@ -15,6 +15,7 @@ import type { MEDIA_PURPOSE_VALUES } from "../db/schema/types-host.js" type MediaPurpose = (typeof MEDIA_PURPOSE_VALUES)[number] import type { Jobs, Storage } from "@civfix/shared/interfaces" import { readEtag } from "./media-etag.js" +import { uploaderOf } from "./media-uploader.js" import { makeMediaPresigner, makePrivateMediaPresigner } from "./media-presign.js" import { makeUnboundOnlyMediaViewAuthorizer, @@ -84,6 +85,7 @@ export interface NewMediaAsset { r2Key: string status: MediaStatus byteSize: number + uploader: string } export interface MediaRepository { @@ -195,6 +197,7 @@ export function makeMediaIntakeService(deps: MediaIntakeDeps): MediaIntakeServic r2Key, status: "validating", byteSize: input.byteSize, + uploader: uploaderOf(owner), }) const presigned = await deps.storage.presignPut(r2Key, { diff --git a/services/api/src/services/media-repository.drizzle.ts b/services/api/src/services/media-repository.drizzle.ts index a40077e0..bf906b67 100644 --- a/services/api/src/services/media-repository.drizzle.ts +++ b/services/api/src/services/media-repository.drizzle.ts @@ -35,6 +35,7 @@ export function makeDrizzleMediaRepository(db: Db): MediaRepository { r2Key: row.r2Key, status: row.status, byteSize: row.byteSize, + uploader: row.uploader, }) }, diff --git a/services/api/src/services/media-served-key.ts b/services/api/src/services/media-served-key.ts index 11e5e9f1..611f782e 100644 --- a/services/api/src/services/media-served-key.ts +++ b/services/api/src/services/media-served-key.ts @@ -3,10 +3,11 @@ import type { Queryable } from "../db/client.js" type SqlFragment = postgres.Fragment -export type MediaAlias = "m" | "am" | "ma" | "a" | "media_assets" +export type MediaAlias = "m" | "am" | "ma" | "a" | "lm" | "media_assets" // Falls back to the uploaded original while the worker has not produced a served copy, so it is only -// for readers that are gated to the uploader and presign privately (a report owner, a chat member). +// for readers gated to the uploader that presign privately: a report's owner, and through +// uploaderServedKeyExpr the sender of a chat or DM attachment. export function servedKeyExpr(sql: Queryable, alias: MediaAlias): SqlFragment { return sql`COALESCE(${sql(alias)}.served_key, CASE WHEN ${sql(alias)}.status = 'validating' THEN ${sql(alias)}.r2_key END)` } @@ -29,3 +30,22 @@ export function moderationMediaKeyExpr(sql: Queryable, alias: MediaAlias): SqlFr export function moderationMediaFilter(sql: Queryable, alias: MediaAlias): SqlFragment { return sql`(${sql(alias)}.status <> 'ready' OR ${sql(alias)}.served_key IS NOT NULL)` } + +// A chat attachment is read by every room member, so the raw fallback is decided per row: the member who +// uploaded it sees their own photo at once, everyone else only the ready served copy. viewerUploader is +// null for a reader with no identity, which compares as unknown and so never matches. +export function uploaderServedKeyExpr( + sql: Queryable, + alias: MediaAlias, + viewerUploader: string | null, +): SqlFragment { + return sql`CASE WHEN ${sql(alias)}.uploader = ${viewerUploader} THEN ${servedKeyExpr(sql, alias)} ELSE ${publicServedKeyExpr(sql, alias)} END` +} + +export function uploaderServableFilter( + sql: Queryable, + alias: MediaAlias, + viewerUploader: string | null, +): SqlFragment { + return sql`${servableMediaFilter(sql, alias)} AND (${sql(alias)}.status = 'ready' OR ${sql(alias)}.uploader = ${viewerUploader})` +} diff --git a/services/api/src/services/media-uploader.ts b/services/api/src/services/media-uploader.ts new file mode 100644 index 00000000..2b526870 --- /dev/null +++ b/services/api/src/services/media-uploader.ts @@ -0,0 +1,20 @@ +// A caller with neither an account nor a signed anon cookie still gets a subject, so a NULL uploader only +// ever means a row written before uploads were attributed. Never an IP: the column outlives the request. +export const UNSESSIONED_UPLOADER = "anon" + +export function userUploader(userId: string): string { + return `u:${userId}` +} + +export function anonUploader(anonSessionId: string): string { + return `a:${anonSessionId}` +} + +export function uploaderOf(owner: { + userId?: string | undefined + anonSessionId?: string | undefined +}): string { + if (owner.userId) return userUploader(owner.userId) + if (owner.anonSessionId) return anonUploader(owner.anonSessionId) + return UNSESSIONED_UPLOADER +} diff --git a/services/api/src/services/message-attachments.drizzle.ts b/services/api/src/services/message-attachments.drizzle.ts index 9c0992ae..126f860b 100644 --- a/services/api/src/services/message-attachments.drizzle.ts +++ b/services/api/src/services/message-attachments.drizzle.ts @@ -1,8 +1,10 @@ +import { AppError } from "@civfix/shared" import type { Queryable } from "../db/client.js" import type { MediaDTO, MediaKind, MediaStatus } from "@civfix/shared" -import { claimableAsAttachment } from "./media-bindings.js" +import { claimableAsAttachment, lockUploadsForClaim } from "./media-bindings.js" import { mapWithLimit, PRESIGN_CONCURRENCY, type PresignMedia } from "./media-presign.js" -import { servableMediaFilter, servedKeyExpr } from "./media-served-key.js" +import { uploaderServableFilter, uploaderServedKeyExpr } from "./media-served-key.js" +import { userUploader } from "./media-uploader.js" export type MessageMediaColumn = "chat_message_id" @@ -16,6 +18,7 @@ export interface MessageAttachmentRepo { messageId: string, uploadIds: string[], messageCreatedAt: Date, + senderId: string, ): Promise } @@ -34,21 +37,28 @@ interface MediaRow { export function makeAttachmentRepo(column: MessageMediaColumn): MessageAttachmentRepo { const otherCols = ALL_COLUMNS.filter((c) => c !== column) return { - async attach(tx, messageId, uploadIds, messageCreatedAt) { + async attach(tx, messageId, uploadIds, messageCreatedAt, senderId) { if (uploadIds.length === 0) return const nullGuards = [...otherCols, ...CLAIM_GUARD_COLUMNS].reduce( (acc, c) => tx`${acc} AND ${tx(c)} IS NULL`, tx``, ) - await tx` + await lockUploadsForClaim(tx, uploadIds) + const claimed = await tx<{ upload_id: string }[]>` UPDATE media_assets SET ${tx(column)} = ${messageId}, chat_message_created_at = ${messageCreatedAt} WHERE upload_id IN ${tx(uploadIds)} AND (${tx(column)} IS NULL OR ${tx(column)} = ${messageId}) ${nullGuards} - AND ${claimableAsAttachment(tx)} + AND ${claimableAsAttachment(tx, [userUploader(senderId)])} AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) + RETURNING upload_id ` + if (claimed.length !== new Set(uploadIds).size) { + throw AppError.validation({ + mediaUploadIds: "One or more media uploads are unavailable.", + }) + } }, } } @@ -58,16 +68,18 @@ export async function loadServableAttachmentsFor( column: MessageMediaColumn, messageIds: string[], presign: PresignMedia, + viewerUserId: string | null, ): Promise> { const byMessage = new Map() if (messageIds.length === 0) return byMessage + const viewer = viewerUserId !== null ? userUploader(viewerUserId) : null const rows = await tag` SELECT id, ${tag(column)} AS message_id, kind, codec, - ${servedKeyExpr(tag, "media_assets")} AS r2_key, + ${uploaderServedKeyExpr(tag, "media_assets", viewer)} AS r2_key, thumb_key, status, width, height FROM media_assets WHERE ${tag(column)} IN ${tag(messageIds)} - AND ${servableMediaFilter(tag, "media_assets")} + AND ${uploaderServableFilter(tag, "media_assets", viewer)} ORDER BY created_at ASC ` const projected = await mapWithLimit(rows, PRESIGN_CONCURRENCY, async (r) => { diff --git a/services/api/src/services/post-repository.drizzle.ts b/services/api/src/services/post-repository.drizzle.ts index 9a8357b9..b0c43bed 100644 --- a/services/api/src/services/post-repository.drizzle.ts +++ b/services/api/src/services/post-repository.drizzle.ts @@ -15,7 +15,8 @@ import type { POST_KIND_VALUES, REPORT_VISIBILITY_VALUES } from "../db/schema/ty import { paginate, parseTimeCursor } from "../db/cursor-helpers.js" import { loadMentionsFor, makeMentionRepo } from "./message-mentions.drizzle.js" import { cleanupStatusExpr, goingScalar } from "./cleanup-sql.js" -import { claimableAsAttachment } from "./media-bindings.js" +import { claimableAsAttachment, lockUploadsForClaim } from "./media-bindings.js" +import { userUploader } from "./media-uploader.js" import { publicServedKeyExpr } from "./media-served-key.js" import { mapWithLimit, PRESIGN_CONCURRENCY, type PresignMedia } from "./media-presign.js" import { publicAuthorIdentity } from "./public-author.js" @@ -1074,12 +1075,13 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep const postId = inserted[0]!.id if (args.mediaUploadIds.length > 0) { + await lockUploadsForClaim(tx, args.mediaUploadIds) const claimed = await tx<{ upload_id: string }[]>` UPDATE media_assets SET post_id = ${postId}, purpose = 'post' WHERE upload_id IN ${tx(args.mediaUploadIds)} AND post_id IS NULL AND chat_message_id IS NULL AND report_id IS NULL - AND ${claimableAsAttachment(tx)} + AND ${claimableAsAttachment(tx, [userUploader(args.authorId)])} AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) RETURNING upload_id ` diff --git a/services/api/src/services/report-repository.drizzle.ts b/services/api/src/services/report-repository.drizzle.ts index 977acf79..661f12d3 100644 --- a/services/api/src/services/report-repository.drizzle.ts +++ b/services/api/src/services/report-repository.drizzle.ts @@ -25,7 +25,8 @@ import type { } from "./report-service.types.js" import { REPORT_CREATE_SCOPE } from "./report-service.types.js" import { servedKeyExpr, servableMediaFilter } from "./media-served-key.js" -import { claimableAsReportMedia } from "./media-bindings.js" +import { claimableAsReportMedia, lockUploadsForClaim } from "./media-bindings.js" +import { userUploader } from "./media-uploader.js" import { reportColumns, selectPublicPins, @@ -186,13 +187,14 @@ export function makeDrizzleReportRepository(sql: Sql): ReportRepository { // 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. + await lockUploadsForClaim(tx, args.mediaUploadIds) const claimed = await tx<{ upload_id: string }[]>` UPDATE media_assets SET report_id = ${args.reportId} WHERE upload_id IN ${tx(args.mediaUploadIds)} AND (report_id IS NULL OR report_id = ${args.reportId}) AND post_id IS NULL AND chat_message_id IS NULL - AND ${claimableAsReportMedia(tx)} + AND ${claimableAsReportMedia(tx, [userUploader(args.reporterUserId)])} AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) RETURNING upload_id ` diff --git a/services/api/test/helpers/media-pg.ts b/services/api/test/helpers/media-pg.ts index 8ffaa68c..bc422abb 100644 --- a/services/api/test/helpers/media-pg.ts +++ b/services/api/test/helpers/media-pg.ts @@ -19,6 +19,7 @@ export interface SeedMediaOptions { kind?: SeedMediaKind status?: SeedMediaStatus purpose?: string + uploader?: string | null byteSize?: number | null width?: number | null height?: number | null @@ -50,12 +51,12 @@ export async function seedMediaAsset( const [row] = await sql<{ id: string }[]>` INSERT INTO media_assets ( - id, upload_id, kind, r2_key, served_key, thumb_key, status, purpose, + id, upload_id, kind, r2_key, served_key, thumb_key, status, purpose, uploader, byte_size, width, height, report_id, post_id, chat_message_id, finalized_at, created_at ) VALUES ( ${over.id ?? randomUUID()}, ${uploadId}, ${over.kind ?? "image"}, ${r2Key}, ${servedKey}, - ${thumbKey}, ${status}, ${over.purpose ?? "report"}, + ${thumbKey}, ${status}, ${over.purpose ?? "report"}, ${over.uploader ?? null}, ${over.byteSize ?? null}, ${over.width ?? null}, ${over.height ?? null}, ${over.reportId ?? null}, ${over.postId ?? null}, ${over.chatMessageId ?? null}, ${over.finalizedAt ?? null}, ${over.createdAt ?? new Date()} diff --git a/services/api/test/integration/avatar-media-pg.test.ts b/services/api/test/integration/avatar-media-pg.test.ts index 0ac188a9..8816372f 100644 --- a/services/api/test/integration/avatar-media-pg.test.ts +++ b/services/api/test/integration/avatar-media-pg.test.ts @@ -5,10 +5,14 @@ import { seedMediaAsset, type SeededMedia } from "../helpers/media-pg.js" import { PgUserStore } from "../../src/auth/pg-stores.js" import { makeChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" import { resolveAvatarMediaOrThrow } from "../../src/services/avatar-media.js" +import { userUploader } from "../../src/services/media-uploader.js" import type { PresignMedia } from "../../src/services/media-presign.js" const pg = await withPg() +const ANY_CLAIMANT = { uploader: userUploader(randomUUID()) } +const GROUP_ADMIN = { uploaderUserId: randomUUID() } + const fakePresign: PresignMedia = (r2Key, thumbKey) => Promise.resolve({ url: `memory://${r2Key}`, @@ -25,6 +29,7 @@ interface SeedOptions { ageHours?: number servedKey?: string | null finalizedAt?: Date | null + uploader?: string } describe.skipIf(!pg)("CVX-004 avatar media validation (integration)", () => { @@ -46,6 +51,7 @@ describe.skipIf(!pg)("CVX-004 avatar media validation (integration)", () => { createdAt: new Date(Date.now() - (over.ageHours ?? 0) * 3_600_000), ...(over.servedKey !== undefined ? { servedKey: over.servedKey } : {}), ...(over.finalizedAt !== undefined ? { finalizedAt: over.finalizedAt } : {}), + ...(over.uploader !== undefined ? { uploader: over.uploader } : {}), }) } @@ -107,101 +113,103 @@ describe.skipIf(!pg)("CVX-004 avatar media validation (integration)", () => { describe("resolveAvatarMediaOrThrow gate", () => { it("resolves a ready, unbound image to {id, r2Key, servedKey}", async () => { const media = await seedMedia() - const ref = await resolveAvatarMediaOrThrow(h.sql, media.uploadId) + const ref = await resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT) expect(ref).toEqual({ id: media.id, r2Key: media.servedKey, servedKey: media.servedKey }) }) it("resolves a finalized-but-validating upload to its raw key (the bind may race the worker)", async () => { const media = await seedMedia({ status: "validating", finalizedAt: new Date() }) - const ref = await resolveAvatarMediaOrThrow(h.sql, media.uploadId) + const ref = await resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT) expect(ref).toEqual({ id: media.id, r2Key: media.r2Key, servedKey: null }) }) it("rejects a nonexistent uploadId (422)", async () => { - await expect(resolveAvatarMediaOrThrow(h.sql, randomUUID())).rejects.toMatchObject(rejects422) + await expect( + resolveAvatarMediaOrThrow(h.sql, randomUUID(), ANY_CLAIMANT), + ).rejects.toMatchObject(rejects422) }) it("rejects a rejected upload (moderation cannot be laundered)", async () => { const media = await seedMedia({ status: "rejected" }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( - rejects422, - ) + await expect( + resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT), + ).rejects.toMatchObject(rejects422) }) it("rejects a still-validating (unfinalized) upload", async () => { const media = await seedMedia({ status: "validating" }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( - rejects422, - ) + await expect( + resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT), + ).rejects.toMatchObject(rejects422) }) it("rejects a ready asset the worker never published (served_key still NULL)", async () => { const media = await seedMedia({ servedKey: null }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( - rejects422, - ) + await expect( + resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT), + ).rejects.toMatchObject(rejects422) }) it("rejects a non-image (video) upload", async () => { const media = await seedMedia({ kind: "video" }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( - rejects422, - ) + await expect( + resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT), + ).rejects.toMatchObject(rejects422) }) it("rejects media bound to a chat/DM message (another user's private attachment)", async () => { const media = await seedMedia({ chatMessageId: randomUUID() }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( - rejects422, - ) + await expect( + resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT), + ).rejects.toMatchObject(rejects422) }) it("rejects media bound to another user's post", async () => { const postId = await seedForeignPost() const media = await seedMedia({ postId }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( - rejects422, - ) + await expect( + resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT), + ).rejects.toMatchObject(rejects422) }) it("rejects media bound to another user's report", async () => { const reportId = await seedForeignReport() const media = await seedMedia({ reportId }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( - rejects422, - ) + await expect( + resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT), + ).rejects.toMatchObject(rejects422) }) it("F074: rejects an upload older than the claim window (a leaked id is not a permanent capability)", async () => { const media = await seedMedia({ ageHours: 7 }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( - rejects422, - ) + await expect( + resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT), + ).rejects.toMatchObject(rejects422) }) it("F074: accepts an upload still inside the claim window", async () => { const media = await seedMedia({ ageHours: 5 }) - const ref = await resolveAvatarMediaOrThrow(h.sql, media.uploadId) + const ref = await resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT) expect(ref).toEqual({ id: media.id, r2Key: media.servedKey, servedKey: media.servedKey }) }) it("F074: rejects a verification document (it cannot be laundered into a public avatar)", async () => { const media = await seedMedia({ purpose: "verification" }) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( - rejects422, - ) + await expect( + resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT), + ).rejects.toMatchObject(rejects422) }) it("F074: rejects an uploadId already claimed as another user's avatar", async () => { const media = await seedMedia() const owner = await seedUser() await claimUserAvatar(owner, media.id) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( - rejects422, - ) + await expect( + resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT), + ).rejects.toMatchObject(rejects422) const stranger = await seedUser() await expect( - resolveAvatarMediaOrThrow(h.sql, media.uploadId, { userId: stranger }), + resolveAvatarMediaOrThrow(h.sql, media.uploadId, { ...ANY_CLAIMANT, userId: stranger }), ).rejects.toMatchObject(rejects422) }) @@ -209,12 +217,12 @@ describe.skipIf(!pg)("CVX-004 avatar media validation (integration)", () => { const media = await seedMedia() const group = await seedGroup() await claimGroupAvatar(group, media.id) - await expect(resolveAvatarMediaOrThrow(h.sql, media.uploadId)).rejects.toMatchObject( - rejects422, - ) + await expect( + resolveAvatarMediaOrThrow(h.sql, media.uploadId, ANY_CLAIMANT), + ).rejects.toMatchObject(rejects422) const otherGroup = await seedGroup() await expect( - resolveAvatarMediaOrThrow(h.sql, media.uploadId, { groupId: otherGroup }), + resolveAvatarMediaOrThrow(h.sql, media.uploadId, { ...ANY_CLAIMANT, groupId: otherGroup }), ).rejects.toMatchObject(rejects422) }) @@ -222,7 +230,10 @@ describe.skipIf(!pg)("CVX-004 avatar media validation (integration)", () => { const media = await seedMedia() const owner = await seedUser() await claimUserAvatar(owner, media.id) - const ref = await resolveAvatarMediaOrThrow(h.sql, media.uploadId, { userId: owner }) + const ref = await resolveAvatarMediaOrThrow(h.sql, media.uploadId, { + ...ANY_CLAIMANT, + userId: owner, + }) expect(ref).toEqual({ id: media.id, r2Key: media.servedKey, servedKey: media.servedKey }) }) @@ -230,7 +241,10 @@ describe.skipIf(!pg)("CVX-004 avatar media validation (integration)", () => { const media = await seedMedia() const group = await seedGroup() await claimGroupAvatar(group, media.id) - const ref = await resolveAvatarMediaOrThrow(h.sql, media.uploadId, { groupId: group }) + const ref = await resolveAvatarMediaOrThrow(h.sql, media.uploadId, { + ...ANY_CLAIMANT, + groupId: group, + }) expect(ref).toEqual({ id: media.id, r2Key: media.servedKey, servedKey: media.servedKey }) }) }) @@ -277,6 +291,30 @@ describe.skipIf(!pg)("CVX-004 avatar media validation (integration)", () => { expect(await avatarMediaIdOf(id)).toBe(media.id) }) + it("accepts the user's own attributed upload", async () => { + const { store, id, handle } = await makeUser() + const media = await seedMedia({ uploader: userUploader(id) }) + await store.updateProfile(id, { + handle, + displayName: "Avatar User", + avatarUploadId: media.uploadId, + }) + expect(await avatarMediaIdOf(id)).toBe(media.id) + }) + + it("rejects another account's upload even while nothing binds it", async () => { + const { store, id, handle } = await makeUser() + const media = await seedMedia({ uploader: userUploader(randomUUID()) }) + await expect( + store.updateProfile(id, { + handle, + displayName: "Avatar User", + avatarUploadId: media.uploadId, + }), + ).rejects.toMatchObject(rejects422) + expect(await avatarMediaIdOf(id)).toBeNull() + }) + it("rejects a nonexistent uploadId and leaves the avatar untouched", async () => { const { store, id, handle } = await makeUser() await expect( @@ -399,45 +437,59 @@ describe.skipIf(!pg)("CVX-004 avatar media validation (integration)", () => { it("resolves a ready, unbound image to its media id", async () => { const media = await seedMedia() - expect(await groups().findMediaIdByUploadId(media.uploadId)).toBe(media.id) + expect(await groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN)).toBe(media.id) }) it("rejects a nonexistent uploadId (422)", async () => { - await expect(groups().findMediaIdByUploadId(randomUUID())).rejects.toMatchObject(rejects422) + await expect(groups().findMediaIdByUploadId(randomUUID(), GROUP_ADMIN)).rejects.toMatchObject( + rejects422, + ) }) it("rejects a rejected upload", async () => { const media = await seedMedia({ status: "rejected" }) - await expect(groups().findMediaIdByUploadId(media.uploadId)).rejects.toMatchObject(rejects422) + await expect( + groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN), + ).rejects.toMatchObject(rejects422) }) it("rejects a still-validating upload that was never finalized", async () => { const media = await seedMedia({ status: "validating" }) - await expect(groups().findMediaIdByUploadId(media.uploadId)).rejects.toMatchObject(rejects422) + await expect( + groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN), + ).rejects.toMatchObject(rejects422) }) it("accepts a finalized-but-validating upload", async () => { const media = await seedMedia({ status: "validating", finalizedAt: new Date() }) - expect(await groups().findMediaIdByUploadId(media.uploadId)).toBe(media.id) + expect(await groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN)).toBe(media.id) }) it("rejects a foreign user's private (chat-bound) media", async () => { const media = await seedMedia({ chatMessageId: randomUUID() }) - await expect(groups().findMediaIdByUploadId(media.uploadId)).rejects.toMatchObject(rejects422) + await expect( + groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN), + ).rejects.toMatchObject(rejects422) }) it("rejects another user's report-bound media", async () => { const reportId = await seedForeignReport() const media = await seedMedia({ reportId }) - await expect(groups().findMediaIdByUploadId(media.uploadId)).rejects.toMatchObject(rejects422) + await expect( + groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN), + ).rejects.toMatchObject(rejects422) }) it("F074: rejects an avatar another group already holds, and stays idempotent for the holder", async () => { const media = await seedMedia() const holder = await seedGroup() await claimGroupAvatar(holder, media.id) - await expect(groups().findMediaIdByUploadId(media.uploadId)).rejects.toMatchObject(rejects422) - expect(await groups().findMediaIdByUploadId(media.uploadId, holder)).toBe(media.id) + await expect( + groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN), + ).rejects.toMatchObject(rejects422) + expect( + await groups().findMediaIdByUploadId(media.uploadId, { ...GROUP_ADMIN, groupId: holder }), + ).toBe(media.id) }) }) }) diff --git a/services/api/test/integration/media-attach-claim-security-pg.test.ts b/services/api/test/integration/media-attach-claim-security-pg.test.ts index 57f27c65..d1111a7e 100644 --- a/services/api/test/integration/media-attach-claim-security-pg.test.ts +++ b/services/api/test/integration/media-attach-claim-security-pg.test.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto" import { withPg, testHandle, type PgHarness } from "../helpers/pg.js" import { seedMediaAsset, type SeededMedia } from "../helpers/media-pg.js" import { attachChatMedia } from "../../src/services/chat-attachments.drizzle.js" +import { userUploader } from "../../src/services/media-uploader.js" import { makeDrizzlePostRepository, type PostRepository, @@ -99,8 +100,19 @@ describe.skipIf(!pg)("post and chat media claims (integration: only unbound fres expect((await mediaRow(document.id)).purpose).toBe("verification") }) + it("a post refuses another user's replaced avatar that nothing binds any more", async () => { + const victim = await newUser("avatar replacer") + const replaced = await seedMediaAsset(h.sql, { uploader: userUploader(victim) }) + + await expect(createPost(replaced.uploadId)).rejects.toMatchObject({ + httpStatus: 422, + fields: { mediaUploadIds: UNAVAILABLE }, + }) + expect((await mediaRow(replaced.id)).post_id).toBeNull() + }) + it("a post still claims the author's own fresh upload", async () => { - const media = await seedMediaAsset(h.sql) + const media = await seedMediaAsset(h.sql, { uploader: userUploader(attackerId) }) const postId = await createPost(media.uploadId) @@ -115,19 +127,38 @@ describe.skipIf(!pg)("post and chat media claims (integration: only unbound fres const avatar = await victimAvatar() const logo = await orgLogo() - await attachChatMedia(h.sql, randomUUID(), [avatar.uploadId, logo.uploadId], new Date()) + await expect( + attachChatMedia( + h.sql, + randomUUID(), + [avatar.uploadId, logo.uploadId], + new Date(), + attackerId, + ), + ).rejects.toMatchObject({ httpStatus: 422, fields: { mediaUploadIds: UNAVAILABLE } }) expect((await mediaRow(avatar.id)).chat_message_id).toBeNull() expect((await mediaRow(logo.id)).chat_message_id).toBeNull() }) + it("a chat message refuses an erased user's upload", async () => { + const erased = await newUser("erased user") + const media = await seedMediaAsset(h.sql, { uploader: userUploader(erased) }) + await h.sql`UPDATE users SET deleted_at = now() WHERE id = ${erased}` + + await expect( + attachChatMedia(h.sql, randomUUID(), [media.uploadId], new Date(), attackerId), + ).rejects.toMatchObject({ httpStatus: 422, fields: { mediaUploadIds: UNAVAILABLE } }) + expect((await mediaRow(media.id)).chat_message_id).toBeNull() + }) + it("a chat message still claims the sender's own fresh upload, and re-claims it idempotently", async () => { - const media = await seedMediaAsset(h.sql) + const media = await seedMediaAsset(h.sql, { uploader: userUploader(attackerId) }) const messageId = randomUUID() const sentAt = new Date() - await attachChatMedia(h.sql, messageId, [media.uploadId], sentAt) - await attachChatMedia(h.sql, messageId, [media.uploadId], sentAt) + await attachChatMedia(h.sql, messageId, [media.uploadId], sentAt, attackerId) + await attachChatMedia(h.sql, messageId, [media.uploadId], sentAt, attackerId) expect((await mediaRow(media.id)).chat_message_id).toBe(messageId) }) diff --git a/services/api/test/integration/report-media-claim-security-pg.test.ts b/services/api/test/integration/report-media-claim-security-pg.test.ts index 139de8c5..7a110a56 100644 --- a/services/api/test/integration/report-media-claim-security-pg.test.ts +++ b/services/api/test/integration/report-media-claim-security-pg.test.ts @@ -7,6 +7,8 @@ import { seedCleanup } from "../helpers/cleanups.js" import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" import { makeReportService, type ReportService } from "../../src/services/report-service.js" import { PROBE_INSIDE_CITY } from "../../src/db/seed-fixtures.js" +import { MEDIA_CLAIM_WINDOW_SEC } from "../../src/services/host/event-media.js" +import { userUploader } from "../../src/services/media-uploader.js" const pg = await withPg() @@ -112,8 +114,38 @@ describe.skipIf(!pg)("report media claim (integration: only unbound report media await expectUnclaimable(media, "verification") }) + it("refuses another user's replaced avatar once nothing binds it any more", async () => { + const victim = await newUser("avatar replacer") + const replaced = await seedMediaAsset(h.sql, { uploader: userUploader(victim) }) + const current = await seedMediaAsset(h.sql, { uploader: userUploader(victim) }) + await h.sql`UPDATE users SET avatar_media_id = ${replaced.id} WHERE id = ${victim}` + await h.sql`UPDATE users SET avatar_media_id = ${current.id} WHERE id = ${victim}` + + await expectUnclaimable(replaced, "report") + }) + + it("refuses an erased user's upload after erasure unbinds their avatar", async () => { + const erased = await newUser("erased user") + const media = await seedMediaAsset(h.sql, { uploader: userUploader(erased) }) + await h.sql`UPDATE users SET avatar_media_id = ${media.id} WHERE id = ${erased}` + await h.sql` + UPDATE users SET deleted_at = now(), avatar_media_id = NULL, avatar_url = NULL + WHERE id = ${erased} + ` + + await expectUnclaimable(media, "report") + }) + + it("refuses an unattributed upload older than the claim window", async () => { + const media = await seedMediaAsset(h.sql, { + createdAt: new Date(Date.now() - (MEDIA_CLAIM_WINDOW_SEC + 60) * 1000), + }) + + await expectUnclaimable(media, "report") + }) + it("still claims the reporter's own unbound report upload", async () => { - const media = await seedMediaAsset(h.sql) + const media = await seedMediaAsset(h.sql, { uploader: userUploader(attackerId) }) const dto = await service.createReport(createReq([media.uploadId]), { userId: attackerId }) diff --git a/services/api/test/unit/anon-repository-security.test.ts b/services/api/test/unit/anon-repository-security.test.ts index 6ad6f4bc..fe0d058a 100644 --- a/services/api/test/unit/anon-repository-security.test.ts +++ b/services/api/test/unit/anon-repository-security.test.ts @@ -8,6 +8,7 @@ import { import { sha256Hex } from "../../src/auth/crypto.js" import type { Queryable, Sql } from "../../src/db/client.js" import { mediaBoundElsewhere } from "../../src/services/media-bindings.js" +import { anonUploader } from "../../src/services/media-uploader.js" import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" const REPORT_ID = "44444444-4444-4444-8444-444444444444" @@ -40,6 +41,7 @@ function createArgs(): CreateAnonReportTxArgs { addrPrecision: null, h3Cell: "8a2830828767fff", mediaUploadIds: [], + mediaUploaders: [anonUploader(ANON_ID)], claimCodeHash: "hash-of-original", reportCap: 5, responseSnapshot: { reportId: REPORT_ID, status: "held", claimCode: ORIGINAL_CODE }, diff --git a/services/api/test/unit/chat-attachment-viewer.test.ts b/services/api/test/unit/chat-attachment-viewer.test.ts new file mode 100644 index 00000000..8db1ae66 --- /dev/null +++ b/services/api/test/unit/chat-attachment-viewer.test.ts @@ -0,0 +1,86 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it } from "vitest" +import type { ChatMessageDTO, MediaDTO } from "@civfix/shared" +import type { Queryable } from "../../src/db/client.js" +import { neutralizeChatViewerFields } from "../../src/services/chat-viewer-fields.js" +import { userUploader } from "../../src/services/media-uploader.js" +import { loadServableAttachmentsFor } from "../../src/services/message-attachments.drizzle.js" +import { makeFakeSql } from "../helpers/fake-sql.js" + +const MESSAGE = "11111111-1111-4111-8111-111111111111" + +const PRESIGN = (r2Key: string) => Promise.resolve({ url: `memory://${r2Key}` }) + +function squash(text: string): string { + return text.replace(/\s+/g, " ").trim() +} + +async function attachmentSql(viewerUserId: string | null) { + const fake = makeFakeSql([{ match: /FROM media_assets/, rows: [] }]) + await loadServableAttachmentsFor( + fake.sql as unknown as Queryable, + "chat_message_id", + [MESSAGE], + PRESIGN, + viewerUserId, + ) + return fake.statements.at(-1)! +} + +describe("chat attachment reads", () => { + it("offers the raw upload key only to the viewer who uploaded it", async () => { + const viewer = randomUUID() + const statement = await attachmentSql(viewer) + const text = squash(statement.sql) + + expect(text).toMatch( + /CASE WHEN media_assets\.uploader = \? THEN COALESCE\(media_assets\.served_key, CASE WHEN media_assets\.status = 'validating' THEN media_assets\.r2_key END\) ELSE CASE WHEN media_assets\.status = 'ready' THEN media_assets\.served_key END END AS r2_key/, + ) + expect(text).toContain("AND (media_assets.status = 'ready' OR media_assets.uploader = ?)") + expect(statement.values.filter((v) => v === userUploader(viewer))).toHaveLength(2) + }) + + it("gives a reader with no identity only ready served copies", async () => { + const statement = await attachmentSql(null) + + expect(statement.values.filter((v) => v === null)).toHaveLength(2) + expect(statement.values.filter((v) => typeof v === "string" && v.startsWith("u:"))).toEqual([]) + expect(squash(statement.sql)).toContain( + "AND (media_assets.status = 'ready' OR media_assets.uploader = ?)", + ) + }) +}) + +function attachment(status: MediaDTO["status"], url: string): MediaDTO { + return { id: randomUUID(), kind: "image", codec: null, url, status } +} + +function message(attachments: MediaDTO[]): ChatMessageDTO { + return { + id: MESSAGE, + body: "", + mine: true, + reactions: [], + mentions: [], + attachments, + } as unknown as ChatMessageDTO +} + +describe("the broadcast copy of a message", () => { + it("never carries a sender-only link to an upload the worker has not published", () => { + const ready = attachment("ready", "memory://processed/uploads/ready") + const validating = attachment("validating", "memory://uploads/raw-original") + + const shared = neutralizeChatViewerFields(message([validating, ready])) + + expect(shared.attachments).toEqual([ready]) + expect(JSON.stringify(shared)).not.toContain("raw-original") + }) + + it("leaves a message without attachments as it was", () => { + const shared = neutralizeChatViewerFields(message([])) + + expect(shared.attachments).toEqual([]) + expect(shared.mine).toBe(false) + }) +}) diff --git a/services/api/test/unit/chat-attachments-servable.test.ts b/services/api/test/unit/chat-attachments-servable.test.ts index 4a35fa7f..83d79f34 100644 --- a/services/api/test/unit/chat-attachments-servable.test.ts +++ b/services/api/test/unit/chat-attachments-servable.test.ts @@ -22,6 +22,8 @@ import { const MESSAGE = "11111111-1111-4111-8111-111111111111" const OTHER_MESSAGE = "22222222-2222-4222-8222-222222222222" +const SENDER = "33333333-3333-4333-8333-333333333333" +const UPLOAD = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" const PRESIGN = (r2Key: string, thumbKey: string | null) => Promise.resolve({ @@ -53,6 +55,7 @@ describe("loadServableAttachmentsFor", () => { "chat_message_id", [MESSAGE], PRESIGN, + SENDER, ) const statement = fake.statements.at(-1)! @@ -68,6 +71,7 @@ describe("loadServableAttachmentsFor", () => { "chat_message_id", [MESSAGE], PRESIGN, + SENDER, ) const statement = fake.statements.at(-1)! @@ -85,6 +89,7 @@ describe("loadServableAttachmentsFor", () => { "chat_message_id", [MESSAGE], PRESIGN, + SENDER, ) const predicate = /AND (\(media_assets\.status = 'validating'[^\n]*?IS NOT NULL\)\))/.exec( @@ -114,6 +119,7 @@ describe("loadServableAttachmentsFor", () => { "chat_message_id", [MESSAGE], PRESIGN, + SENDER, ) expect(fake.statements.at(-1)!.sql).toMatch( @@ -129,6 +135,7 @@ describe("loadServableAttachmentsFor", () => { "chat_message_id", [MESSAGE], PRESIGN, + SENDER, ) expect(byMessage.get(MESSAGE)).toEqual([ @@ -164,6 +171,7 @@ describe("loadServableAttachmentsFor", () => { "chat_message_id", [MESSAGE, OTHER_MESSAGE], PRESIGN, + SENDER, ) expect(byMessage.get(MESSAGE)).toHaveLength(1) @@ -178,6 +186,7 @@ describe("loadServableAttachmentsFor", () => { "chat_message_id", [], PRESIGN, + SENDER, ) expect(byMessage.size).toBe(0) @@ -187,13 +196,14 @@ describe("loadServableAttachmentsFor", () => { describe("makeAttachmentRepo().attach", () => { it("still claims a finalized validating asset — the read now matches what the claim accepts", async () => { - const fake = makeFakeSql([{ match: /UPDATE media_assets/, rows: [] }]) + const fake = makeFakeSql([{ match: /UPDATE media_assets/, rows: [{ upload_id: UPLOAD }] }]) await makeAttachmentRepo("chat_message_id").attach( fake.sql as unknown as Queryable, MESSAGE, - ["cccccccc-cccc-4ccc-8ccc-cccccccccccc"], + [UPLOAD], new Date("2026-09-16T00:00:00.000Z"), + SENDER, ) const statement = fake.statements.at(-1)! @@ -210,6 +220,7 @@ describe("makeAttachmentRepo().attach", () => { MESSAGE, [], new Date(), + SENDER, ) expect(fake.statements).toHaveLength(0) diff --git a/services/api/test/unit/media-attach-claim-security.test.ts b/services/api/test/unit/media-attach-claim-security.test.ts index 69e0ff6c..39456e25 100644 --- a/services/api/test/unit/media-attach-claim-security.test.ts +++ b/services/api/test/unit/media-attach-claim-security.test.ts @@ -54,6 +54,7 @@ function postRepo(fake: FakeSqlControl) { } const POST_CLAIM = /UPDATE media_assets\s+SET post_id/ +const CHAT_CLAIM = /UPDATE media_assets\s+SET "?chat_message_id/ describe("post create: media claim predicate", () => { it("claims only unbound report-purpose uploads, checked before the row is re-purposed", async () => { @@ -82,11 +83,17 @@ describe("post create: media claim predicate", () => { describe("chat message attach: media claim predicate", () => { it("claims only unbound report-purpose uploads and keeps the same-message re-claim", async () => { - const fake = makeFakeSql([]) + const fake = makeFakeSql([{ match: CHAT_CLAIM, rows: [{ upload_id: UPLOAD_ID }] }]) - await attachChatMedia(fake.sql as unknown as Queryable, MESSAGE_ID, [UPLOAD_ID], new Date()) + await attachChatMedia( + fake.sql as unknown as Queryable, + MESSAGE_ID, + [UPLOAD_ID], + new Date(), + AUTHOR, + ) - const where = claimWhereClause(fake, /UPDATE media_assets\s+SET "?chat_message_id/) + const where = claimWhereClause(fake, CHAT_CLAIM) expect(where).toContain("purpose = 'report'") expect(where).toContain(`NOT (${await renderBoundElsewhere()})`) expect(where).toMatch(/\("?chat_message_id"? IS NULL OR "?chat_message_id"? = \?\)/) diff --git a/services/api/test/unit/media-claim-uploader.test.ts b/services/api/test/unit/media-claim-uploader.test.ts new file mode 100644 index 00000000..b5b45626 --- /dev/null +++ b/services/api/test/unit/media-claim-uploader.test.ts @@ -0,0 +1,302 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it } from "vitest" +import { FakeJobs, FakeStorage } from "@civfix/shared/fakes" +import type { ReportDTO } from "@civfix/shared" +import type { Queryable, Sql } from "../../src/db/client.js" +import { makeDrizzleAnonReportRepository } from "../../src/services/anon-repository.drizzle.js" +import type { CreateAnonReportTxArgs } from "../../src/services/anon-service.js" +import { avatarClaimQuery } from "../../src/services/avatar-media.js" +import { attachChatMedia } from "../../src/services/chat-attachments.drizzle.js" +import { MEDIA_CLAIM_WINDOW_SEC } from "../../src/services/host/event-media.js" +import { claimableAsReportMedia } from "../../src/services/media-bindings.js" +import { + makeMediaIntakeService, + type NewMediaAsset, +} from "../../src/services/media-intake-service.js" +import { + UNSESSIONED_UPLOADER, + anonUploader, + userUploader, +} from "../../src/services/media-uploader.js" +import { makeDrizzlePostRepository } from "../../src/services/post-repository.drizzle.js" +import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" +import type { CreateReportTxArgs } from "../../src/services/report-service.types.js" +import { makeFakeSql, type FakeSqlControl, type RecordedStatement } from "../helpers/fake-sql.js" +import { InMemoryMediaRepository } from "../helpers/media.js" + +const UPLOAD_ID = "44444444-4444-4444-8444-444444444444" +const UNAVAILABLE = "One or more media uploads are unavailable." + +function squash(text: string): string { + return text.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim() +} + +function statementMatching(fake: FakeSqlControl, pattern: RegExp): RecordedStatement { + const found = fake.statements.find((s) => pattern.test(s.sql)) + if (!found) throw new Error(`no statement matched ${String(pattern)}`) + return found +} + +function expectLockedBeforeClaim(fake: FakeSqlControl, claim: RegExp): void { + const lockAt = fake.statements.findIndex( + (s) => /FROM media_assets/.test(s.sql) && /FOR UPDATE/.test(s.sql), + ) + const claimAt = fake.statements.findIndex((s) => claim.test(s.sql)) + expect(lockAt).toBeGreaterThanOrEqual(0) + expect(claimAt).toBeGreaterThan(lockAt) +} + +class CapturingMediaRepository extends InMemoryMediaRepository { + readonly inserted: NewMediaAsset[] = [] + + override insert(row: NewMediaAsset): Promise { + this.inserted.push(row) + return super.insert(row) + } +} + +describe("createUpload records who uploaded", () => { + const request = { + kind: "image" as const, + contentType: "image/jpeg", + byteSize: 1024, + sha256: "a".repeat(64), + } + + function service(repo: CapturingMediaRepository) { + return makeMediaIntakeService({ repo, storage: new FakeStorage(), jobs: new FakeJobs() }) + } + + it("stores the signed-in account, over any anon session it also carries", async () => { + const repo = new CapturingMediaRepository() + const userId = randomUUID() + + await service(repo).createUpload(request, { userId, anonSessionId: "anon-1" }) + + expect(repo.inserted[0]?.uploader).toBe(userUploader(userId)) + }) + + it("stores the signed anon session for a guest", async () => { + const repo = new CapturingMediaRepository() + + await service(repo).createUpload(request, { anonSessionId: "anon-1", ipKey: "1.2.3.4" }) + + expect(repo.inserted[0]?.uploader).toBe(anonUploader("anon-1")) + }) + + it("stores the unsessioned marker, never an IP, for a caller with no session at all", async () => { + const repo = new CapturingMediaRepository() + + await service(repo).createUpload(request, { ipKey: "1.2.3.4" }) + + expect(repo.inserted[0]?.uploader).toBe(UNSESSIONED_UPLOADER) + }) +}) + +describe("claimableAsReportMedia", () => { + it("requires the caller's upload, or an unattributed one, inside the claim window", async () => { + const fake = makeFakeSql([]) + const uploader = userUploader(randomUUID()) + + await fake.sql`${claimableAsReportMedia(fake.sql as unknown as Queryable, [uploader])}` + + const statement = fake.statements[0]! + const text = squash(statement.sql) + expect(text).toContain("media_assets.uploader IN (?)") + expect(text).toContain("OR media_assets.uploader IS NULL") + expect(text).toContain("media_assets.created_at > now() - make_interval(secs => ?)") + expect(statement.values).toContain(uploader) + expect(statement.values).toContain(MEDIA_CLAIM_WINDOW_SEC) + }) +}) + +function reportArgs(reporterUserId: string): CreateReportTxArgs { + const reportId = randomUUID() + return { + reportId, + reporterUserId, + idempotencyKey: randomUUID(), + lat: 34.05, + lng: -118.25, + geomSource: "device", + jurisdictionGeoid: null, + jurCode: 0, + category: "trash", + type: "dump", + title: null, + description: null, + addr: null, + addrSource: null, + addrPrecision: null, + status: "published", + visibility: "public", + h3Cell: "h0", + publishedAt: new Date(), + mediaUploadIds: [UPLOAD_ID], + timelineNote: null, + idempotency: { key: randomUUID(), scope: "report_create", userOrAnon: null }, + buildSnapshot: () => Promise.resolve({ id: reportId } as unknown as ReportDTO), + } +} + +function anonArgs(mediaUploaders: string[]): CreateAnonReportTxArgs { + const reportId = randomUUID() + return { + reportId, + anonSessionId: "anon-token", + idempotencyKey: randomUUID(), + lat: 34.1, + lng: -118.3, + geomSource: "device", + jurisdictionGeoid: null, + jurCode: 0, + category: "trash", + type: "dump", + title: null, + description: null, + addr: null, + addrSource: null, + addrPrecision: null, + h3Cell: "8a2830828767fff", + mediaUploadIds: [UPLOAD_ID], + mediaUploaders, + claimCodeHash: "hash", + reportCap: 5, + responseSnapshot: { reportId, status: "held", claimCode: "code" }, + } +} + +const REPORT_CLAIM = /UPDATE media_assets\s+SET report_id/ +const POST_CLAIM = /UPDATE media_assets\s+SET post_id/ +const CHAT_CLAIM = /UPDATE media_assets\s+SET "?chat_message_id/ + +describe("every uploadId claim binds only the caller's own upload", () => { + it("an authenticated report claims as its reporter, after locking the rows", async () => { + const reporter = randomUUID() + const fake = makeFakeSql([{ match: /INSERT INTO reference_counters/, rows: [{ next_val: 1 }] }]) + + await expect( + makeDrizzleReportRepository(fake.sql as unknown as Sql).createReportTx(reportArgs(reporter)), + ).rejects.toMatchObject({ httpStatus: 422, fields: { mediaUploadIds: UNAVAILABLE } }) + + const claim = statementMatching(fake, REPORT_CLAIM) + expect(squash(claim.sql)).toContain("media_assets.uploader IN (?)") + expect(claim.values).toContain(userUploader(reporter)) + expectLockedBeforeClaim(fake, REPORT_CLAIM) + }) + + it("an anonymous report claims as the anon subjects the service resolved", async () => { + const fake = makeFakeSql([ + { match: /reference_counters/i, rows: [{ next_val: 1 }] }, + { match: /UPDATE anon_tokens/i, rows: [{ report_count: 1 }] }, + ]) + const uploaders = [anonUploader("anon-token"), UNSESSIONED_UPLOADER] + + await expect( + makeDrizzleAnonReportRepository(fake.sql as unknown as Sql).createAnonReportTx( + anonArgs(uploaders), + ), + ).rejects.toMatchObject({ httpStatus: 422, fields: { mediaUploadIds: UNAVAILABLE } }) + + const claim = statementMatching(fake, REPORT_CLAIM) + expect(squash(claim.sql)).toContain("media_assets.uploader IN (?,?)") + expect(claim.values).toEqual(expect.arrayContaining(uploaders)) + expectLockedBeforeClaim(fake, REPORT_CLAIM) + }) + + it("a post claims as its author", async () => { + const author = randomUUID() + const fake = makeFakeSql([{ match: /INSERT INTO posts/, rows: [{ id: randomUUID() }] }]) + const repo = makeDrizzlePostRepository(fake.sql as unknown as Sql, { + presignMedia: () => Promise.resolve({ url: "u" }), + presignAvatar: () => Promise.resolve("a"), + }) + + await expect( + repo.createPost({ + authorId: author, + kind: "post", + body: "hello", + replyToId: null, + repostOfId: null, + eventId: null, + reportId: null, + mediaUploadIds: [UPLOAD_ID], + mentionedUserIds: [], + organizationId: null, + }), + ).rejects.toMatchObject({ httpStatus: 422, fields: { mediaUploadIds: UNAVAILABLE } }) + + const claim = statementMatching(fake, POST_CLAIM) + expect(squash(claim.sql)).toContain("media_assets.uploader IN (?)") + expect(claim.values).toContain(userUploader(author)) + expectLockedBeforeClaim(fake, POST_CLAIM) + }) + + it("a chat or DM attachment claims as its sender", async () => { + const sender = randomUUID() + const fake = makeFakeSql([{ match: CHAT_CLAIM, rows: [{ upload_id: UPLOAD_ID }] }]) + + await attachChatMedia( + fake.sql as unknown as Queryable, + randomUUID(), + [UPLOAD_ID], + new Date(), + sender, + ) + + const claim = statementMatching(fake, CHAT_CLAIM) + expect(squash(claim.sql)).toContain("media_assets.uploader IN (?)") + expect(claim.values).toContain(userUploader(sender)) + expectLockedBeforeClaim(fake, CHAT_CLAIM) + }) +}) + +describe("chat attach refuses uploads it could not claim", () => { + it("fails the send instead of dropping an unclaimable upload", async () => { + const fake = makeFakeSql([{ match: CHAT_CLAIM, rows: [{ upload_id: UPLOAD_ID }] }]) + + await expect( + attachChatMedia( + fake.sql as unknown as Queryable, + randomUUID(), + [UPLOAD_ID, randomUUID()], + new Date(), + randomUUID(), + ), + ).rejects.toMatchObject({ + httpStatus: 422, + code: "VALIDATION", + fields: { mediaUploadIds: UNAVAILABLE }, + }) + }) + + it("counts a repeated uploadId once", async () => { + const fake = makeFakeSql([{ match: CHAT_CLAIM, rows: [{ upload_id: UPLOAD_ID }] }]) + + await expect( + attachChatMedia( + fake.sql as unknown as Queryable, + randomUUID(), + [UPLOAD_ID, UPLOAD_ID], + new Date(), + randomUUID(), + ), + ).resolves.toBeUndefined() + }) +}) + +describe("avatar claim", () => { + it("locks the media row it selects and requires the claimant's own upload", async () => { + const fake = makeFakeSql([]) + const userId = randomUUID() + + await avatarClaimQuery(fake.sql, UPLOAD_ID, { uploader: userUploader(userId), userId }) + + const statement = fake.statements[0]! + const text = squash(statement.sql) + expect(text).toMatch(/FOR UPDATE OF m$/) + expect(text).toContain("(m.uploader = ? OR m.uploader IS NULL)") + expect(statement.values).toContain(userUploader(userId)) + }) +}) diff --git a/services/api/test/unit/media-public-served-key.test.ts b/services/api/test/unit/media-public-served-key.test.ts index fc3bdf2e..4c546039 100644 --- a/services/api/test/unit/media-public-served-key.test.ts +++ b/services/api/test/unit/media-public-served-key.test.ts @@ -8,6 +8,7 @@ import { makeChatGroupRepository } from "../../src/services/chat-group-repositor import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" import { makeDrizzleAnnouncementIdentityRepository } from "../../src/services/host/announcement-repository.drizzle.js" import { makeDrizzleOrganizationRepository } from "../../src/services/host/organization-repository.drizzle.js" +import { makeDrizzleHostRegistrationRepository } from "../../src/services/host/registration-repository.drizzle.js" import { publicServedKeyExpr } from "../../src/services/media-served-key.js" import { makeDrizzleSocialRepository } from "../../src/services/social-repository.drizzle.js" import { makeFakeSql, type FakeSqlControl } from "../helpers/fake-sql.js" @@ -17,15 +18,113 @@ const SRC = fileURLToPath(new URL("../../src/", import.meta.url)) const UPLOADED_ORIGINAL_COLUMN = /\.r2_key\b/ -const OWNER_GATED_SERVED_KEY_READERS = [ - "services/message-attachments.drizzle.ts", - "services/report-repository.drizzle.ts", -] +const OWNER_GATED_SERVED_KEY_READERS = ["services/report-repository.drizzle.ts"] + +const RAW_UPLOAD_KEY_IN_SQL = /[\w}]\.r2_key\b/ + +const RAW_UPLOAD_KEY_READERS: Record = { + "services/media-served-key.ts": "defines the uploader, moderation and public key expressions", + "services/avatar-media.ts": + "the avatar claim answers only the claimant, and binds the served copy", + "services/media-worker-repo.ts": "the media worker reads the original to re-encode it", + "services/certificate-repository.drizzle.ts": + "service_hours_certificates.r2_key is a generated certificate, not an upload", + "services/report-sql.ts": + "m is the ready-only firstReadyStillLateral, whose r2_key is served_key", + "services/cleanup-repository.drizzle.ts": + "m is the ready-only firstReadyStillLateral, whose r2_key is served_key", + "services/post-repository.drizzle.ts": + "m is the ready-only firstReadyStillLateral, whose r2_key is served_key", +} + +const SQL_TAG_LOOKBEHIND_CHARS = 1000 + +const SQL_TAG_BEFORE_TEMPLATE = /\b(?:sql|tx|tag|q)\s*(?:<[\s\S]*>)?$/ function sqlOf(fake: FakeSqlControl): string { return fake.statements.map((s) => s.sql.replace(/\s+/g, " ")).join("\n") } +function quotedEnd(code: string, start: number): number { + const quote = code[start] + let i = start + 1 + while (i < code.length && code[i] !== quote && code[i] !== "\n") { + i += code[i] === "\\" ? 2 : 1 + } + return i + 1 +} + +function interpolationEnd(code: string, start: number): number { + let depth = 1 + let i = start + while (i < code.length && depth > 0) { + const c = code[i] + if (c === "`") { + i = templateEnd(code, i + 1) + 1 + continue + } + if (c === "'" || c === '"') { + i = quotedEnd(code, i) + continue + } + if (c === "{") depth++ + if (c === "}") depth-- + i++ + } + return i +} + +function templateEnd(code: string, start: number): number { + let i = start + while (i < code.length) { + const c = code[i] + if (c === "\\") { + i += 2 + continue + } + if (c === "`") return i + if (c === "$" && code[i + 1] === "{") { + i = interpolationEnd(code, i + 2) + continue + } + i++ + } + return code.length +} + +function sqlTemplateBodies(code: string): string[] { + const bodies: string[] = [] + let i = 0 + while (i < code.length) { + const c = code[i] + const next = code[i + 1] + if (c === "/" && next === "/") { + const lineEnd = code.indexOf("\n", i) + i = lineEnd < 0 ? code.length : lineEnd + continue + } + if (c === "/" && next === "*") { + const commentEnd = code.indexOf("*/", i + 2) + i = commentEnd < 0 ? code.length : commentEnd + 2 + continue + } + if (c === "'" || c === '"') { + i = quotedEnd(code, i) + continue + } + if (c === "`") { + const end = templateEnd(code, i + 1) + if (SQL_TAG_BEFORE_TEMPLATE.test(code.slice(Math.max(0, i - SQL_TAG_LOOKBEHIND_CHARS), i))) { + bodies.push(code.slice(i + 1, end)) + } + i = end + 1 + continue + } + i++ + } + return bodies +} + function sourceFiles(dir: string): string[] { return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const path = join(dir, entry.name) @@ -77,6 +176,20 @@ describe("media read by people other than the uploader", () => { "a chat group's avatar", (fake) => makeChatGroupRepository(fake.sql as unknown as Sql).findById(ID), ], + [ + "an event page's cover", + (fake) => makeDrizzleHostRegistrationRepository(fake.sql as unknown as Sql).getPage(ID), + ], + [ + "a public event page's cover and organization logo", + (fake) => + makeDrizzleHostRegistrationRepository(fake.sql as unknown as Sql).getPublicPage("slug"), + ], + [ + "an event page's block images", + (fake) => + makeDrizzleHostRegistrationRepository(fake.sql as unknown as Sql).mediaKeysFor(ID, [ID]), + ], [ "an announcement author's avatar", (fake) => @@ -110,4 +223,17 @@ describe("media read by people other than the uploader", () => { expect(importers).toEqual(OWNER_GATED_SERVED_KEY_READERS) }) + + it("never selects an uploaded original outside the readers allowed to", () => { + const readers = sourceFiles(SRC) + .filter((path) => + sqlTemplateBodies(readFileSync(path, "utf8")).some((body) => + RAW_UPLOAD_KEY_IN_SQL.test(body), + ), + ) + .map((path) => relative(SRC, path).split("\\").join("/")) + .sort() + + expect(readers).toEqual(Object.keys(RAW_UPLOAD_KEY_READERS).sort()) + }) }) From 52972feb31a38f7b929990264ceb00ec003e7b7d Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:10:04 +0000 Subject: [PATCH 29/45] csv exports neutralize formulas hidden behind in-value separators --- services/api/src/services/host/export-csv.ts | 20 +++++++--- .../api/test/unit/export-csv-security.test.ts | 38 ++++++++++++++++--- .../api/test/unit/host-export-csv.test.ts | 11 ++++++ 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/services/api/src/services/host/export-csv.ts b/services/api/src/services/host/export-csv.ts index c092f92b..9071f9ad 100644 --- a/services/api/src/services/host/export-csv.ts +++ b/services/api/src/services/host/export-csv.ts @@ -1,12 +1,20 @@ -const INJECTION_PREFIX_RE = /^[=+\-@\t\r]/ +const FORMULA_ESCAPE = "'" +const LEADING_TRIGGER_RE = /^[=+\-@\t\r]/ +// Excel in a ';'-separator locale (de, es) splits a quoted value on ';' and evaluates a formula at the +// start of the resulting field, so quoting alone does not help. The lookbehind (rather than a +// consuming match) also catches a trigger that follows a tab or CR which was itself a trigger. +const SEPARATED_TRIGGER_RE = /(?<=[;,\t\r\n] *)(?=[=+\-@\t\r])/g + +function neutralizeFormulas(value: string): string { + const separated = value.replace(SEPARATED_TRIGGER_RE, FORMULA_ESCAPE) + return LEADING_TRIGGER_RE.test(separated) ? `${FORMULA_ESCAPE}${separated}` : separated +} export function csvCell(value: string | number | null | undefined): string { if (value === null || value === undefined) return "" - const raw = typeof value === "number" ? String(value) : value - const guarded = INJECTION_PREFIX_RE.test(raw) ? `'${raw}` : raw - // Quoting every cell, not only those holding a comma, keeps a ';' inside the value from opening a - // new field (and a formula) when a list-separator ';' locale of Excel opens the file. - return `"${guarded.replace(/"/g, '""')}"` + // A number cannot carry a formula, and prefixing a negative one would turn it into text. + const text = typeof value === "number" ? String(value) : neutralizeFormulas(value) + return `"${text.replace(/"/g, '""')}"` } export function csvRow(cells: readonly (string | number | null | undefined)[]): string { diff --git a/services/api/test/unit/export-csv-security.test.ts b/services/api/test/unit/export-csv-security.test.ts index ac1ff698..bcd20fca 100644 --- a/services/api/test/unit/export-csv-security.test.ts +++ b/services/api/test/unit/export-csv-security.test.ts @@ -1,15 +1,36 @@ import { describe, expect, it } from "vitest" import { csvCell, csvProvenanceRow, csvRow } from "../../src/services/host/export-csv.js" -describe("csv cells in a semicolon-separator locale", () => { - it("quotes a cell whose second field would start a formula after ';'", () => { - expect(csvRow(["a;=1+1"])).toBe('"a;=1+1"\n') - expect(csvRow(["id", "x;=cmd|' /C calc'!A0"])).toBe('"id","x;=cmd|\' /C calc\'!A0"\n') +describe("csv cells in a locale whose list separator splits a value", () => { + it("neutralizes a formula that would start a new field after ';'", () => { + expect(csvRow(["a;=1+1"])).toBe('"a;\'=1+1"\n') + expect(csvRow(["id", "x;=cmd|' /C calc'!A0"])).toBe('"id","x;\'=cmd|\' /C calc\'!A0"\n') + }) + + it("neutralizes a trigger after ',', a tab, a newline or a carriage return", () => { + expect(csvCell("a,+1")).toBe('"a,\'+1"') + expect(csvCell("a\t@SUM(A1)")).toBe('"a\t\'@SUM(A1)"') + expect(csvCell("line1\n=1+1")).toBe('"line1\n\'=1+1"') + expect(csvCell("line1\r\n-1")).toBe('"line1\r\n\'-1"') + }) + + it("neutralizes a trigger that spaces separate from the separator", () => { + expect(csvCell("a; =1")).toBe('"a; \'=1"') + }) + + it("neutralizes a trigger that is itself a separator, and the trigger after it", () => { + expect(csvCell("a\t\t=1")).toBe("\"a\t'\t'=1\"") + }) + + it("leaves a separator followed by ordinary text untouched", () => { + expect(csvCell("Smith, Alex; Oakland")).toBe('"Smith, Alex; Oakland"') + expect(csvCell("2026-02-05")).toBe('"2026-02-05"') }) it("keeps the leading-character guard inside the quotes", () => { expect(csvCell("=1+1")).toBe('"\'=1+1"') expect(csvCell("@SUM(A1)")).toBe('"\'@SUM(A1)"') + expect(csvCell("=a;=b")).toBe("\"'=a;'=b\"") }) it("quotes cells a spreadsheet would otherwise re-split: leading space and full-width equals", () => { @@ -17,8 +38,13 @@ describe("csv cells in a semicolon-separator locale", () => { expect(csvCell("=1+1")).toBe('"=1+1"') }) - it("quotes numbers and provenance lines too, so no cell is ever bare", () => { + it("never prefixes a number, even a negative one", () => { + expect(csvCell(-3)).toBe('"-3"') + expect(csvCell(-0.5)).toBe('"-0.5"') expect(csvRow([42, "x"])).toBe('"42","x"\n') - expect(csvProvenanceRow("note;=1")).toBe('"# note;=1"\n') + }) + + it("guards provenance lines like any other string cell", () => { + expect(csvProvenanceRow("note;=1")).toBe('"# note;\'=1"\n') }) }) diff --git a/services/api/test/unit/host-export-csv.test.ts b/services/api/test/unit/host-export-csv.test.ts index 2dca4168..1bd62ae8 100644 --- a/services/api/test/unit/host-export-csv.test.ts +++ b/services/api/test/unit/host-export-csv.test.ts @@ -29,6 +29,11 @@ describe("csv cells", () => { expect(csvCell("\tx")).toBe(`"'\tx"`) }) + it("prefixes a string minus sign but leaves a negative number numeric", () => { + expect(csvCell("-1")).toBe(`"'-1"`) + expect(csvCell(-1)).toBe(`"-1"`) + }) + it("renders empty for null and undefined", () => { expect(csvCell(null)).toBe("") expect(csvCell(undefined)).toBe("") @@ -200,6 +205,12 @@ describe("host export build", () => { expect(h.puts[0]!.body.toString("utf8")).toContain("'=HYPERLINK") }) + it("escapes a formula hidden behind an in-value separator", async () => { + const h = harness([["1", "Alex;=cmd|' /C calc'!A0"]]) + await h.service.run(EXPORT_ID) + expect(h.puts[0]!.body.toString("utf8")).toContain("\"Alex;'=cmd|' /C calc'!A0\"") + }) + it("is idempotent: a second run does not re-claim", async () => { const h = harness([["1", "x"]]) await h.service.run(EXPORT_ID) From 67b0a56b545911185f4e7b87f3266bee478e325e Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:10:05 +0000 Subject: [PATCH 30/45] a suspension stops an org's broadcast mid-send, audited --- .../src/services/host/broadcast-pipeline.ts | 17 ++++- .../host/broadcast-repository.memory.ts | 11 ++- .../unit/admin-host-platform-routes.test.ts | 2 + .../unit/broadcast-pipeline-security.test.ts | 70 ++++++++++++++++++- .../broadcast-repository-security.test.ts | 36 ++++++++++ 5 files changed, 125 insertions(+), 11 deletions(-) diff --git a/services/api/src/services/host/broadcast-pipeline.ts b/services/api/src/services/host/broadcast-pipeline.ts index a744ae41..4900c69b 100644 --- a/services/api/src/services/host/broadcast-pipeline.ts +++ b/services/api/src/services/host/broadcast-pipeline.ts @@ -40,6 +40,7 @@ import { export const CHUNK_STALE_MS = 10 * 60 * 1000 export const SENDING_STALE_MS = 5 * 60 * 1000 export const MAX_DELIVERY_ATTEMPTS = 3 +const ORG_SUSPENDED_REASON = "org_suspended" export const AUTH_ABORT_BACKOFF_SEC = [60, 300, 900] as const export const CRITICAL_KINDS = CRITICAL_BROADCAST_KINDS export const AUTOMATED_KINDS = new Set([ @@ -222,15 +223,21 @@ export function makeBroadcastPipeline(deps: BroadcastPipelineDeps) { ): Promise { const moved = await repo.transition(record.id, [from], "failed", { finishedAt: at }) if (moved === null) return false - await repo.suppressRemaining(record.id, "kill_switch") + const suppressed = await repo.suppressRemaining(record.id, "kill_switch") await repo.refreshCounts(record.id) await insights.bumpInsightsGeneration(record.cleanupId) + await deps.audit("event.broadcast_killed", record.createdBy, `broadcast:${record.id}`, { + cleanupId: record.cleanupId, + kind: record.kind, + reason: ORG_SUSPENDED_REASON, + suppressed, + }) deps.logger?.warn( { evt: "broadcast.failed", broadcastId: record.id, cleanupId: record.cleanupId, - reason: "org_suspended", + reason: ORG_SUSPENDED_REASON, }, "broadcast refused: the event's organization is suspended", ) @@ -364,6 +371,12 @@ export function makeBroadcastPipeline(deps: BroadcastPipelineDeps) { } const event = await repo.eventContext(record.cleanupId) if (event === null) return + // An operator can suspend the organization after plan() passed; every chunk rechecks so the + // suspension stops what is still pending rather than only the next broadcast. + if (HOST_COMPOSED_BROADCAST_KINDS.has(record.kind) && event.organizationSuspended) { + await failForSuspendedOrganization(record, "sending", now()) + return + } const claims = await repo.claimChunk({ broadcastId, diff --git a/services/api/src/services/host/broadcast-repository.memory.ts b/services/api/src/services/host/broadcast-repository.memory.ts index 43acc79d..f89b8f52 100644 --- a/services/api/src/services/host/broadcast-repository.memory.ts +++ b/services/api/src/services/host/broadcast-repository.memory.ts @@ -679,13 +679,12 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { suspended: boolean, audit: WriteAuditInput, ): Promise { - this.audits.push(audit) - const existing = this.hosts.get(userId) ?? { - suspended: false, - emailVerified: true, - accountCreatedAt: new Date(0), - } + // A seeded host stands in for a users row: the Postgres upsert selects from users, so an + // unknown id changes nothing and writes no audit row. + const existing = this.hosts.get(userId) + if (existing === undefined) return Promise.resolve(false) this.hosts.set(userId, { ...existing, suspended }) + this.audits.push(audit) return Promise.resolve(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 b3732dee..55d13e55 100644 --- a/services/api/test/unit/admin-host-platform-routes.test.ts +++ b/services/api/test/unit/admin-host-platform-routes.test.ts @@ -394,6 +394,7 @@ describe("admin org verification decision", () => { describe("admin host list", () => { it("returns a suspended host even with no broadcast activity in the window", async () => { const h = await harness() + h.broadcasts.seedHost(HOST) await h.broadcasts.setHostMessagingSuspended(HOST, true, { action: "host.messaging_suspended", actorId: OPERATOR, @@ -419,6 +420,7 @@ describe("admin host list", () => { it("filters to the suspended set when asked", async () => { const h = await harness() + h.broadcasts.seedHost(HOST) await h.broadcasts.setHostMessagingSuspended(HOST, true, { action: "host.messaging_suspended", actorId: OPERATOR, diff --git a/services/api/test/unit/broadcast-pipeline-security.test.ts b/services/api/test/unit/broadcast-pipeline-security.test.ts index ddf882c2..bca40ace 100644 --- a/services/api/test/unit/broadcast-pipeline-security.test.ts +++ b/services/api/test/unit/broadcast-pipeline-security.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi } from "vitest" import type { BroadcastKind } from "@civfix/shared" import { FakeMailer } from "@civfix/shared/fakes" import { InMemoryCacheClient } from "../../src/auth/cache.js" @@ -15,6 +15,7 @@ import type { NotificationService } from "../../src/services/notification-servic const EVENT = "00000000-0000-0000-0000-0000000000ee" const HOST = "00000000-0000-0000-0000-0000000000aa" const MEMBER_COUNT = 3 +const STALE_SENDING_AGE_MS = 60 * 60 * 1000 const CONFIG: BroadcastConfig = { killSwitch: false, @@ -77,6 +78,7 @@ function harness(options: { organizationSuspended: boolean }) { enqueuePlan: () => Promise.resolve(), }) const chunks: number[] = [] + const audits: Array<{ action: string; target: string; meta: Record }> = [] const pipeline = makeBroadcastPipeline({ repo, service, @@ -91,9 +93,12 @@ function harness(options: { organizationSuspended: boolean }) { chunks.push(chunkNo) return Promise.resolve() }, - audit: () => Promise.resolve(), + audit: (action, _actorId, target, meta) => { + audits.push({ action, target, meta }) + return Promise.resolve() + }, }) - return { repo, cache, pipeline, chunks, counterKeys } + return { repo, cache, mailer, pipeline, chunks, counterKeys, audits } } type Harness = ReturnType @@ -198,3 +203,62 @@ describe("plan under a suspended organization", () => { expect(h.repo.allDeliveries()).toHaveLength(MEMBER_COUNT) }) }) + +describe("a chunk under an organization suspended after planning", () => { + it("delivers nothing and fails the broadcast with the suspension reason", async () => { + const h = harness({ organizationSuspended: false }) + const id = await seedBroadcast(h, "host_broadcast", "sending") + expect((await h.pipeline.plan(id)).kind).toBe("planned") + const before = await generation(h) + + h.repo.setEventOrganizationSuspended(EVENT, true) + await h.pipeline.runChunk(id, h.chunks[0]!) + + expect(h.mailer.sent).toHaveLength(0) + expect((await h.repo.findById(id))?.status).toBe("failed") + const deliveries = h.repo.allDeliveries() + expect(deliveries).toHaveLength(MEMBER_COUNT) + expect(deliveries.every((d) => d.status === "suppressed")).toBe(true) + expect(await generation(h)).toBeGreaterThan(before) + expect(h.audits).toEqual([ + { + action: "event.broadcast_killed", + target: `broadcast:${id}`, + meta: expect.objectContaining({ reason: "org_suspended", cleanupId: EVENT }) as unknown, + }, + ]) + }) + + it("stops a chunk the stale-sending sweep resumes", async () => { + const h = harness({ organizationSuspended: false }) + const id = await seedBroadcast(h, "host_broadcast", "sending") + await h.pipeline.plan(id) + const planned = h.chunks.length + + h.repo.setEventOrganizationSuspended(EVENT, true) + vi.useFakeTimers({ toFake: ["Date"] }) + try { + vi.setSystemTime(Date.now() + STALE_SENDING_AGE_MS) + expect((await h.pipeline.sweep()).resumed).toBe(1) + for (const chunkNo of h.chunks.slice(planned)) await h.pipeline.runChunk(id, chunkNo) + } finally { + vi.useRealTimers() + } + + expect(h.mailer.sent).toHaveLength(0) + expect((await h.repo.findById(id))?.status).toBe("failed") + }) + + it("still delivers a critical automated notice", async () => { + const h = harness({ organizationSuspended: false }) + const id = await seedBroadcast(h, "event_cancelled", "sending") + await h.pipeline.plan(id) + + h.repo.setEventOrganizationSuspended(EVENT, true) + for (const chunkNo of h.chunks) await h.pipeline.runChunk(id, chunkNo) + + expect(h.mailer.sent.length).toBeGreaterThan(0) + expect((await h.repo.findById(id))?.status).toBe("sent") + expect(h.audits).toHaveLength(0) + }) +}) diff --git a/services/api/test/unit/broadcast-repository-security.test.ts b/services/api/test/unit/broadcast-repository-security.test.ts index 11ec4aca..b001a918 100644 --- a/services/api/test/unit/broadcast-repository-security.test.ts +++ b/services/api/test/unit/broadcast-repository-security.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" import type { Sql } from "../../src/db/client.js" import { makeDrizzleBroadcastRepository } from "../../src/services/host/broadcast-repository.drizzle.js" +import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" import { makeFakeSql } from "../helpers/fake-sql.js" describe("operator host search", () => { @@ -22,3 +23,38 @@ describe("operator host search", () => { expect(statement?.values).not.toContain("%50%_off\\%") }) }) + +describe("in-memory host messaging suspension", () => { + const KNOWN_HOST = "00000000-0000-0000-0000-0000000000aa" + const UNKNOWN_USER = "00000000-0000-0000-0000-0000000000bb" + const OPERATOR = "00000000-0000-0000-0000-0000000000cc" + + it("returns false and records no audit row for an unknown user", async () => { + const repo = new InMemoryBroadcastRepository() + + const found = await repo.setHostMessagingSuspended(UNKNOWN_USER, true, { + action: "host.messaging_suspended", + actorId: OPERATOR, + target: `user:${UNKNOWN_USER}`, + }) + + expect(found).toBe(false) + expect(repo.audits).toHaveLength(0) + expect(await repo.hostMessagingState(UNKNOWN_USER)).toBeNull() + }) + + it("suspends a known host and records the audit row", async () => { + const repo = new InMemoryBroadcastRepository() + repo.seedHost(KNOWN_HOST) + + const found = await repo.setHostMessagingSuspended(KNOWN_HOST, true, { + action: "host.messaging_suspended", + actorId: OPERATOR, + target: `user:${KNOWN_HOST}`, + }) + + expect(found).toBe(true) + expect(repo.audits.map((a) => a.action)).toEqual(["host.messaging_suspended"]) + expect((await repo.hostMessagingState(KNOWN_HOST))?.suspended).toBe(true) + }) +}) From 5f7fc9a28ab4ed327ac2627088949200398aff8e Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:10:05 +0000 Subject: [PATCH 31/45] inbound mail: lookup failures retry, race losers leave no objects, more consumer domains, one digest per message --- infra/email-worker/src/index.ts | 33 +++-- .../test/pending-key-security.test.ts | 71 +++++++++- .../api/src/services/admin/inbound-bounce.ts | 26 ++++ .../src/services/admin/inbound-processor.ts | 46 ++++--- .../unit/inbound-processor-security.test.ts | 129 ++++++++++++++++++ ...nbound-thread-correlation-security.test.ts | 33 +++++ 6 files changed, 310 insertions(+), 28 deletions(-) diff --git a/infra/email-worker/src/index.ts b/infra/email-worker/src/index.ts index 0ced3b95..d17cdfb7 100644 --- a/infra/email-worker/src/index.ts +++ b/infra/email-worker/src/index.ts @@ -11,8 +11,7 @@ export default { async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext): Promise { const rawBytes = await new Response(message.raw).arrayBuffer() - const messageId = await deriveMessageId(message.headers, rawBytes) - const key = await derivePendingKey(message.headers, rawBytes) + const { messageId, key } = await derivePendingIdentity(message.headers, rawBytes) try { await env.R2_BUCKET.put(key, rawBytes, { @@ -38,19 +37,35 @@ export default { const PENDING_SLUG_MAX_CHARS = 120 const PENDING_DIGEST_CHARS = 32 +export interface PendingIdentity { + messageId: string + key: string +} + // The sender picks the Message-ID, so a key made from it alone lets a later mail (or a Message-ID // that slugs alike) overwrite a pending .eml before the backend drains it; the content digest makes -// the key follow the bytes, while an identical redelivery still lands on the same key. -export async function derivePendingKey(headers: Headers, raw: ArrayBuffer): Promise { +// the key follow the bytes, while an identical redelivery still lands on the same key. One digest +// serves both the key and the fallback id, so a message is hashed once however it is addressed. +export async function derivePendingIdentity( + headers: Headers, + raw: ArrayBuffer, +): Promise { const digest = await contentDigest(raw) - const slug = slugify(headers.get("message-id") ?? "").slice(0, PENDING_SLUG_MAX_CHARS) - const name = slug.length > 0 ? `${slug}.${digest.slice(0, PENDING_DIGEST_CHARS)}` : digest - return `${PENDING_PREFIX}${name}.eml` + const slug = slugify(headers.get("message-id") ?? "") + const keySlug = slug.slice(0, PENDING_SLUG_MAX_CHARS) + const name = keySlug.length > 0 ? `${keySlug}.${digest.slice(0, PENDING_DIGEST_CHARS)}` : digest + return { + messageId: slug.length > 0 ? slug : digest, + key: `${PENDING_PREFIX}${name}.eml`, + } +} + +export async function derivePendingKey(headers: Headers, raw: ArrayBuffer): Promise { + return (await derivePendingIdentity(headers, raw)).key } export async function deriveMessageId(headers: Headers, raw: ArrayBuffer): Promise { - const slug = slugify(headers.get("message-id") ?? "") - return slug.length > 0 ? slug : contentDigest(raw) + return (await derivePendingIdentity(headers, raw)).messageId } async function contentDigest(raw: ArrayBuffer): Promise { diff --git a/infra/email-worker/test/pending-key-security.test.ts b/infra/email-worker/test/pending-key-security.test.ts index 83cdb042..8c758f8c 100644 --- a/infra/email-worker/test/pending-key-security.test.ts +++ b/infra/email-worker/test/pending-key-security.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest" -import { derivePendingKey } from "../src/index" +import { afterEach, describe, expect, it, vi } from "vitest" +import worker, { derivePendingKey, type Env } from "../src/index" const BACKEND_PENDING_KEY_RE = /^inbound\/pending\/[^/]+\.eml$/ @@ -48,3 +48,70 @@ describe("derivePendingKey", () => { expect(key).toMatch(/^inbound\/pending\/[0-9a-f]{64}\.eml$/) }) }) + +interface Stored { + key: string + messageId: string | undefined +} + +async function receive(headers: Headers, raw: string): Promise { + const stored: Stored[] = [] + const env = { + R2_BUCKET: { + put: (key: string, _body: unknown, opts?: { customMetadata?: Record }) => { + stored.push({ key, messageId: opts?.customMetadata?.messageId }) + return Promise.resolve(null) + }, + }, + BACKEND_WEBHOOK_URL: "https://api.example.test/webhooks/inbound-mail", + CF_EMAIL_WEBHOOK_SECRET: "test-secret", + } as unknown as Env + const message = { + raw: new Response(raw).body, + headers, + from: "clerk@example.gov", + to: "reply@civfix.org", + rawSize: raw.length, + setReject: () => {}, + } as unknown as ForwardableEmailMessage + const pending: Promise[] = [] + const ctx = { waitUntil: (p: Promise) => pending.push(p) } as unknown as ExecutionContext + await worker.email(message, env, ctx) + await Promise.all(pending) + return stored +} + +describe("the email handler hashes each message once", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + function countDigests() { + vi.stubGlobal("fetch", () => Promise.resolve(new Response(null, { status: 202 }))) + return vi.spyOn(crypto.subtle, "digest") + } + + it("derives the fallback id and the pending key from one digest when there is no Message-ID", async () => { + const digest = countDigests() + + const stored = await receive(new Headers(), "no id body") + + expect(digest).toHaveBeenCalledTimes(1) + expect(stored).toHaveLength(1) + expect(stored[0]!.messageId).toMatch(/^[0-9a-f]{64}$/) + expect(stored[0]!.key).toBe(`inbound/pending/${stored[0]!.messageId}.eml`) + expect(stored[0]!.key).toBe(await derivePendingKey(new Headers(), bytes("no id body"))) + }) + + it("hashes once and keeps the slug-plus-digest key when a Message-ID is present", async () => { + const digest = countDigests() + const headers = withMessageId("") + + const stored = await receive(headers, "body") + + expect(digest).toHaveBeenCalledTimes(1) + expect(stored[0]!.messageId).toBe("test-001@example.gov") + expect(stored[0]!.key).toBe(await derivePendingKey(headers, bytes("body"))) + }) +}) diff --git a/services/api/src/services/admin/inbound-bounce.ts b/services/api/src/services/admin/inbound-bounce.ts index 92025530..763d7ed3 100644 --- a/services/api/src/services/admin/inbound-bounce.ts +++ b/services/api/src/services/admin/inbound-bounce.ts @@ -98,6 +98,32 @@ export const CONSUMER_MAIL_DOMAINS: ReadonlySet = new Set([ "optonline.net", "centurylink.net", "windstream.net", + "rr.com", + "twc.com", + "roadrunner.com", + "frontier.com", + "frontiernet.net", + "juno.com", + "netzero.net", + "netzero.com", + "mindspring.com", + "embarqmail.com", + "aim.com", + "yahoo.co.uk", + "yahoo.ca", + "yahoo.com.au", + "yahoo.fr", + "yahoo.de", + "hotmail.co.uk", + "hotmail.fr", + "hotmail.de", + "live.co.uk", + "outlook.de", + "gmx.de", + "gmx.at", + "gmx.ch", + "web.de", + "t-online.de", ]) export function isPlausibleBounceSender(input: { diff --git a/services/api/src/services/admin/inbound-processor.ts b/services/api/src/services/admin/inbound-processor.ts index 1c2e6c9e..860c5f15 100644 --- a/services/api/src/services/admin/inbound-processor.ts +++ b/services/api/src/services/admin/inbound-processor.ts @@ -166,6 +166,7 @@ export async function processInboundObject( injected, storage, mailRepo, + bytes, mail, messageId, resolvedThread, @@ -205,6 +206,7 @@ async function routeThreaded( injected: InboundEffectDeps, storage: Storage, mailRepo: MailRepository, + raw: Uint8Array, mail: ParsedMail, messageId: string, thread: MailThreadRecord, @@ -214,16 +216,17 @@ async function routeThreaded( const unaffiliated = authVerdict !== "pass" || !(await isJurisdictionSender(mailRepo, thread.id, mail)) // Message-ID is globally unique, so a stored row means this mail can only be a replay. Skipping - // the upload keeps a sender who reuses someone else's Message-ID from writing any object. - const existing = await mailRepo.findMessageByMessageId(messageId).catch(() => null) - const inserted = - existing !== null - ? null - : await insertThreadedMessage(storage, mailRepo, mail, messageId, thread, unaffiliated) - if (inserted !== null) { + // the upload keeps a sender who reuses someone else's Message-ID from writing any object. A failed + // lookup throws so the pending object stays for the sweep instead of being treated as new mail. + const existing = await mailRepo.findMessageByMessageId(messageId) + const insert = + existing === null + ? await insertThreadedMessage(storage, mailRepo, raw, mail, messageId, thread, unaffiliated) + : null + if (insert?.inserted === true) { await mailRepo.recordEvent({ threadId: thread.id, - messageId: inserted.message.id, + messageId: insert.message.id, type: "delivered", meta: { direction: "in", @@ -231,15 +234,13 @@ async function routeThreaded( messageId, authVerdict, ...(unaffiliated ? { unaffiliated: true } : {}), - ...(inserted.oversize.length > 0 ? { oversizeAttachments: inserted.oversize } : {}), + ...(insert.oversize.length > 0 ? { oversizeAttachments: insert.oversize } : {}), }, }) } const message = - inserted?.message ?? - existing ?? - (await mailRepo.findMessageByMessageId(messageId).catch(() => null)) + existing ?? (insert === null ? null : insert.inserted ? insert.message : insert.stored) if (message !== null && message.threadId === thread.id) { await applyInboundEffects(container, injected, mailRepo, thread, message).catch( (err: unknown) => { @@ -250,21 +251,29 @@ async function routeThreaded( }, ) } - if (inserted === null) return { outcome: "replay" } - return { outcome: "threaded", id: inserted.message.id } + if (insert?.inserted !== true) return { outcome: "replay" } + return { outcome: "threaded", id: insert.message.id } } +type ThreadedInsert = + | { inserted: true; message: MailMessageRecord; oversize: string[] } + | { inserted: false; stored: MailMessageRecord | null } + async function insertThreadedMessage( storage: Storage, mailRepo: MailRepository, + raw: Uint8Array, mail: ParsedMail, messageId: string, thread: MailThreadRecord, unaffiliated: boolean, -): Promise<{ message: MailMessageRecord; oversize: string[] } | null> { +): Promise { + // One folder per raw message, as in routeInbox: a lost insert race discards the objects its row + // does not hold, and a folder shared with another message of the thread would lose that message's + // attachment. const { attachments, oversize } = await streamAttachments( storage, - `inbound-mail/${thread.id}`, + `inbound-mail/${thread.id}/${contentDigest(raw)}`, mail, ) const message = await mailRepo.insertMessage({ @@ -279,7 +288,10 @@ async function insertThreadedMessage( inReplyTo: mail.inReplyTo ?? null, unaffiliated, }) - return message === null ? null : { message, oversize } + if (message !== null) return { inserted: true, message, oversize } + const stored = await mailRepo.findMessageByMessageId(messageId) + await discardUnreferenced(storage, attachments, stored?.attachments ?? []) + return { inserted: false, stored } } function plainTextBody(mail: ParsedMail): string | null { diff --git a/services/api/test/unit/inbound-processor-security.test.ts b/services/api/test/unit/inbound-processor-security.test.ts index db826fff..e6cbab2f 100644 --- a/services/api/test/unit/inbound-processor-security.test.ts +++ b/services/api/test/unit/inbound-processor-security.test.ts @@ -319,3 +319,132 @@ describe("inbox inbound attachments cannot be overwritten by a later mail", () = expect(await keysUnder(c.storage, "inbound-emails/")).toEqual([stored]) }) }) + +function missFirstLookup(repo: InMemoryMailRepository): void { + const lookup = repo.findMessageByMessageId.bind(repo) + let calls = 0 + repo.findMessageByMessageId = (messageId) => { + calls += 1 + return calls === 1 ? Promise.resolve(null) : lookup(messageId) + } +} + +describe("threaded inbound under a Message-ID lookup failure or insert race", () => { + it("surfaces a lookup failure and leaves the pending object for the sweep", async () => { + const c = ctx() + const threadId = seedReportThread(c) + c.mailRepo.findMessageByMessageId = () => Promise.reject(new Error("db down")) + const key = `${INBOUND_PENDING_PREFIX}lookup.eml` + await c.storage.put( + key, + mailWithAttachment({ + from: "clerk@city.gov", + to: REPLY_TO, + messageId: "", + filename: "photo.jpg", + content: CITY_BYTES, + }), + ) + + await expect(processInboundObject(c.container, key, c.deps)).rejects.toThrow("db down") + + expect(await c.storage.getObject(key)).not.toBeNull() + expect(c.mailRepo.messagesOf(threadId).filter((m) => m.direction === "in")).toHaveLength(0) + expect(await keysUnder(c.storage, `inbound-mail/${threadId}/`)).toEqual([]) + }) + + it("discards the loser's attachment objects when a racing insert stored other bytes", async () => { + const c = ctx() + const threadId = seedReportThread(c) + const messageId = "" + await deliver( + c, + `${INBOUND_PENDING_PREFIX}winner.eml`, + mailWithAttachment({ + from: "mallory@gmail.com", + to: REPLY_TO, + messageId, + filename: "photo.jpg", + content: ATTACKER_BYTES, + }), + ) + const winnerKey = c.mailRepo.messagesOf(threadId).find((m) => m.direction === "in")! + .attachments[0]!.key + + missFirstLookup(c.mailRepo) + const outcome = await deliver( + c, + `${INBOUND_PENDING_PREFIX}loser.eml`, + mailWithAttachment({ + from: "clerk@city.gov", + to: REPLY_TO, + messageId, + filename: "photo.jpg", + content: CITY_BYTES, + }), + ) + + expect(outcome).toBe("replay") + expect(await keysUnder(c.storage, `inbound-mail/${threadId}/`)).toEqual([winnerKey]) + expect(text(await c.storage.getObject(winnerKey))).toBe(ATTACKER_BYTES) + }) + + it("keeps the objects the winner references when the same mail raced itself", async () => { + const c = ctx() + const threadId = seedReportThread(c) + const raw = mailWithAttachment({ + from: "clerk@city.gov", + to: REPLY_TO, + messageId: "", + filename: "photo.jpg", + content: CITY_BYTES, + }) + expect(await deliver(c, `${INBOUND_PENDING_PREFIX}one.eml`, raw)).toBe("threaded") + const stored = c.mailRepo.messagesOf(threadId).find((m) => m.direction === "in")! + .attachments[0]!.key + + missFirstLookup(c.mailRepo) + expect(await deliver(c, `${INBOUND_PENDING_PREFIX}two.eml`, raw)).toBe("replay") + + expect(await keysUnder(c.storage, `inbound-mail/${threadId}/`)).toEqual([stored]) + expect(text(await c.storage.getObject(stored))).toBe(CITY_BYTES) + }) + + it("never deletes an identical attachment another message of the thread still holds", async () => { + const c = ctx() + const threadId = seedReportThread(c) + await deliver( + c, + `${INBOUND_PENDING_PREFIX}earlier.eml`, + mailWithAttachment({ + from: "clerk@city.gov", + to: REPLY_TO, + messageId: "", + filename: "photo.jpg", + content: CITY_BYTES, + }), + ) + const earlierKey = c.mailRepo.messagesOf(threadId).find((m) => m.direction === "in")! + .attachments[0]!.key + c.mailRepo.seedMessage({ + threadId: c.mailRepo.seedThread({ threadToken: "fedcba9876543210fedcba98" }).id, + direction: "in", + messageId: "", + }) + + missFirstLookup(c.mailRepo) + await deliver( + c, + `${INBOUND_PENDING_PREFIX}resend.eml`, + mailWithAttachment({ + from: "clerk@city.gov", + to: REPLY_TO, + messageId: "", + filename: "photo.jpg", + content: CITY_BYTES, + }), + ) + + expect(text(await c.storage.getObject(earlierKey))).toBe(CITY_BYTES) + }) +}) diff --git a/services/api/test/unit/inbound-thread-correlation-security.test.ts b/services/api/test/unit/inbound-thread-correlation-security.test.ts index 2d8a360f..1eed953d 100644 --- a/services/api/test/unit/inbound-thread-correlation-security.test.ts +++ b/services/api/test/unit/inbound-thread-correlation-security.test.ts @@ -65,6 +65,39 @@ describe("isJurisdictionSender on a consumer mail domain", () => { expect(await sentBy(["clerk@mail.yahoo.com"], "other@yahoo.com")).toBe(false) }) + it("covers US ISP, legacy webmail and regional consumer providers", async () => { + const providers = [ + "rr.com", + "twc.com", + "roadrunner.com", + "charter.net", + "frontier.com", + "frontiernet.net", + "juno.com", + "netzero.net", + "mindspring.com", + "embarqmail.com", + "aim.com", + "yahoo.co.uk", + "yahoo.ca", + "hotmail.co.uk", + "gmx.de", + "web.de", + "gmx.net", + "t-online.de", + ] + for (const provider of providers) { + expect(await sentBy([`clerk@${provider}`], `other@${provider}`), provider).toBe(false) + expect(await sentBy([`clerk@${provider}`], `clerk@${provider}`), provider).toBe(true) + } + }) + + it("treats a regional subdomain of a consumer provider as that provider", async () => { + expect(await sentBy(["clerk@socal.rr.com"], "other@socal.rr.com")).toBe(false) + expect(await sentBy(["clerk@socal.rr.com"], "other@nyc.rr.com")).toBe(false) + expect(await sentBy(["clerk@socal.rr.com"], "clerk@socal.rr.com")).toBe(true) + }) + it("still aligns by organizational domain for a jurisdiction's own domain", async () => { expect(await sentBy(["publicworks@lacity.org"], "clerk@bss.lacity.org")).toBe(true) expect(await sentBy([CONSUMER_CONTACT, "publicworks@lacity.org"], "clerk@lacity.org")).toBe( From 34a73fed30f963b3ce07e2e11456541f9d27ea23 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:10:05 +0000 Subject: [PATCH 32/45] log redaction bounds the values it walks --- services/api/src/errors/log-redaction.ts | 72 ++++++++++++++----- .../test/unit/log-redaction-security.test.ts | 50 +++++++++++++ 2 files changed, 106 insertions(+), 16 deletions(-) diff --git a/services/api/src/errors/log-redaction.ts b/services/api/src/errors/log-redaction.ts index ec7248d1..81f8d86f 100644 --- a/services/api/src/errors/log-redaction.ts +++ b/services/api/src/errors/log-redaction.ts @@ -42,9 +42,10 @@ const LOG_SENSITIVE_KEYS: ReadonlySet = new Set( const SMTP_KEY = "smtp" const SMTP_RESPONSE_KEY = "response" -// Every log line pays for this walk, so it stops at a fixed depth; anything deeper is dropped rather than -// written unredacted. +// Every log line pays for this walk, so it stops at a fixed depth and a fixed number of visited values; +// anything past either bound is dropped rather than written unredacted. const MAX_REDACTION_DEPTH = 10 +const MAX_REDACTION_NODES = 1_000 const TRUNCATED = "[Truncated]" const CIRCULAR = "[Circular]" @@ -61,9 +62,49 @@ function isPlainObject(value: object): value is Record { interface Walk { depth: number + visited: number ancestors: Set } +function budgetSpent(walk: Walk): boolean { + walk.visited += 1 + return walk.visited > MAX_REDACTION_NODES +} + +function redactArray(value: readonly unknown[], parentKey: string | null, walk: Walk): unknown[] { + const out: unknown[] = [] + for (let i = 0; i < value.length; i++) { + if (budgetSpent(walk)) { + out.push(TRUNCATED) + break + } + out.push(redactValue(value[i], parentKey, walk)) + } + return out +} + +function redactEntries( + source: Record, + target: Record, + parentKey: string | null, + walk: Walk, + ownOnly: boolean, +): void { + // for...in rather than Object.entries: a wide object is abandoned at the budget without first + // materializing every entry. An error keeps its inherited enumerable keys, because the serializer + // would otherwise read them unredacted through the shadow's shared prototype. + for (const key in source) { + if (ownOnly && !Object.prototype.hasOwnProperty.call(source, key)) continue + if (budgetSpent(walk)) { + target[key] = TRUNCATED + break + } + target[key] = isSensitiveLogKey(key, parentKey) + ? LOG_REDACTION_CENSOR + : redactValue(source[key], key, walk) + } +} + function redactValue(value: unknown, parentKey: string | null, walk: Walk): unknown { if (value === null || typeof value !== "object") return value if (walk.depth >= MAX_REDACTION_DEPTH) return TRUNCATED @@ -73,14 +114,10 @@ function redactValue(value: unknown, parentKey: string | null, walk: Walk): unkn walk.ancestors.add(value) walk.depth += 1 try { - if (Array.isArray(value)) return value.map((item) => redactValue(item, parentKey, walk)) + if (Array.isArray(value)) return redactArray(value, parentKey, walk) if (value instanceof Error) return shadowError(value, walk) const out: Record = {} - for (const [key, child] of Object.entries(value)) { - out[key] = isSensitiveLogKey(key, parentKey) - ? LOG_REDACTION_CENSOR - : redactValue(child, key, walk) - } + redactEntries(value, out, parentKey, walk, true) return out } finally { walk.depth -= 1 @@ -98,13 +135,13 @@ function shadowError(error: Error, walk: Walk): Error { if ("cause" in error) hideOn(shadow, "cause", redactValue(error.cause, null, walk)) if (error instanceof AggregateError) hideOn(shadow, "errors", redactValue(error.errors, null, walk)) - const shadowRecord = shadow as unknown as Record - const errorRecord = error as unknown as Record - for (const key in error) { - shadowRecord[key] = isSensitiveLogKey(key, null) - ? LOG_REDACTION_CENSOR - : redactValue(errorRecord[key], key, walk) - } + redactEntries( + error as unknown as Record, + shadow as unknown as Record, + null, + walk, + false, + ) return shadow } @@ -123,5 +160,8 @@ function hideOn(target: object, key: string, value: unknown): void { * and reply the framework logs) pass through untouched for their own serializers. */ export function redactLogObject(object: Record): Record { - return redactValue(object, null, { depth: 0, ancestors: new Set() }) as Record + return redactValue(object, null, { depth: 0, visited: 0, ancestors: new Set() }) as Record< + string, + unknown + > } diff --git a/services/api/test/unit/log-redaction-security.test.ts b/services/api/test/unit/log-redaction-security.test.ts index 3a698a96..4f9852ad 100644 --- a/services/api/test/unit/log-redaction-security.test.ts +++ b/services/api/test/unit/log-redaction-security.test.ts @@ -3,6 +3,7 @@ import Fastify from "fastify" import { AppError, ErrorCode } from "@civfix/shared" import { loggerOptions } from "../../src/server.js" import { loadEnv } from "../../src/env.js" +import { redactLogObject } from "../../src/errors/log-redaction.js" const SECRET = "leak-canary-7f3a" const SENSITIVE_KEYS = ["email", "token", "otp", "phone", "password", "accessCode"] as const @@ -104,6 +105,55 @@ describe("log lines redact sensitive keys at any depth", () => { }) }) +describe("the redaction pass bounds its breadth", () => { + const WIDE = 5_000 + const NODE_BUDGET_CEILING = 1_100 + + function countNodes(value: unknown): number { + if (value === null || typeof value !== "object") return 1 + const children = Array.isArray(value) ? value : Object.values(value) + return 1 + children.reduce((sum, child) => sum + countNodes(child), 0) + } + + it("truncates a wide array instead of copying every element", () => { + const rows = Array.from({ length: WIDE }, (_, i) => ({ i, email: SECRET })) + const out = redactLogObject({ rows }) as { rows: unknown[] } + + expect(out.rows.length).toBeLessThan(WIDE) + expect(out.rows.at(-1)).toBe("[Truncated]") + expect(countNodes(out)).toBeLessThan(NODE_BUDGET_CEILING) + expect(JSON.stringify(out)).not.toContain(SECRET) + }) + + it("truncates a wide object instead of copying every key", () => { + const wide: Record = {} + for (let i = 0; i < WIDE; i++) wide[`k${i}`] = { email: SECRET, n: i } + const out = redactLogObject({ wide }) as { wide: Record } + + const values = Object.values(out.wide) + expect(values.length).toBeLessThan(WIDE) + expect(values.at(-1)).toBe("[Truncated]") + expect(countNodes(out)).toBeLessThan(NODE_BUDGET_CEILING) + expect(JSON.stringify(out)).not.toContain(SECRET) + }) + + it("keeps a wide payload's log line readable and redacted", () => { + const line = capture((log) => + log.info({ rows: Array.from({ length: WIDE }, () => ({ token: SECRET })) }, "wide"), + ) + expect(line).toContain("wide") + expect(line).toContain("[Truncated]") + expect(line).not.toContain(SECRET) + }) + + it("leaves an ordinary payload whole", () => { + const rows = Array.from({ length: 50 }, (_, i) => ({ i, email: SECRET })) + const out = redactLogObject({ rows }) as { rows: Array<{ i: number; email: string }> } + expect(out.rows).toHaveLength(50) + expect(out.rows[49]).toEqual({ i: 49, email: "[REDACTED]" }) + }) +}) + describe("error serialization through the redaction pass", () => { it("keeps type, message, stack and code and redacts sensitive props inside err", () => { const err = Object.assign(new AppError(ErrorCode.CONFLICT, "mail failed"), { From 28e0d2694bad334beba7c0c96b8ed609c5726665 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:10:05 +0000 Subject: [PATCH 33/45] roster export audit is written with the export row --- .../api/src/routes/host/exports.routes.ts | 18 +-- .../api/src/routes/host/unsubscribe.routes.ts | 4 +- .../api/src/services/host/comms-wiring.ts | 2 +- .../host/export-repository.drizzle.ts | 42 +++--- .../api/src/services/host/export-service.ts | 19 ++- .../unit/host-route-audit-security.test.ts | 124 ++++++++++++++++-- 6 files changed, 161 insertions(+), 48 deletions(-) diff --git a/services/api/src/routes/host/exports.routes.ts b/services/api/src/routes/host/exports.routes.ts index 9854e84e..70f446c7 100644 --- a/services/api/src/routes/host/exports.routes.ts +++ b/services/api/src/routes/host/exports.routes.ts @@ -16,7 +16,7 @@ import { route } from "../../versioning/route.js" import { parse } from "../_validate.js" import { requireCapability } from "../../services/host/authz.js" import { HOST_EXPORT_JOB } from "../../services/host/broadcast-queues.js" -import { auditBestEffort, makeCommsRuntime } from "../../services/host/comms-wiring.js" +import { makeCommsRuntime } from "../../services/host/comms-wiring.js" import type { CommsRuntime } from "../../services/host/comms-wiring.js" import { toHostExportDTO, type HostExportService } from "../../services/host/export-service.js" @@ -73,22 +73,18 @@ export async function registerHostExportRoutes( requestedBy: userId, kind: body.kind, filters: body.filters, + audit: (exportId) => ({ + action: "event.roster_exported", + actorId: userId, + target: `cleanup:${body.id}`, + meta: { exportId, kind: body.kind }, + }), }) await container.jobs.enqueue( HOST_EXPORT_JOB, { exportId: payload.id }, { singletonKey: `export:${payload.id}`, retryLimit: 2 }, ) - await auditBestEffort( - container.getDb().sql, - { - action: "event.roster_exported", - actorId: userId, - target: `cleanup:${body.id}`, - meta: { exportId: payload.id, kind: body.kind }, - }, - request.log, - ) reply.status(200).send(payload) }, ) diff --git a/services/api/src/routes/host/unsubscribe.routes.ts b/services/api/src/routes/host/unsubscribe.routes.ts index 41af97a6..70f8fbab 100644 --- a/services/api/src/routes/host/unsubscribe.routes.ts +++ b/services/api/src/routes/host/unsubscribe.routes.ts @@ -75,8 +75,8 @@ export async function registerUnsubscribeRoutes( await runtime().broadcasts.unsubscribe(parsed.data.token) } catch (err) { // Only a verified token reaches the write, so a failure here reveals nothing about token - // validity. 5xx is the retry signal RFC 8058 senders and the web confirmation page act on; - // a 200 would drop the opt-out for good. + // validity. A 5xx is the retry signal for RFC 8058 one-click senders, where a 200 would + // drop the opt-out for good; the exposed copy only replaces the generic internal message. throw exposeMessage( new AppError(ErrorCode.INTERNAL, UNSUBSCRIBE_UNAVAILABLE_COPY, { httpStatus: 503, diff --git a/services/api/src/services/host/comms-wiring.ts b/services/api/src/services/host/comms-wiring.ts index 4f814567..5f875fb3 100644 --- a/services/api/src/services/host/comms-wiring.ts +++ b/services/api/src/services/host/comms-wiring.ts @@ -59,7 +59,7 @@ export interface CommsRuntime { export { apiBaseUrlOf, webBaseUrlOf } from "../../lib/base-url.js" /** - * The effect this row records has already happened (a send, an export enqueue), so a failed audit + * The effect this row records has already happened (a send), so a failed audit * write must not turn it into an error the caller would retry. It is logged instead of dropped so the * gap in the trail is visible. */ diff --git a/services/api/src/services/host/export-repository.drizzle.ts b/services/api/src/services/host/export-repository.drizzle.ts index 6ddd86c6..221d9f2f 100644 --- a/services/api/src/services/host/export-repository.drizzle.ts +++ b/services/api/src/services/host/export-repository.drizzle.ts @@ -1,5 +1,6 @@ import type { HostExportKind, HostExportStatus } from "@civfix/shared" import type { Sql } from "../../db/client.js" +import { writeAudit, type WriteAuditInput } from "../admin/audit.js" export interface HostExportRecord { id: string @@ -22,13 +23,16 @@ export interface HostExportRecord { } export interface HostExportRepository { - create(input: { - cleanupId: string | null - organizationId: string | null - requestedBy: string - kind: HostExportKind - filters: Record - }): Promise + create( + input: { + cleanupId: string | null + organizationId: string | null + requestedBy: string + kind: HostExportKind + filters: Record + }, + audit?: (exportId: string) => WriteAuditInput, + ): Promise findById(exportId: string): Promise listForEvent(cleanupId: string, limit: number): Promise listForOrganization(organizationId: string, limit: number): Promise @@ -94,15 +98,21 @@ function toRecord(row: RowSelect): HostExportRecord { export function makeDrizzleHostExportRepository(sql: Sql): HostExportRepository { return { - async create(input) { - const rows = await sql` - INSERT INTO host_exports (cleanup_id, organization_id, requested_by, kind, filters) - VALUES (${input.cleanupId}, ${input.organizationId}, ${input.requestedBy}, ${input.kind}, - ${sql.json(input.filters as Parameters[0])}) - RETURNING id, cleanup_id, organization_id, requested_by, kind, filters, status, r2_key, - row_count, byte_size, truncated, error_code, run_token, requested_at, started_at, - completed_at, expires_at` - return toRecord(rows[0]!) + create(input, audit) { + // The export hands member PII out of the platform, so its request is never on record without + // its audit row: both commit or neither does. + return sql.begin(async (tx) => { + const rows = await tx` + INSERT INTO host_exports (cleanup_id, organization_id, requested_by, kind, filters) + VALUES (${input.cleanupId}, ${input.organizationId}, ${input.requestedBy}, ${input.kind}, + ${tx.json(input.filters as Parameters[0])}) + RETURNING id, cleanup_id, organization_id, requested_by, kind, filters, status, r2_key, + row_count, byte_size, truncated, error_code, run_token, requested_at, + started_at, completed_at, expires_at` + const record = toRecord(rows[0]!) + if (audit !== undefined) await writeAudit(tx, audit(record.id)) + return record + }) as Promise }, async findById(exportId) { diff --git a/services/api/src/services/host/export-service.ts b/services/api/src/services/host/export-service.ts index f680ba29..bfdaa37d 100644 --- a/services/api/src/services/host/export-service.ts +++ b/services/api/src/services/host/export-service.ts @@ -5,6 +5,7 @@ import type { FastifyBaseLogger } from "fastify" import { csvProvenanceRow, csvRow } from "./export-csv.js" import { hostExportBuilder, type HostExportContext } from "./export-builders.js" import type { HostExportRecord, HostExportRepository } from "./export-repository.drizzle.js" +import type { WriteAuditInput } from "../admin/audit.js" export const EXPORT_DOWNLOAD_URL_TTL_SEC = 300 export const EXPORT_LIST_LIMIT = 50 @@ -58,6 +59,7 @@ export interface HostExportService { requestedBy: string kind: HostExportKind filters: HostExportFilters | undefined + audit?: (exportId: string) => WriteAuditInput }): Promise listForEvent(cleanupId: string): Promise listForOrganization(organizationId: string): Promise @@ -80,13 +82,16 @@ export function makeHostExportService(deps: HostExportServiceDeps): HostExportSe return { async request(args) { - const record = await deps.repo.create({ - cleanupId: args.cleanupId, - organizationId: args.organizationId, - requestedBy: args.requestedBy, - kind: args.kind, - filters: (args.filters ?? {}) as Record, - }) + const record = await deps.repo.create( + { + cleanupId: args.cleanupId, + organizationId: args.organizationId, + requestedBy: args.requestedBy, + kind: args.kind, + filters: (args.filters ?? {}) as Record, + }, + args.audit, + ) return toHostExportDTO(record) }, diff --git a/services/api/test/unit/host-route-audit-security.test.ts b/services/api/test/unit/host-route-audit-security.test.ts index e49b6c9e..8139c27b 100644 --- a/services/api/test/unit/host-route-audit-security.test.ts +++ b/services/api/test/unit/host-route-audit-security.test.ts @@ -1,15 +1,20 @@ import { Writable } from "node:stream" import Fastify, { type FastifyInstance } from "fastify" import { afterEach, describe, expect, it } from "vitest" -import type { BroadcastDTO, HostExportDTO } from "@civfix/shared" +import type { BroadcastDTO } from "@civfix/shared" import { FakeStorage } from "@civfix/shared/fakes" import { makeErrorHandler, makeNotFoundHandler } from "../../src/errors/http-mapper.js" import type { Container } from "../../src/di.js" import { registerHostExportRoutes } from "../../src/routes/host/exports.routes.js" import { registerHostBroadcastRoutes } from "../../src/routes/host/broadcasts.routes.js" -import type { HostExportService } from "../../src/services/host/export-service.js" +import { + makeHostExportService, + type HostExportService, +} from "../../src/services/host/export-service.js" +import { makeDrizzleHostExportRepository } from "../../src/services/host/export-repository.drizzle.js" +import type { Sql } from "../../src/db/client.js" import type { CommsRuntime } from "../../src/services/host/comms-wiring.js" -import { makeFakeSql } from "../helpers/fake-sql.js" +import { makeFakeSql, type FakeSqlControl, type SqlHandler } from "../helpers/fake-sql.js" const USER = "11111111-1111-4111-8111-111111111111" const EVENT = "22222222-2222-4222-8222-222222222222" @@ -104,13 +109,106 @@ function auditWarnings(logs: LogLine[]): LogLine[] { return logs.filter((line) => line.level === WARN_LEVEL && line.action !== undefined) } -describe("host export request when the audit write fails", () => { - it("answers the export it already queued instead of a 500, and logs the lost audit row", async () => { - const exports = { - request: () => Promise.resolve({ id: EXPORT_ID, kind: "roster" } as HostExportDTO), - } as unknown as HostExportService +const EXPORT_ROW = { + id: EXPORT_ID, + cleanup_id: EVENT, + organization_id: null, + requested_by: USER, + kind: "roster", + filters: {}, + status: "queued", + r2_key: null, + row_count: null, + byte_size: null, + truncated: false, + error_code: null, + run_token: null, + requested_at: new Date("2026-09-01T00:00:00.000Z"), + started_at: null, + completed_at: null, + expires_at: null, +} + +const exportInsert: SqlHandler = { match: /INSERT INTO host_exports/, rows: [EXPORT_ROW] } +const auditOk: SqlHandler = { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] } +const auditDown: SqlHandler = { + match: /INSERT INTO audit_log/, + rows: () => { + throw new Error("audit_log unavailable") + }, +} + +function exportServiceOn(sql: FakeSqlControl): HostExportService { + return makeHostExportService({ + repo: makeDrizzleHostExportRepository(sql.sql as unknown as Sql), + storage: { + put: () => Promise.resolve(), + presignGet: () => Promise.resolve("https://signed.example/x"), + delete: () => Promise.resolve(), + }, + config: { maxRows: 10, maxBytes: 1024, ttlHours: 24 }, + }) +} + +describe("roster export audit rides the export row's transaction", () => { + it("writes the export row and its audit row inside one transaction", async () => { + const outer = makeFakeSql() + const tx = makeFakeSql([exportInsert, auditOk]) + outer.sql.begin = (cb) => cb(tx.sql) + const repo = makeDrizzleHostExportRepository(outer.sql as unknown as Sql) + + const record = await repo.create( + { + cleanupId: EVENT, + organizationId: null, + requestedBy: USER, + kind: "roster", + filters: {}, + }, + (exportId) => ({ + action: "event.roster_exported", + actorId: USER, + target: `cleanup:${EVENT}`, + meta: { exportId, kind: "roster" }, + }), + ) + + expect(record.id).toBe(EXPORT_ID) + expect(outer.statements).toEqual([]) + expect(tx.statements.map((s) => /INSERT INTO (\w+)/.exec(s.sql)?.[1])).toEqual([ + "host_exports", + "audit_log", + ]) + expect(tx.statements[1]?.values).toEqual([ + USER, + "event.roster_exported", + `cleanup:${EVENT}`, + { exportId: EXPORT_ID, kind: "roster" }, + ]) + }) +}) + +describe("host export request and its audit row", () => { + it("fails the request and queues nothing when the audit row cannot be written", async () => { + const repoSql = makeFakeSql([exportInsert, auditDown]) const h = await build(registerHostExportRoutes, (instance) => - instance.decorate("hostExportOverrides", { exports }), + instance.decorate("hostExportOverrides", { exports: exportServiceOn(repoSql) }), + ) + + const res = await h.app.inject({ + method: "POST", + url: `/v1/cleanups/${EVENT}/exports`, + payload: { kind: "roster" }, + }) + + expect(res.statusCode).toBe(500) + expect(h.enqueued).toHaveLength(0) + }) + + it("audits the export it queued with the export id", async () => { + const repoSql = makeFakeSql([exportInsert, auditOk]) + const h = await build(registerHostExportRoutes, (instance) => + instance.decorate("hostExportOverrides", { exports: exportServiceOn(repoSql) }), ) const res = await h.app.inject({ @@ -121,8 +219,12 @@ describe("host export request when the audit write fails", () => { expect(res.statusCode).toBe(200) expect(h.enqueued).toHaveLength(1) - expect(auditWarnings(h.logs)).toEqual([ - expect.objectContaining({ action: "event.roster_exported", target: `cleanup:${EVENT}` }), + const audit = repoSql.statements.find((s) => /INSERT INTO audit_log/.test(s.sql)) + expect(audit?.values).toEqual([ + USER, + "event.roster_exported", + `cleanup:${EVENT}`, + { exportId: EXPORT_ID, kind: "roster" }, ]) }) }) From 5e273ac0aeaaecef619f6368b8ecdc498e307577 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:29:31 +0000 Subject: [PATCH 34/45] org invites re-check the inviter under the org lock when created and accepted --- services/api/src/auth/pg-stores.ts | 4 +- .../host/organization-repository.drizzle.ts | 100 ++++-- .../host/organization-repository.memory.ts | 45 ++- .../host/organization-repository.types.ts | 42 ++- .../src/services/host/organization-service.ts | 9 +- ...rganization-repository-security-pg.test.ts | 68 ++++- .../organization-service-security.test.ts | 286 ++++++++++++++++++ 7 files changed, 521 insertions(+), 33 deletions(-) diff --git a/services/api/src/auth/pg-stores.ts b/services/api/src/auth/pg-stores.ts index 8d02f404..831949f3 100644 --- a/services/api/src/auth/pg-stores.ts +++ b/services/api/src/auth/pg-stores.ts @@ -469,8 +469,8 @@ export class PgUserStore implements UserStore { `) } await tx.execute(sql`DELETE FROM organization_members WHERE user_id = ${id}`) - // Accepting an invite seats the role it names without re-checking the inviter, so an invite must not - // outlive the admin who sent it; one addressed to the closed account can never be accepted. + // A pending invite must not outlive the admin who sent it (accept re-checks the inviter too, but a + // revoked row keeps it out of every inbox); one addressed to the closed account can never be accepted. await tx.execute(sql` WITH revoked AS ( UPDATE organization_invites diff --git a/services/api/src/services/host/organization-repository.drizzle.ts b/services/api/src/services/host/organization-repository.drizzle.ts index 699f411c..0e20c114 100644 --- a/services/api/src/services/host/organization-repository.drizzle.ts +++ b/services/api/src/services/host/organization-repository.drizzle.ts @@ -55,8 +55,13 @@ import type { UpdateOrganizationOutcome, UpdateOrganizationPatch, InviterRevocationReason, + InviterStanding, +} from "./organization-repository.types.js" +import { + canManageOrgMembers, + inviterRevocationReason, + roleChangeWithdrawsInvites, } from "./organization-repository.types.js" -import { roleChangeWithdrawsInvites } from "./organization-repository.types.js" const PG_UNIQUE_VIOLATION = "23505" @@ -710,7 +715,8 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit actorId: string now: Date }): Promise { - return sql.begin(async (tx) => { + return sql.begin(async (tx): Promise => { + if (!(await lockOrgForActorIn(tx, args.organizationId, args.actorId))) return "forbidden" const inserted = await tx<{ user_id: string }[]>` INSERT INTO organization_members (organization_id, user_id, role, joined_at) VALUES (${args.organizationId}, ${args.userId}, ${args.role}, ${args.now}) @@ -1250,6 +1256,9 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit args: CreateOrganizationInviteArgs, ): Promise { return sql.begin(async (tx): Promise => { + if (!(await lockOrgForActorIn(tx, args.organizationId, args.invitedBy))) { + return { kind: "forbidden" } + } await expireInvitesInTx(tx, args.organizationId, args.now) const inserted = await tx<{ id: string }[]>` INSERT INTO organization_invites ( @@ -1523,9 +1532,10 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit role: OrganizationInviteRole status: OrganizationInviteStatus expires_at: Date + invited_by: string | null }[] >` - SELECT id, email, user_id, role, status, expires_at FROM organization_invites + SELECT id, email, user_id, role, status, expires_at, invited_by FROM organization_invites WHERE id = ${hit.id} LIMIT 1 FOR UPDATE ` @@ -1558,6 +1568,23 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit await tx`UPDATE organization_invites SET status = 'expired' WHERE id = ${invite.id}` return { kind: "expired" } } + const revocation = inviterRevocationReason( + await inviterStandingIn(tx, orgRow.id, invite.invited_by), + ) + if (revocation !== null) { + await tx` + UPDATE organization_invites + SET status = 'revoked', revoked_at = ${args.now} + WHERE id = ${invite.id} + ` + await writeHostAudit(tx, { + actorId: args.userId, + action: "org.invite_revoked", + target: `organization:${orgRow.id}`, + meta: { inviteId: invite.id, reason: revocation }, + }) + return { kind: "invalid" } + } if (orgRow.suspended) return { kind: "suspended" } const inserted = await tx<{ user_id: string }[]>` INSERT INTO organization_members (organization_id, user_id, role, joined_at) @@ -1759,22 +1786,59 @@ async function revokeInvitesByInviterInTx( reason: InviterRevocationReason }, ): Promise { - const revoked = await tx<{ id: string }[]>` - UPDATE organization_invites - SET status = 'revoked', revoked_at = now() - WHERE organization_id = ${args.organizationId} - AND invited_by = ${args.inviterId} - AND status = 'pending' - RETURNING id + await tx` + WITH revoked AS ( + UPDATE organization_invites + SET status = 'revoked', revoked_at = now() + WHERE organization_id = ${args.organizationId} + AND invited_by = ${args.inviterId} + AND status = 'pending' + RETURNING id + ) + INSERT INTO audit_log (actor_id, action, target, meta) + SELECT ${args.actorId}::uuid, 'org.invite_revoked', ${`organization:${args.organizationId}`}, + jsonb_build_object('inviteId', id, 'reason', ${args.reason}::text) + FROM revoked ` - for (const row of revoked) { - await writeHostAudit(tx, { - actorId: args.actorId, - action: "org.invite_revoked", - target: `organization:${args.organizationId}`, - meta: { inviteId: row.id, reason: args.reason }, - }) - } +} + +/** + * Every role change and removal takes the organizations row lock first, so re-reading the actor's + * role under it sees the latest committed seat. FOR SHARE also holds off an account erasure, which + * deletes seats without the organization lock. + */ +async function lockOrgForActorIn( + tx: Queryable, + organizationId: string, + actorId: string, +): Promise { + await tx` + SELECT id FROM organizations WHERE id = ${organizationId} LIMIT 1 FOR UPDATE + ` + const rows = await tx<{ role: OrganizationMemberRole }[]>` + SELECT role FROM organization_members + WHERE organization_id = ${organizationId} AND user_id = ${actorId} + LIMIT 1 + FOR SHARE + ` + return canManageOrgMembers(rows[0]?.role ?? null) +} + +async function inviterStandingIn( + tx: Queryable, + organizationId: string, + inviterId: string | null, +): Promise { + if (inviterId === null) return null + const rows = await tx<{ role: OrganizationMemberRole | null; deleted: boolean }[]>` + SELECT m.role, (u.deleted_at IS NOT NULL) AS deleted + FROM users u + LEFT JOIN organization_members m ON m.organization_id = ${organizationId} AND m.user_id = u.id + WHERE u.id = ${inviterId} + LIMIT 1 + ` + const row = rows[0] + return row === undefined ? null : { role: row.role, deleted: row.deleted } } async function expireInvitesInTx(tag: Queryable, organizationId: string, now: Date): Promise { diff --git a/services/api/src/services/host/organization-repository.memory.ts b/services/api/src/services/host/organization-repository.memory.ts index 3a403ed3..42e9cb1e 100644 --- a/services/api/src/services/host/organization-repository.memory.ts +++ b/services/api/src/services/host/organization-repository.memory.ts @@ -45,8 +45,13 @@ import type { UpdateOrganizationOutcome, UpdateOrganizationPatch, InviterRevocationReason, + InviterStanding, +} from "./organization-repository.types.js" +import { + canManageOrgMembers, + inviterRevocationReason, + roleChangeWithdrawsInvites, } from "./organization-repository.types.js" -import { roleChangeWithdrawsInvites } from "./organization-repository.types.js" interface StoredOrganization { id: string @@ -442,6 +447,9 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { actorId: string now: Date }): Promise { + if (!this.managesMembers(args.organizationId, args.actorId)) { + return Promise.resolve("forbidden") + } const existing = this.members.find( (m) => m.organizationId === args.organizationId && m.userId === args.userId, ) @@ -460,6 +468,26 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { return Promise.resolve("added") } + private managesMembers(organizationId: string, userId: string): boolean { + const seat = this.members.find( + (m) => m.organizationId === organizationId && m.userId === userId, + ) + return canManageOrgMembers(seat?.role ?? null) + } + + private inviterStanding( + organizationId: string, + inviterId: string | null, + ): InviterStanding | null { + if (inviterId === null) return null + const inviter = this.users.get(inviterId) + if (inviter === undefined) return null + const seat = this.members.find( + (m) => m.organizationId === organizationId && m.userId === inviterId, + ) + return { role: seat?.role ?? null, deleted: inviter.deletedAt !== null } + } + private countAdminSeats(organizationId: string): number { return this.members.filter( (m) => m.organizationId === organizationId && (m.role === "owner" || m.role === "admin"), @@ -918,6 +946,9 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { } createInviteTx(args: CreateOrganizationInviteArgs): Promise { + if (!this.managesMembers(args.organizationId, args.invitedBy)) { + return Promise.resolve({ kind: "forbidden" }) + } this.expireInvites(args.organizationId, args.now) const email = args.email.toLowerCase() const open = this.invites.find( @@ -1095,6 +1126,18 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { if (byToken ? invite.email !== null && !addressed : !addressed) { return Promise.resolve({ kind: "wrong_recipient" }) } + const revocation = inviterRevocationReason(this.inviterStanding(org.id, invite.invitedBy)) + if (revocation !== null) { + invite.status = "revoked" + invite.revokedAt = args.now + this.audits.push({ + actorId: args.userId, + action: "org.invite_revoked", + target: `organization:${org.id}`, + meta: { inviteId: invite.id, reason: revocation }, + }) + return Promise.resolve({ kind: "invalid" }) + } if (org.suspendedAt !== null) return Promise.resolve({ kind: "suspended" }) const existing = this.members.find( (m) => m.organizationId === org.id && m.userId === args.userId, diff --git a/services/api/src/services/host/organization-repository.types.ts b/services/api/src/services/host/organization-repository.types.ts index 2bcfb135..d8b275b9 100644 --- a/services/api/src/services/host/organization-repository.types.ts +++ b/services/api/src/services/host/organization-repository.types.ts @@ -9,19 +9,41 @@ import type { import { can } from "@civfix/shared/host" import type { CleanupOrganizationView, CleanupPersonView } from "../cleanup-repository.types.js" -export type InviterRevocationReason = "inviter_removed" | "inviter_demoted" +export type InviterRevocationReason = "inviter_removed" | "inviter_demoted" | "account_deleted" + +export function canManageOrgMembers(role: OrganizationMemberRole | null): boolean { + return role !== null && can({ eventRole: null, orgRole: role }, "manage_org_members") +} /** - * An accepted invite seats `invite.role` without re-checking the inviter, so a role change that takes - * away the power to invite must also withdraw the invites already sent with it. + * A role change that takes away the power to invite withdraws the invites already sent with it, so + * they stop showing up as open in the inviter's org and the invitee's inbox. */ export function roleChangeWithdrawsInvites( from: OrganizationMemberRole, to: OrganizationMemberRole, ): boolean { - const canInvite = (role: OrganizationMemberRole): boolean => - can({ eventRole: null, orgRole: role }, "manage_org_members") - return canInvite(from) && !canInvite(to) + return canManageOrgMembers(from) && !canManageOrgMembers(to) +} + +/** The inviter as the organization knows them at accept time; `null` when the account is gone. */ +export interface InviterStanding { + role: OrganizationMemberRole | null + deleted: boolean +} + +/** + * An invite seats the role it names on the inviter's authority, so accepting re-checks that authority: + * an invite that outlived it (sent concurrently with the demotion, or before revocation on demotion + * existed) must not seat anyone. `null` means the inviter still holds it. + */ +export function inviterRevocationReason( + inviter: InviterStanding | null, +): InviterRevocationReason | null { + if (inviter === null) return "inviter_removed" + if (inviter.deleted) return "account_deleted" + if (inviter.role === null) return "inviter_removed" + return canManageOrgMembers(inviter.role) ? null : "inviter_demoted" } export interface OrganizationBaseRecord { @@ -258,9 +280,11 @@ export interface CreateOrganizationInviteArgs { } /** `already_invited` carries the open invite so the caller can answer with it (idempotent re-invite). */ +/** `forbidden`: the inviter no longer holds the power to invite once the organization is locked. */ export type CreateOrganizationInviteOutcome = | { kind: "created"; invite: OrganizationInviteRecord } | { kind: "already_invited"; invite: OrganizationInviteRecord } + | { kind: "forbidden" } export type RevokeOrganizationInviteOutcome = "revoked" | "not_found" @@ -289,7 +313,11 @@ export type AcceptOrganizationInviteOutcome = | { kind: "wrong_recipient" } | { kind: "suspended" } -export type AddOrganizationMemberOutcome = "added" | "already_member" | "user_not_found" +export type AddOrganizationMemberOutcome = + | "added" + | "already_member" + | "user_not_found" + | "forbidden" export type RemoveOrganizationMemberOutcome = "removed" | "not_member" | "owner" | "last_admin" diff --git a/services/api/src/services/host/organization-service.ts b/services/api/src/services/host/organization-service.ts index c4851fd3..ce7344e7 100644 --- a/services/api/src/services/host/organization-service.ts +++ b/services/api/src/services/host/organization-service.ts @@ -47,7 +47,6 @@ import type { OrgVerificationRecord, UpdateOrganizationPatch, } from "./organization-repository.types.js" -import { webBaseUrlOf } from "../../lib/base-url.js" export const ORGS_CREATED_PER_DAY = 5 const ORG_CREATE_WINDOW_SEC = 24 * 60 * 60 @@ -114,7 +113,7 @@ export interface OrganizationServiceDeps { affiliations?: AffiliationLoader mailer?: OrganizationMailer notifier?: OrganizationNotifier - webOrigin?: string + webOrigin: string logger?: { error: (obj: unknown, msg?: string) => void warn?: (obj: unknown, msg?: string) => void @@ -398,7 +397,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza const now = deps.now ?? (() => new Date()) const newId = deps.newId ?? (() => randomUUID()) const newToken = deps.newToken ?? (() => generateToken(ORG_INVITE_TOKEN_BYTES)) - const webBase = () => (deps.webOrigin ?? webBaseUrlOf({})).replace(/\/+$/, "") + const webBase = () => deps.webOrigin.replace(/\/+$/, "") async function logoUrlOf(record: OrganizationBaseRecord): Promise { if (record.logoKey === null || deps.presignLogo === undefined) return null @@ -842,6 +841,9 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza expiresAt: new Date(at.getTime() + ORG_INVITE_TTL_MS), now: at, }) + if (outcome.kind === "forbidden") { + throw AppError.forbidden(hostForbiddenCopy("manage_org_members")) + } if (outcome.kind === "created") { await sendInviteEmail(outcome.invite.email ?? email, org, actorId, input.role, token) if (userId !== null) await notifyInvitedUser(userId, org, input.role) @@ -861,6 +863,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza actorId, now: now(), }) + if (outcome === "forbidden") throw AppError.forbidden(hostForbiddenCopy("manage_org_members")) if (outcome === "added") await notifyAddedMember(userId, org, input.role) const member = await deps.repo.findMember(id, userId) return { diff --git a/services/api/test/integration/organization-repository-security-pg.test.ts b/services/api/test/integration/organization-repository-security-pg.test.ts index 618008d4..ce710acb 100644 --- a/services/api/test/integration/organization-repository-security-pg.test.ts +++ b/services/api/test/integration/organization-repository-security-pg.test.ts @@ -51,12 +51,16 @@ describe.skipIf(!pg)("organization invite revocation (integration)", () => { ` } - async function invite(organizationId: string, invitedBy: string): Promise { + async function invite( + organizationId: string, + invitedBy: string, + email = `${randomUUID().slice(0, 8)}@example.test`, + ): Promise { const now = new Date() const outcome = await orgs.createInviteTx({ inviteId: randomUUID(), organizationId, - email: `${randomUUID().slice(0, 8)}@example.test`, + email, userId: null, role: "admin", tokenHash: randomUUID().replace(/-/g, ""), @@ -64,6 +68,7 @@ describe.skipIf(!pg)("organization invite revocation (integration)", () => { expiresAt: new Date(now.getTime() + INVITE_TTL_MS), now, }) + if (outcome.kind === "forbidden") throw new Error("inviter unexpectedly refused") return outcome.invite.id } @@ -132,4 +137,63 @@ describe.skipIf(!pg)("organization invite revocation (integration)", () => { ).resolves.toBe("last_admin") expect((await statusOf(kept)).status).toBe("pending") }) + + it("refuses to seat an invite whose inviter was demoted without it being withdrawn", async () => { + const owner = await newUser("Owner") + const admin = await newUser("Admin") + const orgId = await newOrg(owner) + await seat(orgId, admin, "admin") + const email = `${randomUUID().slice(0, 8)}@example.test` + const inviteId = await invite(orgId, admin, email) + await h.sql` + UPDATE organization_members SET role = 'member' + WHERE organization_id = ${orgId} AND user_id = ${admin} + ` + const [invitee] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name, handle, email, email_verified) + VALUES ('Invitee', ${testHandle()}, ${email}, true) + RETURNING id + ` + const inviteeId = (invitee as { id: string }).id + + await expect( + orgs.acceptInviteTx({ by: { inviteId }, userId: inviteeId, now: new Date() }), + ).resolves.toEqual({ kind: "invalid" }) + + expect(await statusOf(inviteId)).toEqual({ status: "revoked", revoked: true }) + const seated = await h.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM organization_members + WHERE organization_id = ${orgId} AND user_id = ${inviteeId} + ` + expect(seated[0]?.n).toBe(0) + const audits = await h.sql<{ n: number }[]>` + SELECT count(*)::int AS n FROM audit_log + WHERE action = 'org.invite_revoked' + AND meta->>'inviteId' = ${inviteId} + AND meta->>'reason' = 'inviter_demoted' + ` + expect(audits[0]?.n).toBe(1) + }) + + it("refuses an invite from a member who is no longer an admin", async () => { + const owner = await newUser("Owner") + const member = await newUser("Member") + const orgId = await newOrg(owner) + await seat(orgId, member, "member") + const now = new Date() + + await expect( + orgs.createInviteTx({ + inviteId: randomUUID(), + organizationId: orgId, + email: `${randomUUID().slice(0, 8)}@example.test`, + userId: null, + role: "admin", + tokenHash: randomUUID().replace(/-/g, ""), + invitedBy: member, + expiresAt: new Date(now.getTime() + INVITE_TTL_MS), + now, + }), + ).resolves.toEqual({ kind: "forbidden" }) + }) }) diff --git a/services/api/test/unit/host/organization-service-security.test.ts b/services/api/test/unit/host/organization-service-security.test.ts index c838a8bd..832e0dc9 100644 --- a/services/api/test/unit/host/organization-service-security.test.ts +++ b/services/api/test/unit/host/organization-service-security.test.ts @@ -283,3 +283,289 @@ describe("inviter-revocation SQL", () => { expect(indexOf(promoted.statements, REVOKE)).toBe(-1) }) }) + +describe("an invite whose inviter lost the power to invite", () => { + function seatOf(orgId: string, userId: string) { + return repo.members.find((m) => m.organizationId === orgId && m.userId === userId) + } + + it("does not seat anyone when the inviter was demoted without the invite being withdrawn", async () => { + const id = await orgWithAdmin() + const inviteId = await inviteAs(id, ADMIN, SOCK_EMAIL) + const adminSeat = seatOf(id, ADMIN) + if (adminSeat === undefined) throw new Error("expected the admin seat") + adminSeat.role = "member" + + await expect(service.acceptMyInvite(SOCK, inviteId)).rejects.toMatchObject({ + code: "NOT_FOUND", + }) + + expect(seatOf(id, SOCK)).toBeUndefined() + expect(statusOf(inviteId)).toBe("revoked") + expect(repo.audits).toContainEqual({ + actorId: SOCK, + action: "org.invite_revoked", + target: `organization:${id}`, + meta: { inviteId, reason: "inviter_demoted" }, + }) + }) + + it("names the reason: a removed inviter and a closed inviter account", async () => { + const id = await orgWithAdmin() + await service.setMemberRole(id, OWNER, MEMBER, "admin") + const byRemoved = await inviteAs(id, ADMIN, SOCK_EMAIL) + const byDeleted = await inviteAs(id, MEMBER, "other@x.org") + repo.seedUser({ id: OPERATOR, displayName: "Other", email: "other@x.org" }) + repo.members.splice( + repo.members.findIndex((m) => m.organizationId === id && m.userId === ADMIN), + 1, + ) + const deleted = repo.users.get(MEMBER) + if (deleted === undefined) throw new Error("expected the member account") + deleted.deletedAt = clock + + await expect(service.acceptMyInvite(SOCK, byRemoved)).rejects.toMatchObject({ + code: "NOT_FOUND", + }) + await expect(service.acceptMyInvite(OPERATOR, byDeleted)).rejects.toMatchObject({ + code: "NOT_FOUND", + }) + + const reasons = repo.audits + .filter((a) => a.action === "org.invite_revoked") + .map((a) => [a.meta?.inviteId, a.meta?.reason]) + expect(reasons).toEqual([ + [byRemoved, "inviter_removed"], + [byDeleted, "account_deleted"], + ]) + expect(seatOf(id, SOCK)).toBeUndefined() + expect(seatOf(id, OPERATOR)).toBeUndefined() + }) + + it("still seats the invitee when the inviter keeps the power to invite", async () => { + const id = await orgWithAdmin() + const inviteId = await inviteAs(id, ADMIN, SOCK_EMAIL) + + await service.acceptMyInvite(SOCK, inviteId) + + expect(seatOf(id, SOCK)?.role).toBe("admin") + expect(statusOf(inviteId)).toBe("accepted") + }) + + it("refuses to create an invite or seat a handle once the actor lost the power in between", async () => { + const id = await orgWithAdmin() + const findOrganizationById = repo.findOrganizationById.bind(repo) + repo.findOrganizationById = async (orgId, viewerId) => { + const record = await findOrganizationById(orgId, viewerId) + return record === null ? null : { ...record, myRole: "admin" } + } + + await expect(inviteAs(id, MEMBER, SOCK_EMAIL)).rejects.toMatchObject({ + code: "FORBIDDEN", + }) + await expect( + service.inviteMember(id, MEMBER, { + identifierKind: "handle", + identifier: "sock", + role: "admin", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }) + + expect(repo.invites.filter((i) => i.organizationId === id)).toEqual([]) + expect(seatOf(id, SOCK)).toBeUndefined() + }) +}) + +describe("invite SQL under the organization lock", () => { + const INVITE = "88888888-8888-4888-8888-888888888888" + const ORG_LOCK = /FROM organizations\s+WHERE id = \?.*FOR UPDATE/s + const ACTOR_ROLE = /SELECT role FROM organization_members/ + const FUTURE = new Date("2026-09-20T12:00:00.000Z") + const AUDIT: SqlHandler = { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] } + + function indexOf(statements: { sql: string }[], pattern: RegExp): number { + return statements.findIndex((s) => pattern.test(s.sql)) + } + + const inviteRow = { + id: INVITE, + organization_id: ORG, + email: SOCK_EMAIL, + role: "admin", + status: "pending", + created_at: clock, + expires_at: FUTURE, + user_id: null, + user_name: null, + user_handle: null, + user_bio: null, + user_avatar_url: null, + invited_by_id: ADMIN, + invited_by_name: "Adam Admin", + invited_by_handle: "adam", + invited_by_bio: null, + invited_by_avatar_url: null, + } + + function createArgs() { + return { + inviteId: INVITE, + organizationId: ORG, + email: SOCK_EMAIL, + userId: null, + role: "admin" as const, + tokenHash: "hash", + invitedBy: ADMIN, + expiresAt: FUTURE, + now: clock, + } + } + + it("creates an invite only after locking the org and re-reading the inviter's role", async () => { + const fake = makeFakeSql([ + { match: ACTOR_ROLE, rows: [{ role: "admin" }] }, + { match: /INSERT INTO organization_invites/, rows: [{ id: INVITE }] }, + { match: /FROM organization_invites i/, rows: [{ ...inviteRow, created_at: clock }] }, + AUDIT, + ]) + + const outcome = await makeDrizzleOrganizationRepository( + fake.sql as unknown as Sql, + ).createInviteTx(createArgs()) + + expect(outcome.kind).toBe("created") + const lock = indexOf(fake.statements, ORG_LOCK) + const role = indexOf(fake.statements, ACTOR_ROLE) + expect(lock).toBeGreaterThanOrEqual(0) + expect(role).toBeGreaterThan(lock) + expect(indexOf(fake.statements, /INSERT INTO organization_invites/)).toBeGreaterThan(role) + expect(fake.statements[role]?.values).toEqual(expect.arrayContaining([ORG, ADMIN])) + }) + + it("refuses an invite from an actor who is no longer an admin inside the transaction", async () => { + const fake = makeFakeSql([{ match: ACTOR_ROLE, rows: [{ role: "member" }] }]) + + const outcome = await makeDrizzleOrganizationRepository( + fake.sql as unknown as Sql, + ).createInviteTx(createArgs()) + + expect(outcome).toEqual({ kind: "forbidden" }) + expect(indexOf(fake.statements, /INSERT INTO organization_invites/)).toBe(-1) + }) + + it("seats a handle only after locking the org and re-reading the actor's role", async () => { + const allowed = makeFakeSql([ + { match: ACTOR_ROLE, rows: [{ role: "owner" }] }, + { match: /INSERT INTO organization_members/, rows: [{ user_id: SOCK }] }, + AUDIT, + ]) + const args = { organizationId: ORG, userId: SOCK, role: "admin" as const, actorId: OWNER } + + await expect( + makeDrizzleOrganizationRepository(allowed.sql as unknown as Sql).addMemberTx({ + ...args, + now: clock, + }), + ).resolves.toBe("added") + const lock = indexOf(allowed.statements, ORG_LOCK) + expect(lock).toBeGreaterThanOrEqual(0) + expect(indexOf(allowed.statements, ACTOR_ROLE)).toBeGreaterThan(lock) + + const refused = makeFakeSql([{ match: ACTOR_ROLE, rows: [] }]) + await expect( + makeDrizzleOrganizationRepository(refused.sql as unknown as Sql).addMemberTx({ + ...args, + now: clock, + }), + ).resolves.toBe("forbidden") + expect(indexOf(refused.statements, /INSERT INTO organization_members/)).toBe(-1) + }) + + function acceptHandlers(inviter: { role: string | null; deleted: boolean }[]): SqlHandler[] { + return [ + { match: /LEFT JOIN organization_members m/, rows: inviter }, + { + match: /SELECT id, organization_id FROM organization_invites/, + rows: [{ id: INVITE, organization_id: ORG }], + }, + { match: /FROM organizations/, rows: [{ id: ORG, suspended: false }] }, + { + match: /FROM organization_invites\s+WHERE id = \?\s+LIMIT 1 FOR UPDATE/, + rows: [ + { + id: INVITE, + email: null, + user_id: SOCK, + role: "admin", + status: "pending", + expires_at: FUTURE, + invited_by: ADMIN, + }, + ], + }, + AUDIT, + ] + } + + it("answers invalid and revokes the invite when the inviter was demoted", async () => { + const fake = makeFakeSql(acceptHandlers([{ role: "member", deleted: false }])) + + const outcome = await makeDrizzleOrganizationRepository( + fake.sql as unknown as Sql, + ).acceptInviteTx({ by: { inviteId: INVITE }, userId: SOCK, now: clock }) + + expect(outcome).toEqual({ kind: "invalid" }) + expect(indexOf(fake.statements, /INSERT INTO organization_members/)).toBe(-1) + const revoke = fake.statements.find((s) => + /UPDATE organization_invites\s+SET status = 'revoked'/.test(s.sql), + ) + expect(revoke?.values).toEqual(expect.arrayContaining([INVITE])) + const audit = fake.statements.find((s) => /INSERT INTO audit_log/.test(s.sql)) + expect(audit?.values).toContainEqual({ inviteId: INVITE, reason: "inviter_demoted" }) + expect(indexOf(fake.statements, /LEFT JOIN organization_members m/)).toBeGreaterThan( + indexOf(fake.statements, ORG_LOCK), + ) + }) + + it("seats the invitee when the inviter still holds an admin seat", async () => { + const fake = makeFakeSql([ + { match: /INSERT INTO organization_members/, rows: [{ user_id: SOCK }] }, + ...acceptHandlers([{ role: "admin", deleted: false }]), + ]) + + const outcome = await makeDrizzleOrganizationRepository( + fake.sql as unknown as Sql, + ).acceptInviteTx({ by: { inviteId: INVITE }, userId: SOCK, now: clock }) + + expect(outcome).toMatchObject({ kind: "accepted", role: "admin", alreadyMember: false }) + }) +}) + +describe("inviter revocation writes its audit rows in the same statement", () => { + it("revokes and audits every pending invite of the inviter with one statement", async () => { + const fake = makeFakeSql([ + { match: /SELECT role FROM organization_members/, rows: [{ role: "admin" }] }, + { match: /count\(\*\)::int AS n\s+FROM organization_members/, rows: [{ n: 2 }] }, + { match: /DELETE FROM organization_members/, rows: [{ role: "admin" }] }, + { match: /UPDATE organization_invites/, rows: [{ id: "invite-1" }, { id: "invite-2" }] }, + { match: /INSERT INTO audit_log/, rows: [{ id: "audit-1" }] }, + ]) + + await makeDrizzleOrganizationRepository(fake.sql as unknown as Sql).removeMemberTx({ + organizationId: ORG, + userId: ADMIN, + actorId: OWNER, + }) + + const revoking = fake.statements.filter((s) => /UPDATE organization_invites/.test(s.sql)) + expect(revoking).toHaveLength(1) + const stmt = revoking[0]! + expect(stmt.sql).toMatch(/INSERT INTO audit_log/) + expect(stmt.sql).toMatch(/'org\.invite_revoked'/) + expect(stmt.values).toEqual(expect.arrayContaining([ORG, ADMIN, OWNER, "inviter_removed"])) + const perInviteAudits = fake.statements.filter( + (s) => /INSERT INTO audit_log/.test(s.sql) && s.values.includes("org.invite_revoked"), + ) + expect(perInviteAudits).toEqual([]) + }) +}) From 18b27ff5869a3391369a02d7214f5186014f51ec Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:29:31 +0000 Subject: [PATCH 35/45] one ban path cancels every registration, seat, waitlist place and slot claim under the event lock --- .../services/cleanup-repository.drizzle.ts | 26 +-- .../host/registration-repository.drizzle.ts | 149 ++++++++++--- .../host/registration-repository.memory.ts | 101 +++++++-- .../api/src/services/host/registration-sql.ts | 9 + services/api/test/helpers/cleanups.ts | 9 +- ...egistration-repository-security-pg.test.ts | 141 ++++++++++++- .../unit/host/event-ban-consistency.test.ts | 198 ++++++++++++++++++ .../api/test/unit/host/signup-seats.test.ts | 32 +++ 8 files changed, 593 insertions(+), 72 deletions(-) create mode 100644 services/api/test/unit/host/event-ban-consistency.test.ts diff --git a/services/api/src/services/cleanup-repository.drizzle.ts b/services/api/src/services/cleanup-repository.drizzle.ts index 5a572d87..e7e3d752 100644 --- a/services/api/src/services/cleanup-repository.drizzle.ts +++ b/services/api/src/services/cleanup-repository.drizzle.ts @@ -22,7 +22,7 @@ import { publicServedKeyExpr } from "./media-served-key.js" import { MEDIA_CLAIM_WINDOW_SEC } from "./host/event-media.js" import { mediaBoundElsewhere, mediaBoundToCleanup } from "./media-bindings.js" import { isUniqueViolationOn } from "./host/registration-sql.js" -import { cancelWaitlistEntriesIn } from "./host/registration-repository.drizzle.js" +import { applyBanIn } from "./host/registration-repository.drizzle.js" import { deterministicUuid } from "./deterministic-uuid.js" import type { AttendeeView, @@ -1004,28 +1004,8 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { RETURNING user_id ` if (deleted.length > 0) { - await cancelSignupRegistrationIn(tx, { - cleanupId, - userId, - actorId, - now: cleanup.now, - }) - await tx` - INSERT INTO cleanup_bans (cleanup_id, user_id, banned_by_user_id) - VALUES (${cleanupId}, ${userId}, ${actorId}) - ON CONFLICT (cleanup_id, user_id) DO NOTHING - ` - await tx` - DELETE FROM cleanup_slot_claims - WHERE cleanup_id = ${cleanupId} AND user_id = ${userId} - ` - const cancelled = await cancelWaitlistEntriesIn(tx, { - cleanupId, - ticketTypeId: null, - subject: { kind: "user", userId }, - now: cleanup.now, - }) - releasedWaitlistTicketTypeIds = cancelled.releasedTicketTypeIds + const ban = await applyBanIn(tx, { cleanupId, userId, actorId, now: cleanup.now }) + releasedWaitlistTicketTypeIds = ban.releasedTicketTypeIds } const counted = await tx<{ count: number }[]>` SELECT diff --git a/services/api/src/services/host/registration-repository.drizzle.ts b/services/api/src/services/host/registration-repository.drizzle.ts index 6f75e3ab..3cda4506 100644 --- a/services/api/src/services/host/registration-repository.drizzle.ts +++ b/services/api/src/services/host/registration-repository.drizzle.ts @@ -14,6 +14,7 @@ import { cleanupStatusExpr } from "../cleanup-sql.js" import { mediaBoundElsewhere, mediaBoundToCleanup } from "../media-bindings.js" import { likeContains } from "../admin/like.js" import { MEDIA_CLAIM_WINDOW_SEC } from "./event-media.js" +import { isEventPubliclyVisible } from "./authz.js" import { publicServedKeyExpr } from "../media-served-key.js" import { deterministicUuid } from "../deterministic-uuid.js" import { @@ -35,6 +36,7 @@ import { toTicketTypeRecord, toWaitlistRecord, waitlistColumns, + waitlistEntryNotBanned, waitlistJoins, type AnswerRowSelect, type PageRowSelect, @@ -111,14 +113,6 @@ async function isBannedIn(tag: Queryable, cleanupId: string, userId: string): Pr return banned.length > 0 } -/** Backstop for a waiting row whose user was banned by a path that did not cancel it. */ -function waitlistEntryNotBanned(tag: Queryable) { - return tag`NOT EXISTS ( - SELECT 1 FROM cleanup_bans b - WHERE b.cleanup_id = w.cleanup_id AND b.user_id = w.user_id - )` -} - export async function cancelWaitlistEntriesIn( tag: Queryable, args: { @@ -166,6 +160,86 @@ export async function cancelWaitlistEntriesIn( } } +export interface AppliedBan { + cancelledRegistrations: { id: string; ticketTypeId: string | null }[] + /** Ticket types that got seats back, from cancelled registrations or released waitlist offers. */ + releasedTicketTypeIds: string[] +} + +/** + * The one ban path behind removing an attendee from the event and removing them from the roster. + * The event row lock comes first: every registration, join and waitlist path reads the ban under + * FOR SHARE on the same row, so one racing the ban either commits before it (and is undone here) or + * reads the ban. Waitlist rows are cancelled before registrations, the cleanup_waitlist -> + * cleanup_ticket_types order the expiry sweep and leaveWaitlist take, so the two cannot deadlock. + */ +export async function applyBanIn( + tx: Queryable, + args: { cleanupId: string; userId: string; actorId: string; now: Date }, +): Promise { + await tx` + SELECT id FROM cleanups WHERE id = ${args.cleanupId} LIMIT 1 FOR NO KEY UPDATE + ` + await tx` + INSERT INTO cleanup_bans (cleanup_id, user_id, banned_by_user_id) + VALUES (${args.cleanupId}, ${args.userId}, ${args.actorId}) + ON CONFLICT (cleanup_id, user_id) DO NOTHING + ` + await tx` + DELETE FROM cleanup_members + WHERE cleanup_id = ${args.cleanupId} AND user_id = ${args.userId} AND role = 'member' + ` + const waitlist = await cancelWaitlistEntriesIn(tx, { + cleanupId: args.cleanupId, + ticketTypeId: null, + subject: { kind: "user", userId: args.userId }, + now: args.now, + }) + const cancelled = await tx<{ id: string; ticket_type_id: string | null }[]>` + WITH cancelled AS ( + UPDATE cleanup_registrations + SET status = 'cancelled', cancelled_at = ${args.now}, cancelled_by = ${args.actorId} + WHERE cleanup_id = ${args.cleanupId} + AND user_id = ${args.userId} + AND status = 'registered' + RETURNING id, ticket_type_id, party_size + ), seats AS ( + UPDATE cleanup_registration_seats s + SET status = 'cancelled' + FROM cancelled c + WHERE s.registration_id = c.id AND s.status = 'active' + RETURNING s.id + ), held AS ( + SELECT ticket_type_id, sum(party_size)::int AS seats + FROM cancelled + WHERE ticket_type_id IS NOT NULL + GROUP BY ticket_type_id + ), released AS ( + UPDATE cleanup_ticket_types t + SET reserved_seats = GREATEST(t.reserved_seats - h.seats, 0), + updated_at = ${args.now} + FROM held h + WHERE t.id = h.ticket_type_id + RETURNING t.id + ) + SELECT id, ticket_type_id FROM cancelled + ` + await tx` + DELETE FROM cleanup_slot_claims + WHERE cleanup_id = ${args.cleanupId} AND user_id = ${args.userId} + ` + const registrationTypes = cancelled + .map((row) => row.ticket_type_id) + .filter((id): id is string => id !== null) + return { + cancelledRegistrations: cancelled.map((row) => ({ + id: row.id, + ticketTypeId: row.ticket_type_id, + })), + releasedTicketTypeIds: [...new Set([...waitlist.releasedTicketTypeIds, ...registrationTypes])], + } +} + class RegistrationRefusal extends Error { readonly outcome: RegisterTxOutcome @@ -236,7 +310,9 @@ export function guestSelfRegistrationOnPrivateEvent( args: Pick, visibility: EventVisibility, ): boolean { - return args.subject.kind === "guest" && args.source === "self" && visibility === "private" + return ( + args.subject.kind === "guest" && args.source === "self" && !isEventPubliclyVisible(visibility) + ) } function withinSalesWindow(now: Date, opensAt: Date | null, closesAt: Date | null): boolean { @@ -574,6 +650,12 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio args.status === null ? tag`AND w.status IN ('waiting', 'offered')` : tag`AND w.status = ${args.status}` + // A banned user can never be seated, so their open rows are not part of the host's queue; closed + // rows stay listed as history. + const bannedFilter = + args.status === null || args.status === "waiting" || args.status === "offered" + ? tag`AND ${waitlistEntryNotBanned(tag)}` + : tag`` const cursor = parseTimeCursor(args.cursor, { direction: "asc" }) const cursorFilter = cursor === null ? tag`` : tag`AND (w.created_at, w.id) > (${cursor.at}, ${cursor.id}::uuid)` @@ -585,6 +667,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio ${typeFilter} ${statusFilter} ${cursorFilter} + ${bannedFilter} ORDER BY w.created_at ASC, w.id ASC LIMIT ${args.limit + 1} ` @@ -1604,29 +1687,30 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio ` if (target.length === 0) return { kind: "not_found", releasedWaitlistTicketTypeIds: [] } const bannedUserId = args.ban ? (target[0]?.user_id ?? null) : null - let releasedWaitlistTicketTypeIds: string[] = [] - if (bannedUserId !== null) { - await tx` - INSERT INTO cleanup_bans (cleanup_id, user_id, banned_by_user_id) - VALUES (${args.cleanupId}, ${bannedUserId}, ${args.actorId}) - ON CONFLICT (cleanup_id, user_id) DO NOTHING - ` - await tx` - DELETE FROM cleanup_members - WHERE cleanup_id = ${args.cleanupId} AND user_id = ${bannedUserId} AND role = 'member' - ` - // Waitlist rows before the registration: the same cleanup_waitlist -> cleanup_ticket_types - // order the expiry sweep and leaveWaitlist take, so the two cannot deadlock. - const cancelled = await cancelWaitlistEntriesIn(tx, { - cleanupId: args.cleanupId, - ticketTypeId: null, - subject: { kind: "user", userId: bannedUserId }, - now: args.now, - }) - releasedWaitlistTicketTypeIds = cancelled.releasedTicketTypeIds + if (bannedUserId === null) { + const outcome = await cancelRegistrationIn(tx, args) + return { ...outcome, releasedWaitlistTicketTypeIds: [] } + } + const ban = await applyBanIn(tx, { + cleanupId: args.cleanupId, + userId: bannedUserId, + actorId: args.actorId, + now: args.now, + }) + const releasedWaitlistTicketTypeIds = ban.releasedTicketTypeIds + const removed = ban.cancelledRegistrations.find((r) => r.id === args.registrationId) + if (removed === undefined) { + const outcome = await cancelRegistrationIn(tx, args) + return { ...outcome, releasedWaitlistTicketTypeIds } + } + const registration = await loadRegistrationById(tx, args.cleanupId, removed.id) + if (registration === null) return { kind: "not_found", releasedWaitlistTicketTypeIds } + return { + kind: "cancelled", + registration, + ticketTypeId: removed.ticketTypeId, + releasedWaitlistTicketTypeIds, } - const outcome = await cancelRegistrationIn(tx, args) - return { ...outcome, releasedWaitlistTicketTypeIds } }) }, @@ -2272,6 +2356,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio COALESCE(( SELECT sum(w.party_size)::int FROM cleanup_waitlist w WHERE w.cleanup_id = ${cleanupId} AND w.status IN ('waiting', 'offered') + AND ${waitlistEntryNotBanned(sql)} ), 0) AS waitlisted, ( SELECT CASE @@ -2306,6 +2391,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio COALESCE(( SELECT sum(w.party_size)::int FROM cleanup_waitlist w WHERE w.ticket_type_id = t.id AND w.status IN ('waiting', 'offered') + AND ${waitlistEntryNotBanned(sql)} ), 0) AS waitlisted, t.capacity FROM cleanup_ticket_types t @@ -2566,6 +2652,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio LEFT JOIN LATERAL ( SELECT sum(w.party_size)::int AS n FROM cleanup_waitlist w WHERE w.cleanup_id = c.id AND w.status IN ('waiting', 'offered') + AND ${waitlistEntryNotBanned(sql)} ) wl ON true LEFT JOIN LATERAL ( SELECT count(*)::int AS n FROM cleanup_registration_seats s diff --git a/services/api/src/services/host/registration-repository.memory.ts b/services/api/src/services/host/registration-repository.memory.ts index 6c1fa341..5b3f4354 100644 --- a/services/api/src/services/host/registration-repository.memory.ts +++ b/services/api/src/services/host/registration-repository.memory.ts @@ -10,6 +10,7 @@ import { import { LIVE_TAIL_MS } from "@civfix/shared/host" import { ARRIVAL_BUCKET_MINUTES, + type AppliedBan, buildCheckinResult, emptyCheckinResult, guestSelfRegistrationOnPrivateEvent, @@ -802,19 +803,66 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos if (record === undefined || record.cleanupId !== args.cleanupId) { return { kind: "not_found", releasedWaitlistTicketTypeIds: [] } } - let releasedWaitlistTicketTypeIds: string[] = [] - if (args.ban && record.userId !== null) { - this.bans.add(`${args.cleanupId}:${record.userId}`) - const cancelled = await this.leaveWaitlist({ - cleanupId: args.cleanupId, - ticketTypeId: null, - subject: { kind: "user", userId: record.userId }, - now: args.now, - }) - releasedWaitlistTicketTypeIds = cancelled.releasedTicketTypeIds + if (!args.ban || record.userId === null) { + const outcome = await this.cancelRegistration(args) + return { ...outcome, releasedWaitlistTicketTypeIds: [] } + } + const ban = this.applyBan({ + cleanupId: args.cleanupId, + userId: record.userId, + actorId: args.actorId, + now: args.now, + }) + const removed = ban.cancelledRegistrations.find((r) => r.id === record.id) + if (removed === undefined) { + const outcome = await this.cancelRegistration(args) + return { ...outcome, releasedWaitlistTicketTypeIds: ban.releasedTicketTypeIds } + } + return { + kind: "cancelled", + registration: this.toRecord(record), + ticketTypeId: removed.ticketTypeId, + releasedWaitlistTicketTypeIds: ban.releasedTicketTypeIds, + } + } + + applyBan(args: { cleanupId: string; userId: string; actorId: string; now: Date }): AppliedBan { + this.bans.add(`${args.cleanupId}:${args.userId}`) + this.members.delete(`${args.cleanupId}:${args.userId}`) + const waitlist = this.cancelWaitlistEntries({ + cleanupId: args.cleanupId, + ticketTypeId: null, + subject: { kind: "user", userId: args.userId }, + }) + const cancelled: AppliedBan["cancelledRegistrations"] = [] + for (const record of this.registrations.values()) { + if ( + record.cleanupId !== args.cleanupId || + record.userId !== args.userId || + record.status !== "registered" + ) { + continue + } + record.status = "cancelled" + record.cancelledAt = args.now + for (const seat of record.seats) seat.status = "cancelled" + const type = + record.ticketTypeId === null ? undefined : this.ticketTypes.get(record.ticketTypeId) + if (type !== undefined) { + type.reservedSeats = Math.max(type.reservedSeats - record.partySize, 0) + this.recomputeSold(type.id) + } + cancelled.push({ id: record.id, ticketTypeId: record.ticketTypeId }) + } + const registrationTypes = cancelled + .map((r) => r.ticketTypeId) + .filter((id): id is string => id !== null) + return { + cancelledRegistrations: cancelled, + releasedTicketTypeIds: [ + ...new Set([...waitlist.releasedTicketTypeIds, ...registrationTypes]), + ], } - const outcome = await this.cancelRegistration(args) - return { ...outcome, releasedWaitlistTicketTypeIds } } async transferRegistration(args: { @@ -858,12 +906,17 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos return this.bans.has(`${cleanupId}:${userId}`) } + private entryBanned(entry: WaitlistRecord): boolean { + return entry.userId !== null && this.isBanned(entry.cleanupId, entry.userId) + } + private positionOf(entry: WaitlistRecord): number | null { if (entry.status !== "waiting") return null const ahead = [...this.waitlist.values()].filter( (w) => w.ticketTypeId === entry.ticketTypeId && w.status === "waiting" && + !this.entryBanned(w) && (w.createdAt.getTime() < entry.createdAt.getTime() || (w.createdAt.getTime() === entry.createdAt.getTime() && w.id < entry.id)), ).length @@ -940,6 +993,14 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos subject: RegistrationSubject now: Date }): Promise<{ left: number; releasedTicketTypeIds: string[] }> { + return this.cancelWaitlistEntries(args) + } + + private cancelWaitlistEntries(args: { + cleanupId: string + ticketTypeId: string | null + subject: RegistrationSubject + }): { left: number; releasedTicketTypeIds: string[] } { let left = 0 const released: string[] = [] for (const entry of this.waitlist.values()) { @@ -967,7 +1028,11 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos cursor: string | null limit: number }): Promise<{ rows: WaitlistRecord[]; nextCursor: string | null }> { - let rows = [...this.waitlist.values()].filter((w) => w.cleanupId === args.cleanupId) + let rows = [...this.waitlist.values()].filter( + (w) => + w.cleanupId === args.cleanupId && + !((w.status === "waiting" || w.status === "offered") && this.entryBanned(w)), + ) if (args.ticketTypeId !== null) rows = rows.filter((w) => w.ticketTypeId === args.ticketTypeId) rows = rows.filter((w) => args.status === null @@ -1261,7 +1326,10 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos checkedIn: seats.filter((s) => s.status === "active" && s.checkedInAt !== null).length, waitlisted: [...this.waitlist.values()] .filter( - (w) => w.cleanupId === cleanupId && (w.status === "waiting" || w.status === "offered"), + (w) => + w.cleanupId === cleanupId && + (w.status === "waiting" || w.status === "offered") && + !this.entryBanned(w), ) .reduce((sum, w) => sum + w.partySize, 0), noShow: seats.filter((s) => s.status === "active" && s.noShowAt !== null).length, @@ -1283,7 +1351,10 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos .filter((s) => s.checkedInAt !== null).length, waitlisted: [...this.waitlist.values()] .filter( - (w) => w.ticketTypeId === type.id && (w.status === "waiting" || w.status === "offered"), + (w) => + w.ticketTypeId === type.id && + (w.status === "waiting" || w.status === "offered") && + !this.entryBanned(w), ) .reduce((sum, w) => sum + w.partySize, 0), capacity: type.capacity, diff --git a/services/api/src/services/host/registration-sql.ts b/services/api/src/services/host/registration-sql.ts index aae32e0d..87c5f768 100644 --- a/services/api/src/services/host/registration-sql.ts +++ b/services/api/src/services/host/registration-sql.ts @@ -422,6 +422,14 @@ export function waitlistColumns(tag: Queryable) { ` } +/** Backstop for a waiting row whose user was banned by a path that did not cancel it. */ +export function waitlistEntryNotBanned(tag: Queryable, alias: "w" | "w2" = "w") { + return tag`NOT EXISTS ( + SELECT 1 FROM cleanup_bans b + WHERE b.cleanup_id = ${tag(alias)}.cleanup_id AND b.user_id = ${tag(alias)}.user_id + )` +} + export function waitlistJoins(tag: Queryable) { return tag` LEFT JOIN cleanup_ticket_types tt ON tt.id = w.ticket_type_id @@ -433,6 +441,7 @@ export function waitlistJoins(tag: Queryable) { WHERE w2.ticket_type_id = w.ticket_type_id AND w2.status = 'waiting' AND (w2.created_at, w2.id) < (w.created_at, w.id) + AND ${waitlistEntryNotBanned(tag, "w2")} ) pos ON w.status = 'waiting' ` } diff --git a/services/api/test/helpers/cleanups.ts b/services/api/test/helpers/cleanups.ts index 3640c05b..b1f61cfc 100644 --- a/services/api/test/helpers/cleanups.ts +++ b/services/api/test/helpers/cleanups.ts @@ -288,6 +288,9 @@ export interface SignupRegistrationSink { now: Date }): unknown cancelSignupRegistration(args: { cleanupId: string; userId: string; now: Date }): unknown + applyBan(args: { cleanupId: string; userId: string; actorId: string; now: Date }): { + releasedTicketTypeIds: string[] + } } export function signupSeat(): SignupSeat { @@ -1122,9 +1125,11 @@ export class InMemoryCleanupRepository implements CleanupRepository { const idx = this.members.findIndex( (m) => m.cleanupId === cleanupId && m.userId === userId && m.role !== "organizer", ) + let releasedWaitlistTicketTypeIds: string[] = [] if (idx >= 0) { this.members.splice(idx, 1) - this.cancelSignupRegistration(cleanupId, userId) + const ban = this.registrationSink?.applyBan({ cleanupId, userId, actorId, now: this.now() }) + releasedWaitlistTicketTypeIds = ban?.releasedTicketTypeIds ?? [] if (!this.bans.some((b) => b.cleanupId === cleanupId && b.userId === userId)) { this.bans.push({ cleanupId, userId, bannedByUserId: actorId }) } @@ -1133,7 +1138,7 @@ export class InMemoryCleanupRepository implements CleanupRepository { const going = this.goingOf(cleanupId) return Promise.resolve( idx >= 0 - ? { kind: "removed", going, releasedWaitlistTicketTypeIds: [] } + ? { kind: "removed", going, releasedWaitlistTicketTypeIds } : { kind: "not_member", going }, ) } diff --git a/services/api/test/integration/registration-repository-security-pg.test.ts b/services/api/test/integration/registration-repository-security-pg.test.ts index f5f8d2c3..651e3ad9 100644 --- a/services/api/test/integration/registration-repository-security-pg.test.ts +++ b/services/api/test/integration/registration-repository-security-pg.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest" import { randomUUID } from "node:crypto" import { withPg, type PgHarness } from "../helpers/pg.js" import { seedCleanup } from "../helpers/cleanups.js" +import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" import { makeDrizzleHostRegistrationRepository } from "../../src/services/host/registration-repository.drizzle.js" import { makeTicketTokenSigner } from "../../src/services/host/ticket-token.js" import type { @@ -220,7 +221,7 @@ describe.skipIf(!pg)("registration security (integration)", () => { }) expect(outcome.kind).toBe("cancelled") - expect(outcome.releasedWaitlistTicketTypeIds).toEqual([vip]) + expect([...outcome.releasedWaitlistTicketTypeIds].sort()).toEqual([main, vip].sort()) expect(await waitlistStatuses(userId)).toEqual(["cancelled", "cancelled"]) expect(await reservedSeats(vip)).toBe(0) expect(await reservedSeats(main)).toBe(0) @@ -230,4 +231,142 @@ describe.skipIf(!pg)("registration security (integration)", () => { ` expect(bans[0]?.n).toBe(1) }) + + async function claimSlot(cleanupId: string, userId: string): Promise { + const [slot] = await h.sql<{ id: string }[]>` + INSERT INTO cleanup_slots (cleanup_id, title, sort_order) + VALUES (${cleanupId}, ${`Slot ${randomUUID().slice(0, 8)}`}, 0) + RETURNING id + ` + await h.sql` + INSERT INTO cleanup_slot_claims (cleanup_id, user_id, slot_id) + VALUES (${cleanupId}, ${userId}, ${(slot as { id: string }).id}) + ` + } + + async function banState(cleanupId: string, userId: string) { + const [row] = await h.sql< + { active: number; active_seats: number; claims: number; members: number; bans: number }[] + >` + SELECT + (SELECT count(*)::int FROM cleanup_registrations + WHERE cleanup_id = ${cleanupId} AND user_id = ${userId} AND status = 'registered') AS active, + (SELECT count(*)::int FROM cleanup_registration_seats s + JOIN cleanup_registrations r ON r.id = s.registration_id + WHERE r.cleanup_id = ${cleanupId} AND r.user_id = ${userId} AND s.status = 'active') + AS active_seats, + (SELECT count(*)::int FROM cleanup_slot_claims + WHERE cleanup_id = ${cleanupId} AND user_id = ${userId}) AS claims, + (SELECT count(*)::int FROM cleanup_members + WHERE cleanup_id = ${cleanupId} AND user_id = ${userId}) AS members, + (SELECT count(*)::int FROM cleanup_bans + WHERE cleanup_id = ${cleanupId} AND user_id = ${userId}) AS bans + ` + return row + } + + it("removing a ticket holder from the event cancels the ticket and frees its seats", async () => { + const cleanupId = await newCleanup() + const type = await newTicketType(cleanupId) + const userId = await newUser("Ticket holder") + const registered = await repo.registerTx(selfRegistration(cleanupId, userId, type, 2)) + if (registered.kind !== "registered") throw new Error("expected registered") + await claimSlot(cleanupId, userId) + expect(await reservedSeats(type)).toBe(2) + + const outcome = await makeDrizzleCleanupRepository(h.sql).removeMember( + cleanupId, + userId, + await newUser("Host"), + ) + + expect(outcome).toMatchObject({ kind: "removed", releasedWaitlistTicketTypeIds: [type] }) + expect(await reservedSeats(type)).toBe(0) + expect(await banState(cleanupId, userId)).toEqual({ + active: 0, + active_seats: 0, + claims: 0, + members: 0, + bans: 1, + }) + const seatId = registered.registration.seats[0]?.id as string + const scanned = await repo.checkInByToken({ + cleanupId, + tokenHash: tokens.hashFor(seatId), + actorId: await newUser("Door"), + method: "scan", + now: new Date(), + }) + expect(scanned.outcome).toBe("cancelled") + }) + + it("a roster ban through an old registration also cancels the one the user holds now", async () => { + const cleanupId = await newCleanup() + const type = await newTicketType(cleanupId) + const userId = await newUser("Returning") + const first = await repo.registerTx(selfRegistration(cleanupId, userId, type)) + if (first.kind !== "registered") throw new Error("expected registered") + await repo.cancelRegistration({ + cleanupId, + registrationId: first.registration.id, + actorId: userId, + now: new Date(), + }) + const again = await repo.registerTx(selfRegistration(cleanupId, userId, type)) + if (again.kind !== "registered") throw new Error("expected registered again") + await claimSlot(cleanupId, userId) + + const outcome = await repo.removeRegistration({ + cleanupId, + registrationId: first.registration.id, + actorId: await newUser("Host"), + ban: true, + now: new Date(), + }) + + expect(outcome.kind).toBe("already_cancelled") + expect(outcome.releasedWaitlistTicketTypeIds).toEqual([type]) + expect(await reservedSeats(type)).toBe(0) + expect(await banState(cleanupId, userId)).toMatchObject({ + active: 0, + active_seats: 0, + claims: 0, + bans: 1, + }) + await expect(repo.registerTx(selfRegistration(cleanupId, userId, type))).resolves.toEqual({ + kind: "banned", + }) + }) + + it("leaves a banned user's stale waiting row out of the queue positions behind it", async () => { + const cleanupId = await newCleanup() + const type = await newTicketType(cleanupId, { capacity: 1 }) + const banned = await newUser("Banned before") + const next = await newUser("Behind") + const now = new Date() + let nextEntry = "" + for (const [index, userId] of [banned, next].entries()) { + const joined = await repo.joinWaitlist({ + cleanupId, + ticketTypeId: type, + subject: { kind: "user", userId }, + partySize: 1, + accessCodeHash: null, + now: new Date(now.getTime() + index * 1000), + }) + if (joined.kind === "joined") nextEntry = joined.entry.id + } + await h.sql`INSERT INTO cleanup_bans (cleanup_id, user_id) VALUES (${cleanupId}, ${banned})` + + expect((await repo.findWaitlistEntry(cleanupId, nextEntry))?.position).toBe(1) + const listed = await repo.listWaitlist({ + cleanupId, + ticketTypeId: null, + status: null, + cursor: null, + limit: 10, + }) + expect(listed.rows.map((row) => row.userId)).toEqual([next]) + expect((await repo.checkinCounters(cleanupId)).waitlisted).toBe(1) + }) }) diff --git a/services/api/test/unit/host/event-ban-consistency.test.ts b/services/api/test/unit/host/event-ban-consistency.test.ts new file mode 100644 index 00000000..a94fd5a7 --- /dev/null +++ b/services/api/test/unit/host/event-ban-consistency.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest" +import type { Sql } from "../../../src/db/client.js" +import { makeDrizzleCleanupRepository } from "../../../src/services/cleanup-repository.drizzle.js" +import { makeDrizzleHostRegistrationRepository } from "../../../src/services/host/registration-repository.drizzle.js" +import { InMemoryHostRegistrationRepository } from "../../../src/services/host/registration-repository.memory.js" +import { makeFakeSql, type RecordedStatement } from "../../helpers/fake-sql.js" + +const EVENT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" +const HOST = "11111111-1111-4111-8111-111111111111" +const BANNED = "33333333-3333-4333-8333-333333333333" +const NEXT = "44444444-4444-4444-8444-444444444444" +const REGISTRATION = "ffffffff-ffff-4fff-8fff-ffffffffffff" +const TICKETED_TYPE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" +const NOW = new Date("2026-09-01T12:00:00.000Z") + +const EVENT_LOCK = /FROM cleanups WHERE id = \? LIMIT 1 FOR NO KEY UPDATE/ +const BAN = /INSERT INTO cleanup_bans/ +const WAITLIST = /UPDATE cleanup_waitlist/ +const REGISTRATIONS = /UPDATE cleanup_registrations/ +const SLOT_CLAIMS = /DELETE FROM cleanup_slot_claims/ + +function indexOf(statements: RecordedStatement[], pattern: RegExp): number { + return statements.findIndex((s) => pattern.test(s.sql)) +} + +function flat(statement: RecordedStatement | undefined): string { + return (statement?.sql ?? "").replace(/\s+/g, " ") +} + +describe("banning an attendee by removing them from the event", () => { + it("cancels every active registration, ticketed ones included, and releases their seats", async () => { + const fake = makeFakeSql([ + { match: /FROM cleanups WHERE id = /, rows: [{ status: "upcoming", now: NOW }] }, + { match: /DELETE FROM cleanup_members/, rows: [{ user_id: BANNED }] }, + { match: REGISTRATIONS, rows: [{ id: REGISTRATION, ticket_type_id: TICKETED_TYPE }] }, + { match: /AS count/, rows: [{ count: 1 }] }, + ]) + + const outcome = await makeDrizzleCleanupRepository(fake.sql as unknown as Sql).removeMember( + EVENT, + BANNED, + HOST, + ) + + expect(outcome).toEqual({ + kind: "removed", + going: 1, + releasedWaitlistTicketTypeIds: [TICKETED_TYPE], + }) + const cancel = fake.statements[indexOf(fake.statements, REGISTRATIONS)] + expect(flat(cancel)).not.toContain("ticket_type_id IS NULL") + expect(flat(cancel)).toContain("status = 'registered'") + expect(flat(cancel)).toContain("UPDATE cleanup_registration_seats") + expect(flat(cancel)).toContain("reserved_seats = GREATEST(t.reserved_seats -") + expect(cancel?.values).toEqual(expect.arrayContaining([EVENT, BANNED])) + expect(indexOf(fake.statements, SLOT_CLAIMS)).toBeGreaterThan(0) + }) +}) + +describe("banning an attendee from the host roster", () => { + function banFake() { + return makeFakeSql([ + { match: /SELECT user_id FROM cleanup_registrations/, rows: [{ user_id: BANNED }] }, + { match: REGISTRATIONS, rows: [] }, + ]) + } + + it("locks the event row before the ban, so a concurrent registration cannot slip past it", async () => { + const fake = banFake() + + await makeDrizzleHostRegistrationRepository(fake.sql as unknown as Sql).removeRegistration({ + cleanupId: EVENT, + registrationId: REGISTRATION, + actorId: HOST, + ban: true, + now: NOW, + }) + + const lock = indexOf(fake.statements, EVENT_LOCK) + expect(lock).toBeGreaterThanOrEqual(0) + expect(indexOf(fake.statements, BAN)).toBeGreaterThan(lock) + expect(indexOf(fake.statements, WAITLIST)).toBeGreaterThan(lock) + expect(indexOf(fake.statements, REGISTRATIONS)).toBeGreaterThan(lock) + }) + + it("drops the banned user's slot claim and cancels all of their registrations", async () => { + const fake = banFake() + + await makeDrizzleHostRegistrationRepository(fake.sql as unknown as Sql).removeRegistration({ + cleanupId: EVENT, + registrationId: REGISTRATION, + actorId: HOST, + ban: true, + now: NOW, + }) + + const claims = fake.statements[indexOf(fake.statements, SLOT_CLAIMS)] + expect(claims?.values).toEqual(expect.arrayContaining([EVENT, BANNED])) + const byUser = fake.statements.find( + (s) => REGISTRATIONS.test(s.sql) && flat(s).includes("AND user_id = ?"), + ) + expect(byUser?.values).toEqual(expect.arrayContaining([EVENT, BANNED])) + }) + + it("takes no event lock and touches no ban state when the host removes without banning", async () => { + const fake = banFake() + + await makeDrizzleHostRegistrationRepository(fake.sql as unknown as Sql).removeRegistration({ + cleanupId: EVENT, + registrationId: REGISTRATION, + actorId: HOST, + ban: false, + now: NOW, + }) + + for (const pattern of [EVENT_LOCK, BAN, WAITLIST, SLOT_CLAIMS]) { + expect(indexOf(fake.statements, pattern)).toBe(-1) + } + }) +}) + +describe("a waiting row left behind by a ban from before bans cancelled the waitlist", () => { + async function queueWithBannedHead() { + const repo = new InMemoryHostRegistrationRepository() + repo.seedEvent({ cleanupId: EVENT }) + const type = repo.seedTicketType({ cleanupId: EVENT, capacity: 1, waitlistEnabled: true }) + const joins = [] + for (const [userId, at] of [ + [BANNED, NOW], + [NEXT, new Date(NOW.getTime() + 1)], + ] as const) { + joins.push( + await repo.joinWaitlist({ + cleanupId: EVENT, + ticketTypeId: type.id, + subject: { kind: "user", userId }, + partySize: 2, + accessCodeHash: null, + now: at, + }), + ) + } + repo.bans.add(`${EVENT}:${BANNED}`) + const next = joins[1] + if (next?.kind !== "joined") throw new Error("expected the second person to join") + return { repo, type, nextId: next.entry.id } + } + + it("no longer counts toward the queue position of the people behind it", async () => { + const { repo, nextId } = await queueWithBannedHead() + + expect((await repo.findWaitlistEntry(EVENT, nextId))?.position).toBe(1) + }) + + it("is left out of the host's waitlist and the waitlist counts", async () => { + const { repo, type, nextId } = await queueWithBannedHead() + + const listed = await repo.listWaitlist({ + cleanupId: EVENT, + ticketTypeId: null, + status: null, + cursor: null, + limit: 10, + }) + expect(listed.rows.map((row) => [row.id, row.position])).toEqual([[nextId, 1]]) + const counters = await repo.checkinCounters(EVENT) + expect(counters.waitlisted).toBe(2) + expect(counters.byTicketType.find((t) => t.ticketTypeId === type.id)?.waitlisted).toBe(2) + expect((await repo.hostedEventCounts([EVENT])).get(EVENT)?.waitlistCount).toBe(2) + }) + + it("is excluded by the SQL behind the position, the host list and the counts", async () => { + const BANNED_SKIPPED = + /NOT EXISTS \( SELECT 1 FROM cleanup_bans b WHERE b\.cleanup_id = w\.cleanup_id AND b\.user_id = w\.user_id \)/ + const fake = makeFakeSql() + const repo = makeDrizzleHostRegistrationRepository(fake.sql as unknown as Sql) + + await repo.findWaitlistEntry(EVENT, REGISTRATION) + await repo.listWaitlist({ + cleanupId: EVENT, + ticketTypeId: null, + status: null, + cursor: null, + limit: 10, + }) + await repo.checkinCounters(EVENT) + await repo.hostedEventCounts([EVENT]) + + const [entry, list, totals, byType, , hosted] = fake.statements.map(flat) + expect(entry).toContain( + "NOT EXISTS ( SELECT 1 FROM cleanup_bans b WHERE b.cleanup_id = w2.cleanup_id AND b.user_id = w2.user_id )", + ) + expect(list).toMatch(BANNED_SKIPPED) + for (const counted of [totals, byType, hosted]) { + expect(counted).toMatch(BANNED_SKIPPED) + } + }) +}) diff --git a/services/api/test/unit/host/signup-seats.test.ts b/services/api/test/unit/host/signup-seats.test.ts index ef045929..0bb3f697 100644 --- a/services/api/test/unit/host/signup-seats.test.ts +++ b/services/api/test/unit/host/signup-seats.test.ts @@ -224,6 +224,38 @@ describe("a ticketed event", () => { }) }) +describe("a host removing an attendee who holds a ticket", () => { + it("cancels the ticketed registration, releases its seats and kills the ticket", async () => { + const id = seedEvent() + const type = seedTicketType(id) + const seatId = randomUUID() + const registered = await registrations.registerTx({ + cleanupId: id, + subject: { kind: "user", userId: MEMBER }, + ticketTypeId: type.id, + seats: [{ id: seatId, attendeeName: null, tokenHash: TEST_TICKET_SIGNER.hashFor(seatId) }], + accessCodeHash: null, + answers: [], + consent: null, + slotId: null, + source: "self", + idempotencyKey: "ticketed-ban", + waitlistId: null, + now: new Date(), + }) + expect(registered.kind).toBe("registered") + repo.seedMember(id, MEMBER, "member") + + await service.removeMember(id, ORG, MEMBER) + + expect(activeRegistrationsOf(id, MEMBER)).toHaveLength(0) + expect(registrations.ticketTypes.get(type.id)?.reservedSeats).toBe(0) + expect(await rosterUserIds(id)).toEqual([]) + const scanned = await checkin.scan({ id, token: TEST_TICKET_SIGNER.tokenFor(seatId) }, ORG) + expect(scanned.outcome).toBe("cancelled") + }) +}) + describe("a ticket type added after plain sign-ups already exist", () => { it("still lets leaving cancel the seat and kills the token", async () => { const id = seedEvent() From 6aeb79e440d87b39c7659c0d96a680e1b4e5f9df Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:29:31 +0000 Subject: [PATCH 36/45] invite mail requires the web origin --- services/api/src/routes/host/orgs.routes.ts | 1 + services/api/src/routes/host/team.routes.ts | 1 + .../host/host-team-repository.memory.ts | 1 - .../src/services/host/host-team-service.ts | 6 ++-- .../test/unit/host/org-invite-inbox.test.ts | 1 + .../unit/host/organization-service.test.ts | 1 + .../test/unit/web-base-url-fallback.test.ts | 28 +++++++++++++++++-- 7 files changed, 32 insertions(+), 7 deletions(-) diff --git a/services/api/src/routes/host/orgs.routes.ts b/services/api/src/routes/host/orgs.routes.ts index 07105b41..669b7b6d 100644 --- a/services/api/src/routes/host/orgs.routes.ts +++ b/services/api/src/routes/host/orgs.routes.ts @@ -130,6 +130,7 @@ export function makeContainerOrganizationService( ...(overrides.newToken !== undefined ? { newToken: overrides.newToken } : {}), ...(overrides.mailer !== undefined ? { mailer: overrides.mailer } : {}), ...(overrides.notifier !== undefined ? { notifier: overrides.notifier } : {}), + webOrigin: webBaseUrlOf(container.env), logger: app.log, }) } diff --git a/services/api/src/routes/host/team.routes.ts b/services/api/src/routes/host/team.routes.ts index ba4a3938..1f072dca 100644 --- a/services/api/src/routes/host/team.routes.ts +++ b/services/api/src/routes/host/team.routes.ts @@ -95,6 +95,7 @@ export async function registerHostTeamRoutes( ...(overrides.now !== undefined ? { now: overrides.now } : {}), ...(overrides.newId !== undefined ? { newId: overrides.newId } : {}), ...(overrides.newToken !== undefined ? { newToken: overrides.newToken } : {}), + webOrigin: webBaseUrlOf(container.env), logger: app.log, }) } diff --git a/services/api/src/services/host/host-team-repository.memory.ts b/services/api/src/services/host/host-team-repository.memory.ts index 8b8c83c9..52f14fdd 100644 --- a/services/api/src/services/host/host-team-repository.memory.ts +++ b/services/api/src/services/host/host-team-repository.memory.ts @@ -45,7 +45,6 @@ interface StoredPerson { displayName: string handle: string | null email: string | null - /** Mirrors users.email_verified. */ emailVerified: boolean avatarUrl: string | null } diff --git a/services/api/src/services/host/host-team-service.ts b/services/api/src/services/host/host-team-service.ts index c200cba7..a53ebed7 100644 --- a/services/api/src/services/host/host-team-service.ts +++ b/services/api/src/services/host/host-team-service.ts @@ -37,7 +37,6 @@ import type { HostTeamRepository, PendingInviteForUserRecord, } from "./host-team-repository.types.js" -import { webBaseUrlOf } from "../../lib/base-url.js" export const TEAM_INVITES_PER_EVENT_PER_DAY = 30 const TEAM_INVITE_WINDOW_SEC = 24 * 60 * 60 @@ -90,7 +89,7 @@ export interface HostTeamServiceDeps { presignEventMedia?: EventMediaPresigner affiliations?: AffiliationLoader eventTitleOf?: (cleanupId: string) => Promise - webOrigin?: string + webOrigin: string logger?: { warn(obj: unknown, msg?: string): void } now?: () => Date newId?: () => string @@ -228,8 +227,7 @@ export function makeHostTeamService(deps: HostTeamServiceDeps): HostTeamService token: string, ): Promise { if (deps.mailer === undefined) return - const base = deps.webOrigin ?? webBaseUrlOf({}) - const link = `${base}/cleanups/${cleanupId}#teamInvite=${encodeURIComponent(token)}` + const link = `${deps.webOrigin}/cleanups/${cleanupId}#teamInvite=${encodeURIComponent(token)}` try { await deps.mailer.sendTransactional( email, diff --git a/services/api/test/unit/host/org-invite-inbox.test.ts b/services/api/test/unit/host/org-invite-inbox.test.ts index 602d0a92..ae1dd13c 100644 --- a/services/api/test/unit/host/org-invite-inbox.test.ts +++ b/services/api/test/unit/host/org-invite-inbox.test.ts @@ -52,6 +52,7 @@ beforeEach(() => { now: () => clock, newId: () => randomUUID(), presignLogo: (key) => Promise.resolve(`https://cdn.test/${key}`), + webOrigin: "https://civfix.test", }) }) diff --git a/services/api/test/unit/host/organization-service.test.ts b/services/api/test/unit/host/organization-service.test.ts index 72c69311..2d510a74 100644 --- a/services/api/test/unit/host/organization-service.test.ts +++ b/services/api/test/unit/host/organization-service.test.ts @@ -49,6 +49,7 @@ beforeEach(() => { now: () => clock, newId: () => randomUUID(), presignLogo: (key) => Promise.resolve(`https://cdn.test/${key}`), + webOrigin: "https://civfix.test", }) }) diff --git a/services/api/test/unit/web-base-url-fallback.test.ts b/services/api/test/unit/web-base-url-fallback.test.ts index 0554adcd..8e6f1a4b 100644 --- a/services/api/test/unit/web-base-url-fallback.test.ts +++ b/services/api/test/unit/web-base-url-fallback.test.ts @@ -9,9 +9,16 @@ import { loadEnv } from "../../src/env.js" import { buildServer } from "../../src/server.js" import type { HostStandingResolution } from "../../src/services/host/host-standing.js" import { InMemoryHostTeamRepository } from "../../src/services/host/host-team-repository.memory.js" -import { makeHostTeamService } from "../../src/services/host/host-team-service.js" +import { + makeHostTeamService, + type HostTeamServiceDeps, +} from "../../src/services/host/host-team-service.js" import { InMemoryOrganizationRepository } from "../../src/services/host/organization-repository.memory.js" -import { makeOrganizationService } from "../../src/services/host/organization-service.js" +import { + makeOrganizationService, + type OrganizationServiceDeps, +} from "../../src/services/host/organization-service.js" +import { webBaseUrlOf } from "../../src/lib/base-url.js" import { InMemoryGuestRsvpRepository } from "../helpers/guest-rsvp.js" import { fakeCleanupDTO } from "../helpers/host-team.js" @@ -48,6 +55,7 @@ describe("links mailed from a runtime with no web origin configured", () => { loadEvent: (cleanupId: string) => Promise.resolve(fakeCleanupDTO(cleanupId)), notifier: { createNotification: () => Promise.resolve(null) }, eventTitleOf: () => Promise.resolve("Beach cleanup"), + webOrigin: webBaseUrlOf({ NODE_ENV: "test" }), }) await service.inviteMember(EVENT, ORGANIZER, { @@ -69,6 +77,7 @@ describe("links mailed from a runtime with no web origin configured", () => { counters: new InMemoryCounterStore(), newId: () => randomUUID(), mailer, + webOrigin: webBaseUrlOf({ NODE_ENV: "test" }), }) const org = await service.createOrganization( { name: "Creek Trust", slug: "creek-trust" } as Parameters< @@ -128,3 +137,18 @@ describe("links mailed from a runtime with no web origin configured", () => { } }) }) + +describe("services that mail links", () => { + it("cannot be built without the web origin the route wiring resolves from the environment", () => { + // @ts-expect-error a missing origin would otherwise fall back to localhost even in production + const team: HostTeamServiceDeps = { + repo: new InMemoryHostTeamRepository(), + standing: () => Promise.reject(new Error("unused")), + loadEvent: () => Promise.reject(new Error("unused")), + } + // @ts-expect-error a missing origin would otherwise fall back to localhost even in production + const org: OrganizationServiceDeps = { repo: new InMemoryOrganizationRepository() } + + expect([team, org]).toHaveLength(2) + }) +}) From 8aa809ce69701d4c32cbb34b629e051f6fbce330 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:48:40 +0000 Subject: [PATCH 37/45] event, page and organization media claims bind only the actor's own upload --- services/api/src/routes/host/pages.routes.ts | 2 +- .../services/cleanup-repository.drizzle.ts | 17 +- .../src/services/cleanup-repository.types.ts | 2 +- services/api/src/services/cleanup-service.ts | 2 +- .../host/organization-repository.drizzle.ts | 17 +- .../host/organization-repository.memory.ts | 1 + .../host/organization-repository.types.ts | 1 + .../src/services/host/organization-service.ts | 3 +- .../api/src/services/host/page-service.ts | 5 +- .../host/registration-repository.drizzle.ts | 16 +- .../host/registration-repository.types.ts | 1 + .../host-media-claim-uploader-pg.test.ts | 190 ++++++++++++++++++ .../integration/host-page-media-pg.test.ts | 86 +++++++- .../api/test/unit/host/page-service.test.ts | 110 ++++++---- 14 files changed, 377 insertions(+), 76 deletions(-) create mode 100644 services/api/test/integration/host-media-claim-uploader-pg.test.ts diff --git a/services/api/src/routes/host/pages.routes.ts b/services/api/src/routes/host/pages.routes.ts index 260f4b5d..6206ca87 100644 --- a/services/api/src/routes/host/pages.routes.ts +++ b/services/api/src/routes/host/pages.routes.ts @@ -71,7 +71,7 @@ export function registerHostPageRoutes( const { id } = parse(CleanupIdParamsSchema, request.params) await ctx.pageGuards().requireCapability(id, userId, "manage_page") const body = parse(SaveEventPageRequestSchema, bodyWith(request, { id })) - const payload: SaveEventPageResponse = await ctx.pages().save(body) + const payload: SaveEventPageResponse = await ctx.pages().save(body, userId) reply.status(200).send(payload) }, ) diff --git a/services/api/src/services/cleanup-repository.drizzle.ts b/services/api/src/services/cleanup-repository.drizzle.ts index e7e3d752..737372a4 100644 --- a/services/api/src/services/cleanup-repository.drizzle.ts +++ b/services/api/src/services/cleanup-repository.drizzle.ts @@ -19,8 +19,8 @@ import { firstReadyStillLateral, publicReportFilter } from "./report-sql.js" import { hostStandingOf, hostStandingsOf, orgStandingOf } from "./host/host-standing.js" import { NO_HOST_STANDING } from "@civfix/shared/host" import { publicServedKeyExpr } from "./media-served-key.js" -import { MEDIA_CLAIM_WINDOW_SEC } from "./host/event-media.js" -import { mediaBoundElsewhere, mediaBoundToCleanup } from "./media-bindings.js" +import { mediaBoundElsewhere, mediaBoundToCleanup, uploadedByClaimant } from "./media-bindings.js" +import { userUploader } from "./media-uploader.js" import { isUniqueViolationOn } from "./host/registration-sql.js" import { applyBanIn } from "./host/registration-repository.drizzle.js" import { deterministicUuid } from "./deterministic-uuid.js" @@ -117,6 +117,7 @@ async function claimEventMediaInTx( tx: Queryable, cleanupId: string, host: EventHostWrite, + claimantUserId: string, ): Promise { const cover = host.coverMediaId ?? null const gallery = host.galleryMediaIds ?? [] @@ -132,7 +133,7 @@ async function claimEventMediaInTx( AND NOT (${mediaBoundElsewhere(tx, cleanupId)}) AND ( (${mediaBoundToCleanup(tx, cleanupId)}) - OR media_assets.created_at > now() - make_interval(secs => ${MEDIA_CLAIM_WINDOW_SEC}) + OR (${uploadedByClaimant(tx, [userUploader(claimantUserId)])}) ) RETURNING id ` @@ -394,7 +395,7 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { ` await linkReportsInTx(tx, args.cleanupId, args.linkedReportIds, args.organizerUserId) await insertSlotsInTx(tx, args.cleanupId, args.slots) - await claimEventMediaInTx(tx, args.cleanupId, args.host) + await claimEventMediaInTx(tx, args.cleanupId, args.host, args.organizerUserId) if (args.copyFrom !== undefined) { await copyEventExtrasInTx(tx, args.cleanupId, args.copyFrom) } @@ -438,7 +439,11 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { } }, - async updateCleanup(id: string, patch: UpdateCleanupPatch): Promise { + async updateCleanup( + id: string, + patch: UpdateCleanupPatch, + actorUserId: string, + ): Promise { const sets: postgres.Fragment[] = hostSetFragments(sql, patch) if (patch.title !== undefined) sets.push(sql`title = ${patch.title}`) if (patch.description !== undefined) sets.push(sql`description = ${patch.description}`) @@ -469,7 +474,7 @@ export function makeDrizzleCleanupRepository(sql: Sql): CleanupRepository { UPDATE cleanups SET ${setList} WHERE id = ${id} RETURNING id ` if (updated.length === 0) return false - await claimEventMediaInTx(tx, id, patch) + await claimEventMediaInTx(tx, id, patch, actorUserId) return true }) }, diff --git a/services/api/src/services/cleanup-repository.types.ts b/services/api/src/services/cleanup-repository.types.ts index ecf05119..a7405ba5 100644 --- a/services/api/src/services/cleanup-repository.types.ts +++ b/services/api/src/services/cleanup-repository.types.ts @@ -283,7 +283,7 @@ export interface SignupSeat { export interface CleanupRepository { createCleanupTx(args: CreateCleanupTxArgs): Promise - updateCleanup(id: string, patch: UpdateCleanupPatch): Promise + updateCleanup(id: string, patch: UpdateCleanupPatch, actorUserId: string): Promise linkReports(cleanupId: string, reportIds: string[], actorId: string | null): Promise unlinkReport(cleanupId: string, reportId: string, actorId: string | null): Promise reconcileLinkedReports( diff --git a/services/api/src/services/cleanup-service.ts b/services/api/src/services/cleanup-service.ts index f607c780..cad9c19d 100644 --- a/services/api/src/services/cleanup-service.ts +++ b/services/api/src/services/cleanup-service.ts @@ -1278,7 +1278,7 @@ export function makeCleanupService(deps: CleanupServiceDeps): CleanupService { ...(patch.bring !== undefined ? { bring: patch.bring } : {}), ...(reresolvedGeoid !== undefined ? { jurisdictionGeoid: reresolvedGeoid } : {}), } - const updated = await deps.repo.updateCleanup(id, scalarPatch) + const updated = await deps.repo.updateCleanup(id, scalarPatch, requesterUserId) if (!updated) notFoundCleanup() if (desiredLinks !== null) { diff --git a/services/api/src/services/host/organization-repository.drizzle.ts b/services/api/src/services/host/organization-repository.drizzle.ts index 0e20c114..90c8eb01 100644 --- a/services/api/src/services/host/organization-repository.drizzle.ts +++ b/services/api/src/services/host/organization-repository.drizzle.ts @@ -12,8 +12,8 @@ import type { Queryable, Sql } from "../../db/client.js" import { encodeTimeCursor, isUuid, pageWith, parseTimeCursor } from "../../db/cursor-helpers.js" import { likeContains } from "../admin/like.js" import { publicServedKeyExpr } from "../media-served-key.js" -import { MEDIA_CLAIM_WINDOW_SEC } from "./event-media.js" -import { mediaBoundElsewhere } from "../media-bindings.js" +import { mediaBoundElsewhere, uploadedByClaimant } from "../media-bindings.js" +import { userUploader } from "../media-uploader.js" import { writeHostAudit } from "./host-audit.js" import type { AcceptOrganizationInviteOutcome, @@ -285,6 +285,7 @@ async function claimOrgLogoInTx( tx: Queryable, organizationId: string, logoMediaId: string | null, + claimantUserId: string, ): Promise { if (logoMediaId === null) return const claimed = await tx<{ id: string }[]>` @@ -310,7 +311,7 @@ async function claimOrgLogoInTx( SELECT 1 FROM organizations cur WHERE cur.id = ${organizationId} AND cur.logo_media_id = media_assets.id ) - OR media_assets.created_at > now() - make_interval(secs => ${MEDIA_CLAIM_WINDOW_SEC}) + OR (${uploadedByClaimant(tx, [userUploader(claimantUserId)])}) ) RETURNING id ` @@ -330,6 +331,7 @@ export function documentMediaIdsOf(documents: { mediaId?: string }[] | null | un async function claimVerificationDocumentsInTx( tx: Queryable, mediaIds: readonly string[], + claimantUserId: string, ): Promise { if (mediaIds.length === 0) return const claimed = await tx<{ id: string }[]>` @@ -338,7 +340,7 @@ async function claimVerificationDocumentsInTx( WHERE id = ANY(${[...mediaIds]}::uuid[]) AND report_id IS NULL AND post_id IS NULL AND chat_message_id IS NULL AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) - AND created_at > now() - make_interval(secs => ${MEDIA_CLAIM_WINDOW_SEC}) + AND ${uploadedByClaimant(tx, [userUploader(claimantUserId)])} AND NOT (${mediaBoundElsewhere(tx, null)}) RETURNING id ` @@ -424,7 +426,7 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit INSERT INTO organization_members (organization_id, user_id, role, joined_at) VALUES (${args.organizationId}, ${ownerUserId}, 'owner', ${args.now}) ` - await claimOrgLogoInTx(tx, args.organizationId, args.logoMediaId) + await claimOrgLogoInTx(tx, args.organizationId, args.logoMediaId, args.createdBy) if (args.operatorReason !== undefined) { await writeHostAudit(tx, { actorId: args.createdBy, @@ -515,6 +517,7 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit id: string, patch: UpdateOrganizationPatch, now: Date, + actorId: string, audit?: UpdateOrganizationAudit, ): Promise { const sets: postgres.Fragment[] = [sql`updated_at = ${now}`] @@ -538,7 +541,7 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit RETURNING id ` if (updated.length === 0) return "not_found" - await claimOrgLogoInTx(tx, id, patch.logoMediaId ?? null) + await claimOrgLogoInTx(tx, id, patch.logoMediaId ?? null, actorId) if (audit !== undefined) { await writeHostAudit(tx, { actorId: audit.actorId, @@ -895,7 +898,7 @@ export function makeDrizzleOrganizationRepository(sql: Sql): OrganizationReposit ) RETURNING status, kind, submitted_at, reviewed_at, rejection_reason ` - await claimVerificationDocumentsInTx(tx, fresh) + await claimVerificationDocumentsInTx(tx, fresh, args.submittedBy) const dropped = carried.filter((mediaId) => !effective.includes(mediaId)) if (dropped.length > 0) { await tx` diff --git a/services/api/src/services/host/organization-repository.memory.ts b/services/api/src/services/host/organization-repository.memory.ts index 42e9cb1e..a6ab0850 100644 --- a/services/api/src/services/host/organization-repository.memory.ts +++ b/services/api/src/services/host/organization-repository.memory.ts @@ -313,6 +313,7 @@ export class InMemoryOrganizationRepository implements OrganizationRepository { id: string, patch: UpdateOrganizationPatch, now: Date, + _actorId: string, audit?: UpdateOrganizationAudit, ): Promise { const org = this.organizations.get(id) diff --git a/services/api/src/services/host/organization-repository.types.ts b/services/api/src/services/host/organization-repository.types.ts index d8b275b9..dc7e7aa6 100644 --- a/services/api/src/services/host/organization-repository.types.ts +++ b/services/api/src/services/host/organization-repository.types.ts @@ -334,6 +334,7 @@ export interface OrganizationRepository { id: string, patch: UpdateOrganizationPatch, now: Date, + actorId: string, audit?: UpdateOrganizationAudit, ): Promise roleOf(organizationId: string, userId: string): Promise diff --git a/services/api/src/services/host/organization-service.ts b/services/api/src/services/host/organization-service.ts index ce7344e7..9e91ecb3 100644 --- a/services/api/src/services/host/organization-service.ts +++ b/services/api/src/services/host/organization-service.ts @@ -753,6 +753,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza ...(patch.socialLinks !== undefined ? { socialLinks: patch.socialLinks } : {}), }, now(), + actorId, ) if (updated === "not_found") notFoundOrganization() if (updated === "slug_taken") @@ -1150,7 +1151,7 @@ export function makeOrganizationService(deps: OrganizationServiceDeps): Organiza changed.push("socialLinks") } if (changed.length === 0) return adminOrgDTO(id) - const outcome = await deps.repo.updateOrganizationTx(id, patch, now(), { + const outcome = await deps.repo.updateOrganizationTx(id, patch, now(), operatorId, { actorId: operatorId, reason: input.reason, changed, diff --git a/services/api/src/services/host/page-service.ts b/services/api/src/services/host/page-service.ts index c1e50221..51a25627 100644 --- a/services/api/src/services/host/page-service.ts +++ b/services/api/src/services/host/page-service.ts @@ -40,7 +40,7 @@ export interface PageServiceDeps { export interface PageService { get(query: GetEventPageRequest): Promise - save(input: SaveEventPageRequest): Promise + save(input: SaveEventPageRequest, actorId: string): Promise publish(input: PublishEventPageRequest, actorId: string): Promise checkSlug(query: CheckEventPageSlugRequest): Promise getPublicEventPage( @@ -316,7 +316,7 @@ export function makePageService(deps: PageServiceDeps): PageService { return pageDTO(record) }, - async save(input): Promise { + async save(input, actorId): Promise { validatePageBlocks(input.blocks, deps.mediaUrlPrefixes ?? []) if (input.slug != null && RESERVED_SLUGS.has(input.slug)) { throw AppError.validation({ slug: "that address is reserved" }) @@ -325,6 +325,7 @@ export function makePageService(deps: PageServiceDeps): PageService { const blocks = stripResolvedMediaUrls(input.blocks) const outcome = await deps.repo.savePage({ cleanupId: input.id, + actorUserId: actorId, slug: input.slug, themeAccent: input.theme?.accent, blocks, diff --git a/services/api/src/services/host/registration-repository.drizzle.ts b/services/api/src/services/host/registration-repository.drizzle.ts index 3cda4506..79a2470b 100644 --- a/services/api/src/services/host/registration-repository.drizzle.ts +++ b/services/api/src/services/host/registration-repository.drizzle.ts @@ -11,9 +11,9 @@ import { } from "../../db/cursor-helpers.js" import { eventWindowOfRow, hasEventEnded } from "../cleanup-rules.js" import { cleanupStatusExpr } from "../cleanup-sql.js" -import { mediaBoundElsewhere, mediaBoundToCleanup } from "../media-bindings.js" +import { mediaBoundElsewhere, mediaBoundToCleanup, uploadedByClaimant } from "../media-bindings.js" +import { userUploader } from "../media-uploader.js" import { likeContains } from "../admin/like.js" -import { MEDIA_CLAIM_WINDOW_SEC } from "./event-media.js" import { isEventPubliclyVisible } from "./authz.js" import { publicServedKeyExpr } from "../media-served-key.js" import { deterministicUuid } from "../deterministic-uuid.js" @@ -265,6 +265,7 @@ async function claimPageMediaInTx( cleanupId: string, mediaIds: readonly string[], asCover: "event_cover" | null, + claimantUserId: string, ): Promise { const wanted = [...new Set(mediaIds)] if (wanted.length === 0) return [] @@ -288,7 +289,7 @@ async function claimPageMediaInTx( AND NOT (${mediaBoundElsewhere(tx, cleanupId)}) AND ( (${mediaBoundToCleanup(tx, cleanupId)}) - OR media_assets.created_at > now() - make_interval(secs => ${MEDIA_CLAIM_WINDOW_SEC}) + OR (${uploadedByClaimant(tx, [userUploader(claimantUserId)])}) ) RETURNING media_assets.id ` @@ -2484,7 +2485,13 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio const blockIds = [...new Set(args.blockMediaIds)] if (blockIds.length > 0) { - const claimed = await claimPageMediaInTx(tx, args.cleanupId, blockIds, null) + const claimed = await claimPageMediaInTx( + tx, + args.cleanupId, + blockIds, + null, + args.actorUserId, + ) if (claimed.length !== blockIds.length) { return { kind: "block_media_not_found" as const } } @@ -2497,6 +2504,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio args.cleanupId, [args.coverMediaId], "event_cover", + args.actorUserId, ) if (claimed.length !== 1) return { kind: "cover_not_found" as const } } diff --git a/services/api/src/services/host/registration-repository.types.ts b/services/api/src/services/host/registration-repository.types.ts index 82b42144..5f8422b5 100644 --- a/services/api/src/services/host/registration-repository.types.ts +++ b/services/api/src/services/host/registration-repository.types.ts @@ -375,6 +375,7 @@ export interface PageRecord { export interface SavePageArgs { cleanupId: string + actorUserId: string slug: string | null | undefined themeAccent: ThemeAccent | undefined blocks: EventPageBlock[] diff --git a/services/api/test/integration/host-media-claim-uploader-pg.test.ts b/services/api/test/integration/host-media-claim-uploader-pg.test.ts new file mode 100644 index 00000000..f205fc4d --- /dev/null +++ b/services/api/test/integration/host-media-claim-uploader-pg.test.ts @@ -0,0 +1,190 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { randomUUID } from "node:crypto" +import { withPg, testHandle, type PgHarness } from "../helpers/pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { seedMediaAsset, type SeededMedia } from "../helpers/media-pg.js" +import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" +import type { CleanupRepository } from "../../src/services/cleanup-repository.types.js" +import { makeDrizzleOrganizationRepository } from "../../src/services/host/organization-repository.drizzle.js" +import type { OrganizationRepository } from "../../src/services/host/organization-repository.types.js" +import { userUploader } from "../../src/services/media-uploader.js" + +const pg = await withPg() +const FUTURE = new Date(Date.now() + 7 * 86_400_000) +const rejects422 = { httpStatus: 422, code: "VALIDATION" } + +describe.skipIf(!pg)( + "event and organization media claims (integration: the actor's own uploads)", + () => { + let h: PgHarness + let cleanups: CleanupRepository + let orgs: OrganizationRepository + + beforeAll(() => { + h = pg as PgHarness + cleanups = makeDrizzleCleanupRepository(h.sql) + orgs = makeDrizzleOrganizationRepository(h.sql) + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newUser(name: string): Promise { + const [u] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name, handle) VALUES (${name}, ${testHandle()}) RETURNING id + ` + return u!.id + } + + async function uploadBy(userId: string): Promise { + return await seedMediaAsset(h.sql, { uploader: userUploader(userId) }) + } + + async function purposeOf(mediaId: string): Promise { + const [row] = await h.sql<{ purpose: string }[]>` + SELECT purpose FROM media_assets WHERE id = ${mediaId} + ` + return row!.purpose + } + + function newEvent(organizerUserId: string, coverMediaId: string) { + return cleanups.createCleanupTx({ + cleanupId: randomUUID(), + organizerUserId, + type: "site", + eventKind: "cleanup", + title: "Claimed cover sweep", + description: null, + lat: 34.05, + lng: -118.25, + scheduledAt: FUTURE, + status: "upcoming", + bring: null, + address: null, + addressSource: null, + jurisdictionGeoid: null, + jurCode: 0, + linkedReportIds: [], + slots: [], + host: { endsAt: new Date(FUTURE.getTime() + 3_600_000), coverMediaId }, + }) + } + + function newOrg(createdBy: string, logoMediaId: string | null) { + const slug = `claim-${randomUUID().slice(0, 8)}` + return orgs.createOrganizationTx({ + organizationId: randomUUID(), + slug, + name: `Org ${slug}`, + description: null, + websiteUrl: null, + logoMediaId, + socialLinks: null, + createdBy, + now: new Date(), + }) + } + + it("a new event refuses another account's upload as its cover", async () => { + const host = await newUser("event host") + const foreign = await uploadBy(await newUser("uploader")) + + await expect(newEvent(host, foreign.id)).rejects.toMatchObject(rejects422) + expect(await purposeOf(foreign.id)).toBe("report") + }) + + it("a new event claims the organizer's own upload as its cover", async () => { + const host = await newUser("event host") + const own = await uploadBy(host) + + const created = await newEvent(host, own.id) + + expect(created.record.id).toBeDefined() + expect(await purposeOf(own.id)).toBe("event_cover") + }) + + it("an event edit refuses another account's upload for the gallery", async () => { + const host = await newUser("event host") + const cleanupId = await seedCleanup(h.sql, { + organizerUserId: host, + title: "Gallery sweep", + lng: -118.25, + lat: 34.05, + scheduledAt: FUTURE, + }) + const foreign = await uploadBy(await newUser("uploader")) + + await expect( + cleanups.updateCleanup(cleanupId, { galleryMediaIds: [foreign.id] }, host), + ).rejects.toMatchObject(rejects422) + + const [row] = await h.sql<{ gallery_media_ids: string[] }[]>` + SELECT gallery_media_ids FROM cleanups WHERE id = ${cleanupId} + ` + expect(row!.gallery_media_ids).toEqual([]) + expect(await purposeOf(foreign.id)).toBe("report") + }) + + it("a new organization refuses another account's upload as its logo", async () => { + const creator = await newUser("org creator") + const foreign = await uploadBy(await newUser("uploader")) + + await expect(newOrg(creator, foreign.id)).rejects.toMatchObject(rejects422) + expect(await purposeOf(foreign.id)).toBe("report") + }) + + it("an organization edit refuses another account's upload as its logo", async () => { + const owner = await newUser("org owner") + const created = await newOrg(owner, null) + if (created === "slug_taken") throw new Error("slug unexpectedly taken") + const foreign = await uploadBy(await newUser("uploader")) + + await expect( + orgs.updateOrganizationTx(created.id, { logoMediaId: foreign.id }, new Date(), owner), + ).rejects.toMatchObject(rejects422) + + const [row] = await h.sql<{ logo_media_id: string | null }[]>` + SELECT logo_media_id FROM organizations WHERE id = ${created.id} + ` + expect(row!.logo_media_id).toBeNull() + }) + + it("an organization edit claims the editor's own upload as its logo", async () => { + const owner = await newUser("org owner") + const created = await newOrg(owner, null) + if (created === "slug_taken") throw new Error("slug unexpectedly taken") + const own = await uploadBy(owner) + + await orgs.updateOrganizationTx(created.id, { logoMediaId: own.id }, new Date(), owner) + + expect(await purposeOf(own.id)).toBe("org_logo") + }) + + it("a verification application refuses another account's upload as a document", async () => { + const owner = await newUser("org owner") + const created = await newOrg(owner, null) + if (created === "slug_taken") throw new Error("slug unexpectedly taken") + const foreign = await uploadBy(await newUser("uploader")) + + await expect( + orgs.applyVerificationTx({ + verificationId: randomUUID(), + organizationId: created.id, + kind: "nonprofit", + einNumber: null, + documentMediaIds: [foreign.id], + note: null, + submittedBy: owner, + now: new Date(), + }), + ).rejects.toMatchObject(rejects422) + + expect(await purposeOf(foreign.id)).toBe("report") + const applications = await h.sql` + SELECT id FROM org_verifications WHERE organization_id = ${created.id} + ` + expect(applications).toHaveLength(0) + }) + }, +) diff --git a/services/api/test/integration/host-page-media-pg.test.ts b/services/api/test/integration/host-page-media-pg.test.ts index 8c16bd00..4e967c06 100644 --- a/services/api/test/integration/host-page-media-pg.test.ts +++ b/services/api/test/integration/host-page-media-pg.test.ts @@ -5,6 +5,7 @@ import { seedCleanup } from "../helpers/cleanups.js" import { makeDrizzleHostRegistrationRepository } from "../../src/services/host/registration-repository.drizzle.js" import type { EventPageBlock } from "@civfix/shared" import type { HostRegistrationRepository } from "../../src/services/host/registration-repository.types.js" +import { userUploader } from "../../src/services/media-uploader.js" const pg = await withPg() const FUTURE = new Date(Date.now() + 7 * 86_400_000) @@ -39,15 +40,19 @@ describe.skipIf(!pg)("event page media binding (integration)", () => { }) } - async function freshUpload(ageSeconds = 0): Promise<{ id: string; r2Key: string }> { + async function freshUpload( + over: { ageSeconds?: number; uploader?: string } = {}, + ): Promise<{ id: string; servedKey: string }> { const id = randomUUID() - const r2Key = `uploads/2026/09/${id}` + const servedKey = `served/2026/09/${id}` await h.sql` - INSERT INTO media_assets (id, upload_id, kind, r2_key, status, byte_size, created_at) - VALUES (${id}, ${randomUUID()}, 'image', ${r2Key}, 'ready', 10, - now() - make_interval(secs => ${ageSeconds})) + INSERT INTO media_assets ( + id, upload_id, kind, r2_key, served_key, status, byte_size, uploader, created_at + ) + VALUES (${id}, ${randomUUID()}, 'image', ${`uploads/2026/09/${id}`}, ${servedKey}, 'ready', 10, + ${over.uploader ?? null}, now() - make_interval(secs => ${over.ageSeconds ?? 0})) ` - return { id, r2Key } + return { id, servedKey } } function sponsorsBlock(mediaId: string): EventPageBlock { @@ -65,6 +70,7 @@ describe.skipIf(!pg)("event page media binding (integration)", () => { const outcome = await repo.savePage({ cleanupId, + actorUserId: organizer, slug: undefined, themeAccent: undefined, blocks: [sponsorsBlock(logo.id)], @@ -81,16 +87,17 @@ describe.skipIf(!pg)("event page media binding (integration)", () => { expect(bound.map((row) => row.media_id)).toEqual([logo.id]) const keys = await repo.mediaKeysFor(cleanupId, [logo.id]) - expect(keys.get(logo.id)).toBe(logo.r2Key) + expect(keys.get(logo.id)).toBe(logo.servedKey) }) it("refuses a stale upload nothing on this event ever referenced", async () => { const organizer = await newUser("Organizer") const cleanupId = await newCleanup(organizer) - const stale = await freshUpload(24 * 60 * 60) + const stale = await freshUpload({ ageSeconds: 24 * 60 * 60 }) const outcome = await repo.savePage({ cleanupId, + actorUserId: organizer, slug: undefined, themeAccent: undefined, blocks: [sponsorsBlock(stale.id)], @@ -110,6 +117,7 @@ describe.skipIf(!pg)("event page media binding (integration)", () => { await repo.savePage({ cleanupId, + actorUserId: organizer, slug: undefined, themeAccent: undefined, blocks: [sponsorsBlock(first.id)], @@ -120,6 +128,7 @@ describe.skipIf(!pg)("event page media binding (integration)", () => { }) await repo.savePage({ cleanupId, + actorUserId: organizer, slug: undefined, themeAccent: undefined, blocks: [sponsorsBlock(second.id)], @@ -143,6 +152,7 @@ describe.skipIf(!pg)("event page media binding (integration)", () => { await repo.savePage({ cleanupId, + actorUserId: organizer, slug: undefined, themeAccent: undefined, blocks: [sponsorsBlock(firstCover.id)], @@ -153,6 +163,7 @@ describe.skipIf(!pg)("event page media binding (integration)", () => { }) await repo.savePage({ cleanupId, + actorUserId: organizer, slug: undefined, themeAccent: undefined, blocks: [sponsorsBlock(firstCover.id)], @@ -163,7 +174,63 @@ describe.skipIf(!pg)("event page media binding (integration)", () => { }) const keys = await repo.mediaKeysFor(cleanupId, [firstCover.id]) - expect(keys.get(firstCover.id)).toBe(firstCover.r2Key) + expect(keys.get(firstCover.id)).toBe(firstCover.servedKey) + }) + + it("refuses another account's fresh upload as a block image or the cover", async () => { + const organizer = await newUser("Organizer") + const cleanupId = await newCleanup(organizer) + const foreign = await freshUpload({ uploader: userUploader(await newUser("Uploader")) }) + + const asBlock = await repo.savePage({ + cleanupId, + actorUserId: organizer, + slug: undefined, + themeAccent: undefined, + blocks: [sponsorsBlock(foreign.id)], + blockMediaIds: [foreign.id], + seo: undefined, + coverMediaId: undefined, + now: new Date(), + }) + expect(asBlock.kind).toBe("block_media_not_found") + + const asCover = await repo.savePage({ + cleanupId, + actorUserId: organizer, + slug: undefined, + themeAccent: undefined, + blocks: [], + blockMediaIds: [], + seo: undefined, + coverMediaId: foreign.id, + now: new Date(), + }) + expect(asCover.kind).toBe("cover_not_found") + + const [row] = await h.sql<{ purpose: string }[]>` + SELECT purpose FROM media_assets WHERE id = ${foreign.id} + ` + expect(row!.purpose).toBe("report") + }) + + it("claims the saving host's own attributed upload", async () => { + const organizer = await newUser("Organizer") + const cleanupId = await newCleanup(organizer) + const own = await freshUpload({ uploader: userUploader(organizer) }) + + const outcome = await repo.savePage({ + cleanupId, + actorUserId: organizer, + slug: undefined, + themeAccent: undefined, + blocks: [sponsorsBlock(own.id)], + blockMediaIds: [own.id], + seo: undefined, + coverMediaId: own.id, + now: new Date(), + }) + expect(outcome.kind).toBe("saved") }) it("never presigns another event's media, even for a ready event_cover", async () => { @@ -184,6 +251,7 @@ describe.skipIf(!pg)("event page media binding (integration)", () => { const outcome = await repo.savePage({ cleanupId: otherEvent, + actorUserId: stranger, slug: undefined, themeAccent: undefined, blocks: [sponsorsBlock(cover.id)], diff --git a/services/api/test/unit/host/page-service.test.ts b/services/api/test/unit/host/page-service.test.ts index 194039f4..483cf9b7 100644 --- a/services/api/test/unit/host/page-service.test.ts +++ b/services/api/test/unit/host/page-service.test.ts @@ -51,7 +51,7 @@ describe("event page service", () => { }) it("saves blocks and a slug, then publishes", async () => { - await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }) + await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }, HOST) const published = await h.service.publish({ id: EVENT, published: true }, HOST) expect(published.status).toBe("published") expect(published.slug).toBe("beach-sweep") @@ -61,7 +61,7 @@ describe("event page service", () => { await expect(h.service.publish({ id: EVENT, published: true }, HOST)).rejects.toBeInstanceOf( AppError, ) - await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [] }) + await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [] }, HOST) await expect(h.service.publish({ id: EVENT, published: true }, HOST)).rejects.toMatchObject({ fields: { blocks: "add at least one block before publishing" }, }) @@ -69,13 +69,15 @@ describe("event page service", () => { it("refuses a reserved slug and a slug another event already holds", async () => { const reserved = [...RESERVED_SLUGS][0] as string - await expect(h.service.save({ id: EVENT, slug: reserved, blocks: [] })).rejects.toMatchObject({ + await expect( + h.service.save({ id: EVENT, slug: reserved, blocks: [] }, HOST), + ).rejects.toMatchObject({ fields: { slug: "that address is reserved" }, }) h.repo.seedEvent({ cleanupId: OTHER_EVENT, pageSlug: "taken-slug" }) await expect( - h.service.save({ id: EVENT, slug: "taken-slug", blocks: [] }), + h.service.save({ id: EVENT, slug: "taken-slug", blocks: [] }, HOST), ).rejects.toMatchObject({ fields: { slug: "that address is already taken" } }) }) @@ -149,12 +151,20 @@ describe("event page service", () => { it("resolves block mediaIds to presigned urls and never stores the resolved url", async () => { h.repo.mediaKeys.set(MEDIA, "covers/hero.jpg") - const saved = await h.service.save({ - id: EVENT, - blocks: [ - { id: "b1", kind: "hero", mediaId: MEDIA, imageUrl: "https://media.civfix.org/stale.jpg" }, - ], - }) + const saved = await h.service.save( + { + id: EVENT, + blocks: [ + { + id: "b1", + kind: "hero", + mediaId: MEDIA, + imageUrl: "https://media.civfix.org/stale.jpg", + }, + ], + }, + HOST, + ) const hero = saved.blocks[0] as { imageUrl?: string } expect(hero.imageUrl).toBe("memory://covers/hero.jpg") const stored = h.repo.pages.get(EVENT)?.blocks[0] as { imageUrl?: string } @@ -163,26 +173,32 @@ describe("event page service", () => { it("resolves a fresh upload used as a sponsor logo", async () => { h.repo.mediaKeys.set(MEDIA, "pages/sponsor.png") - const saved = await h.service.save({ - id: EVENT, - blocks: [ - { - id: "b1", - kind: "sponsors", - entries: [{ name: "Acme", logoMediaId: MEDIA, url: "https://acme.example" }], - }, - ], - }) + const saved = await h.service.save( + { + id: EVENT, + blocks: [ + { + id: "b1", + kind: "sponsors", + entries: [{ name: "Acme", logoMediaId: MEDIA, url: "https://acme.example" }], + }, + ], + }, + HOST, + ) const sponsors = saved.blocks[0] as { entries: { logoUrl?: string }[] } expect(sponsors.entries[0]?.logoUrl).toBe("memory://pages/sponsor.png") }) it("does not resolve media bound to another event's page", async () => { h.repo.mediaKeys.set(MEDIA, "pages/private-cover.jpg") - await h.service.save({ - id: OTHER_EVENT, - blocks: [{ id: "b1", kind: "hero", mediaId: MEDIA }], - }) + await h.service.save( + { + id: OTHER_EVENT, + blocks: [{ id: "b1", kind: "hero", mediaId: MEDIA }], + }, + HOST, + ) const keys = await h.repo.mediaKeysFor(EVENT, [MEDIA]) expect(keys.size).toBe(0) const theirs = await h.repo.mediaKeysFor(OTHER_EVENT, [MEDIA]) @@ -193,31 +209,37 @@ describe("event page service", () => { const NEXT_COVER = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" h.repo.mediaKeys.set(MEDIA, "covers/first.jpg") h.repo.mediaKeys.set(NEXT_COVER, "covers/second.jpg") - await h.service.save({ - id: EVENT, - coverMediaId: MEDIA, - blocks: [{ id: "b1", kind: "hero", mediaId: MEDIA }], - }) - await h.service.save({ - id: EVENT, - coverMediaId: NEXT_COVER, - blocks: [{ id: "b1", kind: "hero", mediaId: MEDIA }], - }) + await h.service.save( + { + id: EVENT, + coverMediaId: MEDIA, + blocks: [{ id: "b1", kind: "hero", mediaId: MEDIA }], + }, + HOST, + ) + await h.service.save( + { + id: EVENT, + coverMediaId: NEXT_COVER, + blocks: [{ id: "b1", kind: "hero", mediaId: MEDIA }], + }, + HOST, + ) const keys = await h.repo.mediaKeysFor(EVENT, [MEDIA]) expect(keys.get(MEDIA)).toBe("covers/first.jpg") }) it("drops the binding when a block stops referencing the image", async () => { h.repo.mediaKeys.set(MEDIA, "pages/hero.jpg") - await h.service.save({ id: EVENT, blocks: [{ id: "b1", kind: "hero", mediaId: MEDIA }] }) - await h.service.save({ id: EVENT, blocks: [ABOUT] }) + await h.service.save({ id: EVENT, blocks: [{ id: "b1", kind: "hero", mediaId: MEDIA }] }, HOST) + await h.service.save({ id: EVENT, blocks: [ABOUT] }, HOST) const keys = await h.repo.mediaKeysFor(EVENT, [MEDIA]) expect(keys.size).toBe(0) }) it("refuses a block mediaId that is not ready platform media", async () => { await expect( - h.service.save({ id: EVENT, blocks: [{ id: "b1", kind: "hero", mediaId: MEDIA }] }), + h.service.save({ id: EVENT, blocks: [{ id: "b1", kind: "hero", mediaId: MEDIA }] }, HOST), ).rejects.toBeInstanceOf(AppError) }) @@ -247,7 +269,7 @@ describe("event page service", () => { }) it("404s a public page that is not published, for everyone but the team", async () => { - await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }) + await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }, HOST) await expect( h.service.getPublicEventPage({ slug: "beach-sweep" }, STRANGER), ).rejects.toBeInstanceOf(AppError) @@ -262,7 +284,7 @@ describe("event page service", () => { presignCover: (key) => Promise.resolve({ url: `memory://${key}` }), now: () => NOW, }) - await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }) + await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }, HOST) await expect( memberService.getPublicEventPage({ slug: "beach-sweep" }, STRANGER), @@ -285,7 +307,7 @@ describe("event page service", () => { now: () => NOW, }) h.repo.seedEvent({ cleanupId: EVENT, visibility: "private" }) - await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }) + await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }, HOST) await h.service.publish({ id: EVENT, published: true }, HOST) await expect( @@ -295,7 +317,7 @@ describe("event page service", () => { it("404s a private event's page for a stranger even when published", async () => { h.repo.seedEvent({ cleanupId: EVENT, visibility: "private" }) - await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }) + await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }, HOST) await h.service.publish({ id: EVENT, published: true }, HOST) await expect( @@ -308,7 +330,7 @@ describe("event page service", () => { it("serves an unlisted event's page with noindex", async () => { h.repo.seedEvent({ cleanupId: EVENT, visibility: "unlisted" }) - await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }) + await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }, HOST) await h.service.publish({ id: EVENT, published: true }, HOST) const page = await h.service.getPublicEventPage({ slug: "beach-sweep" }, null) @@ -320,7 +342,7 @@ describe("event page service", () => { it("serves the public consent versions and hides hidden ticket types", async () => { h.repo.seedTicketType({ cleanupId: EVENT, name: "Public" }) h.repo.seedTicketType({ cleanupId: EVENT, name: "Hidden", visibility: "hidden" }) - await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }) + await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }, HOST) await h.service.publish({ id: EVENT, published: true }, HOST) const page = await h.service.getPublicEventPage({ slug: "beach-sweep" }, null) @@ -333,7 +355,7 @@ describe("event page service", () => { }) it("caps publishes per host per day", async () => { - await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }) + await h.service.save({ id: EVENT, slug: "beach-sweep", blocks: [ABOUT] }, HOST) for (let i = 0; i < 20; i++) { await h.service.publish({ id: EVENT, published: i % 2 === 0 }, HOST) } From 4edae13d32e11ec67dda52c08fcd44fa2326884f Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:48:40 +0000 Subject: [PATCH 38/45] only the uploader can finalize an upload --- .../api/src/services/media-intake-service.ts | 23 ++++++++++++++----- .../src/services/media-repository.drizzle.ts | 1 + services/api/src/services/media-uploader.ts | 12 ++++++++++ services/api/test/helpers/media.ts | 1 + 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/services/api/src/services/media-intake-service.ts b/services/api/src/services/media-intake-service.ts index d1aa56bf..0ca783c4 100644 --- a/services/api/src/services/media-intake-service.ts +++ b/services/api/src/services/media-intake-service.ts @@ -15,7 +15,8 @@ import type { MEDIA_PURPOSE_VALUES } from "../db/schema/types-host.js" type MediaPurpose = (typeof MEDIA_PURPOSE_VALUES)[number] import type { Jobs, Storage } from "@civfix/shared/interfaces" import { readEtag } from "./media-etag.js" -import { uploaderOf } from "./media-uploader.js" +import { uploaderOf, uploadersOf } from "./media-uploader.js" +import { MEDIA_CLAIM_WINDOW_SEC } from "./host/event-media.js" import { makeMediaPresigner, makePrivateMediaPresigner } from "./media-presign.js" import { makeUnboundOnlyMediaViewAuthorizer, @@ -76,6 +77,7 @@ export interface MediaAssetView { postId?: string | null createdAt?: Date | null finalizedAt?: Date | null + uploader?: string | null } export interface NewMediaAsset { @@ -164,6 +166,17 @@ export function makeMediaIntakeService(deps: MediaIntakeDeps): MediaIntakeServic const presignPrivate = makePrivateMediaPresigner(deps.storage) const authorizer = deps.authorizer ?? makeUnboundOnlyMediaViewAuthorizer(now) + // The uploadId is readable from every served URL, so only the session that created the upload may + // finalize it and set the worker on its bytes. A NULL uploader predates attribution and passes only + // inside the claim window, after which the orphan sweep has already reclaimed it. + function finalizableBy(asset: MediaAssetView, owner: MediaOwner): boolean { + if (asset.uploader == null) { + const createdAt = asset.createdAt?.getTime() + return createdAt !== undefined && now().getTime() - createdAt < MEDIA_CLAIM_WINDOW_SEC * 1000 + } + return uploadersOf(owner).includes(asset.uploader) + } + return { async createUpload( input: CreateMediaUploadRequest, @@ -212,12 +225,10 @@ export function makeMediaIntakeService(deps: MediaIntakeDeps): MediaIntakeServic } }, - async finalize( - input: FinalizeMediaRequest, - _owner: MediaOwner, - ): Promise { + async finalize(input: FinalizeMediaRequest, owner: MediaOwner): Promise { const asset = await deps.repo.findByUploadId(input.uploadId) - if (!asset) { + // A foreign upload answers exactly like a missing one, so the uploadId never confirms it exists. + if (!asset || !finalizableBy(asset, owner)) { throw AppError.notFound("Unknown upload") } diff --git a/services/api/src/services/media-repository.drizzle.ts b/services/api/src/services/media-repository.drizzle.ts index bf906b67..bb03a3c7 100644 --- a/services/api/src/services/media-repository.drizzle.ts +++ b/services/api/src/services/media-repository.drizzle.ts @@ -22,6 +22,7 @@ function toView(row: typeof mediaAssets.$inferSelect): MediaAssetView { postId: row.postId, finalizedAt: row.finalizedAt, createdAt: row.createdAt, + uploader: row.uploader, } } diff --git a/services/api/src/services/media-uploader.ts b/services/api/src/services/media-uploader.ts index 2b526870..4fe0987f 100644 --- a/services/api/src/services/media-uploader.ts +++ b/services/api/src/services/media-uploader.ts @@ -18,3 +18,15 @@ export function uploaderOf(owner: { if (owner.anonSessionId) return anonUploader(owner.anonSessionId) return UNSESSIONED_UPLOADER } + +// Every subject this caller's request proves, where uploaderOf picks the one a new upload is stored under: +// a guest who signs in between presign and finalize still owns the upload its anon cookie created. +export function uploadersOf(owner: { + userId?: string | undefined + anonSessionId?: string | undefined +}): string[] { + const subjects: string[] = [] + if (owner.userId) subjects.push(userUploader(owner.userId)) + if (owner.anonSessionId) subjects.push(anonUploader(owner.anonSessionId)) + return subjects.length > 0 ? subjects : [UNSESSIONED_UPLOADER] +} diff --git a/services/api/test/helpers/media.ts b/services/api/test/helpers/media.ts index 4eff6b44..402d15de 100644 --- a/services/api/test/helpers/media.ts +++ b/services/api/test/helpers/media.ts @@ -31,6 +31,7 @@ export class InMemoryMediaRepository implements MediaRepository { postId: null, finalizedAt: null, createdAt: new Date(), + uploader: row.uploader, } this.byId.set(row.id, stored) this.uploadIndex.set(row.uploadId, row.id) From e4a1543ee10f84bb3af076d695f7df920b4ea0be Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:48:40 +0000 Subject: [PATCH 39/45] group avatar claim holds its lock until the group is written --- .../services/chat-group-repository.drizzle.ts | 50 ++++---- .../api/src/services/chat-group-service.ts | 43 +++---- .../test/integration/avatar-media-pg.test.ts | 114 ++++++++++++++---- .../unit/chat-group-service-gates.test.ts | 1 - 4 files changed, 131 insertions(+), 77 deletions(-) diff --git a/services/api/src/services/chat-group-repository.drizzle.ts b/services/api/src/services/chat-group-repository.drizzle.ts index 0ebc119a..bb4de4d0 100644 --- a/services/api/src/services/chat-group-repository.drizzle.ts +++ b/services/api/src/services/chat-group-repository.drizzle.ts @@ -51,7 +51,7 @@ export interface CreateChatGroupInput { kind: ChatGroupKind name: string description: string | null - avatarMediaId: string | null + avatarUploadId: string | null ownerId: string visibility: ChatGroupVisibility } @@ -59,19 +59,14 @@ export interface CreateChatGroupInput { export interface UpdateChatGroupPatch { name?: string description?: string | null - avatarMediaId?: string + avatarUploadId?: string visibility?: ChatGroupVisibility } -export interface GroupAvatarClaimant { - uploaderUserId: string - groupId?: string | undefined -} - export interface ChatGroupRepository { create(input: CreateChatGroupInput, memberIds: string[]): Promise findById(id: string): Promise - update(id: string, patch: UpdateChatGroupPatch): Promise + update(id: string, patch: UpdateChatGroupPatch, actorId: string): Promise roleOf(groupId: string, userId: string): Promise accessOf(groupId: string, userId: string): Promise addMembers(groupId: string, userIds: string[]): Promise @@ -90,7 +85,6 @@ export interface ChatGroupRepository { cursor: string | null, limit: number, ): Promise<{ members: GroupMemberView[]; nextCursor: string | null }> - findMediaIdByUploadId(uploadId: string, claimant: GroupAvatarClaimant): Promise invitableIdsOf(actorId: string, candidateIds: string[]): Promise blockedPairsAmong(userIds: string[]): Promise> listMemberIds(groupId: string, limit?: number): Promise @@ -211,9 +205,17 @@ export function makeChatGroupRepository(sql: Sql, presign?: PresignMedia): ChatG return { async create(input: CreateChatGroupInput, memberIds: string[]): Promise { return sql.begin(async (tx) => { + const avatarMediaId = + input.avatarUploadId === null + ? null + : ( + await resolveAvatarMediaOrThrow(tx, input.avatarUploadId, { + uploader: userUploader(input.ownerId), + }) + ).id const [g] = await tx<{ id: string }[]>` INSERT INTO chat_groups (kind, name, description, avatar_media_id, owner_id, visibility) - VALUES (${input.kind}, ${input.name}, ${input.description}, ${input.avatarMediaId}, + VALUES (${input.kind}, ${input.name}, ${input.description}, ${avatarMediaId}, ${input.ownerId}, ${input.visibility}) RETURNING id ` @@ -260,15 +262,25 @@ export function makeChatGroupRepository(sql: Sql, presign?: PresignMedia): ChatG return row ? toGroupView(row) : null }, - async update(id: string, patch: UpdateChatGroupPatch): Promise { + async update(id: string, patch: UpdateChatGroupPatch, actorId: string): Promise { const set: Record = {} if (patch.name !== undefined) set["name"] = patch.name if (patch.description !== undefined) set["description"] = patch.description - if (patch.avatarMediaId !== undefined) set["avatar_media_id"] = patch.avatarMediaId if (patch.visibility !== undefined) set["visibility"] = patch.visibility - const cols = Object.keys(set) - if (cols.length === 0) return - await sql`UPDATE chat_groups SET ${sql(set, ...cols)} WHERE id = ${id}` + const avatarUploadId = patch.avatarUploadId + if (Object.keys(set).length === 0 && avatarUploadId === undefined) return + // The claim's FOR UPDATE must still hold the media row when avatar_media_id is written, or a + // report, post or chat claim can bind the same upload between the check and the write. + await sql.begin(async (tx) => { + if (avatarUploadId !== undefined) { + const media = await resolveAvatarMediaOrThrow(tx, avatarUploadId, { + uploader: userUploader(actorId), + groupId: id, + }) + set["avatar_media_id"] = media.id + } + await tx`UPDATE chat_groups SET ${tx(set, ...Object.keys(set))} WHERE id = ${id}` + }) }, async roleOf(groupId: string, userId: string): Promise { @@ -406,14 +418,6 @@ export function makeChatGroupRepository(sql: Sql, presign?: PresignMedia): ChatG } }, - async findMediaIdByUploadId(uploadId: string, claimant: GroupAvatarClaimant): Promise { - const media = await resolveAvatarMediaOrThrow(sql, uploadId, { - uploader: userUploader(claimant.uploaderUserId), - groupId: claimant.groupId, - }) - return media.id - }, - async invitableIdsOf(actorId: string, candidateIds: string[]): Promise { if (candidateIds.length === 0) return [] const rows = await sql<{ id: string }[]>` diff --git a/services/api/src/services/chat-group-service.ts b/services/api/src/services/chat-group-service.ts index b3606ca5..a12694be 100644 --- a/services/api/src/services/chat-group-service.ts +++ b/services/api/src/services/chat-group-service.ts @@ -90,14 +90,6 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi } } - async function resolveAvatar( - uploadId: string | undefined, - uploaderUserId: string, - ): Promise { - if (uploadId === undefined) return null - return groups.findMediaIdByUploadId(uploadId, { uploaderUserId }) - } - async function filterInvitees(actorId: string, memberIds: string[]): Promise { const unique = [...new Set(memberIds)].filter((id) => id !== actorId && !isOfficialAccount(id)) return groups.invitableIdsOf(actorId, unique) @@ -173,18 +165,14 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi async createGroup(ownerId, req) { assertNoSlur(req.name, "name") assertNoSlur(req.description ?? null, "description") - const [avatarMediaId, actorFiltered] = await Promise.all([ - resolveAvatar(req.avatarUploadId, ownerId), - filterInvitees(ownerId, req.memberIds), - ]) - const memberIds = await filterPairwise(actorFiltered) + const memberIds = await filterPairwise(await filterInvitees(ownerId, req.memberIds)) const id = await groups.create( { kind: req.kind, name: req.name, description: req.description !== undefined && req.description !== "" ? req.description : null, - avatarMediaId, + avatarUploadId: req.avatarUploadId ?? null, ownerId, visibility: req.visibility, }, @@ -216,21 +204,18 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi "visibility_owner_only", ) } - await groups.update(req.id, { - ...(req.name !== undefined ? { name: req.name } : {}), - ...(req.description !== undefined - ? { description: req.description === "" ? null : req.description } - : {}), - ...(req.avatarUploadId !== undefined - ? { - avatarMediaId: await groups.findMediaIdByUploadId(req.avatarUploadId, { - uploaderUserId: userId, - groupId: req.id, - }), - } - : {}), - ...(req.visibility !== undefined ? { visibility: req.visibility } : {}), - }) + await groups.update( + req.id, + { + ...(req.name !== undefined ? { name: req.name } : {}), + ...(req.description !== undefined + ? { description: req.description === "" ? null : req.description } + : {}), + ...(req.avatarUploadId !== undefined ? { avatarUploadId: req.avatarUploadId } : {}), + ...(req.visibility !== undefined ? { visibility: req.visibility } : {}), + }, + userId, + ) const updated = await requireGroup(req.id) return toGroupDTO(updated, userId, role) }, diff --git a/services/api/test/integration/avatar-media-pg.test.ts b/services/api/test/integration/avatar-media-pg.test.ts index 8816372f..d66d1836 100644 --- a/services/api/test/integration/avatar-media-pg.test.ts +++ b/services/api/test/integration/avatar-media-pg.test.ts @@ -11,7 +11,7 @@ import type { PresignMedia } from "../../src/services/media-presign.js" const pg = await withPg() const ANY_CLAIMANT = { uploader: userUploader(randomUUID()) } -const GROUP_ADMIN = { uploaderUserId: randomUUID() } +const GROUP_ADMIN = randomUUID() const fakePresign: PresignMedia = (r2Key, thumbKey) => Promise.resolve({ @@ -432,64 +432,130 @@ describe.skipIf(!pg)("CVX-004 avatar media validation (integration)", () => { }) }) - describe("group avatar (findMediaIdByUploadId)", () => { + describe("group avatar (claimed inside the group write)", () => { const groups = () => makeChatGroupRepository(h.sql, fakePresign) - it("resolves a ready, unbound image to its media id", async () => { + async function groupAvatarOf(groupId: string): Promise { + const [row] = await h.sql<{ avatar_media_id: string | null }[]>` + SELECT avatar_media_id FROM chat_groups WHERE id = ${groupId} + ` + return row!.avatar_media_id + } + + async function setAvatar(groupId: string, uploadId: string, actorId = GROUP_ADMIN) { + await groups().update(groupId, { avatarUploadId: uploadId }, actorId) + } + + it("binds a ready, unbound image", async () => { + const group = await seedGroup() const media = await seedMedia() - expect(await groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN)).toBe(media.id) + await setAvatar(group, media.uploadId) + expect(await groupAvatarOf(group)).toBe(media.id) }) it("rejects a nonexistent uploadId (422)", async () => { - await expect(groups().findMediaIdByUploadId(randomUUID(), GROUP_ADMIN)).rejects.toMatchObject( - rejects422, - ) + const group = await seedGroup() + await expect(setAvatar(group, randomUUID())).rejects.toMatchObject(rejects422) + expect(await groupAvatarOf(group)).toBeNull() }) it("rejects a rejected upload", async () => { + const group = await seedGroup() const media = await seedMedia({ status: "rejected" }) - await expect( - groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN), - ).rejects.toMatchObject(rejects422) + await expect(setAvatar(group, media.uploadId)).rejects.toMatchObject(rejects422) }) it("rejects a still-validating upload that was never finalized", async () => { + const group = await seedGroup() const media = await seedMedia({ status: "validating" }) - await expect( - groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN), - ).rejects.toMatchObject(rejects422) + await expect(setAvatar(group, media.uploadId)).rejects.toMatchObject(rejects422) }) it("accepts a finalized-but-validating upload", async () => { + const group = await seedGroup() const media = await seedMedia({ status: "validating", finalizedAt: new Date() }) - expect(await groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN)).toBe(media.id) + await setAvatar(group, media.uploadId) + expect(await groupAvatarOf(group)).toBe(media.id) }) it("rejects a foreign user's private (chat-bound) media", async () => { + const group = await seedGroup() const media = await seedMedia({ chatMessageId: randomUUID() }) - await expect( - groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN), - ).rejects.toMatchObject(rejects422) + await expect(setAvatar(group, media.uploadId)).rejects.toMatchObject(rejects422) }) it("rejects another user's report-bound media", async () => { + const group = await seedGroup() const reportId = await seedForeignReport() const media = await seedMedia({ reportId }) - await expect( - groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN), - ).rejects.toMatchObject(rejects422) + await expect(setAvatar(group, media.uploadId)).rejects.toMatchObject(rejects422) }) it("F074: rejects an avatar another group already holds, and stays idempotent for the holder", async () => { const media = await seedMedia() const holder = await seedGroup() + const other = await seedGroup() await claimGroupAvatar(holder, media.id) + await expect(setAvatar(other, media.uploadId)).rejects.toMatchObject(rejects422) + await setAvatar(holder, media.uploadId) + expect(await groupAvatarOf(holder)).toBe(media.id) + }) + + it("rejects another account's upload and leaves the rest of the edit unwritten", async () => { + const group = await seedGroup() + const media = await seedMedia({ uploader: userUploader(randomUUID()) }) await expect( - groups().findMediaIdByUploadId(media.uploadId, GROUP_ADMIN), + groups().update(group, { name: "Renamed", avatarUploadId: media.uploadId }, GROUP_ADMIN), ).rejects.toMatchObject(rejects422) - expect( - await groups().findMediaIdByUploadId(media.uploadId, { ...GROUP_ADMIN, groupId: holder }), - ).toBe(media.id) + const [row] = await h.sql<{ name: string; avatar_media_id: string | null }[]>` + SELECT name, avatar_media_id FROM chat_groups WHERE id = ${group} + ` + expect(row).toEqual({ name: "Avatar Group", avatar_media_id: null }) + }) + + it("accepts the acting admin's own attributed upload", async () => { + const group = await seedGroup() + const media = await seedMedia({ uploader: userUploader(GROUP_ADMIN) }) + await setAvatar(group, media.uploadId) + expect(await groupAvatarOf(group)).toBe(media.id) + }) + + it("a new group refuses another account's upload and creates nothing", async () => { + const ownerId = await seedUser() + const media = await seedMedia({ uploader: userUploader(randomUUID()) }) + const name = `Refused ${randomUUID()}` + await expect( + groups().create( + { + kind: "group", + name, + description: null, + avatarUploadId: media.uploadId, + ownerId, + visibility: "private", + }, + [], + ), + ).rejects.toMatchObject(rejects422) + const rows = await h.sql`SELECT id FROM chat_groups WHERE name = ${name}` + expect(rows).toHaveLength(0) + }) + + it("a new group binds its owner's own upload", async () => { + const ownerId = await seedUser() + const media = await seedMedia({ uploader: userUploader(ownerId) }) + const id = await groups().create( + { + kind: "group", + name: "Owned avatar", + description: null, + avatarUploadId: media.uploadId, + ownerId, + visibility: "private", + }, + [], + ) + expect(await groupAvatarOf(id)).toBe(media.id) }) }) }) 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 aedaf518..10d9f999 100644 --- a/services/api/test/unit/chat-group-service-gates.test.ts +++ b/services/api/test/unit/chat-group-service-gates.test.ts @@ -73,7 +73,6 @@ function fakeRepo(opts: FakeOpts = {}): FakeRepo { isBanned: (_g: string, userId: string) => Promise.resolve(bannedSet.has(userId)), bannedSet, listMembers: () => Promise.resolve({ members: [], nextCursor: null }), - findMediaIdByUploadId: () => Promise.resolve(null), } as unknown as FakeRepo } From fed8a5a4b885b8d2f27063d8cc61ed9030802dba Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:48:40 +0000 Subject: [PATCH 40/45] a lost anonymous submit race no longer rotates the winner's claim code --- .../src/services/anon-repository.drizzle.ts | 26 ++++++--------- .../unit/anon-repository-security.test.ts | 32 ++++++++++++++----- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/services/api/src/services/anon-repository.drizzle.ts b/services/api/src/services/anon-repository.drizzle.ts index 924838ac..801450e4 100644 --- a/services/api/src/services/anon-repository.drizzle.ts +++ b/services/api/src/services/anon-repository.drizzle.ts @@ -33,14 +33,15 @@ * 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; we - * then replay the winner's stored snapshot - but only when the winner is the SAME anon session (F028), - * so a key squatted by a stranger never replays their report to somebody else. + * 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 * 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. + * 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" @@ -313,18 +314,11 @@ export function makeDrizzleAnonReportRepository( return { kind: "created", snapshot } } catch (err) { if (isUniqueViolation(err)) { - const stored = await replaySnapshot( - args.idempotencyKey, - ANON_REPORT_CREATE_SCOPE, - args.anonSessionId, - ) - if (stored) return { kind: "replayed", snapshot: stored } - // Nothing of OURS to replay: either the winner of the idempotency race has taken the key but - // not yet committed its snapshot, or the key belongs to a DIFFERENT anon session (reports' - // idempotency_key is globally unique, so a squatted key collides here). Both answer the - // retryable 409 the authenticated path answers (report-repository.createReportTx) instead of - // leaking the raw postgres error as a 500 - and, critically, instead of handing a stranger's - // snapshot + claim code to this caller (F028). + // The winner of the key race is still in flight to its client with the only live claim code, + // so this request must not rotate it, and the plaintext is not at rest to replay. A retry of + // the same key reaches findIdempotentSnapshot, which rotates for a client that lost that + // response. A key held by a different anon session lands here too (reports.idempotency_key is + // globally unique) and gets the same answer, never that session's report. throw AppError.conflict("Report submit is still settling; retry") } // A cap-reached AppError (or any other) propagates unchanged: the tx already rolled back, so no diff --git a/services/api/test/unit/anon-repository-security.test.ts b/services/api/test/unit/anon-repository-security.test.ts index fe0d058a..48904915 100644 --- a/services/api/test/unit/anon-repository-security.test.ts +++ b/services/api/test/unit/anon-repository-security.test.ts @@ -148,7 +148,7 @@ describe("replaying an anonymous submit", () => { expect(fake.statements.some((s) => /UPDATE reports/i.test(s.sql))).toBe(false) }) - it("replays with a fresh code when the key race is lost inside the create transaction", async () => { + it("leaves the winner's claim code alive when the key race is lost inside the create transaction", async () => { const fake = makeFakeSql([ { match: /reference_counters/i, rows: [{ next_val: 1 }] }, storedSnapshot({ reportId: REPORT_ID, status: "held" }), @@ -157,14 +157,30 @@ describe("replaying an anonymous submit", () => { const sql = fake.sql as unknown as Sql & { begin: unknown } sql.begin = () => Promise.reject(UNIQUE_VIOLATION) - const result = await makeDrizzleAnonReportRepository(sql, { - newClaimCode: () => FRESH_CODE, - }).createAnonReportTx(createArgs()) + await expect( + makeDrizzleAnonReportRepository(sql, { + newClaimCode: () => FRESH_CODE, + }).createAnonReportTx(createArgs()), + ).rejects.toMatchObject({ code: "CONFLICT", message: "Report submit is still settling; retry" }) - expect(result).toEqual({ - kind: "replayed", - snapshot: { reportId: REPORT_ID, status: "held", claimCode: FRESH_CODE }, - }) + expect(fake.statements.some((s) => /UPDATE reports/i.test(s.sql))).toBe(false) + }) + + it("still rotates on a later replay of the same key, so a retry after the race gets a working code", async () => { + const fake = makeFakeSql([ + storedSnapshot({ reportId: REPORT_ID, status: "held" }), + rotation([{ id: REPORT_ID }]), + ]) + + const replay = await repoOver(fake).findIdempotentSnapshot( + IDEMPOTENCY_KEY, + ANON_REPORT_CREATE_SCOPE, + ANON_ID, + ) + + expect(replay?.claimCode).toBe(FRESH_CODE) + const rotate = fake.statements.find((s) => /UPDATE reports/i.test(s.sql))! + expect(rotate.values).toContain(await sha256Hex(FRESH_CODE)) }) }) From 1ceb72e8db264081c3b4feb50337c1646d68493c Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 08:48:40 +0000 Subject: [PATCH 41/45] media claim actor tests --- .../api/test/unit/media-claim-actor.test.ts | 396 ++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 services/api/test/unit/media-claim-actor.test.ts diff --git a/services/api/test/unit/media-claim-actor.test.ts b/services/api/test/unit/media-claim-actor.test.ts new file mode 100644 index 00000000..70aef537 --- /dev/null +++ b/services/api/test/unit/media-claim-actor.test.ts @@ -0,0 +1,396 @@ +import { randomUUID } from "node:crypto" +import { describe, expect, it } from "vitest" +import { FakeJobs, FakeStorage } from "@civfix/shared/fakes" +import type { AppError } from "@civfix/shared" +import type { Sql } from "../../src/db/client.js" +import { makeChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" +import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" +import type { CreateCleanupTxArgs } from "../../src/services/cleanup-repository.types.js" +import { MEDIA_CLAIM_WINDOW_SEC } from "../../src/services/host/event-media.js" +import { makeDrizzleOrganizationRepository } from "../../src/services/host/organization-repository.drizzle.js" +import { makeDrizzleHostRegistrationRepository } from "../../src/services/host/registration-repository.drizzle.js" +import type { SavePageArgs } from "../../src/services/host/registration-repository.types.js" +import { + MEDIA_CHECKS_JOB, + makeMediaIntakeService, +} from "../../src/services/media-intake-service.js" +import { anonUploader, userUploader } from "../../src/services/media-uploader.js" +import { + makeFakeSql, + type FakeSqlControl, + type RecordedStatement, + type SqlHandler, +} from "../helpers/fake-sql.js" +import { InMemoryMediaRepository } from "../helpers/media.js" + +const MEDIA_ID = "44444444-4444-4444-8444-444444444444" +const GROUP_ID = "55555555-5555-4555-8555-555555555555" +const UPLOAD_ID = "66666666-6666-4666-8666-666666666666" +const MEDIA_CLAIM = /UPDATE media_assets\s+SET purpose/ + +function squash(text: string): string { + return text.replace(/\s+/g, " ").replace(/\(\s+/g, "(").replace(/\s+\)/g, ")").trim() +} + +function statementMatching(fake: FakeSqlControl, pattern: RegExp): RecordedStatement { + const found = fake.statements.find((s) => pattern.test(s.sql)) + if (!found) throw new Error(`no statement matched ${String(pattern)}`) + return found +} + +function expectClaimsAs(claim: RecordedStatement, userId: string): void { + const text = squash(claim.sql) + expect(text).toContain("media_assets.uploader IN (?)") + expect(text).toContain("OR media_assets.uploader IS NULL") + expect(text).toContain("media_assets.created_at > now() - make_interval(secs => ?)") + expect(claim.values).toContain(userUploader(userId)) + expect(claim.values).toContain(MEDIA_CLAIM_WINDOW_SEC) +} + +function repoSql(fake: FakeSqlControl): Sql { + return fake.sql as unknown as Sql +} + +function cleanupArgs(organizerUserId: string): CreateCleanupTxArgs { + return { + cleanupId: randomUUID(), + organizerUserId, + type: "site", + eventKind: "cleanup", + title: "Beach cleanup", + description: null, + lat: 33.99, + lng: -118.47, + scheduledAt: new Date("2026-10-01T17:00:00.000Z"), + status: "upcoming", + bring: null, + address: null, + addressSource: null, + jurisdictionGeoid: null, + jurCode: 1, + linkedReportIds: [], + slots: [], + host: { endsAt: new Date("2026-10-01T21:00:00.000Z"), coverMediaId: MEDIA_ID }, + } +} + +describe("event media claims bind only the acting host's own uploads", () => { + it("a new event claims its cover and gallery as the organizer", async () => { + const organizer = randomUUID() + const fake = makeFakeSql([{ match: /reference_counters/i, rows: [{ next_val: 1 }] }]) + + await expect( + makeDrizzleCleanupRepository(repoSql(fake)).createCleanupTx(cleanupArgs(organizer)), + ).rejects.toMatchObject({ httpStatus: 422 }) + + expectClaimsAs(statementMatching(fake, MEDIA_CLAIM), organizer) + }) + + it("an event edit claims new images as the editor, while images already on the event stay", async () => { + const editor = randomUUID() + const fake = makeFakeSql([{ match: /UPDATE cleanups SET/, rows: [{ id: randomUUID() }] }]) + + await expect( + makeDrizzleCleanupRepository(repoSql(fake)).updateCleanup( + randomUUID(), + { galleryMediaIds: [MEDIA_ID] }, + editor, + ), + ).rejects.toMatchObject({ httpStatus: 422 }) + + const claim = statementMatching(fake, MEDIA_CLAIM) + expectClaimsAs(claim, editor) + expect(squash(claim.sql)).toContain(") OR (media_assets.created_at > now()") + }) +}) + +describe("event page media claims bind only the acting host's own uploads", () => { + function pageArgs(actorUserId: string, over: Partial): SavePageArgs { + return { + cleanupId: randomUUID(), + actorUserId, + slug: undefined, + themeAccent: undefined, + blocks: [], + blockMediaIds: [], + seo: undefined, + coverMediaId: undefined, + now: new Date(), + ...over, + } + } + + it("claims block images as the saving host", async () => { + const host = randomUUID() + const fake = makeFakeSql([{ match: /FOR SHARE/, rows: [{ id: randomUUID() }] }]) + + const outcome = await makeDrizzleHostRegistrationRepository(repoSql(fake)).savePage( + pageArgs(host, { blockMediaIds: [MEDIA_ID] }), + ) + + expect(outcome).toEqual({ kind: "block_media_not_found" }) + expectClaimsAs(statementMatching(fake, MEDIA_CLAIM), host) + }) + + it("claims the page cover as the saving host", async () => { + const host = randomUUID() + const fake = makeFakeSql([{ match: /FOR SHARE/, rows: [{ id: randomUUID() }] }]) + + const outcome = await makeDrizzleHostRegistrationRepository(repoSql(fake)).savePage( + pageArgs(host, { coverMediaId: MEDIA_ID }), + ) + + expect(outcome).toEqual({ kind: "cover_not_found" }) + expectClaimsAs(statementMatching(fake, MEDIA_CLAIM), host) + }) +}) + +describe("organization media claims bind only the acting member's own uploads", () => { + it("a new organization claims its logo as its creator", async () => { + const creator = randomUUID() + const fake = makeFakeSql([]) + + await expect( + makeDrizzleOrganizationRepository(repoSql(fake)).createOrganizationTx({ + organizationId: randomUUID(), + slug: "tidy-org", + name: "Tidy Org", + description: null, + websiteUrl: null, + logoMediaId: MEDIA_ID, + socialLinks: null, + createdBy: creator, + now: new Date(), + }), + ).rejects.toMatchObject({ + httpStatus: 422, + fields: { logoMediaId: "That image is unavailable." }, + }) + + expectClaimsAs(statementMatching(fake, MEDIA_CLAIM), creator) + }) + + it("an organization edit claims its new logo as the editor", async () => { + const editor = randomUUID() + const fake = makeFakeSql([{ match: /UPDATE organizations SET/, rows: [{ id: randomUUID() }] }]) + + await expect( + makeDrizzleOrganizationRepository(repoSql(fake)).updateOrganizationTx( + randomUUID(), + { logoMediaId: MEDIA_ID }, + new Date(), + editor, + ), + ).rejects.toMatchObject({ + httpStatus: 422, + fields: { logoMediaId: "That image is unavailable." }, + }) + + expectClaimsAs(statementMatching(fake, MEDIA_CLAIM), editor) + }) + + it("a verification application claims its documents as the submitter", async () => { + const submitter = randomUUID() + const fake = makeFakeSql([]) + + await expect( + makeDrizzleOrganizationRepository(repoSql(fake)).applyVerificationTx({ + verificationId: randomUUID(), + organizationId: randomUUID(), + kind: "nonprofit", + einNumber: null, + documentMediaIds: [MEDIA_ID], + note: null, + submittedBy: submitter, + now: new Date(), + }), + ).rejects.toMatchObject({ + httpStatus: 422, + fields: { documents: "One or more documents are unavailable." }, + }) + + expectClaimsAs(statementMatching(fake, MEDIA_CLAIM), submitter) + }) +}) + +describe("finalizing an upload", () => { + const request = { + kind: "image" as const, + contentType: "image/jpeg", + byteSize: 1024, + sha256: "a".repeat(64), + } + + async function harness(owner: { userId?: string; anonSessionId?: string }) { + const repo = new InMemoryMediaRepository() + const storage = new FakeStorage() + const jobs = new FakeJobs() + const service = makeMediaIntakeService({ repo, storage, jobs }) + const { uploadId } = await service.createUpload(request, owner) + const asset = (await repo.findByUploadId(uploadId))! + await storage.put(asset.r2Key, new Uint8Array(request.byteSize), { contentType: "image/jpeg" }) + return { repo, jobs, service, uploadId, asset } + } + + async function refusal(run: () => Promise) { + const err = (await run().then( + () => null, + (e: unknown) => e, + )) as AppError | null + expect(err).not.toBeNull() + return { httpStatus: err!.httpStatus, code: err!.code, message: err!.message } + } + + it("refuses another caller's upload exactly as it refuses an unknown one", async () => { + const { repo, jobs, service, uploadId } = await harness({ userId: randomUUID() }) + + const foreign = await refusal(() => service.finalize({ uploadId }, { userId: randomUUID() })) + const unknown = await refusal(() => + service.finalize({ uploadId: randomUUID() }, { userId: randomUUID() }), + ) + + expect(foreign).toEqual(unknown) + expect(foreign.httpStatus).toBe(404) + expect(jobs.jobsFor(MEDIA_CHECKS_JOB)).toHaveLength(0) + expect((await repo.findByUploadId(uploadId))?.finalizedAt).toBeNull() + }) + + it("refuses a guest finalizing a signed-in user's upload, and a stranger replaying a finalized one", async () => { + const owner = randomUUID() + const { service, uploadId } = await harness({ userId: owner }) + + await expect(service.finalize({ uploadId }, { anonSessionId: "anon-1" })).rejects.toMatchObject( + { httpStatus: 404 }, + ) + await service.finalize({ uploadId }, { userId: owner }) + await expect(service.finalize({ uploadId }, {})).rejects.toMatchObject({ httpStatus: 404 }) + }) + + it("lets the uploader finalize and replay idempotently", async () => { + const owner = randomUUID() + const { jobs, service, uploadId, asset } = await harness({ userId: owner }) + + const first = await service.finalize({ uploadId }, { userId: owner }) + const replay = await service.finalize({ uploadId }, { userId: owner }) + + expect(first).toEqual({ mediaId: asset.id, status: "validating" }) + expect(replay).toEqual(first) + expect(jobs.jobsFor(MEDIA_CHECKS_JOB)).toHaveLength(1) + }) + + it("accepts the guest session that uploaded, even once the caller has also signed in", async () => { + const { service, uploadId } = await harness({ anonSessionId: "anon-1" }) + + await expect( + service.finalize({ uploadId }, { userId: randomUUID(), anonSessionId: "anon-1" }), + ).resolves.toMatchObject({ status: "validating" }) + }) + + it("accepts an unattributed upload only inside the claim window", async () => { + const fresh = await harness({ userId: randomUUID() }) + fresh.repo.patch(fresh.asset.id, { uploader: null }) + await expect( + fresh.service.finalize({ uploadId: fresh.uploadId }, { userId: randomUUID() }), + ).resolves.toMatchObject({ status: "validating" }) + + const stale = await harness({ userId: randomUUID() }) + stale.repo.patch(stale.asset.id, { + uploader: null, + createdAt: new Date(Date.now() - (MEDIA_CLAIM_WINDOW_SEC + 60) * 1000), + }) + await expect( + stale.service.finalize({ uploadId: stale.uploadId }, { userId: randomUUID() }), + ).rejects.toMatchObject({ httpStatus: 404 }) + }) + + it("records the uploader it later checks against", async () => { + const { asset } = await harness({ anonSessionId: "anon-2" }) + + expect(asset.uploader).toBe(anonUploader("anon-2")) + }) +}) + +describe("group avatar claims run in the transaction that writes the avatar", () => { + function transactional(handlers: SqlHandler[]) { + const fake = makeFakeSql(handlers) + const inner = fake.sql.begin + fake.sql.begin = async (cb) => { + fake.statements.push({ sql: "BEGIN", values: [] }) + const out = await inner(cb) + fake.statements.push({ sql: "COMMIT", values: [] }) + return out + } + return fake + } + + const avatarRow = { id: MEDIA_ID, r2_key: "uploads/a", served_key: null } + + function expectLockedAndWrittenInOneTransaction(fake: FakeSqlControl, write: RegExp): void { + const at = (pattern: RegExp) => fake.statements.findIndex((s) => pattern.test(s.sql)) + const begin = at(/^BEGIN$/) + const lock = at(/FOR UPDATE OF m/) + const written = at(write) + const commit = at(/^COMMIT$/) + expect(begin).toBeGreaterThanOrEqual(0) + expect(lock).toBeGreaterThan(begin) + expect(written).toBeGreaterThan(lock) + expect(commit).toBeGreaterThan(written) + } + + it("a new group locks its avatar upload and inserts the group in the same transaction", async () => { + const owner = randomUUID() + const fake = transactional([ + { match: /FOR UPDATE OF m/, rows: [avatarRow] }, + { match: /INSERT INTO chat_groups/, rows: [{ id: GROUP_ID }] }, + ]) + + await makeChatGroupRepository(repoSql(fake)).create( + { + kind: "group", + name: "Block party", + description: null, + avatarUploadId: UPLOAD_ID, + ownerId: owner, + visibility: "private", + }, + [], + ) + + expectLockedAndWrittenInOneTransaction(fake, /INSERT INTO chat_groups/) + expect(statementMatching(fake, /INSERT INTO chat_groups/).values).toContain(MEDIA_ID) + expect(statementMatching(fake, /FOR UPDATE OF m/).values).toContain(userUploader(owner)) + }) + + it("an avatar change locks the upload and updates the group in the same transaction", async () => { + const admin = randomUUID() + const fake = transactional([{ match: /FOR UPDATE OF m/, rows: [avatarRow] }]) + + await makeChatGroupRepository(repoSql(fake)).update( + GROUP_ID, + { avatarUploadId: UPLOAD_ID }, + admin, + ) + + expectLockedAndWrittenInOneTransaction(fake, /UPDATE chat_groups SET/) + const lock = statementMatching(fake, /FOR UPDATE OF m/) + expect(lock.values).toContain(userUploader(admin)) + expect(lock.values).toContain(GROUP_ID) + }) + + it("writes nothing when the avatar upload is not the caller's to claim", async () => { + const fake = transactional([]) + + await expect( + makeChatGroupRepository(repoSql(fake)).update( + GROUP_ID, + { name: "Renamed", avatarUploadId: UPLOAD_ID }, + randomUUID(), + ), + ).rejects.toMatchObject({ + httpStatus: 422, + fields: { avatarUploadId: "That image is unavailable." }, + }) + + expect(fake.statements.some((s) => /UPDATE chat_groups/.test(s.sql))).toBe(false) + }) +}) From dfdc71624d015ef41650c09877264dd3c0ae0e6a Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 09:21:35 +0000 Subject: [PATCH 42/45] csv exports neutralize formulas behind quotes after a separator --- services/api/src/services/host/export-csv.ts | 9 ++++++--- services/api/test/unit/export-csv-security.test.ts | 7 +++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/services/api/src/services/host/export-csv.ts b/services/api/src/services/host/export-csv.ts index 9071f9ad..9ad440a7 100644 --- a/services/api/src/services/host/export-csv.ts +++ b/services/api/src/services/host/export-csv.ts @@ -1,9 +1,12 @@ const FORMULA_ESCAPE = "'" const LEADING_TRIGGER_RE = /^[=+\-@\t\r]/ // Excel in a ';'-separator locale (de, es) splits a quoted value on ';' and evaluates a formula at the -// start of the resulting field, so quoting alone does not help. The lookbehind (rather than a -// consuming match) also catches a trigger that follows a tab or CR which was itself a trigger. -const SEPARATED_TRIGGER_RE = /(?<=[;,\t\r\n] *)(?=[=+\-@\t\r])/g +// start of the resulting field, so quoting alone does not help. Spaces and double quotes between the +// separator and the trigger do not stop that evaluation, so the escape goes directly before the +// trigger. A single quote is left out because a field that starts with one is already text. The +// lookbehind (rather than a consuming match) also catches a trigger that follows a tab or CR which +// was itself a trigger. +const SEPARATED_TRIGGER_RE = /(?<=[;,\t\r\n][ "]*)(?=[=+\-@\t\r])/g function neutralizeFormulas(value: string): string { const separated = value.replace(SEPARATED_TRIGGER_RE, FORMULA_ESCAPE) diff --git a/services/api/test/unit/export-csv-security.test.ts b/services/api/test/unit/export-csv-security.test.ts index bcd20fca..748b857b 100644 --- a/services/api/test/unit/export-csv-security.test.ts +++ b/services/api/test/unit/export-csv-security.test.ts @@ -18,6 +18,13 @@ describe("csv cells in a locale whose list separator splits a value", () => { expect(csvCell("a; =1")).toBe('"a; \'=1"') }) + it("neutralizes a trigger that double quotes and spaces separate from the separator", () => { + expect(csvCell('x;"=1+1')).toBe('"x;""\'=1+1"') + expect(csvCell('x;""=1+1""')).toBe('"x;""""\'=1+1"""""') + expect(csvCell('x, "+1')).toBe('"x, ""\'+1"') + expect(csvCell('x;" @a')).toBe('"x;"" \'@a"') + }) + it("neutralizes a trigger that is itself a separator, and the trigger after it", () => { expect(csvCell("a\t\t=1")).toBe("\"a\t'\t'=1\"") }) From bde6b3c7d3e5d24409898bcd59f2ca1715d971ad Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 09:21:42 +0000 Subject: [PATCH 43/45] email-code sign-in refuses an unverified account a provider identity holds; erasure scrubs waitlist before registrations --- services/api/src/auth/auth-services.ts | 19 +- services/api/src/auth/oauth.ts | 2 +- services/api/src/auth/otp.ts | 100 +++++++-- services/api/src/auth/pg-stores.ts | 43 ++-- services/api/src/auth/stores.ts | 8 + services/api/src/routes/users.routes.ts | 6 +- services/api/src/services/admin/audit.ts | 1 + services/api/test/integration/auth-pg.test.ts | 56 ++++- .../api/test/unit/auth-oauth-security.test.ts | 3 +- .../unit/auth-otp-unverified-account.test.ts | 191 ++++++++++++++++++ services/api/test/unit/auth-otp.test.ts | 34 +++- 11 files changed, 423 insertions(+), 40 deletions(-) create mode 100644 services/api/test/unit/auth-otp-unverified-account.test.ts diff --git a/services/api/src/auth/auth-services.ts b/services/api/src/auth/auth-services.ts index 21e845c2..69e074d9 100644 --- a/services/api/src/auth/auth-services.ts +++ b/services/api/src/auth/auth-services.ts @@ -2,7 +2,13 @@ import type { Container } from "../di.js" import type { OAuthProvider, UserDTO } from "@civfix/shared" import { RedisCacheClient, type CacheClient } from "./cache.js" import { SessionService, type SessionLogger } from "./session-service.js" -import { OtpService, REVIEWER_OTP_EMAIL, type OtpLogger, type ReviewerOtpConfig } from "./otp.js" +import { + OtpService, + REVIEWER_OTP_EMAIL, + type OtpAuditSink, + type OtpLogger, + type ReviewerOtpConfig, +} from "./otp.js" import { OAuthService, type OAuthConfig } from "./oauth.js" import type { JwksVerifier } from "./jwks.js" import { @@ -14,6 +20,8 @@ import { import { PgAuthStores } from "./pg-stores.js" import { REVIEWER_OTP_CODE_MIN_LENGTH } from "../env.js" import { resolveLocale } from "../i18n/locales.js" +import { writeAudit } from "../services/admin/audit.js" +import { dataExportSupportEmail } from "../services/data-export-jobs.js" export interface AuthServices { sessions: SessionService @@ -41,6 +49,8 @@ export interface BuildAuthServicesOptions { now?: () => number logger?: OtpLogger & SessionLogger reviewer?: ReviewerOtpConfig + supportEmail?: string + audit?: OtpAuditSink } export function buildAuthServices(opts: BuildAuthServicesOptions): AuthServices { @@ -55,11 +65,14 @@ export function buildAuthServices(opts: BuildAuthServicesOptions): AuthServices const otp = new OtpService({ store: opts.stores.otps, users: opts.stores.users, + identities: opts.stores.oauth, cache: opts.cache, mailer: opts.mailer, ...(now ? { now } : {}), ...(opts.logger ? { logger: opts.logger } : {}), ...(opts.reviewer ? { reviewer: opts.reviewer } : {}), + ...(opts.supportEmail !== undefined ? { supportEmail: opts.supportEmail } : {}), + ...(opts.audit ? { audit: opts.audit } : {}), }) const oauth = new OAuthService({ config: opts.oauthConfig, @@ -94,6 +107,10 @@ export function buildAuthServicesFromContainer( cache, mailer: container.mailer, oauthConfig: oauthConfigFromEnv(container.env), + supportEmail: dataExportSupportEmail(container.env), + audit: async (input) => { + await writeAudit(container.getDb().sql, input) + }, ...(opts.logger ? { logger: opts.logger } : {}), ...(reviewerConfig !== null ? { reviewer: reviewerConfig } : {}), }) diff --git a/services/api/src/auth/oauth.ts b/services/api/src/auth/oauth.ts index ab958783..412608b4 100644 --- a/services/api/src/auth/oauth.ts +++ b/services/api/src/auth/oauth.ts @@ -19,7 +19,7 @@ export const PROVIDER_GOOGLE = "google" export const PROVIDER_APPLE = "apple" export const UNVERIFIED_ACCOUNT_EXISTS_MESSAGE = - "An account already uses this email address. Sign in the way you did before, for example with a code sent to your email." + "An account already uses this email address. Sign in the way you did before, or contact support." export interface OAuthConfig { google?: { diff --git a/services/api/src/auth/otp.ts b/services/api/src/auth/otp.ts index 59ec9106..a328dbdd 100644 --- a/services/api/src/auth/otp.ts +++ b/services/api/src/auth/otp.ts @@ -3,9 +3,10 @@ import { AppError } from "@civfix/shared" import { constantTimeStringEqual, generateNumericCode } from "./crypto.js" import { normalizeIp } from "../abuse/ip-rate-limit.js" import type { CacheClient } from "./cache.js" -import type { OtpStore, UserStore } from "./stores.js" +import type { OAuthIdentityStore, OtpStore, UserRecord, UserStore } from "./stores.js" import { newAccountDisplayName } from "./official-account.js" import type { Mailer } from "@civfix/shared/interfaces" +import type { WriteAuditInput } from "../services/admin/audit.js" export const OTP_CODE_LENGTH = 6 export const OTP_TTL_SECONDS = 5 * 60 @@ -45,6 +46,14 @@ export function verifyOtpCode(codeHash: string, code: string): Promise return argonVerify(codeHash, code) } +export const OTP_REFUSED_UNVERIFIED_ACCOUNT_ACTION = "auth.otp_refused_unverified_account" +export const OTP_REFUSED_UNVERIFIED_ACCOUNT_REASON = "email_unverified_with_provider_identity" + +export function unverifiedAccountNeedsReviewMessage(supportEmail: string | null): string { + const contact = supportEmail === null ? "Contact support" : `Contact support at ${supportEmail}` + return `This email address is linked to an account that needs a quick check before you can sign in. ${contact} and we'll sort it out.` +} + export const REVIEWER_OTP_EMAIL = "reviewer@civfix.org" export const REVIEWER_HANDLE = "reviewer" export const REVIEWER_DISPLAY_NAME = "Reviewer Reviewer" @@ -58,6 +67,10 @@ export interface OtpLogger { warn(obj: unknown, msg?: string): void } +export type OtpAuditSink = (input: WriteAuditInput) => Promise + +type InboxProof = "reviewer" | "code" + type LocaleAwareMailer = Mailer & { sendOtp(to: string, code: string, locale?: string): Promise } @@ -65,11 +78,14 @@ type LocaleAwareMailer = Mailer & { export interface OtpServiceOptions { store: OtpStore users: UserStore + identities: Pick cache: CacheClient mailer: Mailer now?: () => number logger?: OtpLogger reviewer?: ReviewerOtpConfig + supportEmail?: string + audit?: OtpAuditSink } export interface IssueResult { @@ -79,15 +95,19 @@ export interface IssueResult { export class OtpService { private readonly store: OtpStore private readonly users: UserStore + private readonly identities: Pick private readonly cache: CacheClient private readonly mailer: LocaleAwareMailer private readonly now: () => number private readonly logger?: OtpLogger private readonly reviewer: ReviewerOtpConfig | null + private readonly supportEmail: string | null + private readonly audit?: OtpAuditSink constructor(opts: OtpServiceOptions) { this.store = opts.store this.users = opts.users + this.identities = opts.identities this.cache = opts.cache this.mailer = opts.mailer this.now = opts.now ?? Date.now @@ -95,6 +115,8 @@ export class OtpService { this.reviewer = opts.reviewer ? { email: opts.reviewer.email.trim().toLowerCase(), code: opts.reviewer.code } : null + this.supportEmail = opts.supportEmail ?? null + this.audit = opts.audit } private isReviewerEmail(normalizedEmail: string): boolean { @@ -151,6 +173,48 @@ export class OtpService { async verifyOtp(email: string, code: string, ip: string | null): Promise { const normalized = email.trim().toLowerCase() + if ((await this.proveInbox(normalized, code, ip)) === "reviewer") { + return this.ensureReviewerUser(normalized) + } + const existing = await this.users.findByEmail(normalized) + if (existing) { + await this.refuseUnverifiedIdentityHolder(existing) + return existing.id + } + const created = await this.users.create(normalized, { + displayName: newAccountDisplayName( + defaultDisplayName(normalized), + UNNAMED_CITIZEN_DISPLAY_NAME, + ), + role: "citizen", + emailVerified: true, + }) + return created.id + } + + /** + * Confirms a code for a caller who is already signed in, and answers the account the address belongs + * to (or null) for the caller to compare with its own. It never signs anyone in, so the check that + * keeps an inbox owner out of an account a provider identity holds does not apply here: the session + * already proves the account and the code proves the inbox. + */ + async verifyOtpForExistingAccount( + email: string, + code: string, + ip: string | null, + ): Promise { + const normalized = email.trim().toLowerCase() + if ((await this.proveInbox(normalized, code, ip)) === "reviewer") { + return this.ensureReviewerUser(normalized) + } + return (await this.users.findByEmail(normalized))?.id ?? null + } + + private async proveInbox( + normalized: string, + code: string, + ip: string | null, + ): Promise { const now = new Date(this.now()) const ipBucket = ip === null ? null : normalizeIp(ip) @@ -160,7 +224,7 @@ export class OtpService { if (this.isReviewerEmail(normalized)) { if (this.reviewer !== null && constantTimeStringEqual(code, this.reviewer.code)) { - return this.ensureReviewerUser(normalized) + return "reviewer" } await this.bumpVerifyFailure(null, ipBucket) throw AppError.unauthorized("Invalid or expired code.") @@ -203,17 +267,27 @@ export class OtpService { "otp: failed to release per-email cooldown after successful verify", ) }) - const existing = await this.users.findByEmail(normalized) - if (existing) return existing.id - const created = await this.users.create(normalized, { - displayName: newAccountDisplayName( - defaultDisplayName(normalized), - UNNAMED_CITIZEN_DISPLAY_NAME, - ), - role: "citizen", - emailVerified: true, - }) - return created.id + return "code" + } + + // The code proves who owns the inbox, not who created the row. A row that never verified its address + // but carries a provider identity may have been planted by whoever holds that identity, and signing + // the owner into it would share one account between them. Support untangles it; nothing is changed + // here. A row with no identity has no second holder, so it keeps signing in. + private async refuseUnverifiedIdentityHolder(account: UserRecord): Promise { + if (account.emailVerified) return + if (!(await this.identities.hasIdentityForUser(account.id))) return + if (this.audit) { + await this.audit({ + actorId: null, + action: OTP_REFUSED_UNVERIFIED_ACCOUNT_ACTION, + target: `user:${account.id}`, + meta: { reason: OTP_REFUSED_UNVERIFIED_ACCOUNT_REASON }, + }).catch((err: unknown) => { + this.logger?.warn({ err }, "otp: refused sign-in audit write failed") + }) + } + throw AppError.conflict(unverifiedAccountNeedsReviewMessage(this.supportEmail)) } private async ensureReviewerUser(normalizedEmail: string): Promise { diff --git a/services/api/src/auth/pg-stores.ts b/services/api/src/auth/pg-stores.ts index 831949f3..a952a255 100644 --- a/services/api/src/auth/pg-stores.ts +++ b/services/api/src/auth/pg-stores.ts @@ -509,22 +509,8 @@ export class PgUserStore implements UserStore { } private async scrubAttendeeContributions(tx: DbTransaction, id: string): Promise { - await tx.execute(sql` - UPDATE cleanup_registrations SET host_note = NULL - WHERE user_id = ${id} AND host_note IS NOT NULL - `) - await tx.execute(sql` - UPDATE cleanup_registration_seats s - SET attendee_name = NULL - FROM cleanup_registrations r - WHERE s.registration_id = r.id AND r.user_id = ${id} AND s.attendee_name IS NOT NULL - `) - await tx.execute(sql` - UPDATE cleanup_answers a - SET value_text = NULL, value_json = NULL, scrubbed_at = now() - FROM cleanup_registrations r - WHERE a.registration_id = r.id AND r.user_id = ${id} AND a.scrubbed_at IS NULL - `) + // Waitlist and ticket-type rows before registrations, the order applyBanIn and the waitlist sweep + // take, so an erasure racing a ban on one of the user's events cannot deadlock with it. const released = await tx.execute<{ id: string }>(sql` WITH cancelled_waitlist AS ( UPDATE cleanup_waitlist SET status = 'cancelled' @@ -543,6 +529,22 @@ export class PgUserStore implements UserStore { WHERE t.id = r.ticket_type_id RETURNING t.id `) + await tx.execute(sql` + UPDATE cleanup_registrations SET host_note = NULL + WHERE user_id = ${id} AND host_note IS NOT NULL + `) + await tx.execute(sql` + UPDATE cleanup_registration_seats s + SET attendee_name = NULL + FROM cleanup_registrations r + WHERE s.registration_id = r.id AND r.user_id = ${id} AND s.attendee_name IS NOT NULL + `) + await tx.execute(sql` + UPDATE cleanup_answers a + SET value_text = NULL, value_json = NULL, scrubbed_at = now() + FROM cleanup_registrations r + WHERE a.registration_id = r.id AND r.user_id = ${id} AND a.scrubbed_at IS NULL + `) await tx.execute(sql` UPDATE donations SET user_id = NULL, @@ -740,6 +742,15 @@ export class PgOAuthIdentityStore implements OAuthIdentityStore { async deleteAllForUser(userId: string): Promise { await this.db.delete(oauthIdentities).where(eq(oauthIdentities.userId, userId)) } + + async hasIdentityForUser(userId: string): Promise { + const rows = await this.db + .select({ id: oauthIdentities.id }) + .from(oauthIdentities) + .where(eq(oauthIdentities.userId, userId)) + .limit(1) + return rows.length > 0 + } } export class PgOtpStore implements OtpStore { diff --git a/services/api/src/auth/stores.ts b/services/api/src/auth/stores.ts index c60e573b..af4759ec 100644 --- a/services/api/src/auth/stores.ts +++ b/services/api/src/auth/stores.ts @@ -383,6 +383,7 @@ export interface OAuthIdentityStore { findByProvider(provider: string, providerUserId: string): Promise linkIdentity(userId: string, provider: string, providerUserId: string): Promise deleteAllForUser(userId: string): Promise + hasIdentityForUser(userId: string): Promise } export class InMemoryOAuthIdentityStore implements OAuthIdentityStore { @@ -409,6 +410,13 @@ export class InMemoryOAuthIdentityStore implements OAuthIdentityStore { } return Promise.resolve() } + + hasIdentityForUser(userId: string): Promise { + for (const row of this.identities.values()) { + if (row.userId === userId) return Promise.resolve(true) + } + return Promise.resolve(false) + } } export interface OtpRecord { diff --git a/services/api/src/routes/users.routes.ts b/services/api/src/routes/users.routes.ts index 465919cd..7aab8de0 100644 --- a/services/api/src/routes/users.routes.ts +++ b/services/api/src/routes/users.routes.ts @@ -200,7 +200,11 @@ export async function registerUsersRoutes( const me = await store.findById(userId) const email = me?.email ?? null if (email) { - const verifiedUserId = await otp.verifyOtp(email, emailOtp, request.ip || null) + const verifiedUserId = await otp.verifyOtpForExistingAccount( + email, + emailOtp, + request.ip || null, + ) if (verifiedUserId !== userId) { throw AppError.unauthorized("That code could not be verified for this account.") } diff --git a/services/api/src/services/admin/audit.ts b/services/api/src/services/admin/audit.ts index aecde751..e6fa335a 100644 --- a/services/api/src/services/admin/audit.ts +++ b/services/api/src/services/admin/audit.ts @@ -3,6 +3,7 @@ import type { Queryable } from "../../db/client.js" export type AdminAuditAction = | "operator.login" | "operator.logout" + | "auth.otp_refused_unverified_account" | "discovery.contacts_saved" | "discovery.draft_saved" | "discovery.note_added" diff --git a/services/api/test/integration/auth-pg.test.ts b/services/api/test/integration/auth-pg.test.ts index 7e2855cc..396d689f 100644 --- a/services/api/test/integration/auth-pg.test.ts +++ b/services/api/test/integration/auth-pg.test.ts @@ -16,7 +16,8 @@ import { withPg, type PgHarness } from "../helpers/pg.js" import { seedMediaAsset } from "../helpers/media-pg.js" import { InMemoryCacheClient } from "../../src/auth/cache.js" import { SessionService } from "../../src/auth/session-service.js" -import { OtpService } from "../../src/auth/otp.js" +import { OTP_REFUSED_UNVERIFIED_ACCOUNT_ACTION, OtpService } from "../../src/auth/otp.js" +import { writeAudit } from "../../src/services/admin/audit.js" import { PgSessionStore, PgUserStore, @@ -86,7 +87,8 @@ describe.skipIf(!pg)("auth integration: Postgres stores", () => { const users = new PgUserStore(h.db) const cache = new InMemoryCacheClient() const mailer = new FakeMailer() - const otp = new OtpService({ store, users, cache, mailer }) + const identities = new PgOAuthIdentityStore(h.db) + const otp = new OtpService({ store, users, identities, cache, mailer }) const email = "otp.it@example.com" await otp.issueOtp(email, "203.0.113.6") @@ -99,7 +101,7 @@ describe.skipIf(!pg)("auth integration: Postgres stores", () => { // A second sign-in finds the same user. const cache2 = new InMemoryCacheClient() - const otp2 = new OtpService({ store, users, cache: cache2, mailer }) + const otp2 = new OtpService({ store, users, identities, cache: cache2, mailer }) await otp2.issueOtp(email, "203.0.113.6") const code2 = mailer.lastOtpFor(email)! const again = await otp2.verifyOtp(email, code2, "203.0.113.6") @@ -211,4 +213,52 @@ describe.skipIf(!pg)("auth integration: Postgres stores", () => { ` expect(rows[0]!.n).toBe(1) }) + + it("otp: refuses an unverified account that a provider identity holds and audits it without the email", async () => { + const users = new PgUserStore(h.db) + const identities = new PgOAuthIdentityStore(h.db) + const mailer = new FakeMailer() + const otp = new OtpService({ + store: new PgOtpStore(h.db), + users, + identities, + cache: new InMemoryCacheClient(), + mailer, + audit: async (input) => { + await writeAudit(h.sql, input) + }, + }) + const email = "planted.otp.it@example.com" + const planted = await users.create(email, { displayName: "Planted", emailVerified: false }) + await identities.linkIdentity(planted.id, "google", "planted-otp-it-sub") + const bare = await users.create("bare.otp.it@example.com", { + displayName: "Bare", + emailVerified: false, + }) + expect(await identities.hasIdentityForUser(planted.id)).toBe(true) + expect(await identities.hasIdentityForUser(bare.id)).toBe(false) + + await otp.issueOtp(email, "203.0.113.7") + await expect( + otp.verifyOtp(email, mailer.lastOtpFor(email)!, "203.0.113.7"), + ).rejects.toMatchObject({ code: "CONFLICT" }) + + const audits = await h.sql<{ actor_id: string | null; meta: Record }[]>` + SELECT actor_id, meta FROM audit_log + WHERE action = ${OTP_REFUSED_UNVERIFIED_ACCOUNT_ACTION} AND target = ${`user:${planted.id}`} + ` + expect(audits).toHaveLength(1) + expect(audits[0]!.actor_id).toBeNull() + expect(JSON.stringify(audits[0]!.meta)).not.toContain(email) + const after = await users.findById(planted.id) + expect(after?.email).toBe(email) + expect(after?.emailVerified).toBe(false) + expect((await identities.findByProvider("google", "planted-otp-it-sub"))?.userId).toBe( + planted.id, + ) + + await otp.issueOtp("bare.otp.it@example.com", "203.0.113.7") + const bareCode = mailer.lastOtpFor("bare.otp.it@example.com")! + expect(await otp.verifyOtp("bare.otp.it@example.com", bareCode, "203.0.113.7")).toBe(bare.id) + }) }) diff --git a/services/api/test/unit/auth-oauth-security.test.ts b/services/api/test/unit/auth-oauth-security.test.ts index 0d48d8da..755df08b 100644 --- a/services/api/test/unit/auth-oauth-security.test.ts +++ b/services/api/test/unit/auth-oauth-security.test.ts @@ -65,6 +65,7 @@ function makeServices() { const otp = new OtpService({ store: new InMemoryOtpStore(), users, + identities: oauthStore, cache: new InMemoryCacheClient(() => nowMs.value), mailer, now: () => nowMs.value, @@ -116,7 +117,7 @@ describe("provider sign-in with an unverified email", () => { describe("provider sign-in onto an account whose email was never verified", () => { const ACCOUNT_EXISTS_MESSAGE = - "An account already uses this email address. Sign in the way you did before, for example with a code sent to your email." + "An account already uses this email address. Sign in the way you did before, or contact support." async function plantedAccount(users: InMemoryUserStore) { return users.create(VICTIM_EMAIL, { diff --git a/services/api/test/unit/auth-otp-unverified-account.test.ts b/services/api/test/unit/auth-otp-unverified-account.test.ts new file mode 100644 index 00000000..60fc7228 --- /dev/null +++ b/services/api/test/unit/auth-otp-unverified-account.test.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { FakeMailer } from "@civfix/shared/fakes" +import { InMemoryCacheClient } from "../../src/auth/cache.js" +import { + InMemoryOAuthIdentityStore, + InMemoryOtpStore, + InMemoryUserStore, +} from "../../src/auth/stores.js" +import { OtpService } from "../../src/auth/otp.js" +import type { WriteAuditInput } from "../../src/services/admin/audit.js" +import { bearer, makeAuthHarness, type AuthHarness } from "../helpers/auth.js" + +vi.setConfig({ testTimeout: 30_000 }) + +const OWNER_EMAIL = "owner@example.org" +const IP = "203.0.113.20" +const SUPPORT_EMAIL = "support@civfix.test" +const REFUSED_ACTION = "auth.otp_refused_unverified_account" +const NEEDS_REVIEW_MESSAGE = `This email address is linked to an account that needs a quick check before you can sign in. Contact support at ${SUPPORT_EMAIL} and we'll sort it out.` + +function makeOtp(auditSink?: (input: WriteAuditInput) => Promise) { + const nowMs = 1_700_000_000_000 + const users = new InMemoryUserStore() + const identities = new InMemoryOAuthIdentityStore() + const mailer = new FakeMailer() + const audits: WriteAuditInput[] = [] + const service = new OtpService({ + store: new InMemoryOtpStore(), + users, + identities, + cache: new InMemoryCacheClient(() => nowMs), + mailer, + now: () => nowMs, + supportEmail: SUPPORT_EMAIL, + audit: + auditSink ?? + ((input) => { + audits.push(input) + return Promise.resolve() + }), + }) + async function codeFor(email: string): Promise { + await service.issueOtp(email, IP) + const code = mailer.lastOtpFor(email) + if (!code) throw new Error(`no OTP mailed to ${email}`) + return code + } + return { service, users, identities, audits, codeFor } +} + +async function plantedAccount(users: InMemoryUserStore, identities: InMemoryOAuthIdentityStore) { + const planted = await users.create(OWNER_EMAIL, { + displayName: "Planted", + role: "citizen", + emailVerified: false, + }) + await identities.linkIdentity(planted.id, "google", "attacker-sub") + return planted +} + +describe("email-code sign-in onto an unverified account that a provider identity holds", () => { + it("refuses the sign-in, leaves the row and its identity alone and audits the refusal", async () => { + const { service, users, identities, audits, codeFor } = makeOtp() + const planted = await plantedAccount(users, identities) + + await expect( + service.verifyOtp(OWNER_EMAIL, await codeFor(OWNER_EMAIL), IP), + ).rejects.toMatchObject({ code: "CONFLICT", message: NEEDS_REVIEW_MESSAGE }) + + expect(await users.findById(planted.id)).toMatchObject({ + email: OWNER_EMAIL, + emailVerified: false, + displayName: "Planted", + }) + expect((await identities.findByProvider("google", "attacker-sub"))?.userId).toBe(planted.id) + expect(audits).toEqual([ + { + actorId: null, + action: REFUSED_ACTION, + target: `user:${planted.id}`, + meta: { reason: "email_unverified_with_provider_identity" }, + }, + ]) + expect(JSON.stringify(audits)).not.toContain(OWNER_EMAIL) + }) + + it("still refuses when the audit write fails", async () => { + const { service, users, identities, codeFor } = makeOtp(() => + Promise.reject(new Error("audit store unavailable")), + ) + await plantedAccount(users, identities) + + await expect( + service.verifyOtp(OWNER_EMAIL, await codeFor(OWNER_EMAIL), IP), + ).rejects.toMatchObject({ code: "CONFLICT", message: NEEDS_REVIEW_MESSAGE }) + }) + + it("keeps signing in an unverified account that no provider identity holds", async () => { + const { service, users, audits, codeFor } = makeOtp() + const legacy = await users.create(OWNER_EMAIL, { + displayName: "Legacy", + emailVerified: false, + }) + + expect(await service.verifyOtp(OWNER_EMAIL, await codeFor(OWNER_EMAIL), IP)).toBe(legacy.id) + expect(audits).toEqual([]) + }) + + it("keeps signing in a verified account that a provider identity holds", async () => { + const { service, users, identities, audits, codeFor } = makeOtp() + const owner = await users.create(OWNER_EMAIL, { displayName: "Owner", emailVerified: true }) + await identities.linkIdentity(owner.id, "google", "owner-sub") + + expect(await service.verifyOtp(OWNER_EMAIL, await codeFor(OWNER_EMAIL), IP)).toBe(owner.id) + expect(audits).toEqual([]) + }) + + it("still confirms the code for a caller already signed in to that account", async () => { + const { service, users, identities, audits, codeFor } = makeOtp() + const planted = await plantedAccount(users, identities) + + const confirmed = await service.verifyOtpForExistingAccount( + OWNER_EMAIL, + await codeFor(OWNER_EMAIL), + IP, + ) + + expect(confirmed).toBe(planted.id) + expect(audits).toEqual([]) + }) +}) + +describe("POST /v1/auth/otp/verify onto an unverified account that a provider identity holds", () => { + let h: AuthHarness | undefined + afterEach(async () => { + await h?.app.close() + h = undefined + }) + + it("answers 409 and issues no session", async () => { + h = await makeAuthHarness() + const planted = await h.stores.users.create(OWNER_EMAIL, { + displayName: "Planted", + emailVerified: false, + }) + await h.stores.oauth.linkIdentity(planted.id, "google", "attacker-sub") + await h.app.inject({ + method: "POST", + url: "/v1/auth/otp/request", + payload: { email: OWNER_EMAIL }, + }) + + const res = await h.app.inject({ + method: "POST", + url: "/v1/auth/otp/verify", + headers: { "x-client": "mobile" }, + payload: { email: OWNER_EMAIL, code: h.mailer.lastOtpFor(OWNER_EMAIL) }, + }) + + expect(res.statusCode).toBe(409) + expect(res.headers["set-cookie"]).toBeUndefined() + expect(res.json()).not.toHaveProperty("token") + }) + + it("keeps letting the account's signed-in holder confirm a deletion with an email code", async () => { + h = await makeAuthHarness() + const planted = await h.stores.users.create(OWNER_EMAIL, { + displayName: "Planted", + emailVerified: false, + }) + await h.stores.oauth.linkIdentity(planted.id, "google", "attacker-sub") + const token = await h.services.sessions.createSession(planted.id, ["citizen"], { + userAgent: null, + ip: null, + }) + await h.app.inject({ + method: "POST", + url: "/v1/auth/otp/request", + payload: { email: OWNER_EMAIL }, + }) + + const res = await h.app.inject({ + method: "DELETE", + url: "/v1/me", + headers: bearer(token), + payload: { emailOtp: h.mailer.lastOtpFor(OWNER_EMAIL) }, + }) + + expect(res.statusCode).toBe(200) + }) +}) diff --git a/services/api/test/unit/auth-otp.test.ts b/services/api/test/unit/auth-otp.test.ts index ba175aed..3e3e1c72 100644 --- a/services/api/test/unit/auth-otp.test.ts +++ b/services/api/test/unit/auth-otp.test.ts @@ -3,7 +3,11 @@ import { FakeMailer } from "@civfix/shared/fakes" import type { Mailer } from "@civfix/shared/interfaces" import { AppError, ErrorCode } from "@civfix/shared" import { InMemoryCacheClient } from "../../src/auth/cache.js" -import { InMemoryOtpStore, InMemoryUserStore } from "../../src/auth/stores.js" +import { + InMemoryOAuthIdentityStore, + InMemoryOtpStore, + InMemoryUserStore, +} from "../../src/auth/stores.js" import { OtpService, OTP_TTL_SECONDS, @@ -57,7 +61,14 @@ function makeOtp(startMs = 1_700_000_000_000) { const users = new InMemoryUserStore() const cache = new InMemoryCacheClient(now) const mailer = new FakeMailer() - const service = new OtpService({ store, users, cache, mailer, now }) + const service = new OtpService({ + store, + users, + identities: new InMemoryOAuthIdentityStore(), + cache, + mailer, + now, + }) return { service, store, @@ -75,7 +86,15 @@ function makeReviewerOtp(startMs = 1_700_000_000_000) { const users = new InMemoryUserStore() const cache = new InMemoryCacheClient(now) const mailer = new FakeMailer() - const service = new OtpService({ store, users, cache, mailer, now, reviewer: REVIEWER }) + const service = new OtpService({ + store, + users, + identities: new InMemoryOAuthIdentityStore(), + cache, + mailer, + now, + reviewer: REVIEWER, + }) return { service, store, users, cache, mailer, advance: (ms: number) => (clockRef.value += ms) } } @@ -147,7 +166,14 @@ describe("OtpService.issueOtp", () => { const users = new InMemoryUserStore() const cache = new InMemoryCacheClient(now) const mailer = new FlakyMailer(1) - const service = new OtpService({ store, users, cache, mailer, now }) + const service = new OtpService({ + store, + users, + identities: new InMemoryOAuthIdentityStore(), + cache, + mailer, + now, + }) await expect(service.issueOtp(EMAIL, IP)).rejects.toThrow() expect(await cache.get(`otp:rl:email:${EMAIL.toLowerCase()}`)).toBeNull() From 7e0b6d96859a51b63db51dc0ad1122e4efe626b6 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 09:21:42 +0000 Subject: [PATCH 44/45] a signed-in browser can still use the uploads it made as a guest --- services/api/src/auth/context.ts | 19 +- services/api/src/routes/media.routes.ts | 1 + services/api/src/routes/posts.routes.ts | 4 +- services/api/src/routes/reports.routes.ts | 5 +- .../api/src/services/media-intake-service.ts | 1 + services/api/src/services/media-uploader.ts | 7 +- .../src/services/post-repository.drizzle.ts | 8 +- services/api/src/services/post-service.ts | 13 +- .../src/services/report-repository.drizzle.ts | 10 +- services/api/src/services/report-service.ts | 4 +- .../api/src/services/report-service.types.ts | 8 +- .../api/test/unit/media-claim-actor.test.ts | 35 +++- .../test/unit/media-claim-uploader.test.ts | 51 +++++ .../media-guest-upload-after-sign-in.test.ts | 190 ++++++++++++++++++ 14 files changed, 336 insertions(+), 20 deletions(-) create mode 100644 services/api/test/unit/media-guest-upload-after-sign-in.test.ts diff --git a/services/api/src/auth/context.ts b/services/api/src/auth/context.ts index 04aaeeed..98e31952 100644 --- a/services/api/src/auth/context.ts +++ b/services/api/src/auth/context.ts @@ -6,9 +6,16 @@ import type { AuthServices } from "./auth-services.js" import type { AccountStatus } from "./stores.js" import { ANON_COOKIE, presentedSessionToken } from "./transport.js" +// A signed-in browser can still carry the verified anon cookie it held as a guest. That id names only +// the uploads the browser made before signing in; quota, report identity and every other check stay on +// the account, which is why it is not the anonymous subject. +export interface RequestAuthContext extends AuthContext { + guestAnonSessionId?: string +} + declare module "fastify" { interface FastifyRequest { - auth: AuthContext + auth: RequestAuthContext sessionExpiresAtMs?: number accountStatus?: AccountStatus } @@ -43,7 +50,7 @@ function verifiedAnonSessionId(request: FastifyRequest): string | undefined { return verifyAnonTokenSignature(anonCookie, signingKey) ?? undefined } -export async function resolveAuthContext(request: FastifyRequest): Promise { +export async function resolveAuthContext(request: FastifyRequest): Promise { const services: AuthServices | undefined = request.server.authServices const token = presentedSessionToken(request) if (services && token) { @@ -51,7 +58,13 @@ export async function resolveAuthContext(request: FastifyRequest): Promise { const userId = requireAuth(request) const input = parse(PostComposeInputSchema, request.body) - reply.status(201).send(await service().createPost(input, userId)) + reply + .status(201) + .send(await service().createPost(input, userId, request.auth.guestAnonSessionId)) }, ) diff --git a/services/api/src/routes/reports.routes.ts b/services/api/src/routes/reports.routes.ts index 8b01bdd1..725f6904 100644 --- a/services/api/src/routes/reports.routes.ts +++ b/services/api/src/routes/reports.routes.ts @@ -299,7 +299,10 @@ export async function registerReportRoutes( async (request, reply) => { const userId = requireAuth(request) const body = parse(CreateReportBodySchema, request.body) - const dto: ReportDTO = await service().createReport(body, { userId }) + const dto: ReportDTO = await service().createReport(body, { + userId, + guestAnonSessionId: request.auth.guestAnonSessionId, + }) reply.status(201).send(dto) }, ) diff --git a/services/api/src/services/media-intake-service.ts b/services/api/src/services/media-intake-service.ts index 0ca783c4..1e088fe7 100644 --- a/services/api/src/services/media-intake-service.ts +++ b/services/api/src/services/media-intake-service.ts @@ -56,6 +56,7 @@ export interface MediaChecksJob { export interface MediaOwner { userId?: string | undefined anonSessionId?: string | undefined + guestAnonSessionId?: string | undefined ipKey?: string | undefined } diff --git a/services/api/src/services/media-uploader.ts b/services/api/src/services/media-uploader.ts index 4fe0987f..da67b1d8 100644 --- a/services/api/src/services/media-uploader.ts +++ b/services/api/src/services/media-uploader.ts @@ -20,13 +20,18 @@ export function uploaderOf(owner: { } // Every subject this caller's request proves, where uploaderOf picks the one a new upload is stored under: -// a guest who signs in between presign and finalize still owns the upload its anon cookie created. +// a guest who signs in between presign and finalize or submit still owns the uploads its anon cookie +// created, as long as the signed-in request carries that same verified cookie. export function uploadersOf(owner: { userId?: string | undefined anonSessionId?: string | undefined + guestAnonSessionId?: string | undefined }): string[] { const subjects: string[] = [] if (owner.userId) subjects.push(userUploader(owner.userId)) if (owner.anonSessionId) subjects.push(anonUploader(owner.anonSessionId)) + if (owner.guestAnonSessionId && owner.guestAnonSessionId !== owner.anonSessionId) { + subjects.push(anonUploader(owner.guestAnonSessionId)) + } return subjects.length > 0 ? subjects : [UNSESSIONED_UPLOADER] } diff --git a/services/api/src/services/post-repository.drizzle.ts b/services/api/src/services/post-repository.drizzle.ts index b0c43bed..ef5fadda 100644 --- a/services/api/src/services/post-repository.drizzle.ts +++ b/services/api/src/services/post-repository.drizzle.ts @@ -16,7 +16,7 @@ import { paginate, parseTimeCursor } from "../db/cursor-helpers.js" import { loadMentionsFor, makeMentionRepo } from "./message-mentions.drizzle.js" import { cleanupStatusExpr, goingScalar } from "./cleanup-sql.js" import { claimableAsAttachment, lockUploadsForClaim } from "./media-bindings.js" -import { userUploader } from "./media-uploader.js" +import { uploadersOf } from "./media-uploader.js" import { publicServedKeyExpr } from "./media-served-key.js" import { mapWithLimit, PRESIGN_CONCURRENCY, type PresignMedia } from "./media-presign.js" import { publicAuthorIdentity } from "./public-author.js" @@ -56,6 +56,7 @@ function organizationRefOf( export interface CreatePostArgs { authorId: string + guestAnonSessionId?: string | undefined kind: PostKind body: string | null replyToId: string | null @@ -1081,7 +1082,10 @@ export function makeDrizzlePostRepository(sql: Sql, deps: PostRepoDeps): PostRep SET post_id = ${postId}, purpose = 'post' WHERE upload_id IN ${tx(args.mediaUploadIds)} AND post_id IS NULL AND chat_message_id IS NULL AND report_id IS NULL - AND ${claimableAsAttachment(tx, [userUploader(args.authorId)])} + AND ${claimableAsAttachment( + tx, + uploadersOf({ userId: args.authorId, guestAnonSessionId: args.guestAnonSessionId }), + )} AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) RETURNING upload_id ` diff --git a/services/api/src/services/post-service.ts b/services/api/src/services/post-service.ts index 90ff2eda..094d249d 100644 --- a/services/api/src/services/post-service.ts +++ b/services/api/src/services/post-service.ts @@ -97,7 +97,11 @@ export interface PostServiceDeps { } export interface PostService { - createPost(input: PostComposeInput, authorId: string): Promise + createPost( + input: PostComposeInput, + authorId: string, + guestAnonSessionId?: string, + ): Promise getPost(id: string, viewerId: string): Promise deletePost(id: string, viewerId: string): Promise<{ ok: true }> listReplies(postId: string, viewerId: string, pagination: PaginationQuery): Promise @@ -389,7 +393,11 @@ export function makePostService(deps: PostServiceDeps): PostService { } return { - async createPost(input: PostComposeInput, authorId: string): Promise { + async createPost( + input: PostComposeInput, + authorId: string, + guestAnonSessionId?: string, + ): Promise { assertNoSlur(input.body ?? null, "body") if (input.kind === "repost") { @@ -440,6 +448,7 @@ export function makePostService(deps: PostServiceDeps): PostService { const postId = await deps.repo.createPost({ authorId, + guestAnonSessionId, kind, body: input.body ?? null, replyToId: input.replyToId ?? null, diff --git a/services/api/src/services/report-repository.drizzle.ts b/services/api/src/services/report-repository.drizzle.ts index 661f12d3..5baf999d 100644 --- a/services/api/src/services/report-repository.drizzle.ts +++ b/services/api/src/services/report-repository.drizzle.ts @@ -26,7 +26,7 @@ import type { import { REPORT_CREATE_SCOPE } from "./report-service.types.js" import { servedKeyExpr, servableMediaFilter } from "./media-served-key.js" import { claimableAsReportMedia, lockUploadsForClaim } from "./media-bindings.js" -import { userUploader } from "./media-uploader.js" +import { uploadersOf } from "./media-uploader.js" import { reportColumns, selectPublicPins, @@ -194,7 +194,13 @@ export function makeDrizzleReportRepository(sql: Sql): ReportRepository { WHERE upload_id IN ${tx(args.mediaUploadIds)} AND (report_id IS NULL OR report_id = ${args.reportId}) AND post_id IS NULL AND chat_message_id IS NULL - AND ${claimableAsReportMedia(tx, [userUploader(args.reporterUserId)])} + AND ${claimableAsReportMedia( + tx, + uploadersOf({ + userId: args.reporterUserId, + guestAnonSessionId: args.guestAnonSessionId, + }), + )} AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) RETURNING upload_id ` diff --git a/services/api/src/services/report-service.ts b/services/api/src/services/report-service.ts index 97fcfbab..59dd8898 100644 --- a/services/api/src/services/report-service.ts +++ b/services/api/src/services/report-service.ts @@ -43,6 +43,7 @@ import { type ReportDiscussionMeta, type ReportMediaView, type ReportOwner, + type SignedInReportOwner, type ReportRecord, type ReportSearchInput, type ReportService, @@ -206,7 +207,7 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { } const service: ReportService = { - async createReport(input: CreateReportRequest, owner: { userId: string }): Promise { + async createReport(input: CreateReportRequest, owner: SignedInReportOwner): Promise { if (input.honeypot !== undefined && input.honeypot.trim() !== "") { throw AppError.validation({ honeypot: "invalid" }) } @@ -243,6 +244,7 @@ export function makeReportService(deps: ReportServiceDeps): ReportService { const result = await deps.repo.createReportTx({ reportId, reporterUserId: owner.userId, + guestAnonSessionId: owner.guestAnonSessionId, idempotencyKey: input.idempotencyKey, lat: input.lat, lng: input.lng, diff --git a/services/api/src/services/report-service.types.ts b/services/api/src/services/report-service.types.ts index 683e6f3f..c60b4817 100644 --- a/services/api/src/services/report-service.types.ts +++ b/services/api/src/services/report-service.types.ts @@ -43,6 +43,11 @@ export interface ReportOwner { anonSessionId?: string | undefined } +export interface SignedInReportOwner { + userId: string + guestAnonSessionId?: string | undefined +} + export interface ReportMediaView { id: string kind: "image" | "video" @@ -103,6 +108,7 @@ export interface ReportMapPoint { export interface CreateReportTxArgs { reportId: string reporterUserId: string + guestAnonSessionId?: string | undefined idempotencyKey: string lat: number lng: number @@ -242,7 +248,7 @@ export interface ReportServiceDeps { } export interface ReportService { - createReport(input: CreateReportRequest, owner: { userId: string }): Promise + createReport(input: CreateReportRequest, owner: SignedInReportOwner): Promise getReport(id: string, viewer: ReportOwner): Promise listMyReports(userId: string, pagination: PaginationQuery): Promise listReportsInBBox( diff --git a/services/api/test/unit/media-claim-actor.test.ts b/services/api/test/unit/media-claim-actor.test.ts index 70aef537..30243613 100644 --- a/services/api/test/unit/media-claim-actor.test.ts +++ b/services/api/test/unit/media-claim-actor.test.ts @@ -2,6 +2,9 @@ import { randomUUID } from "node:crypto" import { describe, expect, it } from "vitest" import { FakeJobs, FakeStorage } from "@civfix/shared/fakes" import type { AppError } from "@civfix/shared" +import type { FastifyRequest } from "fastify" +import { signAnonToken } from "../../src/abuse/anon-token.js" +import { resolveAuthContext } from "../../src/auth/context.js" import type { Sql } from "../../src/db/client.js" import { makeChatGroupRepository } from "../../src/services/chat-group-repository.drizzle.js" import { makeDrizzleCleanupRepository } from "../../src/services/cleanup-repository.drizzle.js" @@ -21,11 +24,13 @@ import { type RecordedStatement, type SqlHandler, } from "../helpers/fake-sql.js" +import { bearer, makeAuthHarness } from "../helpers/auth.js" import { InMemoryMediaRepository } from "../helpers/media.js" const MEDIA_ID = "44444444-4444-4444-8444-444444444444" const GROUP_ID = "55555555-5555-4555-8555-555555555555" const UPLOAD_ID = "66666666-6666-4666-8666-666666666666" +const GUEST_TOKEN_ID = "77777777-7777-4777-8777-777777777777" const MEDIA_CLAIM = /UPDATE media_assets\s+SET purpose/ function squash(text: string): string { @@ -278,12 +283,30 @@ describe("finalizing an upload", () => { expect(jobs.jobsFor(MEDIA_CHECKS_JOB)).toHaveLength(1) }) - it("accepts the guest session that uploaded, even once the caller has also signed in", async () => { - const { service, uploadId } = await harness({ anonSessionId: "anon-1" }) - - await expect( - service.finalize({ uploadId }, { userId: randomUUID(), anonSessionId: "anon-1" }), - ).resolves.toMatchObject({ status: "validating" }) + it("accepts the guest session that uploaded, once the same browser has also signed in", async () => { + const auth = await makeAuthHarness() + try { + const { token } = await auth.signIn("guest-then-member@example.org") + const context = await resolveAuthContext({ + server: auth.app, + headers: bearer(token), + cookies: { civfix_anon: signAnonToken(GUEST_TOKEN_ID, auth.env.ANON_TOKEN_SIGNING_KEY) }, + } as unknown as FastifyRequest) + const { service, uploadId } = await harness({ anonSessionId: GUEST_TOKEN_ID }) + + await expect( + service.finalize( + { uploadId }, + { + userId: context.userId ?? undefined, + anonSessionId: context.anonSessionId, + guestAnonSessionId: context.guestAnonSessionId, + }, + ), + ).resolves.toMatchObject({ status: "validating" }) + } finally { + await auth.app.close() + } }) it("accepts an unattributed upload only inside the claim window", async () => { diff --git a/services/api/test/unit/media-claim-uploader.test.ts b/services/api/test/unit/media-claim-uploader.test.ts index b5b45626..7a6105fe 100644 --- a/services/api/test/unit/media-claim-uploader.test.ts +++ b/services/api/test/unit/media-claim-uploader.test.ts @@ -233,6 +233,57 @@ describe("every uploadId claim binds only the caller's own upload", () => { expectLockedBeforeClaim(fake, POST_CLAIM) }) + it("a signed-in report also claims the uploads its browser made as a guest", async () => { + const reporter = randomUUID() + const fake = makeFakeSql([{ match: /INSERT INTO reference_counters/, rows: [{ next_val: 1 }] }]) + + await expect( + makeDrizzleReportRepository(fake.sql as unknown as Sql).createReportTx({ + ...reportArgs(reporter), + guestAnonSessionId: "guest-token", + }), + ).rejects.toMatchObject({ httpStatus: 422 }) + + const claim = statementMatching(fake, REPORT_CLAIM) + expect(squash(claim.sql)).toContain("media_assets.uploader IN (?,?)") + expect(claim.values).toEqual( + expect.arrayContaining([userUploader(reporter), anonUploader("guest-token")]), + ) + expect(claim.values).not.toContain(UNSESSIONED_UPLOADER) + }) + + it("a signed-in post also claims the uploads its browser made as a guest", async () => { + const author = randomUUID() + const fake = makeFakeSql([{ match: /INSERT INTO posts/, rows: [{ id: randomUUID() }] }]) + const repo = makeDrizzlePostRepository(fake.sql as unknown as Sql, { + presignMedia: () => Promise.resolve({ url: "u" }), + presignAvatar: () => Promise.resolve("a"), + }) + + await expect( + repo.createPost({ + authorId: author, + guestAnonSessionId: "guest-token", + kind: "post", + body: "hello", + replyToId: null, + repostOfId: null, + eventId: null, + reportId: null, + mediaUploadIds: [UPLOAD_ID], + mentionedUserIds: [], + organizationId: null, + }), + ).rejects.toMatchObject({ httpStatus: 422 }) + + const claim = statementMatching(fake, POST_CLAIM) + expect(squash(claim.sql)).toContain("media_assets.uploader IN (?,?)") + expect(claim.values).toEqual( + expect.arrayContaining([userUploader(author), anonUploader("guest-token")]), + ) + expect(claim.values).not.toContain(UNSESSIONED_UPLOADER) + }) + it("a chat or DM attachment claims as its sender", async () => { const sender = randomUUID() const fake = makeFakeSql([{ match: CHAT_CLAIM, rows: [{ upload_id: UPLOAD_ID }] }]) diff --git a/services/api/test/unit/media-guest-upload-after-sign-in.test.ts b/services/api/test/unit/media-guest-upload-after-sign-in.test.ts new file mode 100644 index 00000000..c3ca4cf5 --- /dev/null +++ b/services/api/test/unit/media-guest-upload-after-sign-in.test.ts @@ -0,0 +1,190 @@ +import { randomUUID } from "node:crypto" +import { afterEach, describe, expect, it } from "vitest" +import type { FastifyRequest } from "fastify" +import type { FakeStorage } from "@civfix/shared/fakes" +import { signAnonToken } from "../../src/abuse/anon-token.js" +import { resolveAuthContext } from "../../src/auth/context.js" +import type { Sql } from "../../src/db/client.js" +import { quotaSubjects } from "../../src/services/media-intake-service.js" +import { anonUploader, uploaderOf, userUploader } from "../../src/services/media-uploader.js" +import { makeDrizzleReportRepository } from "../../src/services/report-repository.drizzle.js" +import { bearer, makeAuthHarness, type AuthHarness } from "../helpers/auth.js" +import { makeFakeSql } from "../helpers/fake-sql.js" +import { InMemoryMediaRepository } from "../helpers/media.js" + +const GUEST_TOKEN_ID = "77777777-7777-4777-8777-777777777777" +const REPORT_CLAIM = /UPDATE media_assets\s+SET report_id/ +const UPLOAD_REQUEST = { + kind: "image", + contentType: "image/jpeg", + byteSize: 1024, + sha256: "d".repeat(64), +} + +function anonCookie(h: AuthHarness): Record { + const signed = signAnonToken(GUEST_TOKEN_ID, h.env.ANON_TOKEN_SIGNING_KEY) + return { cookie: `civfix_anon=${encodeURIComponent(signed)}` } +} + +function reportRow(values: unknown[]): Record { + return { + id: values[0], + reporter_user_id: null, + anon_session_id: null, + category: "trash", + type: "dump", + title: null, + description: null, + addr: null, + addr_source: null, + addr_precision: null, + status: "published", + visibility: "public", + lng: -118.25, + lat: 34.05, + geom_source: "device", + jurisdiction_geoid: null, + reference_code: "DUMP-1", + created_at: new Date(), + published_at: new Date(), + deleted_at: null, + } +} + +async function harness() { + const mediaRepo = new InMemoryMediaRepository() + // The claim answers only for uploads whose recorded uploader is among the subjects the request bound, + // which is what the real predicate decides. + const fake = makeFakeSql([ + { match: /reference_counters/i, rows: [{ next_val: 1 }] }, + { + match: REPORT_CLAIM, + rows: (values) => + [...mediaRepo.byId.values()] + .filter((m) => values.includes(m.uploadId) && values.includes(m.uploader)) + .map((m) => ({ upload_id: m.uploadId })), + }, + { match: /FROM reports WHERE id =/, rows: (values) => [reportRow(values)] }, + ]) + const h = await makeAuthHarness({ + server: { + mediaRepo, + reportOverrides: { repo: makeDrizzleReportRepository(fake.sql as unknown as Sql) }, + }, + }) + return { h, mediaRepo } +} + +async function guestUpload(h: AuthHarness, mediaRepo: InMemoryMediaRepository): Promise { + const res = await h.app.inject({ + method: "POST", + url: "/v1/media/upload", + headers: anonCookie(h), + payload: UPLOAD_REQUEST, + }) + expect(res.statusCode).toBe(200) + const { uploadId } = res.json() as { uploadId: string } + const asset = (await mediaRepo.findByUploadId(uploadId))! + expect(asset.uploader).toBe(anonUploader(GUEST_TOKEN_ID)) + await (h.container.storage as FakeStorage).put( + asset.r2Key, + new Uint8Array(UPLOAD_REQUEST.byteSize), + { contentType: UPLOAD_REQUEST.contentType }, + ) + return uploadId +} + +function fileReport(h: AuthHarness, headers: Record, uploadId: string) { + return h.app.inject({ + method: "POST", + url: "/v1/reports", + headers, + payload: { + idempotencyKey: randomUUID(), + category: "trash", + type: "dump", + lat: 34.05, + lng: -118.25, + geomSource: "device", + mediaUploadIds: [uploadId], + }, + }) +} + +describe("a guest's upload once the same browser signs in", () => { + let h: AuthHarness | undefined + afterEach(async () => { + await h?.app.close() + h = undefined + }) + + it("finalizes and files a report with the upload it made as a guest", async () => { + const built = await harness() + h = built.h + const uploadId = await guestUpload(h, built.mediaRepo) + const { token } = await h.signIn("guest-turned-member@example.org") + const sameBrowser = { ...bearer(token), ...anonCookie(h) } + + const finalized = await h.app.inject({ + method: "POST", + url: `/v1/media/${uploadId}/finalize`, + headers: sameBrowser, + }) + expect(finalized.statusCode).toBe(200) + + const filed = await fileReport(h, sameBrowser, uploadId) + expect(filed.statusCode).toBe(201) + }) + + it("refuses another signed-in account that does not carry the guest's cookie", async () => { + const built = await harness() + h = built.h + const uploadId = await guestUpload(h, built.mediaRepo) + const { token } = await h.signIn("someone-else@example.org") + + const finalized = await h.app.inject({ + method: "POST", + url: `/v1/media/${uploadId}/finalize`, + headers: bearer(token), + }) + expect(finalized.statusCode).toBe(404) + + const filed = await fileReport(h, bearer(token), uploadId) + expect(filed.statusCode).toBe(422) + }) +}) + +describe("the request context of a signed-in browser that still carries its guest cookie", () => { + let h: AuthHarness | undefined + afterEach(async () => { + await h?.app.close() + h = undefined + }) + + function contextFor(token: string) { + const signed = signAnonToken(GUEST_TOKEN_ID, h!.env.ANON_TOKEN_SIGNING_KEY) + const request = { + server: h!.app, + headers: bearer(token), + cookies: { civfix_anon: signed }, + } as unknown as FastifyRequest + return resolveAuthContext(request) + } + + it("keeps the guest id apart from the anonymous subject, so only upload ownership sees it", async () => { + h = await makeAuthHarness() + const { token, userId } = await h.signIn("member@example.org") + + const context = await contextFor(token) + + expect(context).toMatchObject({ userId, anon: false, guestAnonSessionId: GUEST_TOKEN_ID }) + expect(context.anonSessionId).toBeUndefined() + }) + + it("still stores and charges a new upload to the account alone", () => { + const owner = { userId: "member-1", guestAnonSessionId: GUEST_TOKEN_ID, ipKey: "203.0.113.9" } + + expect(uploaderOf(owner)).toBe(userUploader("member-1")) + expect(quotaSubjects(owner)).toEqual([userUploader("member-1")]) + }) +}) From ed96af11f0ce34ba2a4a23bca07ca31adbf2b254 Mon Sep 17 00:00:00 2001 From: Theo Date: Wed, 23 Sep 2026 09:21:42 +0000 Subject: [PATCH 45/45] ticket transfers lock the event first, like bans --- .../host/organization-repository.types.ts | 6 +- .../host/registration-repository.drizzle.ts | 8 +++ .../registration-repository-security.test.ts | 68 +++++++++++++++++++ .../api/test/unit/pg-stores-security.test.ts | 20 ++++++ 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/services/api/src/services/host/organization-repository.types.ts b/services/api/src/services/host/organization-repository.types.ts index dc7e7aa6..d32a1949 100644 --- a/services/api/src/services/host/organization-repository.types.ts +++ b/services/api/src/services/host/organization-repository.types.ts @@ -279,8 +279,10 @@ export interface CreateOrganizationInviteArgs { now: Date } -/** `already_invited` carries the open invite so the caller can answer with it (idempotent re-invite). */ -/** `forbidden`: the inviter no longer holds the power to invite once the organization is locked. */ +/** + * `already_invited` carries the open invite so the caller can answer with it (idempotent re-invite). + * `forbidden`: the inviter no longer holds the power to invite once the organization is locked. + */ export type CreateOrganizationInviteOutcome = | { kind: "created"; invite: OrganizationInviteRecord } | { kind: "already_invited"; invite: OrganizationInviteRecord } diff --git a/services/api/src/services/host/registration-repository.drizzle.ts b/services/api/src/services/host/registration-repository.drizzle.ts index 79a2470b..14ec9084 100644 --- a/services/api/src/services/host/registration-repository.drizzle.ts +++ b/services/api/src/services/host/registration-repository.drizzle.ts @@ -1722,6 +1722,14 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio now: Date }): Promise { return sql.begin(async (tx) => { + // The event row first, as applyBanIn takes it: a ban holds it while it moves ticket-type seats and + // then cancels registrations, so a transfer that locked its registration before the ticket types + // could wait on the ban from the opposite end. registerIn's FOR SHARE on the same row keeps the + // same order. + const event = await tx<{ id: string }[]>` + SELECT id FROM cleanups WHERE id = ${args.cleanupId} LIMIT 1 FOR NO KEY UPDATE + ` + if (event[0] === undefined) return { kind: "not_found" as const } const locked = await tx< { id: string; ticket_type_id: string | null; party_size: number; status: string }[] >` diff --git a/services/api/test/unit/host/registration-repository-security.test.ts b/services/api/test/unit/host/registration-repository-security.test.ts index fe04bd9a..b066e9a1 100644 --- a/services/api/test/unit/host/registration-repository-security.test.ts +++ b/services/api/test/unit/host/registration-repository-security.test.ts @@ -581,3 +581,71 @@ describe("roster search", () => { expect(roster.sql.match(/ILIKE \? ESCAPE '\\'/g)).toHaveLength(4) }) }) + +describe("ticket transfer lock order", () => { + const OTHER_TYPE = "99999999-9999-4999-8999-999999999999" + + function transferHandlers(): SqlHandler[] { + return [ + { match: /FROM cleanups\s+WHERE id = \?/, rows: [{ id: EVENT }] }, + { + match: /FROM cleanup_registrations\s+WHERE id = \?/, + rows: [ + { id: REGISTRATION, ticket_type_id: HIDDEN_TYPE, party_size: 1, status: "registered" }, + ], + }, + { + match: /FROM cleanup_ticket_types\s+WHERE cleanup_id/, + rows: [ + { id: HIDDEN_TYPE, max_party_size: 4, capacity: null, reserved_seats: 1 }, + { id: OTHER_TYPE, max_party_size: 4, capacity: null, reserved_seats: 0 }, + ], + }, + { + match: /UPDATE cleanup_ticket_types\s+SET reserved_seats = reserved_seats \+/, + rows: [{ id: OTHER_TYPE }], + }, + ] + } + + it("locks the event row before the registration and its ticket types, as a ban does", async () => { + const fake = makeFakeSql(transferHandlers()) + const repo = makeDrizzleHostRegistrationRepository(fake.sql as unknown as Sql) + + await repo.transferRegistration({ + cleanupId: EVENT, + registrationId: REGISTRATION, + ticketTypeId: OTHER_TYPE, + now: NOW, + }) + + const eventLock = fake.statements.findIndex((s) => + /FROM cleanups\s+WHERE id = \?\s+LIMIT 1 FOR NO KEY UPDATE/.test(s.sql), + ) + const registrationLock = fake.statements.findIndex((s) => + /FROM cleanup_registrations[\s\S]*FOR UPDATE/.test(s.sql), + ) + const typeLock = fake.statements.findIndex((s) => + /FROM cleanup_ticket_types[\s\S]*FOR UPDATE/.test(s.sql), + ) + expect(eventLock).toBe(0) + expect(fake.statements[eventLock]!.values).toEqual([EVENT]) + expect(registrationLock).toBeGreaterThan(eventLock) + expect(typeLock).toBeGreaterThan(registrationLock) + }) + + it("answers not found without touching the registration when the event is gone", async () => { + const fake = makeFakeSql(transferHandlers().slice(1)) + const repo = makeDrizzleHostRegistrationRepository(fake.sql as unknown as Sql) + + const outcome = await repo.transferRegistration({ + cleanupId: EVENT, + registrationId: REGISTRATION, + ticketTypeId: OTHER_TYPE, + now: NOW, + }) + + expect(outcome).toEqual({ kind: "not_found" }) + expect(fake.statements.some((s) => /cleanup_registrations/.test(s.sql))).toBe(false) + }) +}) diff --git a/services/api/test/unit/pg-stores-security.test.ts b/services/api/test/unit/pg-stores-security.test.ts index 8d3250eb..53c36401 100644 --- a/services/api/test/unit/pg-stores-security.test.ts +++ b/services/api/test/unit/pg-stores-security.test.ts @@ -126,3 +126,23 @@ describe("account erasure revokes the user's pending organization invites", () = expect(revoke.params.slice(0, 2)).toEqual([USER_ID, USER_ID]) }) }) + +describe("account erasure takes attendee locks in the order the event ban takes them", () => { + it("cancels the user's waitlist rows before it touches their registrations", async () => { + const { db, statements } = recordingDb(answerErasure()) + + await new PgUserStore(db).softDeleteAndAnonymize(USER_ID) + + const waitlistAt = statements.findIndex((s) => /update cleanup_waitlist/i.test(s.sql)) + const registrationAt = statements.findIndex((s) => + /update cleanup_registrations\b/i.test(s.sql), + ) + const seatsAt = statements.findIndex((s) => /update cleanup_registration_seats/i.test(s.sql)) + const answersAt = statements.findIndex((s) => /update cleanup_answers/i.test(s.sql)) + expect(waitlistAt).toBeGreaterThanOrEqual(0) + expect(registrationAt).toBeGreaterThan(waitlistAt) + expect(seatsAt).toBeGreaterThan(waitlistAt) + expect(answersAt).toBeGreaterThan(waitlistAt) + expect(statements[waitlistAt]!.sql).toMatch(/update cleanup_ticket_types/i) + }) +})