From 7c867bfda19d372de90a4840e5cbbf0544d8a3fa Mon Sep 17 00:00:00 2001 From: EDOHWARES Date: Fri, 26 Jun 2026 19:14:53 +0100 Subject: [PATCH 1/4] feat(devices): add /devices register and revoke endpoints; emit system events on device add/revoke --- apps/backend/src/routes/devices.ts | 127 +++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/apps/backend/src/routes/devices.ts b/apps/backend/src/routes/devices.ts index b1347cd..30366ef 100644 --- a/apps/backend/src/routes/devices.ts +++ b/apps/backend/src/routes/devices.ts @@ -14,6 +14,9 @@ import { db } from '../db/index.js'; import { devices, signedPreKeys, oneTimePreKeys } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; +import { userDevices, conversationMembers, messages } from '../db/schema.js'; +import { getSocketServer } from '../lib/socket.js'; +import { invalidateConversationCaches } from '../lib/conversationCache.js'; export const devicesRouter: RouterType = Router(); @@ -33,6 +36,14 @@ const UploadPreKeysSchema = z.object({ oneTimePreKeys: z.array(PreKeySchema).min(1, 'At least one one-time prekey is required'), }); +const RegisterDeviceSchema = z.object({ + deviceId: z.string().min(1, 'deviceId is required'), + deviceName: z.string().min(1, 'deviceName is required'), + platform: z.enum(['web', 'ios', 'android']), + identityPublicKey: z.string().min(1, 'identityPublicKey is required'), + registrationId: z.number().int().nonnegative().optional(), +}); + /** Maximum number of stored one-time prekeys per device. */ const OTP_CAP = 200; @@ -191,3 +202,119 @@ devicesRouter.post('/:id/prekeys', validate(UploadPreKeysSchema), async (req: Au capped: trimmedBatch.length < otpBatch.length, }); }); + +// ─── POST /devices — register a new device for an existing user -------------- + +devicesRouter.post('/', validate(RegisterDeviceSchema), async (req: AuthRequest, res) => { + const body = req.body as z.infer; + const userId = req.auth!.userId; + + // Validate identityPublicKey is base64 and 32 bytes when decoded (X25519) + try { + const key = Buffer.from(body.identityPublicKey, 'base64'); + if (key.length !== 32) { + res.status(400).json({ error: 'identityPublicKey must be 32 bytes (base64-encoded)' }); + return; + } + } catch { + res.status(400).json({ error: 'identityPublicKey must be valid base64' }); + return; + } + + // Reject duplicate (userId, deviceId) + const existing = await db.query.userDevices.findFirst({ + where: eq(userDevices.deviceId, body.deviceId), + }); + + if (existing && existing.userId === userId) { + res.status(409).json({ error: 'Device already registered for this user' }); + return; + } + + try { + const [row] = await db + .insert(userDevices) + .values({ + userId, + deviceId: body.deviceId, + deviceName: body.deviceName, + platform: body.platform, + identityPublicKey: body.identityPublicKey, + registrationId: body.registrationId ?? undefined, + }) + .returning({ id: userDevices.id, deviceId: userDevices.deviceId, createdAt: userDevices.createdAt }); + + // Emit system event to each conversation the user belongs to + void emitDeviceChangeEvent(userId, 'device_added'); + + res.status(201).json({ id: row.id, deviceId: row.deviceId, createdAt: row.createdAt }); + } catch (err) { + console.error('Failed to register device:', err); + res.status(500).json({ error: 'Failed to register device' }); + } +}); + +// ─── DELETE /devices/:id — revoke a device for the authenticated user -------- +devicesRouter.delete('/:id', async (req: AuthRequest, res) => { + const userId = req.auth!.userId; + const deviceId = req.params['id'] as string; + + try { + const result = await db + .update(userDevices) + .set({ revokedAt: new Date() }) + .where(eq(userDevices.deviceId, deviceId)) + .returning(); + + if (!result || result.length === 0) { + res.status(404).json({ error: 'Device not found' }); + return; + } + + // Only emit if the device belonged to the user (safety: check last row) + if (result[0].userId !== userId) { + res.status(403).json({ error: 'Not allowed to revoke this device' }); + return; + } + + // Emit system event to each conversation the user belongs to + void emitDeviceChangeEvent(userId, 'device_revoked'); + + res.status(200).json({ revoked: true }); + } catch (err) { + console.error('Failed to revoke device:', err); + res.status(500).json({ error: 'Failed to revoke device' }); + } +}); + +async function emitDeviceChangeEvent(userId: string, change: 'device_added' | 'device_revoked') { + try { + const memberships = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.userId, userId), + columns: { conversationId: true }, + }); + + if (memberships.length === 0) return; + + for (const m of memberships) { + const [msg] = await db + .insert(messages) + .values({ conversationId: m.conversationId, senderId: userId, content: JSON.stringify({ userId, change }) }) + .returning(); + + const io = getSocketServer(); + if (io) { + io.to(m.conversationId).emit('new_message', msg); + } + + // invalidate caches for conversation members + const members = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.conversationId, m.conversationId), + columns: { userId: true }, + }); + await invalidateConversationCaches(members.map((mm) => mm.userId)); + } + } catch (err) { + console.error('emitDeviceChangeEvent error:', err); + } +} From 792a859703f3aa2bcf391b85d60782212d388155 Mon Sep 17 00:00:00 2001 From: codebestia Date: Wed, 1 Jul 2026 20:39:45 +0100 Subject: [PATCH 2/4] style: format and lint codebase --- apps/backend/src/lib/validateMessagePayload.ts | 3 +-- apps/backend/src/routes/devices.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/lib/validateMessagePayload.ts b/apps/backend/src/lib/validateMessagePayload.ts index 9ac37c4..d5982b1 100644 --- a/apps/backend/src/lib/validateMessagePayload.ts +++ b/apps/backend/src/lib/validateMessagePayload.ts @@ -26,8 +26,7 @@ export interface MessagePayload { } export type MessagePayloadValidationResult = - | { ok: true } - | { ok: false; code: 400 | 403; message: string }; + { ok: true } | { ok: false; code: 400 | 403; message: string }; /** All content types clients are allowed to send */ const ALLOWED_CONTENT_TYPES = new Set(['text', 'file', 'image', 'video', 'audio'] as const); diff --git a/apps/backend/src/routes/devices.ts b/apps/backend/src/routes/devices.ts index aacb51a..0051370 100644 --- a/apps/backend/src/routes/devices.ts +++ b/apps/backend/src/routes/devices.ts @@ -324,7 +324,11 @@ devicesRouter.post('/', validate(RegisterDeviceSchema), async (req: AuthRequest, identityPublicKey: body.identityPublicKey, registrationId: body.registrationId ?? undefined, }) - .returning({ id: userDevices.id, deviceId: userDevices.deviceId, createdAt: userDevices.createdAt }); + .returning({ + id: userDevices.id, + deviceId: userDevices.deviceId, + createdAt: userDevices.createdAt, + }); // Emit system event to each conversation the user belongs to void emitDeviceChangeEvent(userId, 'device_added'); @@ -381,7 +385,11 @@ async function emitDeviceChangeEvent(userId: string, change: 'device_added' | 'd for (const m of memberships) { const [msg] = await db .insert(messages) - .values({ conversationId: m.conversationId, senderId: userId, content: JSON.stringify({ userId, change }) }) + .values({ + conversationId: m.conversationId, + senderId: userId, + content: JSON.stringify({ userId, change }), + }) .returning(); const io = getSocketServer(); From c832eb792a9c4c9a674849d7147de32c2f2e1d1d Mon Sep 17 00:00:00 2001 From: codebestia Date: Wed, 1 Jul 2026 20:43:45 +0100 Subject: [PATCH 3/4] style: format and lint codebase --- apps/web/src/app/app/devices/page.tsx | 12 ++--- .../src/components/ui/ProposalCard.test.tsx | 30 ++++++------- apps/web/src/components/ui/ProposalCard.tsx | 44 +++++++++++-------- apps/web/src/lib/x3dh.test.ts | 5 ++- apps/web/src/lib/x3dh.ts | 5 +-- 5 files changed, 51 insertions(+), 45 deletions(-) diff --git a/apps/web/src/app/app/devices/page.tsx b/apps/web/src/app/app/devices/page.tsx index 06c9bf7..ad532ff 100644 --- a/apps/web/src/app/app/devices/page.tsx +++ b/apps/web/src/app/app/devices/page.tsx @@ -176,8 +176,8 @@ export default function DevicesPage() {

