Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions services/api/src/routes/admin/_route-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AppError, IdSchema, type AdminOkResponse } from "@civfix/shared"
import { AppError, IdSchema, type AdminOkResponse, type RoomKind } from "@civfix/shared"
import type { FastifyBaseLogger, FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
import type { ZodTypeAny, z } from "zod"
import { parse } from "../_validate.js"
Expand All @@ -15,6 +15,9 @@ import {

export { parse }

// A one-message page around the target is the smallest read that still returns a tombstoned message.
const TARGET_ONLY = 1

// The columns these feed are `uuid`: a malformed id must be a 422 here rather than reach SQL, where
// `invalid input syntax for type uuid` becomes a hidden 500 and a GlitchTip capture.
function idSegment(value: unknown, field: string): string {
Expand Down Expand Up @@ -90,18 +93,22 @@ export function makeContainerMessageUpdateAnnouncer(
const presign = makePrivateMediaPresigner(container.storage)
const chatRepo = makeDrizzleChatRepository(sql, presign)
const dmRepo = makeDrizzleDmRepository(sql, presign)
const TARGET_ONLY = 1
const historyAround = (kind: RoomKind, roomId: string, messageId: string) => {
switch (kind) {
case "dm":
return dmRepo.history(roomId, undefined, TARGET_ONLY, null, messageId)
case "report":
return chatRepo.reportHistory(roomId, undefined, TARGET_ONLY, null, messageId)
case "group":
return chatRepo.groupHistory(roomId, undefined, TARGET_ONLY, null, messageId)
default:
return chatRepo.history(roomId, undefined, TARGET_ONLY, null, messageId)
}
}
return makeMessageUpdateAnnouncer({
findRoom: (messageId) => findMessageRoom(sql, messageId),
loadMessage: async (kind, roomId, messageId) => {
const page =
kind === "dm"
? await dmRepo.history(roomId, undefined, TARGET_ONLY, null, messageId)
: kind === "report"
? await chatRepo.reportHistory(roomId, undefined, TARGET_ONLY, null, messageId)
: kind === "group"
? await chatRepo.groupHistory(roomId, undefined, TARGET_ONLY, null, messageId)
: await chatRepo.history(roomId, undefined, TARGET_ONLY, null, messageId)
const page = await historyAround(kind, roomId, messageId)
return page.items.find((m) => m.id === messageId) ?? null
},
broadcast: (kind, roomId, message) =>
Expand Down
8 changes: 5 additions & 3 deletions services/api/src/routes/admin/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ 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."
const CF_ACCESS_JWT_HEADER = "cf-access-jwt-assertion"
const LOGIN_VIA_CF_ACCESS = "cf-access"

export interface AdminAuthOverrides {
auditSink(input: WriteAuditInput): Promise<void>
Expand Down Expand Up @@ -68,7 +70,7 @@ export async function registerAdminAuthRoutes(
actorId: user.id,
action: "operator.login_denied",
target: `user:${user.id}`,
meta: { email: user.email, ...detail, via: "cf-access" },
meta: { email: user.email, ...detail, via: LOGIN_VIA_CF_ACCESS },
}).catch((err: unknown) => {
request.log.warn({ err }, "operator.login_denied audit write failed")
})
Expand Down Expand Up @@ -107,7 +109,7 @@ export async function registerAdminAuthRoutes(
actorId: operator.id,
action: "operator.login",
target: `user:${operator.id}`,
meta: { email: operator.email, via: "cf-access" },
meta: { email: operator.email, via: LOGIN_VIA_CF_ACCESS },
})
return operator
}
Expand Down Expand Up @@ -174,7 +176,7 @@ async function verifyHeader(
verify: VerifyAccessJwt,
request: FastifyRequest,
): Promise<AccessIdentity> {
const token = request.headers["cf-access-jwt-assertion"]
const token = request.headers[CF_ACCESS_JWT_HEADER]
if (typeof token !== "string" || token.length === 0) throw AppError.unauthorized()
try {
return await verify(token)
Expand Down
22 changes: 14 additions & 8 deletions services/api/src/routes/admin/broadcasts.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,20 @@ declare module "fastify" {
}
}

export const ADMIN_BROADCAST_DEFAULT_LIMIT = 50
const ADMIN_BROADCAST_DEFAULT_LIMIT = 50

export const ADMIN_HOST_DEFAULT_LIMIT = 50
const ADMIN_HOST_DEFAULT_LIMIT = 50

export const ADMIN_HOST_DEFAULT_WINDOW_DAYS = 30
const ADMIN_HOST_DEFAULT_WINDOW_DAYS = 30

const DAY_MS = 24 * 60 * 60 * 1000

// Enough of the digest to tell subjects apart in the console without echoing the subject text itself.
const SUBJECT_HASH_HEX_LENGTH = 16

function hashSubject(subject: string): string {
return createHash("sha256").update(subject).digest("hex").slice(0, SUBJECT_HASH_HEX_LENGTH)
}

function mergeParams(request: FastifyRequest): Record<string, unknown> {
const params = (request.params ?? {}) as Record<string, unknown>
Expand Down Expand Up @@ -69,10 +78,7 @@ export async function registerAdminBroadcastRoutes(
eventTitle: row.eventTitle,
kind: row.kind,
status: row.status,
subjectHash:
row.subject === null
? null
: createHash("sha256").update(row.subject).digest("hex").slice(0, 16),
subjectHash: row.subject === null ? null : hashSubject(row.subject),
createdBy:
row.createdBy === null
? null
Expand Down Expand Up @@ -103,7 +109,7 @@ export async function registerAdminBroadcastRoutes(
const rows = await broadcastRepo().listAdminHosts({
...(query.q !== undefined ? { q: query.q } : {}),
...(query.suspended !== undefined ? { suspended: query.suspended } : {}),
windowStart: new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000),
windowStart: new Date(Date.now() - windowDays * DAY_MS),
cursor: parseKeysetCursor(query.cursor, { direction: "desc" }),
limit: limit + 1,
})
Expand Down
9 changes: 6 additions & 3 deletions services/api/src/routes/admin/discovery.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import {
} from "../../services/admin/discovery-service.js"
import { makeDrizzleDiscoveryRepository } from "../../services/admin/discovery-repository.drizzle.js"

const FALLBACK_OPERATOR_LABEL = "operator"
const ACTOR_ID_LABEL_CHARS = 8

/** Test-only: an in-memory repo so the whole HTTP flow runs offline. */
export interface DiscoveryRouteOverrides {
repo: DiscoveryRepository
Expand Down Expand Up @@ -103,9 +106,9 @@ export async function registerAdminDiscoveryRoutes(
async function operatorLabel(app: FastifyInstance, actorId: string): Promise<string> {
try {
const user = await app.authServices.users.findById(actorId)
if (!user) return "operator"
return user.handle ?? user.displayName ?? user.email ?? actorId.slice(0, 8)
if (!user) return FALLBACK_OPERATOR_LABEL
return user.handle ?? user.displayName ?? user.email ?? actorId.slice(0, ACTOR_ID_LABEL_CHARS)
} catch {
return "operator"
return FALLBACK_OPERATOR_LABEL
}
}
16 changes: 8 additions & 8 deletions services/api/src/routes/admin/gov.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
} from "../../services/admin/gov-claims-service.js"
import { makeDrizzleGovClaimsRepository } from "../../services/admin/gov-claims-repository.drizzle.js"
import type { RevokeAllSessions } from "../../services/admin/role-change.js"
import type { UserStore } from "../../auth/stores.js"
import type { UserRecord, UserStore } from "../../auth/stores.js"

export interface GovClaimsRouteOverrides {
repo: GovClaimsRepository
Expand All @@ -42,22 +42,22 @@ declare module "fastify" {
}
}

function toProvisionedUser(user: UserRecord): ProvisionedUser {
return { id: user.id, email: user.email, role: user.role, emailVerified: user.emailVerified }
}

function provisionerFromUserStore(store: UserStore): UserProvisioner {
const norm = (email: string): string => email.trim().toLowerCase()
return {
async findByEmail(email: string): Promise<ProvisionedUser | null> {
const user = await store.findByEmail(norm(email))
return user
? { id: user.id, email: user.email, role: user.role, emailVerified: user.emailVerified }
: null
return user ? toProvisionedUser(user) : null
},
async create(email: string, displayName: string): Promise<ProvisionedUser> {
const user = await store.create(norm(email), { displayName, role: "citizen" })
return { id: user.id, email: user.email, role: user.role, emailVerified: user.emailVerified }
return toProvisionedUser(await store.create(norm(email), { displayName, role: "citizen" }))
},
async setRole(id: string, role: Role): Promise<ProvisionedUser> {
const user = await store.setRole(id, role)
return { id: user.id, email: user.email, role: user.role, emailVerified: user.emailVerified }
return toProvisionedUser(await store.setRole(id, role))
},
}
}
Expand Down
6 changes: 4 additions & 2 deletions services/api/src/routes/admin/inbox.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
/** Defends against a crafted mail with thousands of parts. */
const MAX_INBOX_ATTACHMENTS = 50

const INBOUND_EMAIL_NOT_FOUND = "Inbound email not found."

/** Test-only: an in-memory repo and a fake Storage so the HTTP flow runs offline with no DB and no R2. */
export interface AdminInboxRouteOverrides {
repo: InboundRepository
Expand Down Expand Up @@ -62,7 +64,7 @@ export async function registerAdminInboxRoutes(
route(app, "getInboxMessage", async (request, reply) => {
const { id } = idParam(request)
const dto = await repo().get(id)
if (dto === null) throw AppError.notFound("Inbound email not found.")
if (dto === null) throw AppError.notFound(INBOUND_EMAIL_NOT_FOUND)
await auditRead(request, container, requireOperator(request), {
action: "inbox.message_viewed",
target: `inbound_email:${id}`,
Expand Down Expand Up @@ -92,7 +94,7 @@ export async function registerAdminInboxRoutes(
const operatorId = requireOperator(request)
const { id, body } = parseBodyWithId(SetInboxStatusRequestSchema, request)
const ok = await repo().setStatus(id, body.status, operatorId)
if (!ok) throw AppError.notFound("Inbound email not found.")
if (!ok) throw AppError.notFound(INBOUND_EMAIL_NOT_FOUND)
sendOk(reply)
})
}
4 changes: 1 addition & 3 deletions services/api/src/routes/admin/legal.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ import { legalDocumentVersions } from "../../services/legal-service.js"

export async function registerAdminLegalRoutes(
app: FastifyInstance,
container: Container,
_container: Container,
): Promise<void> {
void container

route(app, "adminGetLegalVersions", async (_request, reply) => {
const payload: GetLegalVersionsResponse = {
documents: legalDocumentVersions(),
Expand Down
2 changes: 1 addition & 1 deletion services/api/src/routes/admin/pages.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { makeDrizzleHostRegistrationRepository } from "../../services/host/regis
import { toEventPageDTO } from "../../services/host/registration-dto.js"
import { makeEventMediaPresigner } from "../../services/host/event-media.js"

export const ADMIN_EVENT_PAGE_DEFAULT_LIMIT = 50
const ADMIN_EVENT_PAGE_DEFAULT_LIMIT = 50

type RecordAudit = (entry: WriteAuditInput) => Promise<unknown>

Expand Down
14 changes: 6 additions & 8 deletions services/api/src/routes/admin/report-chat.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
type ChatHistoryResponse,
} from "@civfix/shared"
import type { FastifyInstance } from "fastify"
import { perIdentity } from "../../plugins/rate-limit.js"
import { perIdentity, type RouteRateLimitSpec } from "../../plugins/rate-limit.js"
import type { Container } from "../../di.js"
import { route } from "../../versioning/route.js"
import {
Expand Down Expand Up @@ -48,17 +48,15 @@ declare module "fastify" {
}
}

export const ADMIN_REPORT_CHAT_SEND_RATE_LIMIT = perIdentity({
const OPERATOR_CHAT_WRITE_LIMIT: RouteRateLimitSpec = {
max: 60,
timeWindow: "1 minute",
skipOnError: false,
})
}

export const ADMIN_REPORT_CHAT_REMOVE_RATE_LIMIT = perIdentity({
max: 60,
timeWindow: "1 minute",
skipOnError: false,
})
export const ADMIN_REPORT_CHAT_SEND_RATE_LIMIT = perIdentity(OPERATOR_CHAT_WRITE_LIMIT)

export const ADMIN_REPORT_CHAT_REMOVE_RATE_LIMIT = perIdentity(OPERATOR_CHAT_WRITE_LIMIT)

export async function registerAdminReportChatRoutes(
app: FastifyInstance,
Expand Down
29 changes: 14 additions & 15 deletions services/api/src/routes/admin/reports.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
type OutboundMailService,
} from "../../services/admin/outbound-mail-service.js"
import { makeDrizzleCleanupRepository } from "../../services/cleanup-repository.drizzle.js"
import type { LinkedEventView } from "../../services/cleanup-service.js"
import {
makePacketMediaPresigner,
makePrivateMediaPresigner,
Expand All @@ -46,16 +47,26 @@ import { makeContainerReportChatEmitter } from "../../services/report-chat-emitt
import { makeRouteNotificationService } from "../../services/route-notifier.js"
import type { ReportChatSystemEmitter } from "../../services/report-timeline-event.js"

export const ROUTE_REPORT_RATE_LIMIT = perIdentity({
max: 10,
timeWindow: "1 minute",
skipOnError: false,
})

export const ADMIN_REPORT_MUTATION_RATE_LIMIT = perIdentity({
max: 60,
timeWindow: "1 minute",
skipOnError: false,
})

export interface AdminReportRouteOverrides {
repo: AdminReportRepository
outboundMail: OutboundMailService
presignMedia?: (
r2Key: string,
thumbKey: string | null,
) => Promise<{ url: string; thumbUrl?: string }>
loadLinkedEventsForReports?: (
reportIds: string[],
) => Promise<Map<string, import("../../services/cleanup-service.js").LinkedEventView[]>>
loadLinkedEventsForReports?: (reportIds: string[]) => Promise<Map<string, LinkedEventView[]>>
now?: () => Date
reportChatEmitter?: ReportChatSystemEmitter
forwardTemplates?: ForwardTemplateRepository
Expand Down Expand Up @@ -208,15 +219,3 @@ export async function registerAdminReportsRoutes(
},
)
}

export const ROUTE_REPORT_RATE_LIMIT = perIdentity({
max: 10,
timeWindow: "1 minute",
skipOnError: false,
})

export const ADMIN_REPORT_MUTATION_RATE_LIMIT = perIdentity({
max: 60,
timeWindow: "1 minute",
skipOnError: false,
})
Loading
Loading