diff --git a/docs/erasure-behavior.md b/docs/erasure-behavior.md index 23d07791..f21732d2 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: @@ -34,8 +42,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 +53,40 @@ 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): + `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** — all sessions deleted, ban marker set, 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 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. | @@ -79,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 @@ -295,11 +312,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/docs/security/2026-07-24-full-backend-security-review.md b/docs/security/2026-07-24-full-backend-security-review.md index 475c1eea..4c31a685 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,8 @@ 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 | **Deferred to the NEXT release** (expand/contract, `docs/migrations-expand-contract.md`): 0.43.0 diff --git a/infra/email-worker/README.md b/infra/email-worker/README.md index c114ae7e..92c1f412 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 @@ -73,7 +80,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 240ded1b..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 = `${PENDING_PREFIX}${messageId}.eml` + const { messageId, key } = await derivePendingIdentity(message.headers, rawBytes) try { await env.R2_BUCKET.put(key, rawBytes, { @@ -35,9 +34,41 @@ export default { }, } -export async function deriveMessageId(headers: Headers, raw: ArrayBuffer): Promise { +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. 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") ?? "") - if (slug.length > 0) return slug + 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 { + return (await derivePendingIdentity(headers, raw)).messageId +} + +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..8c758f8c --- /dev/null +++ b/infra/email-worker/test/pending-key-security.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import worker, { derivePendingKey, type Env } 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$/) + }) +}) + +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/.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/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/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/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/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/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/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/context.ts b/services/api/src/auth/context.ts index 4163ca2d..98e31952 100644 --- a/services/api/src/auth/context.ts +++ b/services/api/src/auth/context.ts @@ -1,13 +1,21 @@ 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" +// 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 } @@ -33,9 +41,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) { @@ -43,10 +58,16 @@ export async function resolveAuthContext(request: FastifyRequest): 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 + } + private requireGoogle(): Google { const cfg = this.requireGoogleConfig() if (!this.googleClient) { @@ -239,11 +273,21 @@ 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 providerFallbackName(provider) } @@ -252,6 +296,29 @@ function providerFallbackName(provider: string): string { 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/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 2500cc19..a952a255 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, @@ -21,10 +23,16 @@ 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 { + EmailTakenError, generatePlaceholderHandle, generateTombstoneHandle, type AccountStatus, @@ -81,7 +89,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 +201,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 @@ -232,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 = {} @@ -252,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 @@ -453,6 +469,20 @@ export class PgUserStore implements UserStore { `) } await tx.execute(sql`DELETE FROM organization_members WHERE user_id = ${id}`) + // 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 + 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() @@ -479,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' @@ -513,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, @@ -548,6 +580,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" }) @@ -705,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/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 524d333c..af4759ec 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 { @@ -174,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 } = {}) { @@ -212,6 +230,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 }) } } @@ -323,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(), @@ -338,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 { @@ -357,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 { @@ -383,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 { @@ -479,9 +513,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/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/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/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/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/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..81f8d86f --- /dev/null +++ b/services/api/src/errors/log-redaction.ts @@ -0,0 +1,167 @@ +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 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]" + +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 + 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 + 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 redactArray(value, parentKey, walk) + if (value instanceof Error) return shadowError(value, walk) + const out: Record = {} + redactEntries(value, out, parentKey, walk, true) + 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)) + redactEntries( + error as unknown as Record, + shadow as unknown as Record, + null, + walk, + false, + ) + 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, visited: 0, ancestors: new Set() }) as Record< + string, + unknown + > +} 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/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/admin/auth.routes.ts b/services/api/src/routes/admin/auth.routes.ts index 0db67a10..10de9613 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" @@ -31,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 @@ -57,7 +59,22 @@ export async function registerAdminAuthRoutes( const defaultVerify: VerifyAccessJwt | null = teamDomain && aud ? createAccessVerifier({ teamDomain, aud }) : null - async function provisionOperator(email: string): Promise { + 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) { user = await services.users.create(email, { @@ -69,6 +86,21 @@ 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 auditLoginDenied(user, request, { status: accountStatus }) + throw refusal + } const operator = user.role === "operator" ? user : await services.users.setRole(user.id, "operator") await auditOperatorAuth(app, container, { @@ -87,16 +119,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.") + throw AppError.forbidden(UNAUTHORIZED_OPERATOR_MESSAGE) } - 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) }, @@ -162,6 +196,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, @@ -170,12 +210,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/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/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/routes/chat-powers-wiring.ts b/services/api/src/routes/chat-powers-wiring.ts index c71f8667..d4d5d5fa 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" @@ -60,15 +62,23 @@ export function makeIsDmBlocked( } } -/** The user's global users.role, or null when the row is missing. */ -export async function globalRoleOf( +/** + * 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 }[]>` - SELECT role FROM users WHERE id = ${userId} LIMIT 1 + const rows = await sql<{ role: GlobalRole; email: string | null }[]>` + SELECT role, email FROM users WHERE id = ${userId} LIMIT 1 ` - return rows[0]?.role ?? null + 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. */ @@ -127,7 +137,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/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/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/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/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/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..70f446c7 100644 --- a/services/api/src/routes/host/exports.routes.ts +++ b/services/api/src/routes/host/exports.routes.ts @@ -15,7 +15,6 @@ 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 type { CommsRuntime } from "../../services/host/comms-wiring.js" @@ -74,18 +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 writeAudit(container.getDb().sql, { - action: "event.roster_exported", - actorId: userId, - target: `cleanup:${body.id}`, - meta: { exportId: payload.id, kind: body.kind }, - }) 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 9fd3fa28..669b7b6d 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 @@ -129,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, }) } @@ -144,9 +146,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/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/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/routes/host/team.routes.ts b/services/api/src/routes/host/team.routes.ts index 8bed24f6..1f072dca 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 @@ -94,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, }) } @@ -114,9 +116,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/routes/host/unsubscribe.routes.ts b/services/api/src/routes/host/unsubscribe.routes.ts index c7c28428..70f8fbab 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. 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, + cause: err, + }), + ) } reply.status(200).send(OK) }, 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/routes/media.routes.ts b/services/api/src/routes/media.routes.ts index 715ec19e..d5f5e00e 100644 --- a/services/api/src/routes/media.routes.ts +++ b/services/api/src/routes/media.routes.ts @@ -186,6 +186,7 @@ function ownerOf(request: FastifyRequest): MediaOwner { return { userId: auth?.userId ?? undefined, anonSessionId: auth?.anonSessionId ?? undefined, + guestAnonSessionId: auth?.guestAnonSessionId ?? undefined, // Last-resort quota bucket: a caller with neither an account nor an anon cookie is metered by IP. ipKey: normalizeIp(request.ip), } diff --git a/services/api/src/routes/posts.routes.ts b/services/api/src/routes/posts.routes.ts index 06a81af7..9b08b692 100644 --- a/services/api/src/routes/posts.routes.ts +++ b/services/api/src/routes/posts.routes.ts @@ -42,7 +42,9 @@ export async function registerPostRoutes( async (request, reply) => { 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/routes/users.routes.ts b/services/api/src/routes/users.routes.ts index bf3649c8..7aab8de0 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,9 +23,9 @@ 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 { 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" @@ -53,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, @@ -196,30 +200,48 @@ 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.") } } - await store.softDeleteAndAnonymize(userId) - await sessions.banUser(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) 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", () => @@ -235,7 +257,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/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/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 = 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", + "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: { 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 2116216f..860c5f15 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" @@ -51,6 +53,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 { @@ -127,7 +135,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, @@ -152,12 +160,13 @@ 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, storage, mailRepo, + bytes, mail, messageId, resolvedThread, @@ -197,6 +206,7 @@ async function routeThreaded( injected: InboundEffectDeps, storage: Storage, mailRepo: MailRepository, + raw: Uint8Array, mail: ParsedMail, messageId: string, thread: MailThreadRecord, @@ -205,27 +215,18 @@ 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: plainTextBody(mail), - attachments, - messageId, - inReplyTo: mail.inReplyTo ?? null, - unaffiliated, - }) - if (inserted !== null) { + // 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. 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.id, + messageId: insert.message.id, type: "delivered", meta: { direction: "in", @@ -233,12 +234,13 @@ async function routeThreaded( messageId, authVerdict, ...(unaffiliated ? { unaffiliated: true } : {}), - ...(oversize.length > 0 ? { oversizeAttachments: oversize } : {}), + ...(insert.oversize.length > 0 ? { oversizeAttachments: insert.oversize } : {}), }, }) } - const message = inserted ?? (await mailRepo.findMessageByMessageId(messageId).catch(() => null)) + const message = + 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) => { @@ -249,8 +251,47 @@ async function routeThreaded( }, ) } - if (inserted === null) return { outcome: "replay" } - return { outcome: "threaded", id: inserted.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 { + // 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}/${contentDigest(raw)}`, + 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: plainTextBody(mail), + attachments, + messageId, + inReplyTo: mail.inReplyTo ?? null, + unaffiliated, + }) + 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 { @@ -268,15 +309,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 @@ -295,9 +339,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 @@ -370,7 +428,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 56e9fe1e..b6d32007 100644 --- a/services/api/src/services/admin/inbound-thread-correlation.ts +++ b/services/api/src/services/admin/inbound-thread-correlation.ts @@ -18,8 +18,10 @@ import { DEFAULT_REPLY_DOMAIN, domainOf, domainsAligned, + organizationalDomain, replyAddressToken, } from "../../adapters/inbound-mail.cf.js" +import { CONSUMER_MAIL_DOMAINS } from "./inbound-bounce.js" export { JURISDICTION_REPLY_NOTE } @@ -201,16 +203,35 @@ 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 const recipients = await mailRepo.outboundRecipients(threadId) + 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/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/anon-repository.drizzle.ts b/services/api/src/services/anon-repository.drizzle.ts index d893e933..801450e4 100644 --- a/services/api/src/services/anon-repository.drizzle.ts +++ b/services/api/src/services/anon-repository.drizzle.ts @@ -31,15 +31,21 @@ * 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). - * 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. + * 5. INSERT the response snapshot WITHOUT its claim code into idempotency_keys.response_snapshot, + * owner-scoped by user_or_anon = the anon token id (0078/0079). + * All five happen atomically. A UNIQUE(idempotency_key) (or unique-index) race rolls the tx back and + * answers the retryable 409: the winner's claim code is not at rest to hand back, and rotating it here + * would kill the code the winner's response is carrying at that moment. + * + * REPLAY: the snapshot holds only { reportId, status }, so a replay mints a fresh claim code and rotates + * that report's claim_code_hash onto it (the code the first response carried stops working). That keeps + * the plaintext out of idempotency_keys and its backups while the replayed response still carries a + * working code, and keeps exactly one live code per report. A replay is only ever a request that + * arrived after the winner committed, which is a client that lost the first response. */ import type { Sql } from "../db/client.js" +import { generateToken, sha256Hex } from "../auth/crypto.js" import type { AnonReportRepository, AnonReportStatusRow, @@ -53,6 +59,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, lockUploadsForClaim } from "./media-bindings.js" /** Postgres unique-violation SQLSTATE; surfaced on the idempotency-key race. */ const PG_UNIQUE_VIOLATION = "23505" @@ -105,14 +112,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 +140,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 +167,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 +239,18 @@ 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) { + 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}) - -- 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, args.mediaUploaders)} AND (status = 'ready' OR (status = 'validating' AND finalized_at IS NOT NULL)) RETURNING upload_id ` @@ -257,14 +294,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,18 +314,11 @@ export function makeDrizzleAnonReportRepository(sql: Sql): AnonReportRepository return { kind: "created", snapshot } } catch (err) { if (isUniqueViolation(err)) { - const stored = await readSnapshot( - 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/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-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/src/services/chat-group-repository.drizzle.ts b/services/api/src/services/chat-group-repository.drizzle.ts index c34b7fe8..bb4de4d0 100644 --- a/services/api/src/services/chat-group-repository.drizzle.ts +++ b/services/api/src/services/chat-group-repository.drizzle.ts @@ -6,9 +6,10 @@ 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 { servedKeyExpr } from "./media-served-key.js" +import { publicServedKeyExpr } from "./media-served-key.js" export type GroupMemberRole = (typeof GROUP_MEMBER_ROLE_VALUES)[number] @@ -50,7 +51,7 @@ export interface CreateChatGroupInput { kind: ChatGroupKind name: string description: string | null - avatarMediaId: string | null + avatarUploadId: string | null ownerId: string visibility: ChatGroupVisibility } @@ -58,14 +59,14 @@ export interface CreateChatGroupInput { export interface UpdateChatGroupPatch { name?: string description?: string | null - avatarMediaId?: string + avatarUploadId?: string visibility?: ChatGroupVisibility } 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 @@ -84,7 +85,6 @@ export interface ChatGroupRepository { cursor: string | null, limit: number, ): Promise<{ members: GroupMemberView[]; nextCursor: string | null }> - findMediaIdByUploadId(uploadId: string, groupId?: string): Promise invitableIdsOf(actorId: string, candidateIds: string[]): Promise blockedPairsAmong(userIds: string[]): Promise> listMemberIds(groupId: string, limit?: number): Promise @@ -205,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 ` @@ -240,7 +248,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, @@ -254,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 { @@ -400,11 +418,6 @@ export function makeChatGroupRepository(sql: Sql, presign?: PresignMedia): ChatG } }, - async findMediaIdByUploadId(uploadId: string, groupId?: string): Promise { - const media = await resolveAvatarMediaOrThrow(sql, uploadId, { 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 35910cd7..a12694be 100644 --- a/services/api/src/services/chat-group-service.ts +++ b/services/api/src/services/chat-group-service.ts @@ -90,11 +90,6 @@ export function makeChatGroupService(deps: ChatGroupServiceDeps): ChatGroupServi } } - async function resolveAvatar(uploadId: string | undefined): Promise { - if (uploadId === undefined) return null - return groups.findMediaIdByUploadId(uploadId) - } - async function filterInvitees(actorId: string, memberIds: string[]): Promise { const unique = [...new Set(memberIds)].filter((id) => id !== actorId && !isOfficialAccount(id)) return groups.invitableIdsOf(actorId, unique) @@ -170,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), - 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, }, @@ -213,16 +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, 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/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/cleanup-repository.drizzle.ts b/services/api/src/services/cleanup-repository.drizzle.ts index 49e9a783..737372a4 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 { MEDIA_CLAIM_WINDOW_SEC } from "./host/event-media.js" -import { mediaBoundElsewhere, mediaBoundToCleanup } from "./media-bindings.js" +import { publicServedKeyExpr } from "./media-served-key.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" import type { AttendeeView, @@ -116,6 +117,7 @@ async function claimEventMediaInTx( tx: Queryable, cleanupId: string, host: EventHostWrite, + claimantUserId: string, ): Promise { const cover = host.coverMediaId ?? null const gallery = host.galleryMediaIds ?? [] @@ -131,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 ` @@ -393,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) } @@ -437,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}`) @@ -468,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 }) }, @@ -730,12 +736,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 +761,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 +802,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,27 +1002,15 @@ 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' 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 ban = await applyBanIn(tx, { cleanupId, userId, actorId, now: cleanup.now }) + releasedWaitlistTicketTypeIds = ban.releasedTicketTypeIds } const counted = await tx<{ count: number }[]>` SELECT @@ -1027,7 +1021,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-repository.types.ts b/services/api/src/services/cleanup-repository.types.ts index cb5073e6..a7405ba5 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" } @@ -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 3d48c5d1..cad9c19d 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, @@ -1277,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) { @@ -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/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/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/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/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-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/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/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/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/broadcast-pipeline.ts b/services/api/src/services/host/broadcast-pipeline.ts index e647d4bc..4900c69b 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, @@ -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([ @@ -140,7 +141,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 +205,45 @@ 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 + 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, + }, + "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 +251,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[] = [] @@ -327,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, @@ -760,6 +810,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..f89b8f52 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,13 +674,17 @@ export class InMemoryBroadcastRepository implements BroadcastRepository { return Promise.resolve(this.hosts.get(userId) ?? null) } - setHostMessagingSuspended(userId: string, suspended: boolean): Promise { - const existing = this.hosts.get(userId) ?? { - suspended: false, - emailVerified: true, - accountCreatedAt: new Date(0), - } + setHostMessagingSuspended( + userId: string, + suspended: boolean, + audit: WriteAuditInput, + ): Promise { + // 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/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..5f875fb3 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), 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..9ad440a7 100644 --- a/services/api/src/services/host/export-csv.ts +++ b/services/api/src/services/host/export-csv.ts @@ -1,11 +1,23 @@ -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. 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) + 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 - if (/[",\n\r]/.test(guarded)) return `"${guarded.replace(/"/g, '""')}"` - return guarded + // 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/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/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/host/host-team-repository.memory.ts b/services/api/src/services/host/host-team-repository.memory.ts index ecfcc12f..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,6 +45,7 @@ interface StoredPerson { displayName: string handle: string | null email: string | null + emailVerified: boolean avatarUrl: string | null } @@ -97,6 +98,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 +226,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..a53ebed7 100644 --- a/services/api/src/services/host/host-team-service.ts +++ b/services/api/src/services/host/host-team-service.ts @@ -89,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 @@ -227,8 +227,7 @@ export function makeHostTeamService(deps: HostTeamServiceDeps): HostTeamService token: string, ): Promise { if (deps.mailer === undefined) return - const base = deps.webOrigin ?? "https://civfix.org" - 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/src/services/host/organization-repository.drizzle.ts b/services/api/src/services/host/organization-repository.drizzle.ts index 69502105..90c8eb01 100644 --- a/services/api/src/services/host/organization-repository.drizzle.ts +++ b/services/api/src/services/host/organization-repository.drizzle.ts @@ -11,9 +11,9 @@ 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 { MEDIA_CLAIM_WINDOW_SEC } from "./event-media.js" -import { mediaBoundElsewhere } from "../media-bindings.js" +import { publicServedKeyExpr } from "../media-served-key.js" +import { mediaBoundElsewhere, uploadedByClaimant } from "../media-bindings.js" +import { userUploader } from "../media-uploader.js" import { writeHostAudit } from "./host-audit.js" import type { AcceptOrganizationInviteOutcome, @@ -54,6 +54,13 @@ import type { UpdateOrganizationAudit, UpdateOrganizationOutcome, UpdateOrganizationPatch, + InviterRevocationReason, + InviterStanding, +} from "./organization-repository.types.js" +import { + canManageOrgMembers, + inviterRevocationReason, + roleChangeWithdrawsInvites, } from "./organization-repository.types.js" const PG_UNIQUE_VIOLATION = "23505" @@ -224,7 +231,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, @@ -278,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 }[]>` @@ -303,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 ` @@ -323,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 }[]>` @@ -331,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 ` @@ -417,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, @@ -508,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}`] @@ -531,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, @@ -708,7 +718,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}) @@ -760,6 +771,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 +835,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" }) }, @@ -873,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` @@ -1202,6 +1227,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, @@ -1226,6 +1259,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 ( @@ -1358,7 +1394,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 @@ -1499,9 +1535,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 ` @@ -1534,6 +1571,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) @@ -1725,6 +1779,71 @@ 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 { + 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 + ` +} + +/** + * 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 { 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..a6ab0850 100644 --- a/services/api/src/services/host/organization-repository.memory.ts +++ b/services/api/src/services/host/organization-repository.memory.ts @@ -44,6 +44,13 @@ import type { UpdateOrganizationAudit, UpdateOrganizationOutcome, UpdateOrganizationPatch, + InviterRevocationReason, + InviterStanding, +} from "./organization-repository.types.js" +import { + canManageOrgMembers, + inviterRevocationReason, + roleChangeWithdrawsInvites, } from "./organization-repository.types.js" interface StoredOrganization { @@ -306,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) @@ -440,6 +448,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, ) @@ -458,6 +469,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"), @@ -479,12 +510,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 +556,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 +858,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 +872,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 ( @@ -878,6 +947,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( @@ -1055,6 +1127,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 d1194e4c..d32a1949 100644 --- a/services/api/src/services/host/organization-repository.types.ts +++ b/services/api/src/services/host/organization-repository.types.ts @@ -6,8 +6,46 @@ 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" | "account_deleted" + +export function canManageOrgMembers(role: OrganizationMemberRole | null): boolean { + return role !== null && can({ eventRole: null, orgRole: role }, "manage_org_members") +} + +/** + * 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 { + 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 { id: string slug: string @@ -241,10 +279,14 @@ export interface CreateOrganizationInviteArgs { now: Date } -/** `already_invited` carries the open invite so the caller can answer with it (idempotent re-invite). */ +/** + * `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" @@ -273,7 +315,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" @@ -290,6 +336,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 087ec956..9e91ecb3 100644 --- a/services/api/src/services/host/organization-service.ts +++ b/services/api/src/services/host/organization-service.ts @@ -113,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 @@ -341,14 +341,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 +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 ?? "https://civfix.org").replace(/\/+$/, "") + const webBase = () => deps.webOrigin.replace(/\/+$/, "") async function logoUrlOf(record: OrganizationBaseRecord): Promise { if (record.logoKey === null || deps.presignLogo === undefined) return null @@ -643,7 +642,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) @@ -754,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") @@ -842,6 +842,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 +864,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 { @@ -1147,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/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..14ec9084 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 { @@ -11,8 +11,11 @@ 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 { MEDIA_CLAIM_WINDOW_SEC } from "./event-media.js" +import { mediaBoundElsewhere, mediaBoundToCleanup, uploadedByClaimant } from "../media-bindings.js" +import { userUploader } from "../media-uploader.js" +import { likeContains } from "../admin/like.js" +import { isEventPubliclyVisible } from "./authz.js" +import { publicServedKeyExpr } from "../media-served-key.js" import { deterministicUuid } from "../deterministic-uuid.js" import { isCheckViolationOn, @@ -33,6 +36,7 @@ import { toTicketTypeRecord, toWaitlistRecord, waitlistColumns, + waitlistEntryNotBanned, waitlistJoins, type AnswerRowSelect, type PageRowSelect, @@ -45,6 +49,7 @@ import { import type { AnswerRecord, CancelRegistrationOutcome, + RemoveRegistrationOutcome, CheckinCountersRecord, CheckinResultRecord, ClaimWaitlistOutcome, @@ -99,6 +104,142 @@ 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 +} + +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), + ), + ], + } +} + +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 @@ -124,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 [] @@ -147,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 ` @@ -162,6 +304,18 @@ 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" && !isEventPubliclyVisible(visibility) + ) +} + 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 @@ -497,6 +651,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)` @@ -508,6 +668,7 @@ export function makeDrizzleHostRegistrationRepository(sql: Sql): HostRegistratio ${typeFilter} ${statusFilter} ${cursorFilter} + ${bannedFilter} ORDER BY w.created_at ASC, w.id ASC LIMIT ${args.limit + 1} ` @@ -526,7 +687,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, @@ -608,28 +769,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 +840,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 +1033,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 +1569,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 +1670,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 +1679,40 @@ 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 + 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, + } + }) }, async transferRegistration(args: { @@ -1538,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 }[] >` @@ -1630,6 +1822,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 +1843,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 +1942,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 +1984,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 +2038,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 ` @@ -2200,6 +2365,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 @@ -2234,6 +2400,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 @@ -2281,7 +2448,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, @@ -2304,13 +2471,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 }, @@ -2324,7 +2493,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 } } @@ -2337,6 +2512,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 } } @@ -2433,7 +2609,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, @@ -2441,7 +2617,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' @@ -2492,6 +2668,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 a9e387d1..5b3f4354 100644 --- a/services/api/src/services/host/registration-repository.memory.ts +++ b/services/api/src/services/host/registration-repository.memory.ts @@ -10,13 +10,16 @@ import { import { LIVE_TAIL_MS } from "@civfix/shared/host" import { ARRIVAL_BUCKET_MINUTES, + type AppliedBan, buildCheckinResult, emptyCheckinResult, + guestSelfRegistrationOnPrivateEvent, waitlistEntryAsRegistration, } from "./registration-repository.drizzle.js" import type { AnswerRecord, CancelRegistrationOutcome, + RemoveRegistrationOutcome, CheckinCountersRecord, CheckinResultRecord, ClaimWaitlistOutcome, @@ -505,11 +508,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 +550,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 +798,71 @@ export class InMemoryHostRegistrationRepository implements HostRegistrationRepos actorId: string ban: boolean now: Date - }): Promise { - 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 + }): Promise { + const record = this.registrations.get(args.registrationId) + if (record === undefined || record.cleanupId !== args.cleanupId) { + return { kind: "not_found", releasedWaitlistTicketTypeIds: [] } + } + 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]), + ], + } } async transferRegistration(args: { @@ -839,12 +902,21 @@ 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 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 @@ -867,8 +939,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" } @@ -918,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()) { @@ -945,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 @@ -985,6 +1072,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 +1105,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) { @@ -1235,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, @@ -1257,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-repository.types.ts b/services/api/src/services/host/registration-repository.types.ts index 645f1a26..5f8422b5 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" } @@ -369,6 +375,7 @@ export interface PageRecord { export interface SavePageArgs { cleanupId: string + actorUserId: string slug: string | null | undefined themeAccent: ThemeAccent | undefined blocks: EventPageBlock[] @@ -455,7 +462,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/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/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/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/src/services/media-bindings.ts b/services/api/src/services/media-bindings.ts index d3186849..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", @@ -32,6 +33,40 @@ 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. +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, 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) { return tag` SELECT c.id, c.visibility, c.organization_id diff --git a/services/api/src/services/media-intake-service.ts b/services/api/src/services/media-intake-service.ts index e3ea2426..1e088fe7 100644 --- a/services/api/src/services/media-intake-service.ts +++ b/services/api/src/services/media-intake-service.ts @@ -15,6 +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, uploadersOf } from "./media-uploader.js" +import { MEDIA_CLAIM_WINDOW_SEC } from "./host/event-media.js" import { makeMediaPresigner, makePrivateMediaPresigner } from "./media-presign.js" import { makeUnboundOnlyMediaViewAuthorizer, @@ -54,6 +56,7 @@ export interface MediaChecksJob { export interface MediaOwner { userId?: string | undefined anonSessionId?: string | undefined + guestAnonSessionId?: string | undefined ipKey?: string | undefined } @@ -75,6 +78,7 @@ export interface MediaAssetView { postId?: string | null createdAt?: Date | null finalizedAt?: Date | null + uploader?: string | null } export interface NewMediaAsset { @@ -84,6 +88,7 @@ export interface NewMediaAsset { r2Key: string status: MediaStatus byteSize: number + uploader: string } export interface MediaRepository { @@ -162,6 +167,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, @@ -195,6 +211,7 @@ export function makeMediaIntakeService(deps: MediaIntakeDeps): MediaIntakeServic r2Key, status: "validating", byteSize: input.byteSize, + uploader: uploaderOf(owner), }) const presigned = await deps.storage.presignPut(r2Key, { @@ -209,12 +226,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 a40077e0..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, } } @@ -35,6 +36,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 b8abeb83..611f782e 100644 --- a/services/api/src/services/media-served-key.ts +++ b/services/api/src/services/media-served-key.ts @@ -3,12 +3,22 @@ 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 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)` } +// 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))` } @@ -20,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..da67b1d8 --- /dev/null +++ b/services/api/src/services/media-uploader.ts @@ -0,0 +1,37 @@ +// 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 +} + +// 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 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/message-attachments.drizzle.ts b/services/api/src/services/message-attachments.drizzle.ts index 2634a40e..126f860b 100644 --- a/services/api/src/services/message-attachments.drizzle.ts +++ b/services/api/src/services/message-attachments.drizzle.ts @@ -1,7 +1,10 @@ +import { AppError } from "@civfix/shared" import type { Queryable } from "../db/client.js" import type { MediaDTO, MediaKind, MediaStatus } from "@civfix/shared" +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" @@ -15,6 +18,7 @@ export interface MessageAttachmentRepo { messageId: string, uploadIds: string[], messageCreatedAt: Date, + senderId: string, ): Promise } @@ -33,20 +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, [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.", + }) + } }, } } @@ -56,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 2ea77a63..ef5fadda 100644 --- a/services/api/src/services/post-repository.drizzle.ts +++ b/services/api/src/services/post-repository.drizzle.ts @@ -15,7 +15,9 @@ 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, lockUploadsForClaim } from "./media-bindings.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" import { presentIds } from "./present-ids.js" @@ -54,6 +56,7 @@ function organizationRefOf( export interface CreatePostArgs { authorId: string + guestAnonSessionId?: string | undefined kind: PostKind body: string | null replyToId: string | null @@ -510,7 +513,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 +549,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 ( @@ -1073,11 +1076,16 @@ 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, + 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 a5ebb419..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, @@ -488,8 +497,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/report-repository.drizzle.ts b/services/api/src/services/report-repository.drizzle.ts index ac2f17ea..5baf999d 100644 --- a/services/api/src/services/report-repository.drizzle.ts +++ b/services/api/src/services/report-repository.drizzle.ts @@ -25,6 +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, lockUploadsForClaim } from "./media-bindings.js" +import { uploadersOf } from "./media-uploader.js" import { reportColumns, selectPublicPins, @@ -182,15 +184,23 @@ 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. + 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}) - -- 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, + 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/src/services/social-repository.drizzle.ts b/services/api/src/services/social-repository.drizzle.ts index 15291fdf..ecbb3ee3 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" import { CIVFIX_OFFICIAL_USER_ID } from "../auth/official-account.js" export { searchByHandlePrefix, searchMentionable } from "./user-search.drizzle.js" @@ -281,7 +281,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, @@ -419,7 +419,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 @@ -455,7 +455,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, @@ -512,7 +512,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/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/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..b2a80c9c 100644 --- a/services/api/src/ws/types.ts +++ b/services/api/src/ws/types.ts @@ -33,6 +33,17 @@ export const WS_HANDSHAKE_FRAME_BUFFER = 32 export const WS_HANDSHAKE_BUFFER_BYTES = 64 * 1024 +// 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 + +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/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/helpers/cleanups.ts b/services/api/test/helpers/cleanups.ts index 0bd0dc8e..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,16 +1125,22 @@ 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 }) } 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/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/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) 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/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/integration/avatar-media-pg.test.ts b/services/api/test/integration/avatar-media-pg.test.ts index 0ac188a9..d66d1836 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 = 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( @@ -394,50 +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)).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())).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)).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)).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)).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)).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)).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(groups().findMediaIdByUploadId(media.uploadId)).rejects.toMatchObject(rejects422) - expect(await groups().findMediaIdByUploadId(media.uploadId, holder)).toBe(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().update(group, { name: "Renamed", avatarUploadId: media.uploadId }, GROUP_ADMIN), + ).rejects.toMatchObject(rejects422) + 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/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/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/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/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/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-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/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/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..d1111a7e --- /dev/null +++ b/services/api/test/integration/media-attach-claim-security-pg.test.ts @@ -0,0 +1,165 @@ +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 { userUploader } from "../../src/services/media-uploader.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 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, { uploader: userUploader(attackerId) }) + + 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 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, { uploader: userUploader(attackerId) }) + const messageId = randomUUID() + const sentAt = new Date() + + 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/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), }), } 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/integration/organization-repository-security-pg.test.ts b/services/api/test/integration/organization-repository-security-pg.test.ts new file mode 100644 index 00000000..ce710acb --- /dev/null +++ b/services/api/test/integration/organization-repository-security-pg.test.ts @@ -0,0 +1,199 @@ +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, + email = `${randomUUID().slice(0, 8)}@example.test`, + ): Promise { + const now = new Date() + const outcome = await orgs.createInviteTx({ + inviteId: randomUUID(), + organizationId, + email, + userId: null, + role: "admin", + tokenHash: randomUUID().replace(/-/g, ""), + invitedBy, + expiresAt: new Date(now.getTime() + INVITE_TTL_MS), + now, + }) + if (outcome.kind === "forbidden") throw new Error("inviter unexpectedly refused") + 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") + }) + + 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/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/integration/registration-repository-security-pg.test.ts b/services/api/test/integration/registration-repository-security-pg.test.ts new file mode 100644 index 00000000..651e3ad9 --- /dev/null +++ b/services/api/test/integration/registration-repository-security-pg.test.ts @@ -0,0 +1,372 @@ +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 { + 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].sort()).toEqual([main, vip].sort()) + 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) + }) + + 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/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..7a110a56 --- /dev/null +++ b/services/api/test/integration/report-media-claim-security-pg.test.ts @@ -0,0 +1,158 @@ +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" +import { MEDIA_CLAIM_WINDOW_SEC } from "../../src/services/host/event-media.js" +import { userUploader } from "../../src/services/media-uploader.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("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, { uploader: userUploader(attackerId) }) + + 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/admin-auth-routes-security.test.ts b/services/api/test/unit/admin-auth-routes-security.test.ts new file mode 100644 index 00000000..ae4b314e --- /dev/null +++ b/services/api/test/unit/admin-auth-routes-security.test.ts @@ -0,0 +1,140 @@ +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 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 }) + 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.") + }) +}) 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..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,7 +394,12 @@ 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) + h.broadcasts.seedHost(HOST) + 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 +420,12 @@ describe("admin host list", () => { it("filters to the suspended set when asked", async () => { const h = await harness() - await h.broadcasts.setHostMessagingSuspended(HOST, true) + h.broadcasts.seedHost(HOST) + 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/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/anon-repository-security.test.ts b/services/api/test/unit/anon-repository-security.test.ts new file mode 100644 index 00000000..48904915 --- /dev/null +++ b/services/api/test/unit/anon-repository-security.test.ts @@ -0,0 +1,216 @@ +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 { anonUploader } from "../../src/services/media-uploader.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: [], + mediaUploaders: [anonUploader(ANON_ID)], + 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("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" }), + rotation([{ id: REPORT_ID }]), + ]) + const sql = fake.sql as unknown as Sql & { begin: unknown } + sql.begin = () => Promise.reject(UNIQUE_VIOLATION) + + await expect( + makeDrizzleAnonReportRepository(sql, { + newClaimCode: () => FRESH_CODE, + }).createAnonReportTx(createArgs()), + ).rejects.toMatchObject({ code: "CONFLICT", message: "Report submit is still settling; retry" }) + + 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)) + }) +}) + +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("--") + }) +}) diff --git a/services/api/test/unit/auth-context-security.test.ts b/services/api/test/unit/auth-context-security.test.ts new file mode 100644 index 00000000..f3f2c9ad --- /dev/null +++ b/services/api/test/unit/auth-context-security.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it } from "vitest" +import type { FastifyInstance } from "fastify" +import { buildServer } from "../../src/server.js" +import { buildContainer } from "../../src/di.js" +import { loadEnv } from "../../src/env.js" +import { signAnonToken } from "../../src/abuse/anon-token.js" +import { InMemoryMediaRepository } from "../helpers/media.js" + +const SHA = "c".repeat(64) +const TOKEN_ID = "33333333-3333-4333-8333-333333333333" + +let app: FastifyInstance | undefined +afterEach(async () => { + 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/auth-oauth-security.test.ts b/services/api/test/unit/auth-oauth-security.test.ts new file mode 100644 index 00000000..755df08b --- /dev/null +++ b/services/api/test/unit/auth-oauth-security.test.ts @@ -0,0 +1,289 @@ +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, + identities: oauthStore, + 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("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, or contact support." + + 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() + 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) + }) +}) 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() 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..bca40ace --- /dev/null +++ b/services/api/test/unit/broadcast-pipeline-security.test.ts @@ -0,0 +1,264 @@ +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" +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 STALE_SENDING_AGE_MS = 60 * 60 * 1000 + +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 audits: Array<{ action: string; target: string; meta: Record }> = [] + 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: (action, _actorId, target, meta) => { + audits.push({ action, target, meta }) + return Promise.resolve() + }, + }) + return { repo, cache, mailer, pipeline, chunks, counterKeys, audits } +} + +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) + }) +}) + +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 new file mode 100644 index 00000000..b001a918 --- /dev/null +++ b/services/api/test/unit/broadcast-repository-security.test.ts @@ -0,0 +1,60 @@ +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", () => { + 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\\%") + }) +}) + +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) + }) +}) 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/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/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-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-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 } 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/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/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/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/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/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/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() + }, + ) +}) 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/email-format.test.ts b/services/api/test/unit/email-format.test.ts index c89c3d3d..38d79ac8 100644 --- a/services/api/test/unit/email-format.test.ts +++ b/services/api/test/unit/email-format.test.ts @@ -512,7 +512,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"') @@ -529,7 +529,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(" { + 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 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\"") + }) + + 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", () => { + expect(csvCell(" =1")).toBe('" =1"') + expect(csvCell("=1+1")).toBe('"=1+1"') + }) + + 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') + }) + + it("guards provenance lines like any other string cell", () => { + expect(csvProvenanceRow("note;=1")).toBe('"# note;\'=1"\n') + }) +}) diff --git a/services/api/test/unit/exposed-5xx-copy.test.ts b/services/api/test/unit/exposed-5xx-copy.test.ts new file mode 100644 index 00000000..f1c4dd18 --- /dev/null +++ b/services/api/test/unit/exposed-5xx-copy.test.ts @@ -0,0 +1,108 @@ +import Fastify, { type FastifyInstance } from "fastify" +import { afterEach, describe, expect, it, vi } from "vitest" +import { FakeMailer } from "@civfix/shared/fakes" +import { InMemoryCounterStore } from "../../src/abuse/counter-store.js" +import { buildContainer, type Container } from "../../src/di.js" +import { loadEnv } from "../../src/env.js" +import { makeErrorHandler, makeNotFoundHandler } from "../../src/errors/http-mapper.js" +import { registerUnsubscribeRoutes } from "../../src/routes/host/unsubscribe.routes.js" +import { buildServer } from "../../src/server.js" +import { + mintUnsubscribeToken, + unsubscribeExpiryFrom, +} from "../../src/services/host/broadcast-capability-token.js" +import { InMemoryBroadcastRepository } from "../../src/services/host/broadcast-repository.memory.js" +import { + makeBroadcastService, + type BroadcastConfig, +} from "../../src/services/host/broadcast-service.js" +import type { CommsRuntime } from "../../src/services/host/comms-wiring.js" + +vi.mock("../../src/env.js", async (importOriginal) => { + 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/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/host-export-csv.test.ts b/services/api/test/unit/host-export-csv.test.ts index dba8cc48..1bd62ae8 100644 --- a/services/api/test/unit/host-export-csv.test.ts +++ b/services/api/test/unit/host-export-csv.test.ts @@ -23,10 +23,15 @@ 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("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", () => { @@ -35,8 +40,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 +173,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) }) @@ -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) 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..8139c27b --- /dev/null +++ b/services/api/test/unit/host-route-audit-security.test.ts @@ -0,0 +1,262 @@ +import { Writable } from "node:stream" +import Fastify, { type FastifyInstance } from "fastify" +import { afterEach, describe, expect, it } from "vitest" +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 { + 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, type FakeSqlControl, type SqlHandler } 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) +} + +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: 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({ + method: "POST", + url: `/v1/cleanups/${EVENT}/exports`, + payload: { kind: "roster" }, + }) + + expect(res.statusCode).toBe(200) + expect(h.enqueued).toHaveLength(1) + 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" }, + ]) + }) +}) + +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/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/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/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/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-security.test.ts b/services/api/test/unit/host/organization-service-security.test.ts new file mode 100644 index 00000000..832e0dc9 --- /dev/null +++ b/services/api/test/unit/host/organization-service-security.test.ts @@ -0,0 +1,571 @@ +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) + }) +}) + +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([]) + }) +}) 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/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) } 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..b066e9a1 --- /dev/null +++ b/services/api/test/unit/host/registration-repository-security.test.ts @@ -0,0 +1,651 @@ +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) + }) +}) + +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/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() 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/inbound-processor-security.test.ts b/services/api/test/unit/inbound-processor-security.test.ts new file mode 100644 index 00000000..e6cbab2f --- /dev/null +++ b/services/api/test/unit/inbound-processor-security.test.ts @@ -0,0 +1,450 @@ +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]) + }) +}) + +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 new file mode 100644 index 00000000..1eed953d --- /dev/null +++ b/services/api/test/unit/inbound-thread-correlation-security.test.ts @@ -0,0 +1,208 @@ +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("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( + 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") + }) +}) 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..4f9852ad --- /dev/null +++ b/services/api/test/unit/log-redaction-security.test.ts @@ -0,0 +1,202 @@ +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" +import { redactLogObject } from "../../src/errors/log-redaction.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("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"), { + 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') + } + }) +}) 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) + }) +}) 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..39456e25 --- /dev/null +++ b/services/api/test/unit/media-attach-claim-security.test.ts @@ -0,0 +1,101 @@ +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/ +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 () => { + 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([{ match: CHAT_CLAIM, rows: [{ upload_id: UPLOAD_ID }] }]) + + await attachChatMedia( + fake.sql as unknown as Queryable, + MESSAGE_ID, + [UPLOAD_ID], + new Date(), + AUTHOR, + ) + + 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-actor.test.ts b/services/api/test/unit/media-claim-actor.test.ts new file mode 100644 index 00000000..30243613 --- /dev/null +++ b/services/api/test/unit/media-claim-actor.test.ts @@ -0,0 +1,419 @@ +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" +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 { 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 { + 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, 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 () => { + 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) + }) +}) 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..7a6105fe --- /dev/null +++ b/services/api/test/unit/media-claim-uploader.test.ts @@ -0,0 +1,353 @@ +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 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 }] }]) + + 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-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")]) + }) +}) 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..4c546039 --- /dev/null +++ b/services/api/test/unit/media-public-served-key.test.ts @@ -0,0 +1,239 @@ +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 { 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" + +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/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) + 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 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) => + 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) + }) + + 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()) + }) +}) 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) }) }) 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'`) + }) +}) 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..53c36401 --- /dev/null +++ b/services/api/test/unit/pg-stores-security.test.ts @@ -0,0 +1,148 @@ +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]) + }) +}) + +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) + }) +}) 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/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")') + }) +}) 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'") + }) +}) 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) + }) +}) 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..e9fbdb5f --- /dev/null +++ b/services/api/test/unit/socket-lifecycle-security.test.ts @@ -0,0 +1,217 @@ +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_FRAME_LIMIT, + WS_MAX_JOINED_ROOMS, + 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) + 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 () => { + 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/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") + }) +}) 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..547c7114 --- /dev/null +++ b/services/api/test/unit/users-routes-security.test.ts @@ -0,0 +1,190 @@ +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 () => { + await harness?.app.close() + 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() + 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() + }) + + 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) + }) +}) 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, 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..8e6f1a4b --- /dev/null +++ b/services/api/test/unit/web-base-url-fallback.test.ts @@ -0,0 +1,154 @@ +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, + type HostTeamServiceDeps, +} from "../../src/services/host/host-team-service.js" +import { InMemoryOrganizationRepository } from "../../src/services/host/organization-repository.memory.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" + +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"), + webOrigin: webBaseUrlOf({ NODE_ENV: "test" }), + }) + + 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, + webOrigin: webBaseUrlOf({ NODE_ENV: "test" }), + }) + 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() + } + }) +}) + +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) + }) +}) 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() + }) +})