Link a new device

- Open Clicked on the new device and connect the same wallet. It registers automatically - and shows up in the list below the moment it signs in. + Open Clicked on the new device and connect the same wallet. It registers automatically and + shows up in the list below the moment it signs in.

@@ -240,10 +240,10 @@ export default function DevicesPage() { title="Revoke this device?" >

- This device will be signed out immediately. Because your identity keys change for - anyone who messages you on this account, contacts may see a key-change notice the next - time they message you — that's expected and confirms the old device can no longer - read new messages. + This device will be signed out immediately. Because your identity keys change for anyone + who messages you on this account, contacts may see a key-change notice the next time they + message you — that's expected and confirms the old device can no longer read new + messages.

{/* Expiry Countdown for pending proposals */} - {proposal.status === "pending" &&

{timeLeft}

} + {proposal.status === 'pending' &&

{timeLeft}

} {/* Collapsible content section toggle wrapper */} - {(proposal.status === "executed" || proposal.status === "rejected") && ( + {(proposal.status === 'executed' || proposal.status === 'rejected') && ( )} {!isCollapsed && (
{/* Execute button only visible to verified members when status is approved */} - {proposal.status === "approved" && isMember && ( - )} {/* Expired proposals replace approve/reject with a Finalize button */} - {proposal.status === "expired" && ( - )} @@ -92,4 +98,4 @@ export const ProposalCard: React.FC = ({ )}
); -}; \ No newline at end of file +}; diff --git a/apps/web/src/lib/x3dh.test.ts b/apps/web/src/lib/x3dh.test.ts index 2a357c9..c754bf2 100644 --- a/apps/web/src/lib/x3dh.test.ts +++ b/apps/web/src/lib/x3dh.test.ts @@ -33,7 +33,10 @@ function bundleFrom( signature: toBase64(responder.signedPreKey.signature), }, oneTimePreKey: includeOtp - ? { keyId: responder.oneTimePreKey.keyId, publicKey: toBase64(responder.oneTimePreKey.publicKey) } + ? { + keyId: responder.oneTimePreKey.keyId, + publicKey: toBase64(responder.oneTimePreKey.publicKey), + } : null, }; } diff --git a/apps/web/src/lib/x3dh.ts b/apps/web/src/lib/x3dh.ts index 2718c1b..0d3f67b 100644 --- a/apps/web/src/lib/x3dh.ts +++ b/apps/web/src/lib/x3dh.ts @@ -130,10 +130,7 @@ function concatBytes(...chunks: Uint8Array[]): Uint8Array { * Initiator side: establish a session with `bundle` fetched from * GET /devices/:id/bundle. Verifies the signed prekey before using it. */ -export function initiateSession( - bundle: PreKeyBundle, - myIdentity: IdentityKeyPair, -): X3dhSession { +export function initiateSession(bundle: PreKeyBundle, myIdentity: IdentityKeyPair): X3dhSession { const theirIdentityRawEd = spkiToRawEd25519PublicKey(fromBase64(bundle.identityPublicKey)); const theirSpkPub = fromBase64(bundle.signedPreKey.publicKey); const theirSpkSig = fromBase64(bundle.signedPreKey.signature); From 600201286f118a92e521c62ae7ac3e4c05ab78df Mon Sep 17 00:00:00 2001 From: codebestia Date: Wed, 1 Jul 2026 20:46:39 +0100 Subject: [PATCH 4/4] style: format codebase --- apps/backend/src/lib/validateMessagePayload.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/lib/validateMessagePayload.ts b/apps/backend/src/lib/validateMessagePayload.ts index d5982b1..9ac37c4 100644 --- a/apps/backend/src/lib/validateMessagePayload.ts +++ b/apps/backend/src/lib/validateMessagePayload.ts @@ -26,7 +26,8 @@ export interface MessagePayload { } export type MessagePayloadValidationResult = - { ok: true } | { ok: false; code: 400 | 403; message: string }; + | { ok: true } + | { ok: false; code: 400 | 403; message: string }; /** All content types clients are allowed to send */ const ALLOWED_CONTENT_TYPES = new Set(['text', 'file', 'image', 'video', 'audio'] as const);