mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
* fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds User-visible: TOTAL_BODY_WATER, BONE_MASS, and BLOOD_GLUCOSE now appear in the measurements filter, badge, mobile icon, and edit dialog (issue #109). Server-rendered doctor report now includes body composition rows (audit C-9). Body water + bone mass have proper effective-range thresholds so severity logic doesn't silently return `nominal` (audit C-15). Structural: extracted the per-type display maps from measurement-list.tsx into measurement-list-meta.ts as a deliberate first step toward the single-source manifest planned for phase P5. Coverage tests now fail-fast if a future enum addition skips any of: list label/icon/color, doctor-PDF label/unit/vital-types, chart label, threshold metric order/label/bounds. Audit-2026-05-07 / phase P0: - closes #109 (filter UI gap, root cause for the German raw-enum display) - closes audit C-9 (server PDF used a stale type map) - closes audit C-15 (no warn/critical bands for body composition) Tests: 282 → 296 (14 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: align OpenAPI spec + migration comment with body-composition reality - OpenAPI MeasurementType enum: add TOTAL_BODY_WATER + BONE_MASS (was at v1.3.0 baseline; spec drifted past the v1.3 server release) - Bump openapi version 1.3.0 → 1.3.2 to match package.json - Migration 0022 comment: TOTAL_BODY_WATER stores kg (water content), not percent of body weight. Validators (VALUE_RANGES) and the Withings client (type 77 → kg) treat it as kg already; the comment was the lone outlier and would mislead the next reviewer. Audit-2026-05-07 / phase P1a — closes audit M-B1 (partial; OpenAPI enum) and M-B2 (migration unit comment). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip) C-3 (Withings webhook secret in URL): Now reads X-Withings-Webhook-Secret header in preference; URL ?secret=… fallback retained for legacy Withings configurations and emits a Wide Event warning so operators can spot still-using-the-old-flow integrators. The query-string secret was leaking into Coolify access logs and any GlitchTip event whose request.url got captured. C-4 (idempotency dead for Bearer-token clients): The default userIdResolver only consulted getSession(). Bearer-authed clients (the iOS / n8n / headless-API fleet that PR #117 was built for) fell through, so every retry created duplicate measurements. The default now tries cookie session first, then Bearer token via hashToken+apiToken.findUnique with revoked/expiry checks. All three existing call sites (measurements, mood-entries, medications/intake) get the fix automatically. H-B7 (request.url with secrets shipped to GlitchTip): reportToGlitchtip now strips the query string before forwarding the URL, so Withings legacy ?secret=… and any OAuth ?code=… that errors during the callback don't end up in someone else's incident UI. Audit-2026-05-07 / phase P2. Tests: 296 → 301 (5 new in idempotency.test for the Bearer fallback + revoked/expired/null cases). Deferred to a follow-up (needs DB migration / lock primitive): C-2 moodLog webhook → AES-GCM at rest + HMAC-over-body C-5 idempotency TOCTOU advisory_lock or in-flight sentinel row Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(measurements): add OXYGEN_SATURATION (SpO2) as first-class measurement type — v1.3.3 Closes the OxygenSaturation feature request from #109. - Migration 0024 extends MeasurementType enum (additive, IF NOT EXISTS) - Zod validators: plausibility 50–100% (below 50 is incompatible with sustained life; above 100 is physically impossible) - effective-range thresholds: green 95–100, orange 92–94, red <92. Sources: BTS Guideline 2017 (target 94–98%), ATS clinical practice (≥95% at rest is healthy), AHA/clinical literature (<92% = call provider, <88% = ER). Lower-only concern — upper orange wing collapses to greenMax since saturation cannot exceed 100%. - Personalisation: existing thresholds-override UI lets COPD / chronic-respiratory users with a doctor-set 88–92 baseline tune the bands. METRIC_ORDER + METRIC_LABEL_KEYS extended in thresholds-section.tsx; the exhaustive `Record<ThresholdMetric>` forces TS to catch any missed surface. - Withings ScanWatch type 54 mapping (best-effort — only fires for pulse-ox-capable devices). - Wired through measurement-form, measurement-list-meta (icon: Wind), health-chart, both doctor-report-pdf renderers (core + browser-side near-duplicate), OpenAPI spec (version 1.3.0 → 1.3.3), and i18n in 4 sections × 2 locales. - iOS DTO already declared `OXYGEN_SATURATION` — server enum addition closes the long-standing drift flagged in audit V2. - 4 new tests: numeric green band, upper-wing collapse, user-override (COPD baseline), METRIC_BOUNDS shape. Tests: 301 → 305. Typecheck: clean. Audit-2026-05-07 / v1.3.3 / closes #109 SpO2 feature request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(analytics): truthfulness pass on medical citations + clamp override orangeMax Doctor-agent + hallucination-hunter audit V3 found six medical citations in effective-range.ts that the code did not actually implement. Plus a latent bug in the user-override path that produced physically-impossible orangeMax values (>100% saturation, <0% body-fat). Citation rewrites (no numeric changes): - ACTIVITY_STEPS: "WHO ≥8000 steps/day" → Saint-Maurice JAMA 2020. WHO publishes activity in min/week, not steps. Step-count was a misattribution that also leaked into AI prompts. - TOTAL_BODY_WATER: "ESPEN Body Composition Guidance 2017" → Watson formula / ICRP Reference Man. ESPEN 2017 is BIA practice guidance, it does not publish kg cut-offs. - BONE_MASS: "DEXA-equivalent bone mineral content" → "Bioimpedance- estimated; not DEXA-comparable". Withings BIA values typically run 5–7% below DEXA — calling them DEXA-equivalent in a doctor-report PDF was a real legal-exposure misattribution. - OXYGEN_SATURATION: "BTS Guideline 2017 (target 94–98%) + ATS clinical practice (≥95% at rest is healthy)" → "consumer pulse- oximeter consensus (≥95% normal at rest); slightly tighter than BTS 2017 (94–98%); NICE NG115 ≤92% escalation, BMJ panel / FDA pulse-oximeter labelling ≤88% ER threshold". Code uses 95-100/92-100 band — that's an ATS/consumer-consensus number, not BTS's literal 94-98 target. Soften the citation to match the code, not vice versa. Bug fix (1 latent, 1 real-today): - effective-range override path now clamps orangeMin/orangeMax to METRIC_BOUNDS.{min,max}. Without the clamp, a COPD user setting SpO2 override to {95,100} emitted orangeMax = 100.75 (impossible saturation), and a BODY_FAT override of {2,80} emitted orangeMin = -9.7 (negative percent). Default-range case 210 already hard-coded orangeMax: 100 for the same reason — the override path was the only branch missing the invariant. Tests: 305 → 306 (1 new: clamps override orangeMax to physiological 100%). Typecheck: clean. Audit-2026-05-07-V3 / closes Doctor + Senior-Dev medical-citation findings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure Two CRITICAL fixes from audit V3. NEW-V3-1: Bearer-scope bypass. apiToken.permissions ["*"] is meant to be the wildcard "session-equivalent" scope (the iOS app receives this on login). The previous check permissions.includes(requiredPermission) treated "*" as a literal string — never matched. Today many sensitive routes call requireAuth() *without* a requiredPermission, so a leaked iOS token can act as a full-scope token (account delete, settings wipe). Once those routes adopt requireAuth("scope:name"), this wildcard handling keeps the iOS app working while narrower-scoped tokens (e.g. user-issued ["medication:ingest"]) get correctly 403'd. Same wildcard handling added to the standalone permission checks in src/app/api/ingest/medication/route.ts:65, 119. NEW-V3-2: GDPR Art. 17 account-delete erasure completeness. Feedback + AuditLog rows had `onDelete: SetNull` in the schema, which preserved PII (free-text symptom descriptions, IP addresses, login-city geolocation) after the user was deleted. We now explicitly prisma.feedback.deleteMany + prisma.auditLog.deleteMany before the cascade-delete, making account deletion genuinely complete erasure. Tests 306/306 still pass; typecheck clean. Audit V3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(pdf): real text-content assertions for body composition + SpO2 rows Replaces bytes-only "renders body composition rows" theatre test with pdf-parse-driven text extraction that asserts the actual rendered labels + values for TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION (DE + EN). Closes V3 audit finding STILL-V2-NEW-3: the previous test only verified "%PDF-" header + byte-count, so a regression that drops a row would still pass. Now a missing label fails the test. - adds dev dep: pdf-parse@^2.4.5 - 308/308 tests pass (was 306, +2 SpO2 + EN-locale coverage) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(measurements): close enum drift cousins — derive 5 type-arrays from canonical enum + extend 2 contract enums V3 audit finding "enum drift cousins": 7 hardcoded module-level type arrays silently dropped SpO2/TBW/BoneMass/BloodGlucose from their respective domains. Adding a new measurement type required hunting these down. Now all aggregation paths derive from `measurementTypeEnum.options`: - /api/insights/comprehensive (AI insights) - /api/dashboard/summary (iOS dashboard adapter) - /api/analytics (analytics aggregator) - /lib/insights/general-status (AI general-status fetch) - /api/import (Zod schema — round-trip now covers all 11 types) External-contract enums extended additively (no breakage for old clients): - /api/measurements/series kindEnum: + oxygen, totalBodyWater, boneMass - /api/dashboard/widgets widgetIdEnum: + oxygenSaturation - DashboardWidgetId + DEFAULT_DASHBOARD_LAYOUT: + oxygenSaturation row - formatMeasurementsForExport: glucoseContext now round-trips Coverage test (`measurement-type-enum-coverage.test.ts`) asserts the canonical enum stays the source of truth and documents PDF exclusions (BLOOD_GLUCOSE renders via context section, SLEEP/STEPS lifestyle-only). 313/313 tests pass (was 308, +5 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit) - CSP: chatgpt.com + api.openai.com only on /settings/ai/** routes (was blanket connect-src on every page including /auth/login → DOM-XSS exfil channel) - web-push: webPushSubscriptionSchema.endpoint requires HTTPS + isPublicUrl refinement; was z.url() only → blind SSRF probe via Push subscription - isPublicUrl: also fixes a regression where DNS labels starting with "fc"/"fd" (fcm.googleapis.com etc.) were falsely classified as IPv6 unique-local. Check is now gated on a colon being present in the host. - /api/ai/test: stop returning provider err.message + bodyExcerpt to the client (leaked provider URLs / partial keys / internal headers). Now returns categorised generic message; full details land in the wide-event via annotate() for the operator. - /lib/geo (auditLog IP geo lookup): switch default provider to ipwho.is over HTTPS, accept ipwho.is + ip-api pro response shapes, add IP_GEO_LOOKUP_DISABLED env, refuse non-HTTPS configured URLs. Previously leaked IP+timestamp over plaintext HTTP every auth event (GDPR Art. 32 + Art. 44). - /api/import: add 5/hour rate-limit (was unlimited; bulk-injection vector) Tests: 325/325 pass (was 313, +12 new — webPushSubscriptionSchema SSRF guards, fc/fd-prefix domain regression, geo HTTPS-only, geo provider shapes, geo opt-out env). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening V3 audit follow-on bundle covering 4 HIGH findings. (I) getClientIp — TRUST_PROXY_HOPS env (default 1) replaces blind XFF trust. Reads chain right-to-left so client-supplied leftmost entries cannot rotate the per-IP rate-limit bucket. Default 1 matches typical single-proxy self-host (Coolify/Caddy/Cloudflare-Tunnel); 0 forces x-real-ip / null. New 7-test suite covers the rotation attack + malformed entries + zero-hop fallback. (F) Audit-log retention job — daily 03:15 cleanup of audit_logs older than AUDIT_LOG_RETENTION_DAYS (default 365). Closes GDPR Art. 5(1)(e) "storage limitation" gap; previously IPs + city + login events accumulated forever. 6-test suite covers default + override + misconfig guard (rejects <7 days). (G) isCachableStatus — extracted the inline 4xx/5xx filter into a named, exported, tested function. 7 cases cover the full do-not-cache contract (401 / 403 / 408 / 429 / any 5xx) and confirm 2xx + 4xx validation are still cached. (H) Bearer-mock tightening — apiToken.findUnique mocks in require-auth-bearer.test + idempotency.test now assert the where.tokenHash shape uses the hashed value (not the raw bearer). A regression that switched back to raw-token comparison would have been silent without this assertion. Tests: 345/345 pass (was 332, +13 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2) The mood_log_webhook_secret column previously stored a plaintext secret that the webhook handler compared timing-safe against every enabled user. A DB dump or backup snapshot leaked active webhook credentials. New contract — no schema migration required: - Writes go through encryptMoodLogSecret() (AES-256-GCM via existing crypto helper, ENCRYPTION_KEY). - Reads go through readMoodLogSecret() which transparently decrypts envelopes and tolerates legacy plaintext rows during the transition (graceful upgrade path — no breaking change for self-hosted users). - Webhook lookup decrypts each candidate before timing-safe compare. - Status endpoint decrypts for the user's settings page. One-shot startup migration in reminder-worker rotates any leftover plaintext rows on next worker boot. Idempotent; encrypted rows are skipped. Tests: 350/350 pass (was 345, +5 new — round-trip, legacy passthrough, isLegacyPlaintext semantics, rotateLegacy idempotence). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift) V3 audit Doku Specialist findings — drift between code and meta-docs. - README §Key Features now mentions pulse oximetry (SpO₂) + Withings ScanWatch sync; Tech Stack model count corrected 23 → 25. - AGENTS.md status promoted v1.3.2 → v1.3.3; vitest config corrected `vitest.config.ts` → `vitest.config.mts`; migration range corrected `0001–0022` → `0001–0024`; model count + list updated to 25 (adds Device, IdempotencyKey). - CLAUDE.md model count updated (2 occurrences). - CHANGELOG.md v1.3.3 expanded with Security + Internal sections that were missing the audit-fix-marathon work (Bearer-scope, account-delete cascade, moodLog encryption, CSP tightening, Web-Push SSRF, IP-geo HTTPS, AI-test leak fix, import rate-limit, trusted-proxy XFF, audit-log retention, idempotency cachable filter, Bearer mock tightening, enum-drift cousins, PDF text-content tests). - OpenAPI Bearer description corrected `SHA-256 hashes` → `HMAC-SHA-256 hashes (keyed with API_TOKEN_HMAC_KEY)` so the docs match the actual server-side implementation. Wildcard scope contract documented. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass Multi-agent review of the audit-fix branch surfaced four HIGH issues and a couple of MEDIUM hardenings. All addressed before merge. HIGH-1 — OpenAPI MetricKind enum drift (iOS strict-decode crash risk). The dashboard-summary route now emits totalBodyWater/boneMass/ oxygenSaturation MetricCards, but the spec still listed only the original 7 kinds. Generated iOS Codable would have crashed the dash the moment a user logged SpO2. MetricKind extended with the 3 new kinds + comment documenting unknown-value defensive decoding. HIGH-2 — kindEnum naming consistency on /api/measurements/series. The series adapter shipped "oxygen" while the dashboard summary used "oxygenSaturation"; both routes now use "oxygenSaturation" so iOS decoders can share a single MetricKind type across endpoints. HIGH-3 — /api/ai/test no-leak guard had no test. Added 4 tests that mock provider errors with sk-leaked-key + provider URLs in the err fields and assert the response body never echoes them, plus the 3-status-categorised generic messages (401/429/5xx/other). HIGH-4 — /api/import 5/hour rate-limit had no test. Added 2 tests that mock checkRateLimit allowed/disallowed and assert the 429 path uses the right key shape (`import:<userId>`, 5, 1h) and that the within- quota path proceeds. MEDIUM-3 — getClientIp under-length XFF chain regression. With TRUST_PROXY_HOPS=2 and a chain of length 1, the previous clamp returned the leftmost (attacker-controlled) entry, silently re-introducing the rotation attack the helper was meant to close. Now: when chain is shorter than configured hops, refuse XFF entirely (fall back to x-real-ip / null). Added regression test. LOW-1 — TRUST_PROXY_HOPS=garbage now throws at boot instead of silently degrading to hops=0. Added regression test. Tests: 358/358 pass (was 350, +8 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { headers } from "next/headers";
|
|
import type { User } from "@/generated/prisma/client";
|
|
import { WideEventBuilder } from "./logging/event-builder";
|
|
import { eventStorage, getEvent } from "./logging/context";
|
|
import { emitIfSampled } from "./logging/transports";
|
|
import { getSession } from "./auth/session";
|
|
import { hashToken } from "./auth/hmac";
|
|
import { prisma } from "./db";
|
|
import { auditLog } from "./auth/audit";
|
|
|
|
/**
|
|
* Custom error class for HTTP errors with status codes.
|
|
* Throw inside apiHandler-wrapped routes to return a JSON error response.
|
|
*/
|
|
export class HttpError extends Error {
|
|
constructor(
|
|
public statusCode: number,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "HttpError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wraps an API route handler with Wide Event logging.
|
|
* Creates a WideEventBuilder, runs the handler inside AsyncLocalStorage,
|
|
* catches errors, and emits the event on completion.
|
|
*
|
|
* No CSRF check — HealthLog does not use CSRF tokens.
|
|
* Auth annotation happens in routes via requireAuth().
|
|
*/
|
|
// Next.js route handlers come in two shapes — `(request)` for static routes
|
|
// and `(request, { params })` for dynamic ones. The variadic generic is the
|
|
// only signature TS accepts that covers both at the call site. The `any[]`
|
|
// here is constrained by the bound (T must return Promise<Response>) so it
|
|
// does not loosen handler bodies — only their parameter list.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
|
handler: T,
|
|
): T {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const wrapped = async (...args: any[]): Promise<Response> => {
|
|
const request = args[0] as NextRequest;
|
|
const url = new URL(request.url);
|
|
|
|
const evt = new WideEventBuilder("http");
|
|
|
|
// Propagate x-request-id if present
|
|
const incomingRequestId = request.headers.get("x-request-id");
|
|
if (incomingRequestId) evt.setRequestId(incomingRequestId);
|
|
|
|
evt.setHttp({
|
|
method: request.method,
|
|
path: url.pathname,
|
|
route: url.pathname,
|
|
status: 200,
|
|
user_agent: request.headers.get("user-agent") ?? undefined,
|
|
ip:
|
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
request.headers.get("x-real-ip") ||
|
|
undefined,
|
|
});
|
|
|
|
return eventStorage.run(evt, async () => {
|
|
let response: Response | undefined;
|
|
try {
|
|
response = await handler(...args);
|
|
} catch (error) {
|
|
if (error instanceof HttpError) {
|
|
evt.setError(error);
|
|
response = NextResponse.json(
|
|
{ data: null, error: error.message },
|
|
{ status: error.statusCode },
|
|
);
|
|
} else if (error instanceof SyntaxError) {
|
|
evt.setError(error);
|
|
response = NextResponse.json(
|
|
{ data: null, error: "Invalid JSON body" },
|
|
{ status: 400 },
|
|
);
|
|
} else {
|
|
evt.setError(error);
|
|
// Report to GlitchTip (fire-and-forget)
|
|
reportToGlitchtip(error, request, evt).catch(() => {});
|
|
response = NextResponse.json(
|
|
{ data: null, error: "Interner Serverfehler" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
} finally {
|
|
const status = (response as Response | undefined)?.status ?? 500;
|
|
evt.finish(status);
|
|
try { emitIfSampled(evt.toJSON()); } catch { /* logging must never crash the handler */ }
|
|
}
|
|
const nr = response as NextResponse;
|
|
nr.headers.set("x-request-id", evt.getRequestId());
|
|
return nr;
|
|
});
|
|
};
|
|
return wrapped as T;
|
|
}
|
|
|
|
/**
|
|
* Authenticated request context. Returned by both session-cookie and Bearer-token
|
|
* authentication paths. The `session.id` is the session-record id for cookie auth
|
|
* and the `ApiToken` id for bearer auth — callers must not assume the id refers
|
|
* to a Session row.
|
|
*/
|
|
export type AuthContext = {
|
|
session: { id: string; expiresAt: Date };
|
|
user: User;
|
|
};
|
|
|
|
/**
|
|
* Require an authenticated request. Throws HttpError(401) / HttpError(403) on failure.
|
|
*
|
|
* Auth precedence (cookie-first, never both):
|
|
* 1. Valid session cookie → cookie path (existing behaviour, requiredPermission ignored).
|
|
* 2. No cookie + `Authorization: Bearer hlk_<...>` → API token path.
|
|
* 3. Neither → 401.
|
|
*
|
|
* @param requiredPermission Optional permission scope. Only enforced for Bearer
|
|
* auth — cookie sessions always pass (full user access). When set and missing
|
|
* from `ApiToken.permissions`, throws HttpError(403).
|
|
*/
|
|
export async function requireAuth(
|
|
requiredPermission?: string,
|
|
): Promise<AuthContext> {
|
|
// 1. Session cookie path — unchanged.
|
|
const sessionData = await getSession();
|
|
if (sessionData) {
|
|
const evt = getEvent();
|
|
if (evt) {
|
|
evt.setAuth({
|
|
user_id: sessionData.user.id,
|
|
user_role: sessionData.user.role,
|
|
auth_method: "session",
|
|
});
|
|
}
|
|
return sessionData;
|
|
}
|
|
|
|
// 2. Bearer-token path.
|
|
// `headers()` is only valid inside a Next.js request scope. Outside one
|
|
// (e.g. during direct unit tests of legacy routes that pre-date Bearer auth)
|
|
// we treat the absence of a header context as "no Bearer present" and fall
|
|
// through to the unauthenticated case below.
|
|
let authHeader: string | null = null;
|
|
try {
|
|
const headerList = await headers();
|
|
authHeader = headerList.get("authorization");
|
|
} catch {
|
|
authHeader = null;
|
|
}
|
|
if (authHeader?.startsWith("Bearer ")) {
|
|
return await authenticateBearer(authHeader.slice(7), requiredPermission);
|
|
}
|
|
|
|
// 3. No credentials.
|
|
throw new HttpError(401, "Not authenticated");
|
|
}
|
|
|
|
/**
|
|
* Authenticate a raw Bearer token against `ApiToken`.
|
|
* Annotates the Wide Event with `auth_method: "api_key"` and writes audit-log
|
|
* entries for both success and failure.
|
|
*/
|
|
async function authenticateBearer(
|
|
rawToken: string,
|
|
requiredPermission: string | undefined,
|
|
): Promise<AuthContext> {
|
|
const tokenHashValue = hashToken(rawToken);
|
|
|
|
const apiToken = await prisma.apiToken.findUnique({
|
|
where: { tokenHash: tokenHashValue },
|
|
select: {
|
|
id: true,
|
|
userId: true,
|
|
permissions: true,
|
|
revoked: true,
|
|
expiresAt: true,
|
|
},
|
|
});
|
|
|
|
if (!apiToken) {
|
|
auditLog("auth.bearer.failure", {
|
|
details: { reason: "unknown_token" },
|
|
}).catch(() => {});
|
|
throw new HttpError(401, "Invalid token");
|
|
}
|
|
|
|
if (apiToken.revoked) {
|
|
auditLog("auth.bearer.failure", {
|
|
userId: apiToken.userId,
|
|
details: { reason: "revoked", tokenId: apiToken.id },
|
|
}).catch(() => {});
|
|
throw new HttpError(401, "Invalid token");
|
|
}
|
|
|
|
if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) {
|
|
auditLog("auth.bearer.failure", {
|
|
userId: apiToken.userId,
|
|
details: { reason: "expired", tokenId: apiToken.id },
|
|
}).catch(() => {});
|
|
throw new HttpError(401, "Token expired");
|
|
}
|
|
|
|
// Audit V3 NEW-V3-1 fix: `["*"]` is a real wildcard — it grants the
|
|
// session-equivalent scope (the iOS app receives this on login). Without
|
|
// the wildcard branch, EVERY future requireAuth("scope:name") call would
|
|
// 403 every iOS-issued token because string-literal `.includes("*"...)`
|
|
// never matches. Worse: today many sensitive routes call requireAuth()
|
|
// *without* a requiredPermission, so a leaked iOS token can act as a
|
|
// full-scope token (account delete, settings wipe). Once those routes
|
|
// adopt requireAuth("scope:name"), the wildcard handling here keeps
|
|
// the iOS app working while narrower-scoped tokens (e.g. ["medication:
|
|
// ingest"]) get correctly 403'd.
|
|
if (
|
|
requiredPermission &&
|
|
!apiToken.permissions.includes("*") &&
|
|
!apiToken.permissions.includes(requiredPermission)
|
|
) {
|
|
auditLog("auth.bearer.failure", {
|
|
userId: apiToken.userId,
|
|
details: {
|
|
reason: "insufficient_permissions",
|
|
tokenId: apiToken.id,
|
|
required: requiredPermission,
|
|
},
|
|
}).catch(() => {});
|
|
throw new HttpError(403, "Insufficient permissions");
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: apiToken.userId },
|
|
});
|
|
|
|
if (!user) {
|
|
auditLog("auth.bearer.failure", {
|
|
userId: apiToken.userId,
|
|
details: { reason: "user_missing", tokenId: apiToken.id },
|
|
}).catch(() => {});
|
|
throw new HttpError(401, "Invalid token");
|
|
}
|
|
|
|
// Fire-and-forget: refresh lastUsedAt without blocking the request.
|
|
prisma.apiToken
|
|
.update({
|
|
where: { id: apiToken.id },
|
|
data: { lastUsedAt: new Date() },
|
|
})
|
|
.catch(() => {});
|
|
|
|
auditLog("auth.bearer.success", {
|
|
userId: user.id,
|
|
details: { tokenId: apiToken.id },
|
|
}).catch(() => {});
|
|
|
|
const evt = getEvent();
|
|
if (evt) {
|
|
evt.setAuth({
|
|
user_id: user.id,
|
|
user_role: user.role,
|
|
auth_method: "bearer",
|
|
});
|
|
}
|
|
|
|
// Use the token expiry as the session expiry; fall back to a 30-day window if
|
|
// the token has no fixed expiry so the contract `{ expiresAt: Date }` holds.
|
|
const expiresAt =
|
|
apiToken.expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
|
|
return {
|
|
session: { id: apiToken.id, expiresAt },
|
|
user,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Require an authenticated admin user. Throws HttpError(401) or HttpError(403).
|
|
* Cookie-only — Bearer tokens never elevate to admin (security boundary).
|
|
* Automatically annotates the Wide Event with auth context.
|
|
*/
|
|
export async function requireAdmin(): Promise<AuthContext> {
|
|
const sessionData = await getSession();
|
|
if (!sessionData) throw new HttpError(401, "Not authenticated");
|
|
|
|
const evt = getEvent();
|
|
if (evt) {
|
|
evt.setAuth({
|
|
user_id: sessionData.user.id,
|
|
user_role: sessionData.user.role,
|
|
auth_method: "session",
|
|
});
|
|
}
|
|
|
|
if (sessionData.user.role !== "ADMIN") {
|
|
throw new HttpError(403, "Admin access required");
|
|
}
|
|
return sessionData;
|
|
}
|
|
|
|
/**
|
|
* Report unhandled errors to GlitchTip (fire-and-forget).
|
|
* Uses dynamic import to avoid circular dependencies and startup cost.
|
|
*/
|
|
async function reportToGlitchtip(
|
|
error: unknown,
|
|
request: NextRequest,
|
|
evt: WideEventBuilder,
|
|
): Promise<void> {
|
|
const [{ getGlitchtipSettings }, { sendGlitchtipEvent }] = await Promise.all([
|
|
import("@/lib/monitoring-settings"),
|
|
import("@/lib/monitoring/glitchtip"),
|
|
]);
|
|
|
|
const settings = await getGlitchtipSettings();
|
|
if (!settings.glitchtipEnabled || !settings.glitchtipDsn) return;
|
|
|
|
const err =
|
|
error instanceof Error
|
|
? error
|
|
: new Error(typeof error === "string" ? error : "Unknown error");
|
|
|
|
// Skip expected errors from bot scanners (malformed JSON bodies)
|
|
if (err instanceof SyntaxError) return;
|
|
|
|
// Audit H-B7 / phase P2: strip the query string before forwarding to
|
|
// GlitchTip. Withings legacy callbacks ship `?secret=…` (see C-3) and
|
|
// OAuth callbacks ship `?code=…&state=…`; if any of those error we
|
|
// don't want their secrets in someone's incident UI.
|
|
let scrubbedUrl = request.url;
|
|
try {
|
|
const u = new URL(request.url);
|
|
u.search = "";
|
|
scrubbedUrl = u.toString();
|
|
} catch {
|
|
// Invalid URL — fall through with the raw value (only happens in
|
|
// degenerate test fixtures).
|
|
}
|
|
|
|
await sendGlitchtipEvent({
|
|
dsn: settings.glitchtipDsn,
|
|
input: {
|
|
environment: settings.glitchtipEnvironment || "production",
|
|
message: err.message,
|
|
level: "error",
|
|
type: err.name || "Error",
|
|
stack: err.stack,
|
|
url: scrubbedUrl,
|
|
sourceTag: "healthlog-api-handler",
|
|
requestId: evt.getRequestId(),
|
|
},
|
|
});
|
|
}
|