Merge branch 'retire-legacy-notification'
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -116,16 +116,6 @@ export const db = {
|
||||
return rows.map(toStoredRow);
|
||||
},
|
||||
|
||||
/** Scheduler iteration; excludes `__legacy__` rows. */
|
||||
async getAllForScheduler(): Promise<StoredRow[]> {
|
||||
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<void> {
|
||||
getDatabase()
|
||||
.prepare(
|
||||
`UPDATE fcm_registrations SET last_notified_at = ? WHERE id = ?`
|
||||
)
|
||||
.run(patch.lastNotifiedAt, id);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
+1
-82
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof setInterval> | 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<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
+1
-107
@@ -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<string, unknown>
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<string, unknown> = {}
|
||||
): 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<string, string> = {
|
||||
...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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user