mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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.
This commit is contained in:
1 parent
d8c270859b
commit
a880f764fc
6 files changed
+39
-41
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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) —
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 (
|
||||
<div className="shell-safe-area flex min-h-dvh flex-col pb-[env(safe-area-inset-bottom,0px)]">
|
||||
{demoMode ? <DemoBanner /> : null}
|
||||
|
||||
@@ -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<void> {
|
||||
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<Prisma.TransactionClient, "notificationChannel">,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user