feat: implement NotificationsHub for real-time vault sync notifications

- Added NotificationsHub durable object to handle WebSocket connections for vault sync notifications.
- Integrated SignalR protocol for message framing and communication.
- Updated storage service methods to return revision date and user ID for vault sync notifications.
- Enhanced existing handlers (attachments, ciphers, folders, sends, and import) to notify users of vault sync events.
- Created new notifications handler for WebSocket negotiation and binding user IDs.
- Updated frontend to establish WebSocket connection for receiving vault sync notifications.
- Improved CORS headers to support new notification endpoints.
- Bumped wrangler version in package.json to 4.71.0.
This commit is contained in:
shuaiplus
2026-03-09 00:25:34 +08:00
parent 54cf1ff718
commit 899f1004a3
18 changed files with 779 additions and 76 deletions
+365
View File
@@ -0,0 +1,365 @@
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_PING_INTERVAL_MS = 15_000;
type HubProtocol = 'json' | 'messagepack';
interface ConnectionState {
handshakeComplete: boolean;
protocol: HubProtocol;
}
function concatBytes(chunks: Uint8Array[]): Uint8Array {
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
}
return out;
}
function encodeUtf8(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
function encodeMsgPackInteger(value: number): Uint8Array {
const normalized = Math.trunc(value);
if (normalized >= 0 && normalized <= 0x7f) {
return new Uint8Array([normalized]);
}
if (normalized >= 0 && normalized <= 0xff) {
return new Uint8Array([0xcc, normalized]);
}
if (normalized >= 0 && normalized <= 0xffff) {
return new Uint8Array([0xcd, normalized >> 8, normalized & 0xff]);
}
const safe = normalized >>> 0;
return new Uint8Array([
0xce,
(safe >>> 24) & 0xff,
(safe >>> 16) & 0xff,
(safe >>> 8) & 0xff,
safe & 0xff,
]);
}
function encodeMsgPackString(value: string): Uint8Array {
const bytes = encodeUtf8(value);
const len = bytes.length;
if (len < 32) {
return concatBytes([new Uint8Array([0xa0 | len]), bytes]);
}
if (len <= 0xff) {
return concatBytes([new Uint8Array([0xd9, len]), bytes]);
}
return concatBytes([new Uint8Array([0xda, (len >> 8) & 0xff, len & 0xff]), bytes]);
}
function encodeMsgPackTimestamp(date: Date): Uint8Array {
const seconds = BigInt(Math.floor(date.getTime() / 1000));
const nanos = BigInt(date.getMilliseconds()) * 1000000n;
const timestamp = (nanos << 34n) | seconds;
const payload = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
payload[i] = Number((timestamp >> BigInt((7 - i) * 8)) & 0xffn);
}
return concatBytes([new Uint8Array([0xc7, 0x08, 0xff]), payload]);
}
function encodeMsgPackArray(values: unknown[]): Uint8Array {
const items = values.map(encodeMsgPack);
const len = items.length;
const header =
len < 16
? new Uint8Array([0x90 | len])
: new Uint8Array([0xdc, (len >> 8) & 0xff, len & 0xff]);
return concatBytes([header, ...items]);
}
function encodeMsgPackMap(value: Record<string, unknown>): Uint8Array {
const entries = Object.entries(value);
const len = entries.length;
const header =
len < 16
? new Uint8Array([0x80 | len])
: new Uint8Array([0xde, (len >> 8) & 0xff, len & 0xff]);
const chunks: Uint8Array[] = [header];
for (const [key, entryValue] of entries) {
chunks.push(encodeMsgPackString(key), encodeMsgPack(entryValue));
}
return concatBytes(chunks);
}
function encodeMsgPack(value: unknown): Uint8Array {
if (value === null || value === undefined) return new Uint8Array([0xc0]);
if (value instanceof Date) return encodeMsgPackTimestamp(value);
if (typeof value === 'string') return encodeMsgPackString(value);
if (typeof value === 'number') return encodeMsgPackInteger(value);
if (typeof value === 'boolean') return new Uint8Array([value ? 0xc3 : 0xc2]);
if (Array.isArray(value)) return encodeMsgPackArray(value);
if (value instanceof Uint8Array) {
const len = value.length;
if (len <= 0xff) return concatBytes([new Uint8Array([0xc4, len]), value]);
return concatBytes([new Uint8Array([0xc5, (len >> 8) & 0xff, len & 0xff]), value]);
}
return encodeMsgPackMap(value as Record<string, unknown>);
}
function frameSignalRBinary(payload: Uint8Array): Uint8Array {
const len = payload.length;
const prefix: number[] = [];
let value = len;
do {
let current = value & 0x7f;
value >>>= 7;
if (value > 0) current |= 0x80;
prefix.push(current);
} while (value > 0);
return concatBytes([new Uint8Array(prefix), payload]);
}
function buildSignalRJsonInvocation(userId: string, revisionDate: string, contextId: string | null): string {
return JSON.stringify({
type: 1,
target: 'ReceiveMessage',
arguments: [
{
ContextId: contextId,
Type: SIGNALR_UPDATE_TYPE_SYNC_VAULT,
Payload: {
UserId: userId,
Date: revisionDate,
},
},
],
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
}
function buildSignalRJsonPing(): string {
return JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
}
function buildSignalRMessagePackInvocation(userId: string, revisionDate: string, contextId: string | null): Uint8Array {
// SignalR MessagePack hub protocol uses an array-based invocation shape:
// [type, headers, invocationId, target, arguments]
const payload = encodeMsgPack([
1,
{},
null,
'ReceiveMessage',
[
{
ContextId: contextId,
Type: SIGNALR_UPDATE_TYPE_SYNC_VAULT,
Payload: {
UserId: userId,
Date: new Date(revisionDate),
},
},
],
]);
return frameSignalRBinary(payload);
}
function buildSignalRMessagePackPing(): Uint8Array {
return frameSignalRBinary(encodeMsgPack([6]));
}
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
if (typeof data === 'string') return data;
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
}
export class NotificationsHub {
private readonly connections = new Map<WebSocket, ConnectionState>();
private userId = '';
private pingTimer: ReturnType<typeof setInterval> | null = null;
constructor(private readonly state: DurableObjectState, private readonly env: Env) {
void this.state;
void this.env;
}
async fetch(request: Request): Promise<Response> {
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;
} | 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);
return new Response(null, { status: 204 });
}
if (url.pathname !== '/notifications/hub') {
return new Response('Not found', { status: 404 });
}
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return new Response('Expected websocket', { status: 426 });
}
if (!this.userId) {
return new Response('Unauthorized', { status: 401 });
}
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
server.accept();
this.connections.set(server, {
handshakeComplete: false,
protocol: 'messagepack',
});
this.ensurePingLoop();
server.addEventListener('message', (event) => {
void this.handleSocketMessage(server, event.data);
});
server.addEventListener('close', () => {
this.connections.delete(server);
this.stopPingLoopIfIdle();
});
server.addEventListener('error', () => {
this.connections.delete(server);
this.stopPingLoopIfIdle();
try {
server.close(1011, 'Socket error');
} catch {
// ignore close races
}
});
return new Response(null, {
status: 101,
webSocket: client,
});
}
private async handleSocketMessage(socket: WebSocket, rawData: string | ArrayBuffer | ArrayBufferView): Promise<void> {
const connection = this.connections.get(socket);
if (!connection) return;
if (!connection.handshakeComplete) {
const text = decodeIncomingMessage(rawData);
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
for (const frame of frames) {
try {
const handshake = JSON.parse(frame) as { protocol?: string };
const protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
connection.protocol = protocol;
connection.handshakeComplete = true;
socket.send(SIGNALR_HANDSHAKE_ACK);
return;
} catch {
// Ignore malformed pre-handshake payloads.
}
}
return;
}
}
private ensurePingLoop(): void {
if (this.pingTimer !== null) return;
this.pingTimer = setInterval(() => {
this.broadcastPing();
}, SIGNALR_PING_INTERVAL_MS);
}
private stopPingLoopIfIdle(): void {
if (this.connections.size > 0 || this.pingTimer === null) return;
clearInterval(this.pingTimer);
this.pingTimer = null;
}
private broadcastPing(): void {
if (this.connections.size === 0) {
this.stopPingLoopIfIdle();
return;
}
for (const [socket, connection] of this.connections) {
if (!connection.handshakeComplete) continue;
try {
if (connection.protocol === 'json') {
socket.send(buildSignalRJsonPing());
} else {
socket.send(buildSignalRMessagePackPing());
}
} catch {
this.connections.delete(socket);
try {
socket.close(1011, 'Ping send failed');
} catch {
// ignore close races
}
}
}
this.stopPingLoopIfIdle();
}
private broadcastVaultSync(revisionDate: string, contextId: string | null): void {
if (!this.userId || this.connections.size === 0) return;
for (const [socket, connection] of this.connections) {
if (!connection.handshakeComplete) continue;
try {
if (connection.protocol === 'json') {
socket.send(buildSignalRJsonInvocation(this.userId, revisionDate, contextId));
} else {
socket.send(buildSignalRMessagePackInvocation(this.userId, revisionDate, contextId));
}
} catch {
this.connections.delete(socket);
try {
socket.close(1011, 'Notification send failed');
} catch {
// ignore close races
}
}
}
this.stopPingLoopIfIdle();
}
}
export async function notifyUserVaultSync(
env: Env,
userId: string,
revisionDate: string,
contextId?: string | null
): Promise<void> {
try {
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
const stub = env.NOTIFICATIONS_HUB.get(id);
await stub.fetch('https://notifications/internal/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-NodeWarden-UserId': userId,
},
body: JSON.stringify({ revisionDate, contextId: contextId || null }),
});
} catch (error) {
console.error('Failed to broadcast vault sync notification:', error);
}
}
+23 -3
View File
@@ -1,10 +1,12 @@
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers';
import { LIMITS } from '../config/limits';
import { readActingDeviceIdentifier } from '../utils/device';
import {
deleteBlobObject,
getAttachmentObjectKey,
@@ -13,6 +15,15 @@ import {
putBlobObject,
} from '../services/blob-store';
async function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): Promise<void> {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
// Format file size to human readable
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Bytes`;
@@ -73,7 +84,10 @@ export async function handleCreateAttachment(
await storage.addAttachmentToCipher(cipherId, attachmentId);
// Update cipher revision date
await storage.updateCipherRevisionDate(cipherId);
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
// Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId);
@@ -165,7 +179,10 @@ export async function handleUploadAttachment(
}
// Update cipher revision date
await storage.updateCipherRevisionDate(cipherId);
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
return new Response(null, { status: 200 });
}
@@ -304,7 +321,10 @@ export async function handleDeleteAttachment(
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
// Update cipher revision date
await storage.updateCipherRevisionDate(cipherId);
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
if (revisionInfo) {
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
}
// Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId);
+29 -8
View File
@@ -1,9 +1,20 @@
import { Env, Cipher, CipherResponse, Attachment } from '../types';
import { StorageService } from '../services/storage';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher } from './attachments';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { readActingDeviceIdentifier } from '../utils/device';
async function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): Promise<void> {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
if (!source || typeof source !== 'object') return { present: false, value: undefined };
@@ -276,7 +287,8 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
}
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [], {
@@ -342,7 +354,8 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
}
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [], {
@@ -364,7 +377,8 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
cipher.deletedAt = new Date().toISOString();
cipher.updatedAt = cipher.deletedAt;
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [], {
@@ -389,7 +403,8 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
if (cipher.deletedAt) {
await deleteAllAttachmentsForCipher(env, id);
await storage.deleteCipher(id, userId);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 });
}
@@ -409,7 +424,8 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
await deleteAllAttachmentsForCipher(env, id);
await storage.deleteCipher(id, userId);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 });
}
@@ -426,7 +442,8 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
cipher.deletedAt = null;
cipher.updatedAt = new Date().toISOString();
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [], {
@@ -464,7 +481,8 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
cipher.updatedAt = new Date().toISOString();
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [], {
@@ -493,7 +511,10 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
if (!folderOk) return errorResponse('Folder not found', 404);
}
await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
const revisionDate = await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
if (revisionDate) {
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
}
return new Response(null, { status: 204 });
}
+17 -3
View File
@@ -1,9 +1,20 @@
import { Env, Folder, FolderResponse } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
async function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): Promise<void> {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
// Convert internal folder to API response format
function folderToResponse(folder: Folder): FolderResponse {
return {
@@ -75,7 +86,8 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
};
await storage.saveFolder(folder);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(folderToResponse(folder), 200);
}
@@ -102,7 +114,8 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
folder.updatedAt = new Date().toISOString();
await storage.saveFolder(folder);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(folderToResponse(folder));
}
@@ -118,7 +131,8 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
await storage.clearFolderFromCiphers(userId, id);
await storage.deleteFolder(id, userId);
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 204 });
}
+4 -1
View File
@@ -1,6 +1,8 @@
import { Env, Cipher, Folder, CipherType } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
@@ -268,7 +270,8 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
}
// Update revision date
await storage.updateRevisionDate(userId);
const revisionDate = await storage.updateRevisionDate(userId);
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
if (returnCipherMap) {
return jsonResponse({
+61
View File
@@ -0,0 +1,61 @@
import { AuthService } from '../services/auth';
import type { Env } from '../types';
import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
function extractAccessToken(request: Request): string | null {
const url = new URL(request.url);
const queryToken = String(url.searchParams.get('access_token') || '').trim();
if (queryToken) return queryToken;
const authHeader = String(request.headers.get('Authorization') || '').trim();
const match = authHeader.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || null;
}
async function authenticateNotificationsRequest(request: Request, env: Env): Promise<string | null> {
const accessToken = extractAccessToken(request);
if (!accessToken) return null;
const auth = new AuthService(env);
const payload = await auth.verifyAccessToken(`Bearer ${accessToken}`);
return payload?.sub || null;
}
export async function handleNotificationsNegotiate(request: Request, env: Env): Promise<Response> {
const userId = await authenticateNotificationsRequest(request, env);
if (!userId) return errorResponse('Unauthorized', 401);
const connectionId = generateUUID();
return jsonResponse({
connectionId,
connectionToken: connectionId,
negotiateVersion: 1,
availableTransports: [
{
transport: 'WebSockets',
transferFormats: ['Text', 'Binary'],
},
],
});
}
export async function handleNotificationsHub(request: Request, env: Env): Promise<Response> {
const userId = await authenticateNotificationsRequest(request, env);
if (!userId) return errorResponse('Unauthorized', 401);
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return errorResponse('Expected websocket', 426);
}
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);
}
+33 -11
View File
@@ -1,7 +1,9 @@
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { LIMITS } from '../config/limits';
@@ -23,6 +25,15 @@ const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available';
const SEND_PASSWORD_ITERATIONS = 100_000;
const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
async function notifyVaultSyncForRequest(
request: Request,
env: Env,
userId: string,
revisionDate: string
): Promise<void> {
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
if (!source || typeof source !== 'object') return { present: false, value: undefined };
for (const key of aliases) {
@@ -604,7 +615,8 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
}
await storage.saveSend(send);
await storage.updateRevisionDate(userId);
let revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
@@ -727,7 +739,8 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
}
await storage.saveSend(send);
await storage.updateRevisionDate(userId);
let revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse({
fileUploadType: 0,
@@ -835,7 +848,8 @@ export async function handleUploadSendFile(
return errorResponse('Attachment storage is not configured', 500);
}
await storage.updateRevisionDate(userId);
let revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 200 });
}
@@ -981,7 +995,8 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
await storage.updateRevisionDate(userId);
let revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
@@ -1004,7 +1019,8 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
}
await storage.deleteSend(sendId, userId);
await storage.updateRevisionDate(userId);
let revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return new Response(null, { status: 200 });
}
@@ -1021,7 +1037,8 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
await setSendPassword(send, null);
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
await storage.updateRevisionDate(userId);
let revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
@@ -1039,7 +1056,8 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
send.emails = null;
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
await storage.updateRevisionDate(userId);
let revisionDate = await storage.updateRevisionDate(userId);
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
@@ -1100,7 +1118,8 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
await storage.updateRevisionDate(send.userId);
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
}
const creatorIdentifier = await getCreatorIdentifier(storage, send);
@@ -1173,7 +1192,8 @@ export async function handleAccessSendFile(
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
await storage.updateRevisionDate(send.userId);
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
const token = await createSendFileDownloadToken(send.id, fileId, secret);
const url = new URL(request.url);
@@ -1213,7 +1233,8 @@ export async function handleAccessSendV2(request: Request, env: Env): Promise<Re
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
await storage.updateRevisionDate(send.userId);
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
}
const creatorIdentifier = await getCreatorIdentifier(storage, send);
@@ -1254,7 +1275,8 @@ export async function handleAccessSendFileV2(request: Request, env: Env, fileId:
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
await storage.updateRevisionDate(send.userId);
const revisionDate = await storage.updateRevisionDate(send.userId);
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
const downloadToken = await createSendFileDownloadToken(send.id, fileId, secret);
const url = new URL(request.url);
+3
View File
@@ -1,4 +1,5 @@
import { Env } from './types';
import { NotificationsHub } from './durable/notifications-hub';
import { handleRequest } from './router';
import { StorageService } from './services/storage';
import { applyCors, jsonResponse } from './utils/response';
@@ -54,3 +55,5 @@ export default {
return applyCors(request, resp);
},
};
export { NotificationsHub };
+20 -2
View File
@@ -104,6 +104,10 @@ import {
handleAdminExportBackup,
handleAdminImportBackup,
} from './handlers/backup';
import {
handleNotificationsHub,
handleNotificationsNegotiate,
} from './handlers/notifications';
function isSameOriginWriteRequest(request: Request): boolean {
const targetOrigin = new URL(request.url).origin;
@@ -474,6 +478,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
}
if (path === '/notifications/hub/negotiate' && method === 'POST') {
return handleNotificationsNegotiate(request, env);
}
if (path === '/notifications/hub' && method === 'GET') {
return handleNotificationsHub(request, env);
}
// All other API endpoints require authentication
const auth = new AuthService(env);
const authHeader = request.headers.get('Authorization');
@@ -483,6 +495,13 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return errorResponse('Unauthorized', 401);
}
const actingDeviceId = String(payload.did || '').trim();
if (actingDeviceId) {
const nextHeaders = new Headers(request.headers);
nextHeaders.set('X-NodeWarden-Acting-Device-Id', actingDeviceId);
request = new Request(request, { headers: nextHeaders });
}
const userId = payload.sub;
const storage = new StorageService(env.DB);
const currentUser = await storage.getUserById(userId);
@@ -566,9 +585,8 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleSync(request, env, userId);
}
// Notifications hub (stub): now requires authentication.
if (path.startsWith('/notifications/')) {
return new Response(null, { status: 200 });
return errorResponse('Not found', 404);
}
// Cipher endpoints
+7 -6
View File
@@ -504,8 +504,8 @@ export class StorageService {
});
}
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> {
if (ids.length === 0) return;
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<string | null> {
if (ids.length === 0) return null;
const now = new Date().toISOString();
const uniqueIds = Array.from(new Set(ids));
const patch = JSON.stringify({
@@ -528,7 +528,7 @@ export class StorageService {
.run();
}
await this.updateRevisionDate(userId);
return this.updateRevisionDate(userId);
}
// --- Folders ---
@@ -744,12 +744,13 @@ export class StorageService {
await this.db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
}
async updateCipherRevisionDate(cipherId: string): Promise<void> {
async updateCipherRevisionDate(cipherId: string): Promise<{ userId: string; revisionDate: string } | null> {
const cipher = await this.getCipher(cipherId);
if (!cipher) return;
if (!cipher) return null;
cipher.updatedAt = new Date().toISOString();
await this.saveCipher(cipher);
await this.updateRevisionDate(cipher.userId);
const revisionDate = await this.updateRevisionDate(cipher.userId);
return { userId: cipher.userId, revisionDate };
}
// --- Refresh tokens ---
+1
View File
@@ -1,6 +1,7 @@
// Environment bindings
export interface Env {
DB: D1Database;
NOTIFICATIONS_HUB: DurableObjectNamespace;
// Prefer R2 when available. Optional to support KV-only deployments.
ATTACHMENTS?: R2Bucket;
// Optional fallback for attachment/send file storage (no credit card required).
+4
View File
@@ -72,3 +72,7 @@ export function readKnownDeviceProbe(request: Request): { email: string | null;
return { email, deviceIdentifier };
}
export function readActingDeviceIdentifier(request: Request): string | null {
return normalizeDeviceIdentifier(request.headers.get('X-NodeWarden-Acting-Device-Id'));
}
+28 -2
View File
@@ -1,7 +1,21 @@
import { LIMITS } from '../config/limits';
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version, X-Request-Email, X-Device-Identifier, X-Device-Name';
const DEFAULT_CORS_HEADERS = [
'Content-Type',
'Authorization',
'Accept',
'Device-Type',
'Device-Identifier',
'Device-Name',
'Bitwarden-Client-Name',
'Bitwarden-Client-Version',
'Bitwarden-Package-Type',
'Is-Prerelease',
'X-Request-Email',
'X-Device-Identifier',
'X-Device-Name',
];
function isTrustedClientOrigin(origin: string): boolean {
// Official browser extension / desktop-webview common origins.
@@ -25,9 +39,15 @@ function getAllowedOrigin(request: Request): string | null {
}
function buildCorsHeaders(request: Request): Record<string, string> {
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const allowHeaders = Array.from(new Set([...DEFAULT_CORS_HEADERS, ...requestedHeaders]));
const headers: Record<string, string> = {
'Access-Control-Allow-Methods': CORS_METHODS,
'Access-Control-Allow-Headers': CORS_HEADERS,
'Access-Control-Allow-Headers': allowHeaders.join(', '),
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
};
@@ -44,6 +64,12 @@ export function applyCors(
request: Request,
response: Response
): Response {
// WebSocket upgrade responses must be returned untouched.
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
if (response.status === 101 || webSocket) {
return response;
}
const headers = new Headers(response.headers);
const corsHeaders = buildCorsHeaders(request);
for (const [k, v] of Object.entries(corsHeaders)) {