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