feat(notifications): bind device registrations to authenticated user DID

Scope register and refresh to verified JWT identity (req.did). Persist
devices under userId::deviceId, reject client-supplied userId, and dedupe
FCM tokens per user.
This commit is contained in:
Jose Olarte III
2026-05-19 19:02:42 +08:00
parent 4bf57d26fd
commit 8e502a2335
3 changed files with 89 additions and 22 deletions

View File

@@ -8,6 +8,7 @@ const dataFile = path.join(dataDir, "fcm-tokens.json");
export type StoredRow = {
id: string;
userId: string;
deviceId: string;
fcmToken: string;
platform: string;
@@ -19,6 +20,7 @@ export type StoredRow = {
type ParsedRow = {
id?: string;
userId?: string;
deviceId?: string;
fcmToken: string;
platform: string;
@@ -28,8 +30,12 @@ type ParsedRow = {
lastNotifiedAt?: number | string;
};
export function storageKey(userId: string, deviceId: string): string {
return `${userId}::${deviceId}`;
}
function mergeDeviceRows(
deviceId: string,
key: string,
a: StoredRow,
b: StoredRow
): StoredRow {
@@ -43,7 +49,8 @@ function mergeDeviceRows(
return {
...primary,
id: primary.id,
deviceId,
userId: primary.userId,
deviceId: primary.deviceId,
fcmToken: primary.fcmToken,
lastNotifiedAt: lastMs > 0 ? lastMs : undefined,
createdAt: created,
@@ -77,8 +84,18 @@ function normalizeParsedRow(
onMutate();
}
let userId = r.userId?.trim();
if (userId === undefined || userId === "") {
const fromKey = mapKey.includes("::")
? mapKey.slice(0, mapKey.indexOf("::"))
: "";
userId = fromKey || "__legacy__";
onMutate();
}
return {
id,
userId,
deviceId,
fcmToken: r.fcmToken,
platform: r.platform,
@@ -89,6 +106,13 @@ function normalizeParsedRow(
};
}
function rowKey(row: StoredRow): string {
if (row.userId === "__legacy__") {
return row.deviceId;
}
return storageKey(row.userId, row.deviceId);
}
async function load(): Promise<Record<string, StoredRow>> {
try {
const raw = await readFile(dataFile, "utf8");
@@ -102,23 +126,21 @@ async function load(): Promise<Record<string, StoredRow>> {
for (const [mapKey, rawRow] of Object.entries(parsed)) {
const row = normalizeParsedRow(mapKey, rawRow, markDirty);
if (mapKey !== row.deviceId) markDirty();
const list = buckets.get(row.deviceId) ?? [];
const key = rowKey(row);
if (mapKey !== key) markDirty();
const list = buckets.get(key) ?? [];
list.push(row);
buckets.set(row.deviceId, list);
buckets.set(key, list);
}
const out: Record<string, StoredRow> = {};
for (const [did, rows] of buckets) {
for (const [key, rows] of buckets) {
if (rows.length === 1) {
out[did] = rows[0];
out[key] = rows[0];
} else {
out[did] = rows
out[key] = rows
.slice(1)
.reduce(
(acc, cur) => mergeDeviceRows(did, acc, cur),
rows[0]
);
.reduce((acc, cur) => mergeDeviceRows(key, acc, cur), rows[0]);
markDirty();
}
}
@@ -142,6 +164,7 @@ async function save(records: Record<string, StoredRow>): Promise<void> {
export const db = {
async upsert(row: {
userId: string;
deviceId: string;
fcmToken: string;
platform: string;
@@ -149,11 +172,12 @@ export const db = {
updatedAt: Date;
}): Promise<void> {
const all = await load();
const key = row.deviceId;
const key = storageKey(row.userId, row.deviceId);
const prev = all[key];
const now = row.updatedAt.toISOString();
all[key] = {
id: prev?.id ?? randomUUID(),
userId: row.userId,
deviceId: row.deviceId,
fcmToken: row.fcmToken,
platform: row.platform,
@@ -164,7 +188,12 @@ export const db = {
};
for (const k of [...Object.keys(all)]) {
if (k !== key && all[k].fcmToken === row.fcmToken) {
const other = all[k];
if (
k !== key &&
other.userId === row.userId &&
other.fcmToken === row.fcmToken
) {
delete all[k];
}
}
@@ -177,9 +206,17 @@ export const db = {
return Object.values(all);
},
async getByDeviceId(deviceId: string): Promise<StoredRow | undefined> {
async getByUserId(userId: string): Promise<StoredRow[]> {
const all = await load();
return all[deviceId];
return Object.values(all).filter((r) => r.userId === userId);
},
async getByDeviceId(
userId: string,
deviceId: string
): Promise<StoredRow | undefined> {
const all = await load();
return all[storageKey(userId, deviceId)];
},
async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> {

View File

@@ -1,6 +1,8 @@
export interface Device {
/** Internal row id used for persistence updates. */
id: string;
/** Authenticated user DID (from verified JWT). */
userId: string;
/** Client-provided stable physical device identity. */
deviceId: string;
fcmToken: string;

View File

@@ -4,14 +4,23 @@ import { requireAuth } from "../middleware/auth.js";
export const notificationsRouter = Router();
notificationsRouter.use(requireAuth);
notificationsRouter.get("/", (_req, res) => {
res.json({ ok: true, resource: "notifications" });
});
notificationsRouter.post("/refresh", async (_req, res) => {
console.log("[Refresh] Request received");
notificationsRouter.post("/refresh", requireAuth, async (req, res) => {
const userId = req.did;
if (userId === undefined) {
res.status(401).json({ success: false, message: "Unauthorized" });
return;
}
const devices = await db.getByUserId(userId);
console.log(
"[Refresh] authenticated refresh request:",
userId,
`(${devices.length} device(s))`
);
const now = Date.now();
res.json({
@@ -20,7 +29,24 @@ notificationsRouter.post("/refresh", async (_req, res) => {
});
});
notificationsRouter.post("/register", async (req, res) => {
notificationsRouter.post("/register", requireAuth, async (req, res) => {
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
) {
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;
@@ -44,13 +70,15 @@ notificationsRouter.post("/register", async (req, res) => {
const canonicalDeviceId = deviceId.trim();
try {
const existing = await db.getByDeviceId(canonicalDeviceId);
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);
}
await db.upsert({
userId,
deviceId: canonicalDeviceId,
fcmToken,
platform,