From cb9ac29cb49303d4a1b8610d5df555b84d2f9032 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 10 Jun 2026 11:49:11 -0700 Subject: [PATCH 1/2] Red: listCollections must drop deleted-collection tombstones /collections/v2 is a sync API: deleted collections remain in the response forever with isDeleted: true, and their /collections/v2/diff endpoint returns HTTP 404. A long-lived account accumulates hundreds of tombstones, so any caller that iterates listCollections() output (backup, backup-metadata) dies on the first one. --- test/client/usage.test.ts | 41 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/test/client/usage.test.ts b/test/client/usage.test.ts index 0c2beeb..800fb32 100644 --- a/test/client/usage.test.ts +++ b/test/client/usage.test.ts @@ -241,6 +241,40 @@ const buildServer = async (): Promise => { updationTime: 1700000000000000, }; + // A DELETED collection. /collections/v2 is a sync API: deleted + // collections stay in the response forever as tombstones with + // isDeleted: true (a long-lived real account accumulates hundreds). + // Their keys still decrypt, but /collections/v2/diff returns + // HTTP 404 for them, so they must never surface from + // listCollections(); a client that naively iterates them dies on + // the first deleted album. + const deletedKey = sodium.crypto_secretbox_keygen(); + const dkNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encDeletedKey = sodium.crypto_secretbox_easy( + deletedKey, + dkNonce, + masterKey, + ); + const deletedNameNonce = sodium.randombytes_buf( + sodium.crypto_secretbox_NONCEBYTES, + ); + const encDeletedName = sodium.crypto_secretbox_easy( + new TextEncoder().encode("Old Album"), + deletedNameNonce, + deletedKey, + ); + const rawDeletedCollection = { + id: 3, + owner: { id: 42 }, + encryptedKey: toBase64(encDeletedKey), + keyDecryptionNonce: toBase64(dkNonce), + encryptedName: toBase64(encDeletedName), + nameDecryptionNonce: toBase64(deletedNameNonce), + type: "album", + updationTime: 1700000000000000, + isDeleted: true, + }; + const rawFile = { id: 100, collectionID: 1, @@ -262,6 +296,8 @@ const buildServer = async (): Promise => { (globalThis as Record).__mockRawCollection = rawCollection; (globalThis as Record).__mockRawSharedCollection = rawSharedCollection; + (globalThis as Record).__mockRawDeletedCollection = + rawDeletedCollection; (globalThis as Record).__mockRawFile = rawFile; return { @@ -341,6 +377,7 @@ const buildMockFetch = (s: ServerState) => { collections: [ g.__mockRawCollection, g.__mockRawSharedCollection, + g.__mockRawDeletedCollection, ], }); } @@ -469,7 +506,11 @@ describe("quak Client usage guide", () => { const collections = await client.listCollections(); + // The mock server also returns a deleted collection (id 3). + // Deleted collections are tombstones in the sync protocol: their + // file diff endpoint 404s, so listCollections must drop them. expect(collections.length).toBe(2); + expect(collections.find((c) => c.id === 3)).toBeUndefined(); const owned = collections.find((c) => c.id === 1)!; expect(owned.name).toBe("Vacation"); From d0b4ee979e24c8362a661d1377afd7115a664da9 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 10 Jun 2026 11:49:45 -0700 Subject: [PATCH 2/2] Green: filter isDeleted tombstones out of listCollections --- src/client.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/client.ts b/src/client.ts index b09402e..a4d14ce 100644 --- a/src/client.ts +++ b/src/client.ts @@ -155,17 +155,21 @@ export class Client { const { collections } = await this.api.getJSON<{ collections: RawCollection[]; }>("/collections/v2", { sinceTime: 0 }); - return collections.map((raw) => - decryptCollection( - raw, - { - masterKey: this.masterKey, - publicKey: this.publicKey, - secretKey: this.secretKey, - }, - this.userID, - ), - ); + // The sync API keeps returning deleted collections as tombstones + // (isDeleted: true); their diff endpoint 404s, so drop them. + return collections + .filter((raw) => !raw.isDeleted) + .map((raw) => + decryptCollection( + raw, + { + masterKey: this.masterKey, + publicKey: this.publicKey, + secretKey: this.secretKey, + }, + this.userID, + ), + ); } async listFiles(