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; }