From a880f764fc1ac80f41e3ee7a3a2419de36e5cb29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 14:15:56 +0100 Subject: [PATCH] chore: keep the handlers this release touched within their size baseline The register refusal codes, the end of the no-device rest, the no-recipient annotation and the sign-in shell comment had each grown a function the size ratchet holds. The codes become constants, the rest ends through a helper beside the one that starts it, and the annotation moves into that helper. The settings Web Push card dropped under the limit when its subscription post moved out, so its entry goes. --- .../baselines/source-function-lines.json | 5 ++-- src/app/api/auth/register/route.ts | 16 +++++------ src/app/api/devices/route.ts | 28 +++++++------------ src/components/layout/auth-shell.tsx | 6 ++-- src/lib/notifications/channel-state.ts | 17 +++++++++++ src/lib/notifications/dispatcher.ts | 8 +----- 6 files changed, 39 insertions(+), 41 deletions(-) diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 833dc4e09..c716d1bc3 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 337, - "total": 109501, + "count": 336, + "total": 109298, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -218,7 +218,6 @@ "components/settings/sharing-section.tsx :: ShareLinksCard": 260, "components/settings/sources-section.tsx :: SourcesSection": 601, "components/settings/thresholds-editor-section.tsx :: MetricRow": 241, - "components/settings/web-push-card.tsx :: WebPushCard": 203, "components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317, "components/symptoms/symptoms-section.tsx :: SymptomsSection": 205, "components/targets/range-bar.tsx :: RangeBar": 220, diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index eacdaa55e..0cf6a7547 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -24,6 +24,10 @@ import { } from "@/lib/tz/resolver"; import { isOidcOnly } from "@/lib/auth/oidc"; +// One code for a taken username and a taken address, like the one message. +const TAKEN = { errorCode: "auth.register.taken" }; +const INVITE_INVALID = { errorCode: "auth.register.invite_invalid" }; + export const POST = apiHandler(async (request: NextRequest) => { // OIDC_ONLY must block self-registration too — otherwise anyone can // self-provision a fresh password account and bypass the operator's @@ -144,9 +148,7 @@ export const POST = apiHandler(async (request: NextRequest) => { prisma.user.findUnique({ where: { username } }), ]); if (existingEmail || existingUsername) { - return apiError("Username or email already taken", 409, { - errorCode: "auth.register.taken", - }); + return apiError("Username or email already taken", 409, TAKEN); } // Validate password strength (locale-aware feedback) @@ -198,9 +200,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); if (!registrationEnabled) { - return apiError("Invalid or expired invite", 403, { - errorCode: "auth.register.invite_invalid", - }); + return apiError("Invalid or expired invite", 403, INVITE_INVALID); } } } @@ -253,9 +253,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (user === null) { - return apiError("Username or email already taken", 409, { - errorCode: "auth.register.taken", - }); + return apiError("Username or email already taken", 409, TAKEN); } // Create session immediately. v1.4.22 W5 reconcile (Sr-H1) — diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index f8d8e5a71..30d21330e 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -31,6 +31,7 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; import { checkRateLimit } from "@/lib/rate-limit"; +import { endApnsNoDeviceRest } from "@/lib/notifications/channel-state"; const deviceSchema = z .object({ @@ -86,15 +87,14 @@ const deviceSchema = z export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - // Per-user rate cap on device registration. The handler returns a - // distinct 409 (`apns_token_owned_by_other_user` / - // `token_owned_by_other_user`) when an `apnsToken` is already - // bound to a different account — useful for the legitimate - // cross-user-hijack guard, but it also means an authenticated user - // could enumerate token ownership across accounts unboundedly. 20 - // device-mutations per 15 minutes is well outside any realistic - // client behaviour (the iOS app registers once on login + on token - // rotation; not 20× / window) while capping enumeration cost. + // Per-user rate cap on device registration. The handler returns a distinct + // 409 (`apns_token_owned_by_other_user` / `token_owned_by_other_user`) when + // an `apnsToken` is already bound to a different account — useful for the + // legitimate cross-user-hijack guard, but it also means an authenticated + // user could enumerate token ownership across accounts unboundedly. 20 + // device-mutations per 15 minutes is well outside any realistic client + // behaviour (the iOS app registers once on login + on token rotation; not + // 20× / window) while capping enumeration cost. const rl = await checkRateLimit( `devices:register:${user.id}`, 20, @@ -272,15 +272,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }, update: {}, }); - // The iPhone a no-device pause was waiting for: deliver at once. - await tx.notificationChannel.updateMany({ - where: { - userId: user.id, - type: "APNS", - lastFailureReason: "apns_no_devices", - }, - data: { nextRetryAt: null, lastFailureReason: null }, - }); + await endApnsNoDeviceRest(tx, user.id); } return { id: device.id }; diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 6aec4f08f..10a355491 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -344,10 +344,8 @@ export function AuthShell({ // `min-h-dvh`, not `h-dvh`: a phone held sideways is ~390 px tall, and // a sign-in form that is taller than a fixed-height box centres out of // it at the top, where nothing can scroll back to it. A minimum lets the - // box grow and the document scroll. The safe-area padding keeps the form - // off the status bar and the home indicator in an installed app, and - // `shell-safe-area` paints the strip behind the status bar so its white - // clock stays readable in the light theme, as in the signed-in shell. + // box grow and the document scroll. The safe-area band (painted behind + // the status bar, see globals.css) and padding keep the form clear. return (
{demoMode ? : null} diff --git a/src/lib/notifications/channel-state.ts b/src/lib/notifications/channel-state.ts index a915cd961..2e311cebb 100644 --- a/src/lib/notifications/channel-state.ts +++ b/src/lib/notifications/channel-state.ts @@ -6,7 +6,9 @@ * soft reject" rules unit-testable and audit-loggable in one place. */ import { prisma } from "@/lib/db"; +import type { Prisma } from "@/generated/prisma/client"; import { auditLog } from "@/lib/auth/audit"; +import { getEvent } from "@/lib/logging/context"; import { NO_RECIPIENT_COOLDOWN_MS, nextRetryAt, @@ -140,6 +142,10 @@ export async function recordChannelNoRecipient( reason: string, now: Date = new Date(), ): Promise { + getEvent()?.addMeta( + `notification_skip_${channel.type.toLowerCase()}_no_recipient`, + reason, + ); await prisma.notificationChannel.update({ where: { id: channel.id }, data: { @@ -150,6 +156,17 @@ export async function recordChannelNoRecipient( }); } +/** Pairing an iPhone ends a rest that was waiting for one. */ +export async function endApnsNoDeviceRest( + db: Pick, + userId: string, +): Promise { + await db.notificationChannel.updateMany({ + where: { userId, type: "APNS", lastFailureReason: "apns_no_devices" }, + data: { nextRetryAt: null, lastFailureReason: null }, + }); +} + /** * Re-enable a channel that was previously auto-disabled. Clears the * disabled-reason + counter and the cooldown so the next dispatch diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 610b771d5..75bada3d3 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -434,14 +434,8 @@ export async function dispatchNotification( } if (outcome.reason && NO_RECIPIENT_REASONS.has(outcome.reason)) { - // Nothing went anywhere: a caller that reads an attempt as "the - // alert was announced" must not read this one so. - channelsAttempted -= 1; + channelsAttempted -= 1; // nothing went anywhere await recordChannelNoRecipient(ref, outcome.reason, now); - getEvent()?.addMeta( - `notification_skip_${channel.type.toLowerCase()}_no_recipient`, - outcome.reason, - ); continue; }