diff --git a/src/durable/notifications-hub.ts b/src/durable/notifications-hub.ts index 6b3a0aa..526dc1f 100644 --- a/src/durable/notifications-hub.ts +++ b/src/durable/notifications-hub.ts @@ -3,6 +3,8 @@ import type { Env } from '../types'; const SIGNALR_RECORD_SEPARATOR = 0x1e; const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]); const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; +const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; +const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; const SIGNALR_PING_INTERVAL_MS = 15_000; type HubProtocol = 'json' | 'messagepack'; @@ -10,6 +12,7 @@ type HubProtocol = 'json' | 'messagepack'; interface ConnectionState { handshakeComplete: boolean; protocol: HubProtocol; + deviceIdentifier: string | null; } function concatBytes(chunks: Uint8Array[]): Uint8Array { @@ -123,14 +126,19 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array { return concatBytes([new Uint8Array(prefix), payload]); } -function buildSignalRJsonInvocation(userId: string, revisionDate: string, contextId: string | null): string { +function buildSignalRJsonInvocation( + userId: string, + updateType: number, + revisionDate: string, + contextId: string | null +): string { return JSON.stringify({ type: 1, target: 'ReceiveMessage', arguments: [ { ContextId: contextId, - Type: SIGNALR_UPDATE_TYPE_SYNC_VAULT, + Type: updateType, Payload: { UserId: userId, Date: revisionDate, @@ -144,7 +152,12 @@ function buildSignalRJsonPing(): string { return JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR); } -function buildSignalRMessagePackInvocation(userId: string, revisionDate: string, contextId: string | null): Uint8Array { +function buildSignalRMessagePackInvocation( + userId: string, + updateType: number, + revisionDate: string, + contextId: string | null +): Uint8Array { // SignalR MessagePack hub protocol uses an array-based invocation shape: // [type, headers, invocationId, target, arguments] const payload = encodeMsgPack([ @@ -155,7 +168,7 @@ function buildSignalRMessagePackInvocation(userId: string, revisionDate: string, [ { ContextId: contextId, - Type: SIGNALR_UPDATE_TYPE_SYNC_VAULT, + Type: updateType, Payload: { UserId: userId, Date: new Date(revisionDate), @@ -189,25 +202,32 @@ export class NotificationsHub { async fetch(request: Request): Promise { const url = new URL(request.url); - if (url.pathname === '/internal/bind-user' && request.method === 'POST') { - const body = (await request.json().catch(() => null)) as { userId?: string } | null; - this.userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || this.userId).trim(); - return new Response(null, { status: 204 }); - } - if (url.pathname === '/internal/notify' && request.method === 'POST') { const body = (await request.json().catch(() => null)) as { revisionDate?: string; userId?: string; contextId?: string | null; + updateType?: number; + targetDeviceIdentifier?: string | null; } | null; const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString(); this.userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || this.userId).trim(); const contextId = String(body?.contextId || '').trim() || null; - this.broadcastVaultSync(revisionDate, contextId); + const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT; + const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null; + this.broadcastMessage(updateType, revisionDate, contextId, targetDeviceIdentifier); return new Response(null, { status: 204 }); } + if (url.pathname === '/internal/online' && request.method === 'GET') { + return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); + } + if (url.pathname !== '/notifications/hub') { return new Response('Not found', { status: 404 }); } @@ -216,6 +236,12 @@ export class NotificationsHub { return new Response('Expected websocket', { status: 426 }); } + const requestUserId = String(url.searchParams.get('nw_uid') || '').trim(); + const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null; + if (requestUserId) { + this.userId = requestUserId; + } + if (!this.userId) { return new Response('Unauthorized', { status: 401 }); } @@ -228,6 +254,7 @@ export class NotificationsHub { this.connections.set(server, { handshakeComplete: false, protocol: 'messagepack', + deviceIdentifier: requestDeviceIdentifier, }); this.ensurePingLoop(); @@ -235,12 +262,16 @@ export class NotificationsHub { void this.handleSocketMessage(server, event.data); }); server.addEventListener('close', () => { + const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete; this.connections.delete(server); this.stopPingLoopIfIdle(); + if (shouldBroadcast) this.broadcastDeviceStatus(); }); server.addEventListener('error', () => { + const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete; this.connections.delete(server); this.stopPingLoopIfIdle(); + if (shouldBroadcast) this.broadcastDeviceStatus(); try { server.close(1011, 'Socket error'); } catch { @@ -268,6 +299,7 @@ export class NotificationsHub { connection.protocol = protocol; connection.handshakeComplete = true; socket.send(SIGNALR_HANDSHAKE_ACK); + this.broadcastDeviceStatus(); return; } catch { // Ignore malformed pre-handshake payloads. @@ -317,16 +349,31 @@ export class NotificationsHub { this.stopPingLoopIfIdle(); } - private broadcastVaultSync(revisionDate: string, contextId: string | null): void { + private getOnlineDeviceIdentifiers(): string[] { + const out = new Set(); + for (const connection of this.connections.values()) { + if (!connection.handshakeComplete || !connection.deviceIdentifier) continue; + out.add(connection.deviceIdentifier); + } + return Array.from(out); + } + + private broadcastMessage( + updateType: number, + revisionDate: string, + contextId: string | null, + targetDeviceIdentifier: string | null + ): void { if (!this.userId || this.connections.size === 0) return; for (const [socket, connection] of this.connections) { if (!connection.handshakeComplete) continue; + if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue; try { if (connection.protocol === 'json') { - socket.send(buildSignalRJsonInvocation(this.userId, revisionDate, contextId)); + socket.send(buildSignalRJsonInvocation(this.userId, updateType, revisionDate, contextId)); } else { - socket.send(buildSignalRMessagePackInvocation(this.userId, revisionDate, contextId)); + socket.send(buildSignalRMessagePackInvocation(this.userId, updateType, revisionDate, contextId)); } } catch { this.connections.delete(socket); @@ -340,6 +387,10 @@ export class NotificationsHub { this.stopPingLoopIfIdle(); } + + private broadcastDeviceStatus(): void { + this.broadcastMessage(SIGNALR_UPDATE_TYPE_DEVICE_STATUS, new Date().toISOString(), null, null); + } } export async function notifyUserVaultSync( @@ -347,6 +398,38 @@ export async function notifyUserVaultSync( userId: string, revisionDate: string, contextId?: string | null +): Promise { + return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null); +} + +export async function notifyUserLogout( + env: Env, + userId: string, + targetDeviceIdentifier?: string | null +): Promise { + return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null); +} + +export async function getOnlineUserDevices(env: Env, userId: string): Promise { + try { + const id = env.NOTIFICATIONS_HUB.idFromName(userId); + const stub = env.NOTIFICATIONS_HUB.get(id); + const response = await stub.fetch('https://notifications/internal/online'); + if (!response.ok) return []; + const body = (await response.json().catch(() => null)) as { deviceIdentifiers?: string[] } | null; + return Array.isArray(body?.deviceIdentifiers) ? body.deviceIdentifiers.filter((value) => !!String(value || '').trim()) : []; + } catch { + return []; + } +} + +async function notifyUserUpdate( + env: Env, + userId: string, + updateType: number, + revisionDate: string, + contextId: string | null, + targetDeviceIdentifier: string | null ): Promise { try { const id = env.NOTIFICATIONS_HUB.idFromName(userId); @@ -357,9 +440,14 @@ export async function notifyUserVaultSync( 'Content-Type': 'application/json', 'X-NodeWarden-UserId': userId, }, - body: JSON.stringify({ revisionDate, contextId: contextId || null }), + body: JSON.stringify({ + revisionDate, + contextId: contextId || null, + updateType, + targetDeviceIdentifier: targetDeviceIdentifier || null, + }), }); } catch (error) { - console.error('Failed to broadcast vault sync notification:', error); + console.error('Failed to broadcast realtime notification:', error); } } diff --git a/src/handlers/devices.ts b/src/handlers/devices.ts index a901d2e..4400253 100644 --- a/src/handlers/devices.ts +++ b/src/handlers/devices.ts @@ -1,4 +1,5 @@ import { Env } from '../types'; +import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { StorageService } from '../services/storage'; import { errorResponse, jsonResponse } from '../utils/response'; import { readKnownDeviceProbe } from '../utils/device'; @@ -46,10 +47,12 @@ export async function handleGetDevices(request: Request, env: Env, userId: strin export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise { void request; const storage = new StorageService(env.DB); - const [devices, trusted] = await Promise.all([ + const [devices, trusted, onlineDeviceIdentifiers] = await Promise.all([ storage.getDevicesByUserId(userId), storage.getTrustedDeviceTokenSummariesByUserId(userId), + getOnlineUserDevices(env, userId), ]); + const onlineSet = new Set(onlineDeviceIdentifiers); const trustedByIdentifier = new Map(); for (const row of trusted) { @@ -67,6 +70,7 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use type: device.type, creationDate: device.createdAt, revisionDate: device.updatedAt, + online: onlineSet.has(device.deviceIdentifier), trusted: !!trustedInfo, trustedTokenCount: trustedInfo?.tokenCount || 0, trustedUntil: trustedInfo?.expiresAt ? new Date(trustedInfo.expiresAt).toISOString() : null, @@ -83,6 +87,7 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use type: 14, creationDate: '', revisionDate: '', + online: onlineSet.has(row.deviceIdentifier), trusted: true, trustedTokenCount: row.tokenCount, trustedUntil: row.expiresAt ? new Date(row.expiresAt).toISOString() : null, @@ -136,6 +141,9 @@ export async function handleDeleteDevice( await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized); + if (deleted) { + await notifyUserLogout(env, userId, normalized); + } return jsonResponse({ success: deleted }); } @@ -154,6 +162,7 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId: user.securityStamp = generateUUID(); user.updatedAt = new Date().toISOString(); await storage.saveUser(user); + await notifyUserLogout(env, userId, null); return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices }); } diff --git a/src/handlers/notifications.ts b/src/handlers/notifications.ts index 5796b57..a25f6a2 100644 --- a/src/handlers/notifications.ts +++ b/src/handlers/notifications.ts @@ -1,5 +1,5 @@ import { AuthService } from '../services/auth'; -import type { Env } from '../types'; +import type { Env, JWTPayload } from '../types'; import { errorResponse, jsonResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; @@ -13,18 +13,17 @@ function extractAccessToken(request: Request): string | null { return match?.[1]?.trim() || null; } -async function authenticateNotificationsRequest(request: Request, env: Env): Promise { +async function authenticateNotificationsRequest(request: Request, env: Env): Promise { const accessToken = extractAccessToken(request); if (!accessToken) return null; const auth = new AuthService(env); - const payload = await auth.verifyAccessToken(`Bearer ${accessToken}`); - return payload?.sub || null; + return auth.verifyAccessToken(`Bearer ${accessToken}`); } export async function handleNotificationsNegotiate(request: Request, env: Env): Promise { - const userId = await authenticateNotificationsRequest(request, env); - if (!userId) return errorResponse('Unauthorized', 401); + const payload = await authenticateNotificationsRequest(request, env); + if (!payload?.sub) return errorResponse('Unauthorized', 401); const connectionId = generateUUID(); return jsonResponse({ @@ -41,21 +40,19 @@ export async function handleNotificationsNegotiate(request: Request, env: Env): } export async function handleNotificationsHub(request: Request, env: Env): Promise { - const userId = await authenticateNotificationsRequest(request, env); - if (!userId) return errorResponse('Unauthorized', 401); + const payload = await authenticateNotificationsRequest(request, env); + if (!payload?.sub) return errorResponse('Unauthorized', 401); if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') { return errorResponse('Expected websocket', 426); } + const userId = payload.sub; const id = env.NOTIFICATIONS_HUB.idFromName(userId); const stub = env.NOTIFICATIONS_HUB.get(id); - await stub.fetch('https://notifications/internal/bind-user', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-NodeWarden-UserId': userId, - }, - body: JSON.stringify({ userId }), - }); - return stub.fetch(request); + const forwardedUrl = new URL(request.url); + forwardedUrl.searchParams.set('nw_uid', userId); + if (payload.did) { + forwardedUrl.searchParams.set('nw_did', payload.did); + } + return stub.fetch(new Request(forwardedUrl.toString(), request)); } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 18bc1eb..c356b5b 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { Link, Route, Switch, useLocation } from 'wouter'; import { useQuery } from '@tanstack/react-query'; -import { ArrowUpDown, Cloud, Clock3, Folder, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; +import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import AuthViews from '@/components/AuthViews'; import ConfirmDialog from '@/components/ConfirmDialog'; import ToastHost from '@/components/ToastHost'; @@ -87,7 +87,7 @@ import { } from '@/lib/export-formats'; import { t } from '@/lib/i18n'; import type { CiphersImportPayload } from '@/lib/api'; -import type { AppPhase, AuthorizedDevice, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; +import type { AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; interface PendingTotp { email: string; @@ -296,6 +296,9 @@ function buildPublicSendUrl(origin: string, accessId: string, keyPart: string): } const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e); +const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; +const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; +const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; interface WebVaultSignalRInvocation { type?: number; @@ -371,11 +374,12 @@ export default function App() { const [toasts, setToasts] = useState([]); const [mobileLayout, setMobileLayout] = useState(false); - const [decryptedFolders, setDecryptedFolders] = useState([]); + const [decryptedFolders, setDecryptedFolders] = useState([]); const [decryptedCiphers, setDecryptedCiphers] = useState([]); const [decryptedSends, setDecryptedSends] = useState([]); const migratedPlainFolderIdsRef = useRef>(new Set()); const silentRefreshVaultRef = useRef<() => Promise>(async () => {}); + const refreshAuthorizedDevicesRef = useRef<() => Promise>(async () => {}); useEffect(() => { const syncInviteFromUrl = () => { @@ -1031,6 +1035,7 @@ export default function App() { socket.addEventListener('open', () => { reconnectAttempts = 0; + void refreshAuthorizedDevicesRef.current(); try { socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`); } catch { @@ -1045,6 +1050,16 @@ export default function App() { const frames = parseSignalRTextFrames(event.data); for (const frame of frames) { if (frame.type !== 1 || frame.target !== 'ReceiveMessage') continue; + const updateType = Number(frame.arguments?.[0]?.Type || 0); + if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) { + logoutNow(); + return; + } + if (updateType === SIGNALR_UPDATE_TYPE_DEVICE_STATUS) { + void refreshAuthorizedDevicesRef.current(); + continue; + } + if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue; const contextId = String(frame.arguments?.[0]?.ContextId || '').trim(); if (contextId && contextId === getCurrentDeviceIdentifier()) continue; void silentRefreshVaultRef.current(); @@ -1053,6 +1068,7 @@ export default function App() { socket.addEventListener('close', () => { socket = null; + void refreshAuthorizedDevicesRef.current(); scheduleReconnect(); }); @@ -1084,6 +1100,8 @@ export default function App() { await authorizedDevicesQuery.refetch(); } + refreshAuthorizedDevicesRef.current = refreshAuthorizedDevices; + async function revokeDeviceTrustAction(device: AuthorizedDevice) { await revokeAuthorizedDeviceTrust(authedFetch, device.identifier); await authorizedDevicesQuery.refetch(); @@ -1751,7 +1769,8 @@ export default function App() { } function downloadBytesAsFile(bytes: Uint8Array, fileName: string, mimeType: string) { - const blob = new Blob([bytes], { type: mimeType || 'application/octet-stream' }); + const payload = bytes.slice(); + const blob = new Blob([payload], { type: mimeType || 'application/octet-stream' }); const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = objectUrl; @@ -1967,7 +1986,7 @@ export default function App() { title={sidebarToggleTitle} onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))} > - + )}