fix: preserve vault fields and align cipher compatibility

Preserve linked and unknown encrypted fields in Web edits, reject stale and concurrent saves, and support official bulk delete and restore methods while retaining legacy routes.

Add 20 offline compatibility regressions and update undici to 8.10.2. Verified 51 tests, TypeScript checks, locale validation, Web build and Worker deployment dry run. The unpatched braces advisory remains visible.
This commit is contained in:
shuaiplus committed 2026-10-05 14:25:06 +08:00
1 parent 5f71cb388c
commit 293f5ce196
26 files changed
+456 -25

No files matched your search

+17 -12
View File
@@ -174,7 +174,7 @@ function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: stri
const existingTs = Date.parse(existingUpdatedAt);
const clientTs = Date.parse(clientRevisionDate);
if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false;
return existingTs - clientTs > 1000;
return existingTs > clientTs;
}
function syncCipherComputedAliases(cipher: Cipher): Cipher {
@@ -522,6 +522,7 @@ function normalizeCipherSecureNoteForCompatibility(secureNote: any): CipherSecur
if (!secureNote || typeof secureNote !== 'object') return null;
const type = Number(secureNote?.type ?? secureNote?.Type ?? 0);
return {
...secureNote,
type: Number.isFinite(type) ? type : 0,
};
}
@@ -655,10 +656,6 @@ function readIncomingAttachmentMetadata(source: any): IncomingAttachmentMetadata
return [...merged.values()];
}
function hasIncomingAttachmentMetadata(source: any): boolean {
return readIncomingAttachmentMetadata(source).length > 0;
}
async function syncIncomingAttachmentMetadata(
storage: StorageService,
cipherId: string,
@@ -1048,7 +1045,6 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
const incomingPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData);
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
const preserveRevisionDate =
shouldPreserveRepairableCipherUris(request)
&& (body.preserveRevisionDate === true || cipherData.preserveRevisionDate === true);
@@ -1057,7 +1053,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400);
}
if (!hasAttachmentMigrationMetadata && isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
}
@@ -1065,7 +1061,13 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
const { preserveRevisionDate: _preserveRevisionDate, PreserveRevisionDate: _pascalPreserveRevisionDate, ...cipherDataWithoutFlags } = cipherData;
const {
preserveRevisionDate: _preserveRevisionDate,
PreserveRevisionDate: _pascalPreserveRevisionDate,
lastKnownRevisionDate: _lastKnownRevisionDate,
LastKnownRevisionDate: _pascalLastKnownRevisionDate,
...cipherDataWithoutFlags
} = cipherData;
const cipher: Cipher = {
...existingCipher, // start with all existing stored data (including unknowns)
...cipherDataWithoutFlags, // overlay all client data (including new/unknown fields)
@@ -1076,7 +1078,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
favorite: cipherData.favorite ?? existingCipher.favorite,
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt,
updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date().toISOString(),
updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date(Math.max(Date.now(), Date.parse(existingCipher.updatedAt) + 1)).toISOString(),
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt,
};
@@ -1115,8 +1117,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
if (!folderOk) return errorResponse('Folder not found', 404);
}
if (!(await storage.updateCipherIfUnchanged(cipher, existingCipher.updatedAt))) {
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
}
// Rejected updates must not modify attachment metadata either.
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
@@ -1451,7 +1456,7 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
return new Response(null, { status: 204 });
}
// POST /api/ciphers/restore - Bulk restore
// PUT /api/ciphers/restore (POST retained for older NodeWarden clients)
export async function handleBulkRestoreCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
@@ -1472,7 +1477,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
return new Response(null, { status: 204 });
return buildCipherListResponse(request, storage, userId, body.ids);
}
// POST /api/ciphers/delete-permanent - Bulk permanent delete