diff --git a/README.md b/README.md index 8d67de3..a3f0d04 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,11 @@ On first use, the service creates `NOTIFY_DATA_DIR` (default `./data`) and the S ### Authentication -`POST /notifications/register` and `POST /notifications/refresh` require a Bearer JWT. After local JWT verification, the service checks the token with Endorser. Registration and refresh continue only if Endorser accepts the JWT. +`POST /notifications/register` requires a Bearer JWT. After local JWT verification, the service checks the token with Endorser. Registration continues only if Endorser accepts the JWT. `POST /notifications/alert-authorization` uses the same current-user Bearer JWT + Endorser check. It does **not** accept the `testMode` local bypass. The 100 delegated JWTs in the body are stored credentials, not the request authenticator. -**Local notification test bypass:** send `testMode: true` in the JSON body and omit the `Authorization` header. The request skips JWT and Endorser checks and uses a synthetic local test user, same as before. This applies to register/refresh only. +**Local notification test bypass:** send `testMode: true` in the JSON body and omit the `Authorization` header. The request skips JWT and Endorser checks and uses a synthetic local test user, same as before. This applies to register only. Set `NODE_ENV=test-local` in `.env` to bypass ethr JWT *expiry* verification during local development (this is separate from the `testMode` bypass above). diff --git a/src/db/fcmTokensSqlite.ts b/src/db/fcmTokensSqlite.ts index 389837c..6b7edfa 100644 --- a/src/db/fcmTokensSqlite.ts +++ b/src/db/fcmTokensSqlite.ts @@ -116,16 +116,6 @@ export const db = { return rows.map(toStoredRow); }, - /** Scheduler iteration; excludes `__legacy__` rows. */ - async getAllForScheduler(): Promise { - const rows = getDatabase() - .prepare( - `SELECT ${ROW_COLUMNS} FROM fcm_registrations WHERE user_id != '__legacy__'` - ) - .all() as DbRow[]; - return rows.map(toStoredRow); - }, - /** * Resolve a device owned by userId via deviceId and/or fcmToken. * When both are given, they must refer to the same row. @@ -208,12 +198,4 @@ export const db = { .get(userId, fcmToken) as DbRow | undefined; return row === undefined ? undefined : toStoredRow(row); }, - - async update(id: string, patch: { lastNotifiedAt: number }): Promise { - getDatabase() - .prepare( - `UPDATE fcm_registrations SET last_notified_at = ? WHERE id = ?` - ) - .run(patch.lastNotifiedAt, id); - }, }; diff --git a/src/index.ts b/src/index.ts index 7b2a691..dcce41d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,6 @@ import { notificationsRouter } from "./routes/notifications.js"; import { notifySmsRouter } from "./routes/notifySms.js"; import { startAlertSearchScheduler } from "./alertSearch/scheduler.js"; import { startSmsAlertSearchScheduler } from "./alertSearch/smsScheduler.js"; -import { startScheduler } from "./scheduler.js"; import { log } from "./util/log.js"; const app = express(); @@ -40,7 +39,6 @@ if (DEBUG_ENDPOINT) { assertSmsConfigured(); -startScheduler(); startAlertSearchScheduler(); if (smsConfig().enabled) { startSmsAlertSearchScheduler(); diff --git a/src/routes/debug.ts b/src/routes/debug.ts index fe87479..442e9b5 100644 --- a/src/routes/debug.ts +++ b/src/routes/debug.ts @@ -1,13 +1,6 @@ import express, { Router } from "express"; import { db } from "../db/fcmTokensSqlite.js"; -import { - requireAuth, - requireAuthOrNotificationLocalTest, -} from "../middleware/auth.js"; -import { - computeNextEligibleAt, - sendPushToDevice, -} from "../services/pushService.js"; +import { requireAuth } from "../middleware/auth.js"; import { formatElapsedMs } from "../util/formatElapsed.js"; import { maskToken } from "../util/maskToken.js"; import { log } from "../util/log.js"; @@ -21,7 +14,6 @@ function deviceDebugPayload(row: { testMode?: boolean; createdAt: string; updatedAt: string; - lastNotifiedAt?: number; fcmToken: string; }) { return { @@ -31,22 +23,10 @@ function deviceDebugPayload(row: { testMode: row.testMode ?? false, createdAt: row.createdAt, updatedAt: row.updatedAt, - lastNotifiedAt: row.lastNotifiedAt, - nextEligibleAt: computeNextEligibleAt(row), fcmTokenSuffix: maskToken(row.fcmToken), }; } -function sendWakeupFailureReason( - result: "sent" | "skipped" | "failed" -): string | undefined { - if (result === "sent") return undefined; - if (result === "skipped") { - return "Device was notified within the eligibility threshold"; - } - return "FCM send failed"; -} - debugRouter.get("/device/:token", requireAuth, async (req, res) => { const started = Date.now(); const userId = req.did; @@ -82,64 +62,3 @@ debugRouter.get("/device/:token", requireAuth, async (req, res) => { suffix ); }); - -debugRouter.post("/send-wakeup", requireAuthOrNotificationLocalTest, async (req, res) => { - const started = Date.now(); - const userId = req.did; - if (userId === undefined) { - res.status(401).json({ success: false, message: "Unauthorized" }); - return; - } - - const { fcmToken } = req.body as { fcmToken?: unknown }; - if (typeof fcmToken !== "string" || fcmToken.length === 0) { - log.info( - "[DebugEndpoint] Send-wakeup rejected in", - formatElapsedMs(Date.now() - started) + ":", - "fcmToken is required" - ); - res.status(400).json({ - success: false, - failureReason: "fcmToken is required", - }); - return; - } - - const suffix = maskToken(fcmToken); - log.info("[DebugEndpoint] Send-wakeup request, token suffix:", suffix); - - const row = await db.resolveOwnedDevice(userId, { fcmToken }); - if (row === undefined) { - log.info( - "[DebugEndpoint] Send-wakeup rejected in", - formatElapsedMs(Date.now() - started) + ",", - "token suffix:", - suffix + ",", - "reason: Device not found" - ); - res.status(404).json({ - success: false, - failureReason: "Device not found", - fcmTokenSuffix: suffix, - }); - return; - } - - const result = await sendPushToDevice(fcmToken); - const success = result === "sent"; - const failureReason = sendWakeupFailureReason(result); - - res.json({ - success, - ...(failureReason !== undefined ? { failureReason } : {}), - fcmTokenSuffix: suffix, - }); - - log.info( - "[DebugEndpoint] Send-wakeup completed in", - formatElapsedMs(Date.now() - started) + ",", - success ? "success" : result + ",", - "token suffix:", - suffix - ); -}); diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index 01ee9de..ff3ddfd 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -23,81 +23,6 @@ notificationsRouter.get("/", (_req, res) => { res.json({ ok: true, resource: "notifications" }); }); -notificationsRouter.post( - "/refresh", - requireAuthOrNotificationLocalTest, - requireEndorserAuth, - async (req, res) => { - const started = Date.now(); - const userId = req.did; - if (userId === undefined) { - res.status(401).json({ success: false, message: "Unauthorized" }); - return; - } - - const { deviceId, fcmToken } = req.body as { - deviceId?: unknown; - fcmToken?: unknown; - }; - - const canonicalDeviceId = - typeof deviceId === "string" ? deviceId.trim() : undefined; - const token = - typeof fcmToken === "string" && fcmToken.length > 0 - ? fcmToken - : undefined; - - log.info( - "[Refresh] Request received", - canonicalDeviceId !== undefined ? `deviceId=${canonicalDeviceId}` : "", - token !== undefined ? `token suffix=${maskToken(token)}` : "" - ); - - if ( - (canonicalDeviceId === undefined || canonicalDeviceId.length === 0) && - token === undefined - ) { - log.info( - "[Refresh] Rejected in", - formatElapsedMs(Date.now() - started) + ":", - "deviceId or fcmToken is required" - ); - res.status(400).json({ error: "deviceId or fcmToken is required" }); - return; - } - - const device = await db.resolveOwnedDevice(userId, { - deviceId: canonicalDeviceId, - fcmToken: token, - }); - - if (device === undefined) { - log.info( - "[Refresh] Device not found in", - formatElapsedMs(Date.now() - started), - canonicalDeviceId !== undefined - ? `deviceId=${canonicalDeviceId}` - : "", - token !== undefined ? `token suffix=${maskToken(token)}` : "" - ); - res.status(404).json({ error: "Device not found" }); - return; - } - - const now = Date.now(); - res.json({ - shouldNotify: true, - nextNotifications: [{ timestamp: now + 600000 }], - }); - log.info( - "[Refresh] Completed in", - formatElapsedMs(Date.now() - started) + ",", - "deviceId=" + device.deviceId + ",", - "token suffix=" + maskToken(device.fcmToken) - ); - } -); - notificationsRouter.post( "/alert-authorization", requireAuth, @@ -188,7 +113,7 @@ notificationsRouter.post( /** * Turn the push channel off: every batch and every JWT for this DID, used or * not, so the alertSearch scheduler stops listing the identity. Device - * registrations are untouched; WAKEUP_PING is a separate mechanism. + * registrations are untouched. */ notificationsRouter.delete( "/alert-authorization", diff --git a/src/scheduler.ts b/src/scheduler.ts deleted file mode 100644 index 3ec369a..0000000 --- a/src/scheduler.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { db } from "./db/fcmTokensSqlite.js"; -import { sendPushToDevice } from "./services/pushService.js"; -import { errorMessage, formatElapsedMs } from "./util/formatElapsed.js"; -import { log } from "./util/log.js"; - -let intervalId: ReturnType | undefined; - -export function startScheduler(): void { - if (intervalId !== undefined) return; - - intervalId = setInterval(async () => { - const passStarted = Date.now(); - log.info("[Scheduler] Pass started"); - - try { - const devices = await db.getAllForScheduler(); - const seenTokens = new Set(); - let checked = 0; - let sent = 0; - let skipped = 0; - let failed = 0; - let duplicates = 0; - - for (const d of devices) { - if (seenTokens.has(d.fcmToken)) { - duplicates++; - continue; - } - seenTokens.add(d.fcmToken); - checked++; - - const result = await sendPushToDevice(d.fcmToken); - if (result === "sent") sent++; - else if (result === "skipped") skipped++; - else failed++; - } - - const summaryParts = [ - `Checked ${checked} devices`, - `sent ${sent} pushes`, - `skipped ${skipped}`, - ]; - if (failed > 0) summaryParts.push(`failed ${failed}`); - if (duplicates > 0) { - summaryParts.push(`${duplicates} duplicates ignored`); - } - log.info("[Scheduler]", summaryParts.join(", ")); - log.info( - "[Scheduler] Pass completed in", - formatElapsedMs(Date.now() - passStarted) - ); - } catch (err) { - log.error( - "[Scheduler] Pass failed in", - formatElapsedMs(Date.now() - passStarted) + ":", - errorMessage(err) - ); - } - }, 5 * 60 * 1000); -} - -export function stopScheduler(): void { - if (intervalId !== undefined) { - clearInterval(intervalId); - intervalId = undefined; - } -} diff --git a/src/services/pushService.ts b/src/services/pushService.ts index 7cbe03b..8a92a8e 100644 --- a/src/services/pushService.ts +++ b/src/services/pushService.ts @@ -1,118 +1,12 @@ -import { db, type StoredRow } from "../db/fcmTokensSqlite.js"; import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js"; import { maskToken } from "../util/maskToken.js"; import { messaging } from "./firebase.js"; import { log } from "../util/log.js"; -const MS_PRODUCTION = 23 * 60 * 60 * 1000; -const MS_TEST = 10 * 60 * 1000; - -export function notifyThresholdMs(testMode?: boolean): number { - return testMode === true ? MS_TEST : MS_PRODUCTION; -} - -/** Epoch ms when the device may receive another push (diagnostics only). */ -export function computeNextEligibleAt(row: { - lastNotifiedAt?: number; - testMode?: boolean; -}): number { - const threshold = notifyThresholdMs(row.testMode); - if (row.lastNotifiedAt === undefined) { - return Date.now(); - } - return row.lastNotifiedAt + threshold; -} - -function lastNotifiedMs(row: StoredRow | undefined): number | undefined { - const v = row?.lastNotifiedAt; - if (v === undefined) return undefined; - if (typeof v === "number") return Number.isNaN(v) ? undefined : v; - return undefined; -} - -function stringifyData( - payload: Record -): Record { - const out: Record = {}; - for (const [k, v] of Object.entries(payload)) { - out[k] = v === undefined || v === null ? "" : String(v); - } - return out; -} - -/** - * Sends an FCM data message if the token is outside the dedupe window - * (23h production, 10m test). - */ -export async function sendPushToDevice( - fcmToken: string, - payload: Record = {} -): Promise<"sent" | "skipped" | "failed"> { - const suffix = maskToken(fcmToken); - const row = await db.getByFcmToken(fcmToken); - const now = Date.now(); - const last = lastNotifiedMs(row); - - if ( - last !== undefined && - now - last < notifyThresholdMs(row?.testMode) - ) { - return "skipped"; - } - - const sendStarted = Date.now(); - log.info("[Push] Send attempt, token suffix:", suffix); - - try { - const data: Record = { - ...stringifyData(payload), - type: "WAKEUP_PING", - }; - - await messaging.send({ - token: fcmToken, - apns: { - headers: { - "apns-push-type": "background", - "apns-priority": "5", - }, - payload: { - aps: { - contentAvailable: true, - }, - }, - }, - data, - }); - - const persisted = await db.getByFcmToken(fcmToken); - if (persisted !== undefined) { - await db.update(persisted.id, { lastNotifiedAt: Date.now() }); - } - log.info( - "[Push] Send completed in", - formatElapsedMs(Date.now() - sendStarted) + ",", - "token suffix:", - suffix - ); - return "sent"; - } catch (err) { - log.error( - "[Push] Send failed in", - formatElapsedMs(Date.now() - sendStarted) + ",", - "token suffix:", - suffix + ":", - errorMessage(err) - ); - return "failed"; - } -} - export const ALERT_SEARCH_FCM_TYPE = "alert_search"; /** - * User-visible AlertSearch digest push. Does not apply the WAKEUP_PING - * last_notified_at window and does not write last_notified_at. + * User-visible AlertSearch digest push. */ export async function sendAlertSearchPushToDevice( fcmToken: string, diff --git a/test/alertSearch/scheduler.test.ts b/test/alertSearch/scheduler.test.ts index 2c2c072..dbb0969 100644 --- a/test/alertSearch/scheduler.test.ts +++ b/test/alertSearch/scheduler.test.ts @@ -394,7 +394,7 @@ describe("alertSearch scheduler pass", () => { }); describe("alertSearch scheduler isolation from FCM", () => { - it("does not call sendPushToDevice or WAKEUP_PING", () => { + it("does not reference the retired WAKEUP_PING producer", () => { const alertSched = readFileSync( path.join(process.cwd(), "src/alertSearch/scheduler.ts"), "utf8" @@ -403,23 +403,23 @@ describe("alertSearch scheduler isolation from FCM", () => { assert.equal(alertSched.includes("WAKEUP_PING"), false); }); - it("leaves the FCM scheduler on sendPushToDevice only", () => { - const fcmSched = readFileSync( - path.join(process.cwd(), "src/scheduler.ts"), + it("does not start the legacy FCM WAKEUP scheduler from the entrypoint", () => { + const index = readFileSync( + path.join(process.cwd(), "src/index.ts"), "utf8" ); - assert.equal(fcmSched.includes("sendPushToDevice"), true); - assert.equal(fcmSched.includes("runDailyAlertSearch"), false); - assert.equal(fcmSched.includes("runAlertSearchSchedulerPass"), false); - assert.equal(fcmSched.includes("startAlertSearchScheduler"), false); + assert.equal(index.includes("startScheduler"), false); + assert.equal(index.includes("./scheduler.js"), false); + assert.equal(index.includes("startAlertSearchScheduler"), true); }); - it("leaves WAKEUP_PING last_notified_at logic on sendPushToDevice", () => { + it("keeps AlertSearch FCM delivery without WAKEUP_PING", () => { const push = readFileSync( path.join(process.cwd(), "src/services/pushService.ts"), "utf8" ); - assert.equal(push.includes('type: "WAKEUP_PING"'), true); assert.equal(push.includes("sendAlertSearchPushToDevice"), true); + assert.equal(push.includes("sendPushToDevice"), false); + assert.equal(push.includes("WAKEUP_PING"), false); }); });