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

View File

@@ -206,6 +206,44 @@ export const db = {
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[]> {
const all = await load();
return Object.values(all).filter((r) => r.userId === userId);
@@ -221,7 +259,14 @@ export const db = {
async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> {
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(

View File

@@ -9,8 +9,9 @@ const port = Number(process.env.PORT) || 3000;
app.use(express.json());
// Keep stable for diagnostics tooling compatibility
app.get("/health", (_req, res) => {
res.status(200).json({ status: "ok" });
res.status(200).json({ ok: true });
});
app.use("/notifications", notificationsRouter);

View File

@@ -1,6 +1,7 @@
import { Router } from "express";
import { db } from "../db/fcmTokens.js";
import { requireAuth } from "../middleware/auth.js";
import { maskToken } from "../util/maskToken.js";
export const notificationsRouter = Router();
@@ -15,11 +16,46 @@ notificationsRouter.post("/refresh", requireAuth, async (req, res) => {
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(
"[Refresh] authenticated refresh request:",
userId,
`(${devices.length} device(s))`
"[Refresh] Device ownership validated:",
device.deviceId,
maskToken(device.fcmToken)
);
const now = Date.now();
@@ -70,11 +106,23 @@ notificationsRouter.post("/register", requireAuth, async (req, res) => {
const canonicalDeviceId = deviceId.trim();
try {
console.log("[Register] user authenticated:", userId);
const existing = await db.getByDeviceId(userId, canonicalDeviceId);
console.log("[Register] Upserting device:", canonicalDeviceId);
if (existing !== undefined && existing.fcmToken !== fcmToken) {
console.log("[Register] Replacing token for device:", canonicalDeviceId);
if (existing !== undefined) {
if (existing.fcmToken !== fcmToken) {
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({

View File

@@ -1,5 +1,6 @@
import { db } from "./db/fcmTokens.js";
import { sendPushToDevice } from "./services/pushService.js";
import { maskToken } from "./util/maskToken.js";
let intervalId: ReturnType<typeof setInterval> | undefined;
@@ -9,8 +10,19 @@ export function startScheduler(): void {
intervalId = setInterval(async () => {
try {
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) {
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);
}
} catch (err) {