feat(notifications): allow local debug register/refresh without JWT
When the Notification Debug Panel sends testMode: true and omits Authorization, skip requireAuth on /notifications/register and /refresh and scope devices under a synthetic local-test user id. Requests with a Bearer token or without testMode still use full JWT auth unchanged.
This commit is contained in:
@@ -1,181 +1,221 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
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 { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
|
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
|
||||||
import { maskToken } from "../util/maskToken.js";
|
import { maskToken } from "../util/maskToken.js";
|
||||||
|
|
||||||
|
/** Synthetic userId for unauthenticated local debug registrations (testMode). */
|
||||||
|
const LOCAL_TEST_USER_ID = "__notification_local_test__";
|
||||||
|
|
||||||
|
function isNotificationLocalTestBypass(req: Request): boolean {
|
||||||
|
if (req.headers.authorization?.startsWith("Bearer ")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const body = req.body;
|
||||||
|
return (
|
||||||
|
body !== null &&
|
||||||
|
typeof body === "object" &&
|
||||||
|
(body as { testMode?: unknown }).testMode === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireAuthOrNotificationLocalTest(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
if (isNotificationLocalTestBypass(req)) {
|
||||||
|
req.did = LOCAL_TEST_USER_ID;
|
||||||
|
console.log("[Auth] Local notification test bypass");
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return requireAuth(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
export const notificationsRouter = Router();
|
export const notificationsRouter = Router();
|
||||||
|
|
||||||
notificationsRouter.get("/", (_req, res) => {
|
notificationsRouter.get("/", (_req, res) => {
|
||||||
res.json({ ok: true, resource: "notifications" });
|
res.json({ ok: true, resource: "notifications" });
|
||||||
});
|
});
|
||||||
|
|
||||||
notificationsRouter.post("/refresh", requireAuth, async (req, res) => {
|
notificationsRouter.post(
|
||||||
const started = Date.now();
|
"/refresh",
|
||||||
const userId = req.did;
|
requireAuthOrNotificationLocalTest,
|
||||||
if (userId === undefined) {
|
async (req, res) => {
|
||||||
res.status(401).json({ success: false, message: "Unauthorized" });
|
const started = Date.now();
|
||||||
return;
|
const userId = req.did;
|
||||||
}
|
if (userId === undefined) {
|
||||||
|
res.status(401).json({ success: false, message: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { deviceId, fcmToken } = req.body as {
|
const { deviceId, fcmToken } = req.body as {
|
||||||
deviceId?: unknown;
|
deviceId?: unknown;
|
||||||
fcmToken?: unknown;
|
fcmToken?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const canonicalDeviceId =
|
const canonicalDeviceId =
|
||||||
typeof deviceId === "string" ? deviceId.trim() : undefined;
|
typeof deviceId === "string" ? deviceId.trim() : undefined;
|
||||||
const token =
|
const token =
|
||||||
typeof fcmToken === "string" && fcmToken.length > 0 ? fcmToken : undefined;
|
typeof fcmToken === "string" && fcmToken.length > 0
|
||||||
|
? fcmToken
|
||||||
|
: undefined;
|
||||||
|
|
||||||
console.log(
|
|
||||||
"[Refresh] Request received",
|
|
||||||
canonicalDeviceId !== undefined ? `deviceId=${canonicalDeviceId}` : "",
|
|
||||||
token !== undefined ? `token suffix=${maskToken(token)}` : ""
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
(canonicalDeviceId === undefined || canonicalDeviceId.length === 0) &&
|
|
||||||
token === undefined
|
|
||||||
) {
|
|
||||||
console.log(
|
console.log(
|
||||||
"[Refresh] Rejected in",
|
"[Refresh] Request received",
|
||||||
formatElapsedMs(Date.now() - started) + ":",
|
canonicalDeviceId !== undefined ? `deviceId=${canonicalDeviceId}` : "",
|
||||||
"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) {
|
|
||||||
console.log(
|
|
||||||
"[Refresh] Device not found in",
|
|
||||||
formatElapsedMs(Date.now() - started),
|
|
||||||
canonicalDeviceId !== undefined
|
|
||||||
? `deviceId=${canonicalDeviceId}`
|
|
||||||
: "",
|
|
||||||
token !== undefined ? `token suffix=${maskToken(token)}` : ""
|
token !== undefined ? `token suffix=${maskToken(token)}` : ""
|
||||||
);
|
);
|
||||||
res.status(404).json({ error: "Device not found" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
if (
|
||||||
res.json({
|
(canonicalDeviceId === undefined || canonicalDeviceId.length === 0) &&
|
||||||
shouldNotify: true,
|
token === undefined
|
||||||
nextNotifications: [{ timestamp: now + 600000 }],
|
) {
|
||||||
});
|
console.log(
|
||||||
console.log(
|
"[Refresh] Rejected in",
|
||||||
"[Refresh] Completed in",
|
formatElapsedMs(Date.now() - started) + ":",
|
||||||
formatElapsedMs(Date.now() - started) + ",",
|
"deviceId or fcmToken is required"
|
||||||
"deviceId=" + device.deviceId + ",",
|
);
|
||||||
"token suffix=" + maskToken(device.fcmToken)
|
res.status(400).json({ error: "deviceId or fcmToken is required" });
|
||||||
);
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
notificationsRouter.post("/register", requireAuth, async (req, res) => {
|
const device = await db.resolveOwnedDevice(userId, {
|
||||||
const started = Date.now();
|
|
||||||
const userId = req.did;
|
|
||||||
if (userId === undefined) {
|
|
||||||
res.status(401).json({ success: false, message: "Unauthorized" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
req.body !== null &&
|
|
||||||
typeof req.body === "object" &&
|
|
||||||
"userId" in req.body
|
|
||||||
) {
|
|
||||||
console.log(
|
|
||||||
"[Register] Rejected in",
|
|
||||||
formatElapsedMs(Date.now() - started) + ":",
|
|
||||||
"userId must not be sent in the request body"
|
|
||||||
);
|
|
||||||
res.status(400).json({
|
|
||||||
error: "userId must not be sent in the request body",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { deviceId, fcmToken, platform, testMode } = req.body as {
|
|
||||||
deviceId?: unknown;
|
|
||||||
fcmToken?: unknown;
|
|
||||||
platform?: unknown;
|
|
||||||
testMode?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof deviceId !== "string" || deviceId.trim().length === 0) {
|
|
||||||
console.log(
|
|
||||||
"[Register] Rejected in",
|
|
||||||
formatElapsedMs(Date.now() - started) + ":",
|
|
||||||
"deviceId is required"
|
|
||||||
);
|
|
||||||
res.status(400).json({ error: "deviceId is required" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
|
|
||||||
console.log(
|
|
||||||
"[Register] Rejected in",
|
|
||||||
formatElapsedMs(Date.now() - started) + ":",
|
|
||||||
"fcmToken is required"
|
|
||||||
);
|
|
||||||
res.status(400).json({ error: "fcmToken is required" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof platform !== "string" || platform.length === 0) {
|
|
||||||
console.log(
|
|
||||||
"[Register] Rejected in",
|
|
||||||
formatElapsedMs(Date.now() - started) + ":",
|
|
||||||
"platform is required"
|
|
||||||
);
|
|
||||||
res.status(400).json({ error: "platform is required" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canonicalDeviceId = deviceId.trim();
|
|
||||||
console.log(
|
|
||||||
"[Register] Request received,",
|
|
||||||
"deviceId=" + canonicalDeviceId + ",",
|
|
||||||
"platform=" + platform + ",",
|
|
||||||
"token suffix=" + maskToken(fcmToken)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const existing = await db.getByDeviceId(userId, canonicalDeviceId);
|
|
||||||
const action =
|
|
||||||
existing === undefined
|
|
||||||
? "create"
|
|
||||||
: existing.fcmToken !== fcmToken
|
|
||||||
? "update-token"
|
|
||||||
: "update";
|
|
||||||
|
|
||||||
await db.upsert({
|
|
||||||
userId,
|
|
||||||
deviceId: canonicalDeviceId,
|
deviceId: canonicalDeviceId,
|
||||||
fcmToken,
|
fcmToken: token,
|
||||||
platform,
|
});
|
||||||
testMode: typeof testMode === "boolean" ? testMode : undefined,
|
|
||||||
updatedAt: new Date(),
|
if (device === undefined) {
|
||||||
|
console.log(
|
||||||
|
"[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 }],
|
||||||
});
|
});
|
||||||
res.sendStatus(200);
|
|
||||||
console.log(
|
console.log(
|
||||||
"[Register] Completed in",
|
"[Refresh] Completed in",
|
||||||
formatElapsedMs(Date.now() - started) + ",",
|
formatElapsedMs(Date.now() - started) + ",",
|
||||||
"deviceId=" + canonicalDeviceId + ",",
|
"deviceId=" + device.deviceId + ",",
|
||||||
"action=" + action
|
"token suffix=" + maskToken(device.fcmToken)
|
||||||
);
|
);
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
"[Register] Failed in",
|
|
||||||
formatElapsedMs(Date.now() - started) + ",",
|
|
||||||
"deviceId=" + canonicalDeviceId + ":",
|
|
||||||
errorMessage(err)
|
|
||||||
);
|
|
||||||
res.sendStatus(500);
|
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
|
notificationsRouter.post(
|
||||||
|
"/register",
|
||||||
|
requireAuthOrNotificationLocalTest,
|
||||||
|
async (req, res) => {
|
||||||
|
const started = Date.now();
|
||||||
|
const userId = req.did;
|
||||||
|
if (userId === undefined) {
|
||||||
|
res.status(401).json({ success: false, message: "Unauthorized" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
req.body !== null &&
|
||||||
|
typeof req.body === "object" &&
|
||||||
|
"userId" in req.body
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
"[Register] Rejected in",
|
||||||
|
formatElapsedMs(Date.now() - started) + ":",
|
||||||
|
"userId must not be sent in the request body"
|
||||||
|
);
|
||||||
|
res.status(400).json({
|
||||||
|
error: "userId must not be sent in the request body",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { deviceId, fcmToken, platform, testMode } = req.body as {
|
||||||
|
deviceId?: unknown;
|
||||||
|
fcmToken?: unknown;
|
||||||
|
platform?: unknown;
|
||||||
|
testMode?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof deviceId !== "string" || deviceId.trim().length === 0) {
|
||||||
|
console.log(
|
||||||
|
"[Register] Rejected in",
|
||||||
|
formatElapsedMs(Date.now() - started) + ":",
|
||||||
|
"deviceId is required"
|
||||||
|
);
|
||||||
|
res.status(400).json({ error: "deviceId is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof fcmToken !== "string" || fcmToken.length === 0) {
|
||||||
|
console.log(
|
||||||
|
"[Register] Rejected in",
|
||||||
|
formatElapsedMs(Date.now() - started) + ":",
|
||||||
|
"fcmToken is required"
|
||||||
|
);
|
||||||
|
res.status(400).json({ error: "fcmToken is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof platform !== "string" || platform.length === 0) {
|
||||||
|
console.log(
|
||||||
|
"[Register] Rejected in",
|
||||||
|
formatElapsedMs(Date.now() - started) + ":",
|
||||||
|
"platform is required"
|
||||||
|
);
|
||||||
|
res.status(400).json({ error: "platform is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalDeviceId = deviceId.trim();
|
||||||
|
console.log(
|
||||||
|
"[Register] Request received,",
|
||||||
|
"deviceId=" + canonicalDeviceId + ",",
|
||||||
|
"platform=" + platform + ",",
|
||||||
|
"token suffix=" + maskToken(fcmToken)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await db.getByDeviceId(userId, canonicalDeviceId);
|
||||||
|
const action =
|
||||||
|
existing === undefined
|
||||||
|
? "create"
|
||||||
|
: existing.fcmToken !== fcmToken
|
||||||
|
? "update-token"
|
||||||
|
: "update";
|
||||||
|
|
||||||
|
await db.upsert({
|
||||||
|
userId,
|
||||||
|
deviceId: canonicalDeviceId,
|
||||||
|
fcmToken,
|
||||||
|
platform,
|
||||||
|
testMode: typeof testMode === "boolean" ? testMode : undefined,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
res.sendStatus(200);
|
||||||
|
console.log(
|
||||||
|
"[Register] Completed in",
|
||||||
|
formatElapsedMs(Date.now() - started) + ",",
|
||||||
|
"deviceId=" + canonicalDeviceId + ",",
|
||||||
|
"action=" + action
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
"[Register] Failed in",
|
||||||
|
formatElapsedMs(Date.now() - started) + ",",
|
||||||
|
"deviceId=" + canonicalDeviceId + ":",
|
||||||
|
errorMessage(err)
|
||||||
|
);
|
||||||
|
res.sendStatus(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user