fix(auth): harden refresh ownership and scheduler after auth migration

Restore /health to { ok: true }. Scope refresh to owned devices via
deviceId/fcmToken, improve register upsert logging, skip legacy rows in
the scheduler with per-token dedupe, and prefer non-legacy rows for push.
This commit is contained in:
Jose Olarte III
2026-05-19 19:53:21 +08:00
parent afbc2e9a57
commit 9764b30aed
4 changed files with 117 additions and 11 deletions
+46 -1
View File
@@ -206,6 +206,44 @@ export const db = {
return Object.values(all); return Object.values(all);
}, },
/** Scheduler iteration; excludes legacy rows pending migration cleanup. */
async getAllForScheduler(): Promise<StoredRow[]> {
const all = await load();
// TODO: migrate or remove __legacy__ rows after auth rollout
return Object.values(all).filter((r) => r.userId !== "__legacy__");
},
/**
* Resolve a device owned by userId via deviceId and/or fcmToken.
* When both are given, they must refer to the same row.
*/
async resolveOwnedDevice(
userId: string,
query: { deviceId?: string; fcmToken?: string }
): Promise<StoredRow | undefined> {
const deviceId = query.deviceId?.trim();
const fcmToken = query.fcmToken;
if (deviceId !== undefined && deviceId.length > 0) {
const byDevice = await this.getByDeviceId(userId, deviceId);
if (byDevice === undefined) return undefined;
if (
fcmToken !== undefined &&
fcmToken.length > 0 &&
byDevice.fcmToken !== fcmToken
) {
return undefined;
}
return byDevice;
}
if (fcmToken !== undefined && fcmToken.length > 0) {
return this.getByFcmTokenForUser(userId, fcmToken);
}
return undefined;
},
async getByUserId(userId: string): Promise<StoredRow[]> { async getByUserId(userId: string): Promise<StoredRow[]> {
const all = await load(); const all = await load();
return Object.values(all).filter((r) => r.userId === userId); return Object.values(all).filter((r) => r.userId === userId);
@@ -221,7 +259,14 @@ export const db = {
async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> { async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> {
const all = await load(); const all = await load();
return Object.values(all).find((r) => r.fcmToken === fcmToken); const matches = Object.values(all).filter((r) => r.fcmToken === fcmToken);
if (matches.length === 0) return undefined;
const owned = matches.filter((r) => r.userId !== "__legacy__");
const pool = owned.length > 0 ? owned : matches;
return pool.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)[0];
}, },
async getByFcmTokenForUser( async getByFcmTokenForUser(
+2 -1
View File
@@ -9,8 +9,9 @@ const port = Number(process.env.PORT) || 3000;
app.use(express.json()); app.use(express.json());
// Keep stable for diagnostics tooling compatibility
app.get("/health", (_req, res) => { app.get("/health", (_req, res) => {
res.status(200).json({ status: "ok" }); res.status(200).json({ ok: true });
}); });
app.use("/notifications", notificationsRouter); app.use("/notifications", notificationsRouter);
+56 -8
View File
@@ -1,6 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import { db } from "../db/fcmTokens.js"; import { db } from "../db/fcmTokens.js";
import { requireAuth } from "../middleware/auth.js"; import { requireAuth } from "../middleware/auth.js";
import { maskToken } from "../util/maskToken.js";
export const notificationsRouter = Router(); export const notificationsRouter = Router();
@@ -15,11 +16,46 @@ notificationsRouter.post("/refresh", requireAuth, async (req, res) => {
return; return;
} }
const devices = await db.getByUserId(userId); 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;
if (
(canonicalDeviceId === undefined || canonicalDeviceId.length === 0) &&
token === undefined
) {
res.status(400).json({ error: "deviceId or fcmToken is required" });
return;
}
const device = await db.resolveOwnedDevice(userId, {
deviceId: canonicalDeviceId,
fcmToken: token,
});
if (device === undefined) {
console.log(
"[Refresh] Device lookup failed for user:",
userId,
canonicalDeviceId !== undefined
? `deviceId=${canonicalDeviceId}`
: "",
token !== undefined ? `token suffix=${maskToken(token)}` : ""
);
res.status(404).json({ error: "Device not found" });
return;
}
console.log( console.log(
"[Refresh] authenticated refresh request:", "[Refresh] Device ownership validated:",
userId, device.deviceId,
`(${devices.length} device(s))` maskToken(device.fcmToken)
); );
const now = Date.now(); const now = Date.now();
@@ -70,11 +106,23 @@ notificationsRouter.post("/register", requireAuth, async (req, res) => {
const canonicalDeviceId = deviceId.trim(); const canonicalDeviceId = deviceId.trim();
try { try {
console.log("[Register] user authenticated:", userId);
const existing = await db.getByDeviceId(userId, canonicalDeviceId); const existing = await db.getByDeviceId(userId, canonicalDeviceId);
console.log("[Register] Upserting device:", canonicalDeviceId); if (existing !== undefined) {
if (existing !== undefined && existing.fcmToken !== fcmToken) { if (existing.fcmToken !== fcmToken) {
console.log("[Register] Replacing token for device:", canonicalDeviceId); console.log(
"[Register] Updating existing device token:",
canonicalDeviceId,
maskToken(fcmToken)
);
} else {
console.log("[Register] Updating existing device:", canonicalDeviceId);
}
} else {
console.log(
"[Register] Creating new device registration:",
canonicalDeviceId,
maskToken(fcmToken)
);
} }
await db.upsert({ await db.upsert({
+13 -1
View File
@@ -1,5 +1,6 @@
import { db } from "./db/fcmTokens.js"; import { db } from "./db/fcmTokens.js";
import { sendPushToDevice } from "./services/pushService.js"; import { sendPushToDevice } from "./services/pushService.js";
import { maskToken } from "./util/maskToken.js";
let intervalId: ReturnType<typeof setInterval> | undefined; let intervalId: ReturnType<typeof setInterval> | undefined;
@@ -9,8 +10,19 @@ export function startScheduler(): void {
intervalId = setInterval(async () => { intervalId = setInterval(async () => {
try { try {
console.log("[Scheduler] Checking devices..."); console.log("[Scheduler] Checking devices...");
const devices = await db.getAll(); const devices = await db.getAllForScheduler();
const seenTokens = new Set<string>();
for (const d of devices) { for (const d of devices) {
if (seenTokens.has(d.fcmToken)) {
console.log(
"[Scheduler] Duplicate device skipped:",
d.deviceId,
maskToken(d.fcmToken)
);
continue;
}
seenTokens.add(d.fcmToken);
await sendPushToDevice(d.fcmToken); await sendPushToDevice(d.fcmToken);
} }
} catch (err) { } catch (err) {