Add official Bitwarden resource sync notifications

This commit is contained in:
shuaiplus
2026-06-21 15:14:42 +08:00
parent add921b3b3
commit fe0c66c561
6 changed files with 408 additions and 6 deletions
+248 -1
View File
@@ -3,11 +3,21 @@ 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_CIPHER_UPDATE = 0;
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE = 1;
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE = 3;
const SIGNALR_UPDATE_TYPE_SYNC_CIPHERS = 4;
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE = 7;
const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE = 8;
const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE = 9;
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12;
const SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE = 13;
const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
type HubProtocol = 'json' | 'messagepack';
type HubKind = 'user' | 'anonymous-auth-request';
@@ -422,6 +432,243 @@ export function notifyUserVaultSync(
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null));
}
export function notifyUserCiphersSync(
env: Env,
userId: string,
revisionDate: string,
contextId?: string | null
): void {
waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_CIPHERS, revisionDate, contextId ?? null, null));
}
export function notifyUserCipherCreate(
env: Env,
payload: {
userId: string;
cipherId: string;
revisionDate: string;
organizationId?: string | null;
collectionIds?: string[] | null;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.cipherId,
OrganizationId: payload.organizationId ?? null,
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserCipherUpdate(
env: Env,
payload: {
userId: string;
cipherId: string;
revisionDate: string;
organizationId?: string | null;
collectionIds?: string[] | null;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.cipherId,
OrganizationId: payload.organizationId ?? null,
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserCipherDelete(
env: Env,
payload: {
userId: string;
cipherId: string;
revisionDate: string;
organizationId?: string | null;
collectionIds?: string[] | null;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.cipherId,
OrganizationId: payload.organizationId ?? null,
CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserFolderCreate(
env: Env,
payload: {
userId: string;
folderId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.folderId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserFolderUpdate(
env: Env,
payload: {
userId: string;
folderId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.folderId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserFolderDelete(
env: Env,
payload: {
userId: string;
folderId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.folderId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserSendCreate(
env: Env,
payload: {
userId: string;
sendId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.sendId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserSendUpdate(
env: Env,
payload: {
userId: string;
sendId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.sendId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserSendDelete(
env: Env,
payload: {
userId: string;
sendId: string;
revisionDate: string;
contextId?: string | null;
}
): void {
waitUntil(notifyUserUpdate(
env,
payload.userId,
SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE,
payload.revisionDate,
payload.contextId ?? null,
null,
{
UserId: payload.userId,
Id: payload.sendId,
RevisionDate: payload.revisionDate,
}
));
}
export function notifyUserLogout(
env: Env,
userId: string,
+74 -1
View File
@@ -11,7 +11,13 @@ import {
PasswordHistory,
} from '../types';
import { StorageService } from '../services/storage';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import {
notifyUserCipherCreate,
notifyUserCipherDelete,
notifyUserCipherUpdate,
notifyUserCiphersSync,
notifyUserVaultSync,
} from '../durable/notifications-hub';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
@@ -51,6 +57,60 @@ function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
function notifyCipherCreateForRequest(
request: Request,
env: Env,
cipher: Cipher,
revisionDate: string
): void {
notifyUserCipherCreate(env, {
userId: cipher.userId,
cipherId: cipher.id,
revisionDate,
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
collectionIds: Array.isArray((cipher as any).collectionIds)
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
: null,
contextId: readActingDeviceIdentifier(request),
});
}
function notifyCipherUpdateForRequest(
request: Request,
env: Env,
cipher: Cipher,
revisionDate: string
): void {
notifyUserCipherUpdate(env, {
userId: cipher.userId,
cipherId: cipher.id,
revisionDate,
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
collectionIds: Array.isArray((cipher as any).collectionIds)
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
: null,
contextId: readActingDeviceIdentifier(request),
});
}
function notifyCipherDeleteForRequest(
request: Request,
env: Env,
cipher: Cipher,
revisionDate: string
): void {
notifyUserCipherDelete(env, {
userId: cipher.userId,
cipherId: cipher.id,
revisionDate,
organizationId: normalizeOptionalId((cipher as any).organizationId ?? null),
collectionIds: Array.isArray((cipher as any).collectionIds)
? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean)
: null,
contextId: readActingDeviceIdentifier(request),
});
}
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
if (!source || typeof source !== 'object') return { present: false, value: undefined };
for (const key of aliases) {
@@ -815,6 +875,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherCreateForRequest(request, env, cipher, revisionDate);
const responseOptions = cipherResponseOptionsForRequest(request);
return jsonResponse(
@@ -925,6 +986,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
const responseOptions = cipherResponseOptionsForRequest(request);
@@ -949,6 +1011,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', {
id: cipher.id,
type: cipher.type,
@@ -978,6 +1041,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
id,
type: cipher.type,
@@ -1005,6 +1069,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
await storage.deleteCipher(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherDeleteForRequest(request, env, cipher, revisionDate);
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
id,
type: cipher.type,
@@ -1029,6 +1094,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
@@ -1068,6 +1134,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
return jsonResponse(
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
@@ -1144,6 +1211,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse(
@@ -1192,6 +1260,7 @@ export async function handleBulkArchiveCiphers(request: Request, env: Env, userI
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
return buildCipherListResponse(request, storage, userId, ids);
@@ -1216,6 +1285,7 @@ export async function handleBulkUnarchiveCiphers(request: Request, env: Env, use
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
return buildCipherListResponse(request, storage, userId, ids);
@@ -1239,6 +1309,7 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', {
count: body.ids.length,
});
@@ -1265,6 +1336,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
return new Response(null, { status: 204 });
@@ -1301,6 +1373,7 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
if (revisionDate) {
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', {
count: ownedIds.length,
requestedCount: ids.length,
+24 -1
View File
@@ -1,5 +1,10 @@
import { Env, Folder, FolderResponse } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import {
notifyUserFolderCreate,
notifyUserFolderDelete,
notifyUserFolderUpdate,
notifyUserVaultSync,
} from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
@@ -111,6 +116,12 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserFolderCreate(env, {
userId,
folderId: folder.id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
return jsonResponse(folderToResponse(folder), 200);
}
@@ -139,6 +150,12 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
await storage.saveFolder(folder);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserFolderUpdate(env, {
userId,
folderId: folder.id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
return jsonResponse(folderToResponse(folder));
}
@@ -156,6 +173,12 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
await storage.deleteFolder(id, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyUserFolderDelete(env, {
userId,
folderId: id,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
await writeFolderAudit(storage, request, userId, 'folder.delete', {
id,
});
+9
View File
@@ -16,6 +16,9 @@ import {
formatSize,
getAliasedProp,
normalizeEmails,
notifySendCreateForRequest,
notifySendDeleteForRequest,
notifySendUpdateForRequest,
notifyVaultSyncForRequest,
parseDate,
parseFileLength,
@@ -249,6 +252,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
@@ -372,6 +376,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendCreateForRequest(request, env, send.id, userId, revisionDate);
const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) {
return errorResponse('Server configuration error', 500);
@@ -619,6 +624,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
return jsonResponse(sendToResponse(send));
}
@@ -641,6 +647,7 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
await storage.deleteSend(sendId, userId);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendDeleteForRequest(request, env, sendId, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.delete', {
id: sendId,
type: send.type,
@@ -697,6 +704,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.password.remove', {
id: send.id,
type: send.type,
@@ -718,6 +726,7 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
await storage.saveSend(send);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifySendUpdateForRequest(request, env, send.id, userId, revisionDate);
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
id: send.id,
type: send.type,
+51 -1
View File
@@ -1,5 +1,10 @@
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
import { notifyUserVaultSync } from '../durable/notifications-hub';
import {
notifyUserSendCreate,
notifyUserSendDelete,
notifyUserSendUpdate,
notifyUserVaultSync,
} from '../durable/notifications-hub';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { readActingDeviceIdentifier } from '../utils/device';
@@ -18,6 +23,51 @@ export function notifyVaultSyncForRequest(
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
export function notifySendCreateForRequest(
request: Request,
env: Env,
sendId: string,
userId: string,
revisionDate: string
): void {
notifyUserSendCreate(env, {
userId,
sendId,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
}
export function notifySendUpdateForRequest(
request: Request,
env: Env,
sendId: string,
userId: string,
revisionDate: string
): void {
notifyUserSendUpdate(env, {
userId,
sendId,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
}
export function notifySendDeleteForRequest(
request: Request,
env: Env,
sendId: string,
userId: string,
revisionDate: string
): void {
notifyUserSendDelete(env, {
userId,
sendId,
revisionDate,
contextId: readActingDeviceIdentifier(request),
});
}
export 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) {
+2 -2
View File
@@ -136,8 +136,8 @@ const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1';
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;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
type ThemePreference = 'system' | 'light' | 'dark';
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;