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