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
+53 -16
View File
@@ -8,6 +8,7 @@ const dataFile = path.join(dataDir, "fcm-tokens.json");
export type StoredRow = { export type StoredRow = {
id: string; id: string;
userId: string;
deviceId: string; deviceId: string;
fcmToken: string; fcmToken: string;
platform: string; platform: string;
@@ -19,6 +20,7 @@ export type StoredRow = {
type ParsedRow = { type ParsedRow = {
id?: string; id?: string;
userId?: string;
deviceId?: string; deviceId?: string;
fcmToken: string; fcmToken: string;
platform: string; platform: string;
@@ -28,8 +30,12 @@ type ParsedRow = {
lastNotifiedAt?: number | string; lastNotifiedAt?: number | string;
}; };
export function storageKey(userId: string, deviceId: string): string {
return `${userId}::${deviceId}`;
}
function mergeDeviceRows( function mergeDeviceRows(
deviceId: string, key: string,
a: StoredRow, a: StoredRow,
b: StoredRow b: StoredRow
): StoredRow { ): StoredRow {
@@ -43,7 +49,8 @@ function mergeDeviceRows(
return { return {
...primary, ...primary,
id: primary.id, id: primary.id,
deviceId, userId: primary.userId,
deviceId: primary.deviceId,
fcmToken: primary.fcmToken, fcmToken: primary.fcmToken,
lastNotifiedAt: lastMs > 0 ? lastMs : undefined, lastNotifiedAt: lastMs > 0 ? lastMs : undefined,
createdAt: created, createdAt: created,
@@ -77,8 +84,18 @@ function normalizeParsedRow(
onMutate(); onMutate();
} }
let userId = r.userId?.trim();
if (userId === undefined || userId === "") {
const fromKey = mapKey.includes("::")
? mapKey.slice(0, mapKey.indexOf("::"))
: "";
userId = fromKey || "__legacy__";
onMutate();
}
return { return {
id, id,
userId,
deviceId, deviceId,
fcmToken: r.fcmToken, fcmToken: r.fcmToken,
platform: r.platform, 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>> { async function load(): Promise<Record<string, StoredRow>> {
try { try {
const raw = await readFile(dataFile, "utf8"); 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)) { for (const [mapKey, rawRow] of Object.entries(parsed)) {
const row = normalizeParsedRow(mapKey, rawRow, markDirty); const row = normalizeParsedRow(mapKey, rawRow, markDirty);
if (mapKey !== row.deviceId) markDirty(); const key = rowKey(row);
const list = buckets.get(row.deviceId) ?? []; if (mapKey !== key) markDirty();
const list = buckets.get(key) ?? [];
list.push(row); list.push(row);
buckets.set(row.deviceId, list); buckets.set(key, list);
} }
const out: Record<string, StoredRow> = {}; const out: Record<string, StoredRow> = {};
for (const [did, rows] of buckets) { for (const [key, rows] of buckets) {
if (rows.length === 1) { if (rows.length === 1) {
out[did] = rows[0]; out[key] = rows[0];
} else { } else {
out[did] = rows out[key] = rows
.slice(1) .slice(1)
.reduce( .reduce((acc, cur) => mergeDeviceRows(key, acc, cur), rows[0]);
(acc, cur) => mergeDeviceRows(did, acc, cur),
rows[0]
);
markDirty(); markDirty();
} }
} }
@@ -142,6 +164,7 @@ async function save(records: Record<string, StoredRow>): Promise<void> {
export const db = { export const db = {
async upsert(row: { async upsert(row: {
userId: string;
deviceId: string; deviceId: string;
fcmToken: string; fcmToken: string;
platform: string; platform: string;
@@ -149,11 +172,12 @@ export const db = {
updatedAt: Date; updatedAt: Date;
}): Promise<void> { }): Promise<void> {
const all = await load(); const all = await load();
const key = row.deviceId; const key = storageKey(row.userId, row.deviceId);
const prev = all[key]; const prev = all[key];
const now = row.updatedAt.toISOString(); const now = row.updatedAt.toISOString();
all[key] = { all[key] = {
id: prev?.id ?? randomUUID(), id: prev?.id ?? randomUUID(),
userId: row.userId,
deviceId: row.deviceId, deviceId: row.deviceId,
fcmToken: row.fcmToken, fcmToken: row.fcmToken,
platform: row.platform, platform: row.platform,
@@ -164,7 +188,12 @@ export const db = {
}; };
for (const k of [...Object.keys(all)]) { 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]; delete all[k];
} }
} }
@@ -177,9 +206,17 @@ export const db = {
return Object.values(all); return Object.values(all);
}, },
async getByDeviceId(deviceId: string): Promise<StoredRow | undefined> { async getByUserId(userId: string): Promise<StoredRow[]> {
const all = await load(); 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> { async getByFcmToken(fcmToken: string): Promise<StoredRow | undefined> {
+2
View File
@@ -1,6 +1,8 @@
export interface Device { export interface Device {
/** Internal row id used for persistence updates. */ /** Internal row id used for persistence updates. */
id: string; id: string;
/** Authenticated user DID (from verified JWT). */
userId: string;
/** Client-provided stable physical device identity. */ /** Client-provided stable physical device identity. */
deviceId: string; deviceId: string;
fcmToken: string; fcmToken: string;
+34 -6
View File
@@ -4,14 +4,23 @@ import { requireAuth } from "../middleware/auth.js";
export const notificationsRouter = Router(); export const notificationsRouter = Router();
notificationsRouter.use(requireAuth);
notificationsRouter.get("/", (_req, res) => { notificationsRouter.get("/", (_req, res) => {
res.json({ ok: true, resource: "notifications" }); res.json({ ok: true, resource: "notifications" });
}); });
notificationsRouter.post("/refresh", async (_req, res) => { notificationsRouter.post("/refresh", requireAuth, async (req, res) => {
console.log("[Refresh] Request received"); 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(); const now = Date.now();
res.json({ 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 { const { deviceId, fcmToken, platform, testMode } = req.body as {
deviceId?: unknown; deviceId?: unknown;
fcmToken?: unknown; fcmToken?: unknown;
@@ -44,13 +70,15 @@ notificationsRouter.post("/register", async (req, res) => {
const canonicalDeviceId = deviceId.trim(); const canonicalDeviceId = deviceId.trim();
try { 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); console.log("[Register] Upserting device:", canonicalDeviceId);
if (existing !== undefined && existing.fcmToken !== fcmToken) { if (existing !== undefined && existing.fcmToken !== fcmToken) {
console.log("[Register] Replacing token for device:", canonicalDeviceId); console.log("[Register] Replacing token for device:", canonicalDeviceId);
} }
await db.upsert({ await db.upsert({
userId,
deviceId: canonicalDeviceId, deviceId: canonicalDeviceId,
fcmToken, fcmToken,
platform, platform,