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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user