feat(security): alert on a new-device sign-in

On a completed sign-in (password, second-factor, or passkey) from a device or
location not seen before, send one notification through the dispatcher cascade,
honouring the user's per-channel preferences via a new SECURITY_ALERT event
type. Dedupe on a one-way, per-user fingerprint: a SHA-256 over the userId, the
coarse User-Agent family, and a country/ASN-level geo signal, stored in the new
user_known_devices ledger (migration 0203). The first sighting of a fingerprint
alerts and records; later sightings only bump last-seen and stay silent. The
raw User-Agent and IP are never persisted — only the salted hash and a coarse
label. Registration records its device silently so a fresh account does not
alert on its own first session. The whole path is fire-and-forget and never
blocks or fails a sign-in.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:34:31 +02:00
1 parent b882698a44
commit bed6588699
10 files changed
+618

No files matched your search

@@ -0,0 +1,33 @@
-- v1.23 — new-device sign-in alert dedupe ledger.
--
-- One row per (user, coarse device fingerprint). The fingerprint
-- (`device_hash`) is a one-way SHA-256 hash of
-- `userId | normalised-User-Agent | coarse-location-or-asn`. The raw
-- User-Agent and IP are NEVER stored — the table holds only the salted hash
-- (the userId is part of the digest input, so the same browser on two
-- accounts produces two unrelated hashes) plus an optional coarse,
-- human-readable `label` for the security-activity surface (e.g.
-- "Firefox on macOS — Berlin, DE"; the label omits the IP).
--
-- A successful sign-in upserts on (user_id, device_hash): the first sighting
-- of a hash inserts the row and fires one new-device notification through the
-- dispatcher; subsequent sightings only bump `last_seen_at` and stay silent.
CREATE TABLE "user_known_devices" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"device_hash" TEXT NOT NULL,
"label" TEXT,
"first_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_known_devices_pkey" PRIMARY KEY ("id")
);
-- Dedupe is per-user: a coarse fingerprint can never be inserted twice for
-- one account, so "first sighting → alert" is a single unique-constrained
-- upsert with no race window.
CREATE UNIQUE INDEX "user_known_devices_user_id_device_hash_key" ON "user_known_devices"("user_id", "device_hash");
CREATE INDEX "user_known_devices_user_id_idx" ON "user_known_devices"("user_id");
ALTER TABLE "user_known_devices" ADD CONSTRAINT "user_known_devices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+28
View File
@@ -575,6 +575,7 @@ model User {
mfaChallenges MfaChallenge[]
mfaRecoveryCodes MfaRecoveryCode[]
webauthnMfaCredentials WebauthnMfaCredential[]
knownDevices UserKnownDevice[]
measurements Measurement[]
medications Medication[]
intakeEvents MedicationIntakeEvent[]
@@ -875,6 +876,33 @@ model WebauthnMfaCredential {
@@map("webauthn_mfa_credentials")
}
// v1.23 — login-alert dedupe ledger. One row per (user, coarse device
// fingerprint). The fingerprint is a one-way SHA-256 hash of
// `userId | normalised-User-Agent | coarse-location-or-asn` — never the raw
// User-Agent or IP, so the table holds no directly identifying request
// metadata (`deviceHash` is salted by the userId, so the same browser on two
// accounts yields two unrelated hashes). `label` is a coarse, human-readable
// descriptor for the activity surface (e.g. "Firefox on macOS — Berlin, DE");
// it intentionally omits the IP. First sighting of a hash fires one new-device
// notification through the dispatcher; later sightings only bump `lastSeenAt`.
model UserKnownDevice {
id String @id @default(cuid())
userId String @map("user_id")
deviceHash String @map("device_hash")
label String?
firstSeenAt DateTime @default(now()) @map("first_seen_at")
lastSeenAt DateTime @default(now()) @map("last_seen_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Dedupe is per-user: the same coarse fingerprint can never be inserted
// twice for one account, so the "first sighting → alert" decision is a
// single unique-constrained upsert.
@@unique([userId, deviceHash])
@@index([userId])
@@map("user_known_devices")
}
model AuthChallenge {
id String @id @default(cuid())
userId String? @map("user_id") // null for registration
@@ -1,5 +1,6 @@
import { verifyAuthentication } from "@/lib/auth/passkey";
import { createSession } from "@/lib/auth/session";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { prisma } from "@/lib/db";
@@ -76,6 +77,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: ip,
});
// v1.23 — new-device / new-location alert, fire-and-forget (see finishLogin).
void recordSignInDevice({ userId: user.id, ip, userAgent: ua });
annotate({ action: { name: "auth.login.passkey" } });
if (
+24
View File
@@ -6,6 +6,7 @@ import {
import { registerSchema } from "@/lib/validations/auth";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
@@ -19,6 +20,8 @@ import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { checkPasswordBreach } from "@/lib/auth/hibp";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
isValidTimezone,
resolveServerDefaultTimezone,
@@ -121,6 +124,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
422,
);
}
// v1.23 — reject a registration password found in a known breach corpus
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
const breach = await checkPasswordBreach(password);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
422,
);
}
const passwordHash = await hashPassword(password);
// v1.15.20 — consume the invite LAST, after every other validation,
@@ -176,6 +190,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
const ua = request.headers.get("user-agent");
await createSession(user.id, true, ip, ua);
// v1.23 — record the registering device silently so the account's first
// login from this same device does not immediately fire a "new device"
// alert. A genuinely new device later still alerts on its first sighting.
void recordSignInDevice({
userId: user.id,
ip,
userAgent: ua,
alertOnNew: false,
});
// Stamp the consumer onto the invite (informational, best-effort —
// the use itself was already counted atomically above).
if (inviteId) {
@@ -0,0 +1,117 @@
import { describe, it, expect } from "vitest";
import {
coarseDeviceLabel,
normaliseUserAgent,
coarseLocationSignal,
computeDeviceHash,
maskIp,
} from "../device-fingerprint";
const FIREFOX_MAC =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0";
const FIREFOX_MAC_MINOR =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0";
const CHROME_WIN =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36";
describe("coarseDeviceLabel", () => {
it("reduces a UA to family + platform", () => {
expect(coarseDeviceLabel(FIREFOX_MAC)).toBe("Firefox on macOS");
expect(coarseDeviceLabel(CHROME_WIN)).toBe("Chrome on Windows");
});
it("handles a missing UA", () => {
expect(coarseDeviceLabel(null)).toBe("Unknown device");
expect(coarseDeviceLabel("")).toBe("Unknown device");
});
});
describe("coarseLocationSignal", () => {
it("reduces a city string to its country token", () => {
expect(coarseLocationSignal("Berlin, DE")).toBe("DE");
});
it("returns empty for no location", () => {
expect(coarseLocationSignal(null)).toBe("");
});
});
describe("computeDeviceHash", () => {
it("is deterministic and a hex SHA-256 (no raw UA/IP leak)", () => {
const h = computeDeviceHash({
userId: "u1",
userAgent: FIREFOX_MAC,
coarseSignal: "DE",
});
expect(h).toMatch(/^[0-9a-f]{64}$/);
// The hash must not embed the raw UA.
expect(h).not.toContain("Firefox");
expect(h).not.toContain("Mozilla");
expect(
computeDeviceHash({
userId: "u1",
userAgent: FIREFOX_MAC,
coarseSignal: "DE",
}),
).toBe(h);
});
it("is invariant to a minor browser-version bump (no false new-device)", () => {
const a = computeDeviceHash({
userId: "u1",
userAgent: FIREFOX_MAC,
coarseSignal: "DE",
});
const b = computeDeviceHash({
userId: "u1",
userAgent: FIREFOX_MAC_MINOR,
coarseSignal: "DE",
});
expect(a).toBe(b);
});
it("is salted per user — same browser, different account ⇒ different hash", () => {
const a = computeDeviceHash({
userId: "u1",
userAgent: FIREFOX_MAC,
coarseSignal: "DE",
});
const b = computeDeviceHash({
userId: "u2",
userAgent: FIREFOX_MAC,
coarseSignal: "DE",
});
expect(a).not.toBe(b);
});
it("treats a different country as a new device", () => {
const a = computeDeviceHash({
userId: "u1",
userAgent: FIREFOX_MAC,
coarseSignal: "DE",
});
const b = computeDeviceHash({
userId: "u1",
userAgent: FIREFOX_MAC,
coarseSignal: "US",
});
expect(a).not.toBe(b);
});
});
describe("normaliseUserAgent", () => {
it("matches the coarse label so the hash input is stable", () => {
expect(normaliseUserAgent(FIREFOX_MAC)).toBe("Firefox on macOS");
});
});
describe("maskIp", () => {
it("masks the host portion of an IPv4", () => {
expect(maskIp("203.0.113.42")).toBe("203.0.x.x");
});
it("masks an IPv6 down to two hextets", () => {
expect(maskIp("2001:db8:1234:5678::1")).toBe("2001:db8::");
});
it("returns null for a blank IP", () => {
expect(maskIp(null)).toBeNull();
expect(maskIp("")).toBeNull();
});
});
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
userKnownDevice: {
findUnique: vi.fn(),
update: vi.fn(),
create: vi.fn(),
},
user: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/geo", () => ({
lookupIpLocation: vi.fn(async () => "Berlin, DE"),
lookupIpAsn: vi.fn(() => null),
}));
vi.mock("@/lib/notifications/dispatcher", () => ({
dispatchNotification: vi.fn().mockResolvedValue({ dispatched: true }),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/i18n/server-translator", () => ({
getServerTranslator: () => ({ t: (k: string) => k }),
}));
vi.mock("@/lib/logging/context", () => ({ getEvent: () => null }));
import { recordSignInDevice } from "../login-alert";
import { prisma } from "@/lib/db";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
const UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0";
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue({
locale: "en",
} as never);
});
describe("recordSignInDevice — new-device dedupe", () => {
it("first sighting records the device AND fires one alert", async () => {
vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null);
vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never);
const res = await recordSignInDevice({
userId: "u1",
ip: "203.0.113.9",
userAgent: UA,
});
expect(res).toEqual({ known: false, alerted: true });
expect(prisma.userKnownDevice.create).toHaveBeenCalledTimes(1);
expect(dispatchNotification).toHaveBeenCalledTimes(1);
const payload = vi.mocked(dispatchNotification).mock.calls[0][0];
expect(payload.eventType).toBe("SECURITY_ALERT");
expect(payload.userId).toBe("u1");
});
it("second sighting of the same device is silent (no alert)", async () => {
vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue({
id: "kd-1",
} as never);
vi.mocked(prisma.userKnownDevice.update).mockResolvedValue({} as never);
const res = await recordSignInDevice({
userId: "u1",
ip: "203.0.113.9",
userAgent: UA,
});
expect(res).toEqual({ known: true, alerted: false });
expect(prisma.userKnownDevice.update).toHaveBeenCalledTimes(1);
expect(prisma.userKnownDevice.create).not.toHaveBeenCalled();
expect(dispatchNotification).not.toHaveBeenCalled();
});
it("records silently when alertOnNew is false (registration)", async () => {
vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null);
vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never);
const res = await recordSignInDevice({
userId: "u1",
ip: "203.0.113.9",
userAgent: UA,
alertOnNew: false,
});
expect(res).toEqual({ known: false, alerted: false });
expect(prisma.userKnownDevice.create).toHaveBeenCalledTimes(1);
expect(dispatchNotification).not.toHaveBeenCalled();
});
it("a unique-index race on create does not double-alert", async () => {
vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null);
vi.mocked(prisma.userKnownDevice.create).mockRejectedValue(
new Error("unique violation"),
);
const res = await recordSignInDevice({
userId: "u1",
ip: "203.0.113.9",
userAgent: UA,
});
expect(res).toEqual({ known: true, alerted: false });
expect(dispatchNotification).not.toHaveBeenCalled();
});
it("never persists the raw User-Agent — only the salted hash + coarse label", async () => {
vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null);
vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never);
await recordSignInDevice({
userId: "u1",
ip: "203.0.113.9",
userAgent: UA,
});
const data = vi.mocked(prisma.userKnownDevice.create).mock.calls[0][0].data;
expect(data.deviceHash).toMatch(/^[0-9a-f]{64}$/);
expect(JSON.stringify(data)).not.toContain("Mozilla");
expect(JSON.stringify(data)).not.toContain("203.0.113.9");
// The coarse label is the family + platform descriptor.
expect(data.label).toContain("Firefox on macOS");
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* v1.23 — coarse device fingerprinting shared by the active-session list and
* the new-device sign-in alert.
*
* The fingerprint that backs login-alert dedupe is deliberately COARSE and
* one-way: a SHA-256 over `userId | normalised-User-Agent | coarse-signal`.
* The raw User-Agent and IP never leave this module into storage — only the
* salted hash (userId-salted, so the same browser on two accounts hashes
* differently) and a coarse human-readable label. A minor-version UA bump or a
* differently-formatted IP must NOT mint a "new device", so the UA is reduced
* to its family + platform and the geo signal to a country/ASN granularity.
*/
import { createHash } from "node:crypto";
/**
* Reduce a User-Agent to a stable family + platform descriptor, e.g.
* "Firefox on macOS". Minor versions, build numbers, and engine noise are
* dropped so a routine browser update does not register as a new device.
* Returns "Unknown device" when the UA is missing or unrecognised.
*/
export function coarseDeviceLabel(
userAgent: string | null | undefined,
): string {
const ua = (userAgent ?? "").trim();
if (!ua) return "Unknown device";
const browser = /\bEdg(?:e|A|iOS)?\//.test(ua)
? "Edge"
: /\bOPR\/|\bOpera\b/.test(ua)
? "Opera"
: /\bFirefox\//.test(ua)
? "Firefox"
: /\bChrome\//.test(ua) && !/\bChromium\//.test(ua)
? "Chrome"
: /\bChromium\//.test(ua)
? "Chromium"
: /\bSafari\//.test(ua) && /\bVersion\//.test(ua)
? "Safari"
: /HealthLog-iOS/i.test(ua)
? "HealthLog iOS app"
: null;
const platform = /\bWindows NT\b/.test(ua)
? "Windows"
: /\b(iPhone|iPad|iPod)\b/.test(ua)
? "iOS"
: /\bMac OS X\b|\bMacintosh\b/.test(ua)
? "macOS"
: /\bAndroid\b/.test(ua)
? "Android"
: /\bLinux\b/.test(ua)
? "Linux"
: null;
if (browser && platform) return `${browser} on ${platform}`;
if (browser) return browser;
if (platform) return platform;
return "Unknown device";
}
/**
* Reduce a User-Agent to the stable string fed into the dedupe hash. Uses the
* coarse label so the hash is invariant to minor-version churn.
*/
export function normaliseUserAgent(
userAgent: string | null | undefined,
): string {
return coarseDeviceLabel(userAgent);
}
/**
* Reduce a resolved geo string to a coarse country-level signal, e.g.
* "Berlin, DE" → "DE". Falls back to the whole string when there is no
* trailing country token, and to "" when no location resolved. Kept coarse so
* a user moving across a city/region does not register as a new device while a
* cross-country sign-in still does.
*/
export function coarseLocationSignal(
location: string | null | undefined,
): string {
const loc = (location ?? "").trim();
if (!loc) return "";
const parts = loc.split(",").map((p) => p.trim());
return parts[parts.length - 1] || loc;
}
/**
* One-way, per-user device fingerprint. The userId is part of the digest so
* the same browser on two accounts yields two unrelated hashes (no
* cross-account correlation), and the raw inputs are never recoverable.
*/
export function computeDeviceHash(params: {
userId: string;
userAgent: string | null | undefined;
coarseSignal: string;
}): string {
const material = `${params.userId}|${normaliseUserAgent(params.userAgent)}|${params.coarseSignal}`;
return createHash("sha256").update(material).digest("hex");
}
/**
* Mask an IP for the user-facing security surfaces: keep enough to recognise
* the network, drop the host. IPv4 → first two octets; IPv6 → first two
* hextets. Returns null for a null/blank input.
*/
export function maskIp(ip: string | null | undefined): string | null {
const raw = (ip ?? "").trim();
if (!raw) return null;
if (raw.includes(":")) {
const groups = raw.split(":").filter((g) => g.length > 0);
return `${groups.slice(0, 2).join(":")}::`;
}
const octets = raw.split(".");
if (octets.length === 4) {
return `${octets[0]}.${octets[1]}.x.x`;
}
return raw;
}
+143
View File
@@ -0,0 +1,143 @@
/**
* v1.23 — new-device / new-location sign-in alert.
*
* Called on every completed sign-in (password, MFA-verify, passkey). Computes
* a coarse, one-way device fingerprint (see `device-fingerprint.ts`) and:
* - first sighting of the fingerprint → record it AND fire one notification
* through the dispatcher cascade (respecting the user's per-channel prefs),
* - any later sighting → bump `lastSeenAt` only, stay silent (dedupe).
*
* The whole thing is best-effort and is invoked fire-and-forget from the login
* paths: a failure here must never block or fail a sign-in. The raw IP/UA are
* never persisted — only the salted hash + a coarse label (and the audit row,
* which already records IP/geo for `auth.*` events).
*/
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { lookupIpLocation, lookupIpAsn } from "@/lib/geo";
import {
coarseDeviceLabel,
coarseLocationSignal,
computeDeviceHash,
} from "@/lib/auth/device-fingerprint";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getEvent } from "@/lib/logging/context";
function isLocale(value: string | null | undefined): value is Locale {
return (
value === "de" ||
value === "en" ||
value === "fr" ||
value === "es" ||
value === "it" ||
value === "pl"
);
}
export interface RecordSignInResult {
known: boolean;
alerted: boolean;
}
export async function recordSignInDevice(params: {
userId: string;
ip: string | null;
userAgent: string | null;
/**
* When false the device is recorded silently with no notification — used by
* account registration, where the very first device should not trigger a
* "new device" alert the moment the account is created.
*/
alertOnNew?: boolean;
}): Promise<RecordSignInResult> {
const { userId, ip, userAgent } = params;
const alertOnNew = params.alertOnNew ?? true;
try {
// Coarse geo signal — best-effort. The location resolver is online-first
// with its own timeout; a miss falls back to the ASN number so a device
// on the same network still dedupes even without a city.
let location: string | null = null;
try {
location = await lookupIpLocation(ip);
} catch {
location = null;
}
const coarseSignal =
coarseLocationSignal(location) || lookupIpAsn(ip)?.asn?.toString() || "";
const deviceHash = computeDeviceHash({ userId, userAgent, coarseSignal });
const deviceLabel = coarseDeviceLabel(userAgent);
const storedLabel = location ? `${deviceLabel} — ${location}` : deviceLabel;
const existing = await prisma.userKnownDevice.findUnique({
where: { userId_deviceHash: { userId, deviceHash } },
select: { id: true },
});
if (existing) {
await prisma.userKnownDevice
.update({
where: { id: existing.id },
data: { lastSeenAt: new Date() },
})
.catch(() => {});
return { known: true, alerted: false };
}
// First sighting. Insert; if a concurrent first-login races us to the
// unique (userId, deviceHash) index, swallow it and treat as already known
// so we never double-alert.
try {
await prisma.userKnownDevice.create({
data: { userId, deviceHash, label: storedLabel },
});
} catch {
return { known: true, alerted: false };
}
if (!alertOnNew) return { known: false, alerted: false };
await auditLog("auth.login.new_device", {
userId,
ipAddress: ip,
details: { device: deviceLabel },
});
// Localise against the recipient's own locale, then fan out through the
// dispatcher (which applies the user's per-channel SECURITY_ALERT prefs).
let locale: Locale = defaultLocale;
try {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true },
});
if (isLocale(row?.locale)) locale = row.locale;
} catch {
locale = defaultLocale;
}
const { t } = getServerTranslator(locale);
const locationText =
location ?? t("notifications.security.unknownLocation");
await dispatchNotification({
eventType: "SECURITY_ALERT",
userId,
title: t("notifications.security.newDeviceTitle"),
message: t("notifications.security.newDeviceBody", {
device: deviceLabel,
location: locationText,
}),
urgent: true,
});
return { known: false, alerted: true };
} catch (err) {
getEvent()?.addWarning(
`recordSignInDevice failed: ${err instanceof Error ? err.message : String(err)}`,
);
return { known: false, alerted: false };
}
}
+7
View File
@@ -26,6 +26,7 @@ import {
isCookielessNativeCaller,
} from "@/lib/auth/native-client";
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import type { User } from "@/generated/prisma/client";
export interface FinishLoginParams {
@@ -54,6 +55,12 @@ export async function finishLogin(
): Promise<Response> {
const { user, request, ip, userAgent, source, mfaVerified } = params;
// v1.23 — new-device / new-location alert. Fire-and-forget: never let the
// device ledger or the dispatcher add latency to — or fail — a sign-in. Runs
// for every transport (web cookie + native bundle) since the factor checks
// are already done by the time finishLogin is reached.
void recordSignInDevice({ userId: user.id, ip, userAgent });
if (
shouldIssueBearerToken(request.headers) ||
isNativeClientRequest(request.headers)
+11
View File
@@ -94,6 +94,13 @@ export const EVENT_TYPES = [
// user-visible Telegram message on every dose would be noise). Routed
// through the dedicated intake-sync sender, not the alert cascade.
"MEDICATION_INTAKE_SYNC",
// v1.23 — account-security alert (new-device / new-location sign-in). Fired
// by the login completion path on the first sighting of a coarse device
// fingerprint. ON by default at the channel layer: a sign-in the user did
// not make is exactly the case they need to hear about, so silence-by-default
// would defeat the feature. An explicit per-channel `NotificationPreference`
// row still wins for a user who opts out.
"SECURITY_ALERT",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
@@ -151,6 +158,10 @@ export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
// toggle: this is plumbing that keeps the user's own devices coherent,
// not a notification the user chooses to receive.
MEDICATION_INTAKE_SYNC: true,
// v1.23 — ON by default; a new-device sign-in is a security event the user
// needs surfaced. An explicit per-channel `NotificationPreference` row
// (enabled=false) still suppresses it for a user who opts out.
SECURITY_ALERT: true,
};
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {