home+native: removal tombstones — deleting a space on home finally sticks (#501)
Removing a saved space on the home page kept coming back: sync never
deletes (by design), so any signed-in iOS/Mac app still holding a copy
re-upserted it on its next push. The removal now leaves a tombstone —
{url, ts} in the blob's top-level `removed` list — that sync honours on
both sides:
- home: delete/destroy write a tombstone; create/enrol clear it (a
deliberate re-add wins); the 409 merge unions tombstones newest-per-
url and drops tombstoned entries.
- native push: skips instances older than their tombstone (and heals
tombstoned vault-only entries a stale client re-pushed); a copy gained
AFTER the removal clears the tombstone vault-wide.
- native pull/sign-in: drops local copies (and the Mac rail entry, which
would otherwise re-extract the webview identity and resurrect the
space); SavedInstance gains addedAt so "before or after the removal"
is decidable.
Tombstones prune at a year / newest 200 so the list can't grow without
bound. Old clients spread-merge unknown top-level keys, so `removed`
survives their writes; the vault self-heals when any updated client
next pushes.
serverlist.js and HomeVault/HomeSync stay mirrored, with the same
tests both sides (serverlist.test.mjs + HomeVaultTests, incl. the
extracted-for-testability HomeSync.patchedVault).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
12 files changed,
+420
−50
home/test/serverlist.test.mjs+57 −0home/web/static/enroll.js+6 −1home/web/static/home.js+18 −1home/web/static/serverlist.js+62 −0ios/LChat/LChatApp.swift+37 −11ios/LChat/Views/SignInWithEleven.swift+3 −0ios/LChatCore/Sources/LChatCore/HomeSync.swift+74 −31ios/LChatCore/Sources/LChatCore/HomeVault.swift+28 −1ios/LChatCore/Sources/LChatCore/Models.swift+9 −1ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift+77 −0ios/LChatMac/LChatMacApp.swift+47 −2ios/LChatMac/MacHomeSignIn.swift+2 −2
diff --git a/home/test/serverlist.test.mjs b/home/test/serverlist.test.mjs| index 3a52562..6e94ed3 100644 |
| --- a/home/test/serverlist.test.mjs |
| +++ b/home/test/serverlist.test.mjs |
| @@ -12,6 +12,11 @@ import { |
| filterServers, |
| fuzzyMatch, |
| soleAdminHold, |
| + addTombstone, |
| + clearTombstone, |
| + tombstonedAt, |
| + mergeRemoved, |
| + pruneTombstones, |
| } from "../web/static/serverlist.js"; |
| let failures = 0; |
| @@ -225,5 +230,57 @@ await test("soleAdminHold engages only on an explicit sole-admin answer (fail-op |
| assert.equal(soleAdminHold(null), false, "no answer at all"); |
| }); |
| +await test("addTombstone upserts newest-per-url without mutating the input", () => { |
| + const removed = [{ url: "a", ts: 100 }]; |
| + const next = addTombstone(removed, "b", 200); |
| + assert.deepEqual( |
| + next.map((t) => t.url).sort(), |
| + ["a", "b"], |
| + ); |
| + assert.equal(removed.length, 1, "input untouched"); |
| + const bumped = addTombstone(next, "a", 300); |
| + assert.equal(tombstonedAt(bumped, "a"), 300, "re-removal keeps the newest ts"); |
| + assert.equal(bumped.filter((t) => t.url === "a").length, 1, "one tombstone per url"); |
| +}); |
| + |
| +await test("clearTombstone removes the url; an emptied list drops out entirely", () => { |
| + const removed = [{ url: "a", ts: 100 }, { url: "b", ts: 200 }]; |
| + assert.deepEqual(clearTombstone(removed, "a").map((t) => t.url), ["b"]); |
| + assert.equal(clearTombstone([{ url: "a", ts: 100 }], "a"), undefined, |
| + "empty list → undefined, so the blob key disappears"); |
| +}); |
| + |
| +await test("tombstonedAt returns the removal time, 0 when absent or malformed", () => { |
| + assert.equal(tombstonedAt([{ url: "a", ts: 42 }], "a"), 42); |
| + assert.equal(tombstonedAt([{ url: "a", ts: 42 }], "b"), 0); |
| + assert.equal(tombstonedAt(null, "a"), 0); |
| + assert.equal(tombstonedAt([{ url: "a", ts: "soon" }], "a"), 0, "malformed ts reads as never"); |
| +}); |
| + |
| +await test("mergeRemoved unions two lists, newest tombstone per url", () => { |
| + const merged = mergeRemoved( |
| + [{ url: "a", ts: 100 }, { url: "b", ts: 500 }], |
| + [{ url: "a", ts: 300 }, { url: "c", ts: 200 }], |
| + ); |
| + assert.equal(tombstonedAt(merged, "a"), 300, "newer side wins"); |
| + assert.equal(tombstonedAt(merged, "b"), 500); |
| + assert.equal(tombstonedAt(merged, "c"), 200); |
| + assert.equal(merged.length, 3); |
| +}); |
| + |
| +await test("pruneTombstones drops year-old entries and caps the list at the newest 200", () => { |
| + const now = 1_000_000_000_000; |
| + const year = 365 * 24 * 60 * 60 * 1000; |
| + const fresh = { url: "fresh", ts: now - 1000 }; |
| + const stale = { url: "stale", ts: now - year - 1 }; |
| + assert.deepEqual(pruneTombstones([fresh, stale], now), [fresh]); |
| + |
| + const many = Array.from({ length: 250 }, (_, i) => ({ url: "u" + i, ts: now - i })); |
| + const capped = pruneTombstones(many, now); |
| + assert.equal(capped.length, 200); |
| + assert.equal(tombstonedAt(capped, "u0"), now, "newest kept"); |
| + assert.equal(tombstonedAt(capped, "u249"), 0, "oldest dropped"); |
| +}); |
| + |
| console.log(failures ? `\n${failures} FAILED` : "\nALL PASSED"); |
| process.exit(failures ? 1 : 0); |
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js| index a930c7b..9f47a9e 100644 |
| --- a/home/web/static/enroll.js |
| +++ b/home/web/static/enroll.js |
| @@ -18,6 +18,7 @@ import { |
| sealBlob, unpackBlob, openBlob, |
| } from "/static/crypto.js"; |
| import { beginRegister, beginLogin, runRegister, runLogin, fetchBlob, putBlob } from "/static/backup-core.js"; |
| +import { clearTombstone } from "/static/serverlist.js"; |
| const $ = (id) => document.getElementById(id); |
| function setStatus(msg, kind) { |
| @@ -214,9 +215,13 @@ async function addWithLogin({ token, credId, prf }) { |
| } |
| } |
| - // Add the entry and save, retrying once on a version race. |
| + // Add the entry and save, retrying once on a version race. An explicit save |
| + // beats a removal tombstone (serverlist.js): re-enrolling a space the user |
| + // deleted on the home page must actually bring it back. |
| for (let attempt = 0; attempt < 2; attempt++) { |
| const obj = { ...opened.obj, servers: upsert(opened.obj.servers, payload.entry) }; |
| + obj.removed = clearTombstone(obj.removed, payload.entry.url); |
| + if (!obj.removed) delete obj.removed; |
| const sealed = await sealBlob(obj, dek, opened.keys); |
| const v = await putBlob(token, sealed, cur.version); |
| if (v !== null) return succeed(); |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 0a23fc1..4c88174 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -22,6 +22,7 @@ import { |
| import { |
| partitionServers, partitionArchived, setArchived, |
| toggleStar, moveInGroup, orderServers, filterServers, soleAdminHold, |
| + addTombstone, clearTombstone, mergeRemoved, |
| } from "/static/serverlist.js"; |
| import { mountFader } from "/static/fader.js"; |
| import { startHomeLoader, finishHomeLoader } from "/static/bootscene.js"; |
| @@ -106,7 +107,18 @@ async function saveVault() { |
| const opened = await openBlob(cur.bytes, dekKey); |
| const merged = new Map(opened.obj.servers.map((s) => [s.url, s])); |
| for (const s of vault.obj.servers) merged.set(s.url, s); |
| - vault = { obj: { ...opened.obj, servers: [...merged.values()] }, keys: opened.keys, version: cur.version }; |
| + // Tombstones union newest-per-url, except where OUR list still holds the |
| + // entry — this page's list is the user's current intent, so a removal that |
| + // raced in from elsewhere loses to it (and an entry we just deleted here |
| + // keeps its tombstone and wins over the other writer's copy). |
| + const removed = mergeRemoved(opened.obj.removed, vault.obj.removed) |
| + .filter((t) => !vault.obj.servers.some((s) => s.url === t.url)); |
| + const obj = { |
| + ...opened.obj, |
| + servers: [...merged.values()].filter((s) => !removed.some((t) => t.url === s.url)), |
| + }; |
| + if (removed.length) obj.removed = removed; else delete obj.removed; |
| + vault = { obj, keys: opened.keys, version: cur.version }; |
| } |
| throw new Error("could not save after retry"); |
| } |
| @@ -574,6 +586,7 @@ async function requestSpace() { |
| const host = new URL(url).host; |
| vault.obj.servers = (vault.obj.servers || []).filter((s) => s.url !== url); |
| vault.obj.servers.push({ url, name: host }); |
| + vault.obj.removed = clearTombstone(vault.obj.removed, url); // a deliberate add wins |
| try { |
| await saveVault(); |
| } catch { |
| @@ -925,6 +938,9 @@ async function deleteAccess() { |
| if (!archiving || sternSoleAdmin) return; // the last-admin hold — see updateSternHold |
| const s = archiving; |
| vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== s.url); |
| + // The tombstone makes the removal STICK: signed-in devices drop their copy |
| + // and stop re-pushing it, instead of resurrecting the entry on next sync. |
| + vault.obj.removed = addTombstone(vault.obj.removed, s.url, Date.now()); |
| archiveStatus("Deleting…", "busy"); |
| try { |
| await saveVault(); |
| @@ -966,6 +982,7 @@ async function destroyForEveryone() { |
| throw new Error(body.message || body.error || `Couldn't delete (HTTP ${res.status}).`); |
| } |
| vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== s.url); |
| + vault.obj.removed = addTombstone(vault.obj.removed, s.url, Date.now()); |
| try { await saveVault(); } catch { /* the space is gone; the entry retries next save */ } |
| afterDelete("Space destroyed."); |
| } catch (e) { |
diff --git a/home/web/static/serverlist.js b/home/web/static/serverlist.js| index c1cde52..443f4a8 100644 |
| --- a/home/web/static/serverlist.js |
| +++ b/home/web/static/serverlist.js |
| @@ -130,6 +130,68 @@ export function moveInGroup(servers, url, dir) { |
| return arr; |
| } |
| +// ---- removal tombstones --------------------------------------------------- |
| +// |
| +// Deleting a space on the home page must STICK. Sync never deletes (by design — |
| +// see ios HomeSync), so every signed-in device happily re-upserts any server it |
| +// still holds; a bare removal from `servers` comes back on the next push. A |
| +// removal therefore also writes a tombstone into the blob's top-level `removed` |
| +// list — { url, ts } (epoch ms, newest wins per url) — which sync consults: |
| +// an entry whose device copy predates its tombstone is dropped and never |
| +// re-pushed, while a space the user deliberately re-added AFTER the removal |
| +// (device addedAt > ts, or an explicit save/create on the web) clears the |
| +// tombstone and wins. Old clients spread-merge unknown top-level keys, so |
| +// tombstones survive their writes even though they don't consult them. |
| +// HomeVault.swift mirrors these helpers — keep them in lockstep. |
| + |
| +const TOMBSTONE_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000; |
| +const TOMBSTONE_MAX = 200; |
| + |
| +// addTombstone returns a NEW removed list with { url, ts } upserted (newest |
| +// wins per url) and pruned. The input list is never mutated. |
| +export function addTombstone(removed, url, ts) { |
| + return pruneTombstones([ |
| + ...(removed || []).filter((t) => t && t.url !== url), |
| + { url, ts }, |
| + ], ts); |
| +} |
| + |
| +// clearTombstone returns a NEW removed list without `url` — the deliberate |
| +// re-add path. Returns undefined when nothing is left, so the key drops out of |
| +// the blob instead of lingering as []. |
| +export function clearTombstone(removed, url) { |
| + const list = (removed || []).filter((t) => t && t.url !== url); |
| + return list.length ? list : undefined; |
| +} |
| + |
| +// tombstonedAt returns when `url` was removed (epoch ms), or 0 if it wasn't. |
| +export function tombstonedAt(removed, url) { |
| + const t = (removed || []).find((t) => t && t.url === url); |
| + return t && Number.isFinite(t.ts) ? t.ts : 0; |
| +} |
| + |
| +// mergeRemoved unions two removed lists, newest tombstone per url — the merge |
| +// rule for version races (two writers, each with its own removals). |
| +export function mergeRemoved(a, b) { |
| + const byUrl = new Map(); |
| + for (const t of [...(a || []), ...(b || [])]) { |
| + if (!t || !t.url) continue; |
| + const prev = byUrl.get(t.url); |
| + if (!prev || (t.ts || 0) > (prev.ts || 0)) byUrl.set(t.url, t); |
| + } |
| + return [...byUrl.values()]; |
| +} |
| + |
| +// pruneTombstones caps the list so it can't grow unboundedly across years of |
| +// use: entries older than a year go (any device that hasn't synced in a year |
| +// re-learns the space at worst), and the newest TOMBSTONE_MAX are kept. |
| +export function pruneTombstones(removed, now) { |
| + return (removed || []) |
| + .filter((t) => t && t.url && now - (t.ts || 0) < TOMBSTONE_MAX_AGE_MS) |
| + .sort((x, y) => (y.ts || 0) - (x.ts || 0)) |
| + .slice(0, TOMBSTONE_MAX); |
| +} |
| + |
| // soleAdminHold decides whether "Delete your access" must be held, given a |
| // space's /api/me answer. The hold protects against orphaning: if this key is |
| // the space's ONLY admin key, dropping its last saved copy leaves the space |
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift| index 35ae11b..9af8e3b 100644 |
| --- a/ios/LChat/LChatApp.swift |
| +++ b/ios/LChat/LChatApp.swift |
| @@ -64,8 +64,12 @@ final class AppState: ObservableObject { |
| } |
| } |
| - /// Add a new instance, or replace one with the same id (re-join). |
| + /// Add a new instance, or replace one with the same id (re-join). Both are |
| + /// deliberate acts, so the entry is stamped addedAt=now (unless the caller |
| + /// set it) — that's what lets it beat an older home-page removal tombstone. |
| func add(_ inst: SavedInstance) { |
| + var inst = inst |
| + if inst.addedAt == nil { inst.addedAt = Date().timeIntervalSince1970 * 1000 } |
| if let i = instances.firstIndex(where: { $0.id == inst.id }) { |
| instances[i] = inst |
| } else { |
| @@ -139,27 +143,49 @@ final class AppState: ObservableObject { |
| HomeSync.pushSoon(instances: instances) |
| } |
| - /// Merge a PULLED vault into this device (the foreground sync): spaces |
| - /// signed into elsewhere are added (their credentials ride the vault), |
| - /// then the vault's stars/archive/names/order apply — the same vault-wins |
| - /// rule as sign-in. No push back: the device state now equals the vault. |
| - func merge(vault servers: [HomeVault.Server]) { |
| - for s in servers { |
| + /// Merge a PULLED vault into this device (the foreground sync): removals |
| + /// made on the home page apply first (tombstones drop our stale copies), |
| + /// spaces signed into elsewhere are added (their credentials ride the |
| + /// vault), then the vault's stars/archive/names/order apply — the same |
| + /// vault-wins rule as sign-in. No push back: the device state now equals |
| + /// the vault. |
| + func merge(vault contents: HomeVault.Contents) { |
| + dropTombstoned(contents.removed) |
| + for s in contents.servers { |
| guard let creds = s.credentials, |
| - !instances.contains(where: { $0.origin == s.url }) else { continue } |
| + !instances.contains(where: { $0.origin == s.url }), |
| + // An entry AND a tombstone (a stale client re-pushed after the |
| + // removal): adding it would stamp addedAt=now, and the next |
| + // push would read that as a deliberate re-add and clear the |
| + // tombstone vault-wide. Let the vault heal instead. |
| + HomeVault.tombstonedAt(contents.removed, url: s.url) == 0 else { continue } |
| instances.append(SavedInstance( |
| origin: s.url, deviceToken: creds.token, |
| screenName: creds.name ?? s.name ?? "", |
| userPub: creds.userPub, userPriv: creds.userPriv, |
| groupName: s.name ?? "", |
| starred: s.isStarred ? true : nil, |
| - archived: s.isArchived ? true : nil)) |
| + archived: s.isArchived ? true : nil, |
| + addedAt: Date().timeIntervalSince1970 * 1000)) |
| } |
| - applyVault(servers.map { |
| + applyVault(contents.servers.map { |
| (url: $0.url, starred: $0.isStarred, archived: $0.isArchived, name: $0.name) |
| }) |
| } |
| + /// Apply home-page removals to this device: drop any instance whose vault |
| + /// tombstone is newer than when the device gained it. In-memory + persist |
| + /// only — deliberately no push (push independently skips tombstoned |
| + /// entries, so there's nothing to write back). |
| + func dropTombstoned(_ removed: [HomeVault.Tombstone]?) { |
| + guard let removed, !removed.isEmpty else { return } |
| + let before = instances.count |
| + instances.removeAll { |
| + HomeVault.tombstonedAt(removed, url: $0.origin) > ($0.addedAt ?? 0) |
| + } |
| + if instances.count != before { InstancesStore.save(instances) } |
| + } |
| + |
| /// Archive or unarchive a space: hidden from the drawer's main lists, |
| /// grouped under Archived. Repositions with the home vault's setArchived |
| /// semantics so the array order stays in lockstep: archiving drops the star |
| @@ -394,7 +420,7 @@ struct RootView: View { |
| // The silent vault pull (no-op when signed out or rate-limited). |
| private func pullHome() { |
| Task { |
| - if let contents = await HomeSync.pull() { app.merge(vault: contents.servers) } |
| + if let contents = await HomeSync.pull() { app.merge(vault: contents) } |
| } |
| } |
diff --git a/ios/LChat/Views/SignInWithEleven.swift b/ios/LChat/Views/SignInWithEleven.swift| index 4de35a1..8727f44 100644 |
| --- a/ios/LChat/Views/SignInWithEleven.swift |
| +++ b/ios/LChat/Views/SignInWithEleven.swift |
| @@ -169,6 +169,9 @@ final class HomeSignInModel: ObservableObject { |
| HomeSessionStore.save(HomeSession(token: result.token, |
| credentialID: result.credentialID, |
| dek: opened.dek)) |
| + // Removals made on the home page win over stale device copies — apply |
| + // their tombstones before anything flows back up. |
| + app.dropTombstoned(opened.contents.removed) |
| var added = 0 |
| for server in opened.contents.servers { // array order is the master order |
| // A credential-less entry (a space saved by URL alone on the home |
diff --git a/ios/LChatCore/Sources/LChatCore/HomeSync.swift b/ios/LChatCore/Sources/LChatCore/HomeSync.swift| index 5260502..4237601 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/HomeSync.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/HomeSync.swift |
| @@ -21,6 +21,11 @@ import Foundation |
| // - Nothing is ever deleted from the vault by sync. Removing a space from the |
| // backup is an explicit act on the home page, not a side effect of removing |
| // it from one device. |
| +// - But a removal made THERE must stick: the home page leaves a tombstone |
| +// (HomeVault.Tombstone), which push honours (never re-upsert a copy older |
| +// than its tombstone; a deliberate re-add clears it) and pull applies (drop |
| +// the local copy). Without this, any signed-in device resurrected deleted |
| +// entries on its next sync. |
| public struct HomeSession: Codable { |
| public var token: String // home link token — transport auth, NOT a key |
| public var credentialID: String // which enrolled passkey wraps our DEK |
| @@ -96,38 +101,10 @@ public enum HomeSync { |
| guard let dek = session.dek else { return false } |
| for _ in 0..<2 { |
| guard let fetch = try? await client.fetchBlob(token: session.token), |
| - var obj = try? HomeVault.openRawObject(fetch.bytes, dek: dek) else { return false } |
| - |
| - let existing = (obj["servers"] as? [[String: Any]]) ?? [] |
| - var byUrl: [String: [String: Any]] = [:] |
| - for e in existing { if let u = e["url"] as? String { byUrl[u] = e } } |
| - |
| - // Device servers first, in device order — each PATCHED onto its |
| - // existing vault entry so unknown fields survive. |
| - var servers: [[String: Any]] = [] |
| - for inst in instances { |
| - var e = byUrl.removeValue(forKey: inst.origin) ?? [:] |
| - e["url"] = inst.origin |
| - // Only write a name we actually know — the device's ignorance |
| - // must not delete one another client stored. |
| - if !inst.groupName.isEmpty { e["name"] = inst.groupName } |
| - if inst.isStarred { e["starred"] = true } else { e.removeValue(forKey: "starred") } |
| - if inst.isArchived { e["archived"] = true } else { e.removeValue(forKey: "archived") } |
| - var creds = (e["credentials"] as? [String: Any]) ?? [:] |
| - creds["token"] = inst.deviceToken |
| - creds["userPub"] = inst.userPub |
| - creds["userPriv"] = inst.userPriv |
| - creds["name"] = inst.screenName |
| - e["credentials"] = creds |
| - servers.append(e) |
| - } |
| - // Vault-only entries survive verbatim, tailed in their relative order. |
| - for e in existing { |
| - if let u = e["url"] as? String, byUrl[u] != nil { servers.append(e) } |
| - } |
| - obj["servers"] = servers |
| + let obj = try? HomeVault.openRawObject(fetch.bytes, dek: dek) else { return false } |
| - guard let sealed = try? HomeVault.sealRawObject(obj, existingEnvelope: fetch.bytes, |
| + let patched = patchedVault(obj, instances: instances) |
| + guard let sealed = try? HomeVault.sealRawObject(patched, existingEnvelope: fetch.bytes, |
| dek: dek) |
| else { return false } |
| if let _ = try? await client.putBlob(token: session.token, bytes: sealed, |
| @@ -138,6 +115,72 @@ public enum HomeSync { |
| } |
| return false |
| } |
| + |
| + /// The pure patch step of push — the device's list upserted into the raw |
| + /// vault object. Split from the network loop so the merge rules (unknown- |
| + /// field round-trip, tombstone skip/clear) are unit-testable. |
| + static func patchedVault(_ obj: [String: Any], instances: [SavedInstance]) -> [String: Any] { |
| + var obj = obj |
| + let existing = (obj["servers"] as? [[String: Any]]) ?? [] |
| + var byUrl: [String: [String: Any]] = [:] |
| + for e in existing { if let u = e["url"] as? String { byUrl[u] = e } } |
| + |
| + // Removal tombstones (written by the home page — see HomeVault |
| + // .Tombstone): a server the user deleted there must NOT be |
| + // re-upserted just because this device still holds a copy. Only a |
| + // copy gained AFTER the removal (a deliberate re-add) wins — and |
| + // clears the tombstone so every other client follows. |
| + var removed = (obj["removed"] as? [[String: Any]]) ?? [] |
| + func tombstoneTS(_ url: String) -> Double { |
| + for t in removed where (t["url"] as? String) == url { |
| + return (t["ts"] as? NSNumber)?.doubleValue ?? 0 |
| + } |
| + return 0 |
| + } |
| + |
| + // Device servers first, in device order — each PATCHED onto its |
| + // existing vault entry so unknown fields survive. |
| + var servers: [[String: Any]] = [] |
| + for inst in instances { |
| + let ts = tombstoneTS(inst.origin) |
| + if ts > 0 { |
| + if (inst.addedAt ?? 0) > ts { |
| + removed.removeAll { ($0["url"] as? String) == inst.origin } |
| + } else { |
| + // Deleted on the home page after we got it — skip (the |
| + // next pull drops our local copy too). Consume any |
| + // vault entry so the tail below can't resurrect it. |
| + byUrl.removeValue(forKey: inst.origin) |
| + continue |
| + } |
| + } |
| + var e = byUrl.removeValue(forKey: inst.origin) ?? [:] |
| + e["url"] = inst.origin |
| + // Only write a name we actually know — the device's ignorance |
| + // must not delete one another client stored. |
| + if !inst.groupName.isEmpty { e["name"] = inst.groupName } |
| + if inst.isStarred { e["starred"] = true } else { e.removeValue(forKey: "starred") } |
| + if inst.isArchived { e["archived"] = true } else { e.removeValue(forKey: "archived") } |
| + var creds = (e["credentials"] as? [String: Any]) ?? [:] |
| + creds["token"] = inst.deviceToken |
| + creds["userPub"] = inst.userPub |
| + creds["userPriv"] = inst.userPriv |
| + creds["name"] = inst.screenName |
| + e["credentials"] = creds |
| + servers.append(e) |
| + } |
| + // Vault-only entries survive verbatim, tailed in their relative |
| + // order — except tombstoned ones, which an old client re-pushed |
| + // after the removal; dropping them here heals the vault. |
| + for e in existing { |
| + guard let u = e["url"] as? String, byUrl[u] != nil else { continue } |
| + if tombstoneTS(u) > 0 { continue } |
| + servers.append(e) |
| + } |
| + obj["servers"] = servers |
| + if removed.isEmpty { obj.removeValue(forKey: "removed") } else { obj["removed"] = removed } |
| + return obj |
| + } |
| } |
| private extension Task where Success == Void, Failure == Never { |
diff --git a/ios/LChatCore/Sources/LChatCore/HomeVault.swift b/ios/LChatCore/Sources/LChatCore/HomeVault.swift| index b755804..a41b10d 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/HomeVault.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/HomeVault.swift |
| @@ -51,16 +51,43 @@ public struct HomeVault { |
| } |
| } |
| + // A removal made on the home page — { url, ts } (epoch ms, newest wins per |
| + // url), the blob's top-level `removed` list. Sync never deletes, so without |
| + // these every signed-in device re-upserts a deleted server on its next |
| + // push; a tombstone newer than the device copy's addedAt makes the removal |
| + // stick (drop locally, never re-push), while a space deliberately re-added |
| + // AFTER the removal clears its tombstone and wins. serverlist.js is the |
| + // shared contract — keep the two in lockstep. |
| + public struct Tombstone: Codable, Equatable { |
| + public var url: String |
| + public var ts: Double |
| + |
| + public init(url: String, ts: Double) { |
| + self.url = url |
| + self.ts = ts |
| + } |
| + } |
| + |
| public struct Contents: Codable { |
| public var v: Int |
| public var servers: [Server] |
| + // Optional so pre-tombstone blobs decode; nil and [] read the same. |
| + public var removed: [Tombstone]? |
| - public init(v: Int = 1, servers: [Server]) { |
| + public init(v: Int = 1, servers: [Server], removed: [Tombstone]? = nil) { |
| self.v = v |
| self.servers = servers |
| + self.removed = removed |
| } |
| } |
| + /// When `url` was removed on the home page (epoch ms), or 0 if it wasn't — |
| + /// mirrors serverlist.js tombstonedAt. Callers compare against a device |
| + /// copy's addedAt (missing → 0, so pre-feature entries always lose). |
| + public static func tombstonedAt(_ removed: [Tombstone]?, url: String) -> Double { |
| + removed?.first(where: { $0.url == url })?.ts ?? 0 |
| + } |
| + |
| // ---- pure list helpers (mirror serverlist.js — keep in lockstep) -------- |
| /// Split into (starred, others), each preserving its own array order. |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index 2d821f6..3552ac9 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -21,6 +21,13 @@ public struct SavedInstance: Codable, Identifiable, Equatable, Hashable { |
| // Archived spaces hide from the rail AND the overflow list (reachable only |
| // via the "…" menu's Archived group). Same optional/sync shape as starred. |
| public var archived: Bool? |
| + // When this device gained the space (epoch ms) — compared against the home |
| + // vault's removal tombstones: a tombstone newer than addedAt means the space |
| + // was deleted on the home page after we got it, so sync drops it here and |
| + // never re-pushes it; an instance added AFTER the tombstone wins instead. |
| + // Optional so pre-tombstone lists decode; missing reads as 0 (older than |
| + // any tombstone) — exactly right for entries that predate the feature. |
| + public var addedAt: Double? |
| public var isStarred: Bool { starred ?? false } |
| public var isArchived: Bool { archived ?? false } |
| @@ -28,7 +35,7 @@ public struct SavedInstance: Codable, Identifiable, Equatable, Hashable { |
| public init(origin: String, deviceToken: String, screenName: String, |
| userPub: String, userPriv: String, |
| myUserId: String = "", groupName: String = "", starred: Bool? = nil, |
| - archived: Bool? = nil) { |
| + archived: Bool? = nil, addedAt: Double? = nil) { |
| self.origin = origin |
| self.deviceToken = deviceToken |
| self.screenName = screenName |
| @@ -38,6 +45,7 @@ public struct SavedInstance: Codable, Identifiable, Equatable, Hashable { |
| self.groupName = groupName |
| self.starred = starred |
| self.archived = archived |
| + self.addedAt = addedAt |
| } |
| // One identity per instance per device (v1). The origin is the stable handle. |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift b/ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift| index ba020f3..355ea6b 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift |
| @@ -122,6 +122,23 @@ final class HomeVaultTests: XCTestCase { |
| let jsonOn = try JSONEncoder().encode(srv("a", starred: true)) |
| XCTAssertTrue(String(data: jsonOn, encoding: .utf8)!.contains("\"starred\":true")) |
| } |
| + |
| + // ---- removal tombstones (mirrors serverlist.test.mjs) ------------------- |
| + |
| + func testTombstonedAtReadsTsAndToleratesAbsence() { |
| + let removed = [HomeVault.Tombstone(url: "a", ts: 42)] |
| + XCTAssertEqual(HomeVault.tombstonedAt(removed, url: "a"), 42) |
| + XCTAssertEqual(HomeVault.tombstonedAt(removed, url: "b"), 0) |
| + XCTAssertEqual(HomeVault.tombstonedAt(nil, url: "a"), 0) |
| + } |
| + |
| + func testContentsDecodesWithAndWithoutRemoved() throws { |
| + let legacy = #"{"v":1,"servers":[]}"#.data(using: .utf8)! |
| + XCTAssertNil(try JSONDecoder().decode(HomeVault.Contents.self, from: legacy).removed) |
| + let modern = #"{"v":1,"servers":[],"removed":[{"url":"a","ts":100}]}"#.data(using: .utf8)! |
| + let contents = try JSONDecoder().decode(HomeVault.Contents.self, from: modern) |
| + XCTAssertEqual(contents.removed, [HomeVault.Tombstone(url: "a", ts: 100)]) |
| + } |
| } |
| // 🤖 The raw read-modify-write cycle HomeSync.push uses must round-trip fields |
| @@ -163,3 +180,63 @@ extension HomeVaultTests { |
| "credential-less entry must ride through untouched") |
| } |
| } |
| + |
| +// 🤖 HomeSync.patchedVault — the pure merge step of push. The tombstone rules |
| +// are the load-bearing part: a server deleted on the home page must not be |
| +// resurrected by a device that still holds a copy (the original bug), while a |
| +// deliberate re-add (addedAt newer than the tombstone) wins and clears it. |
| +extension HomeVaultTests { |
| + private func inst(_ origin: String, addedAt: Double? = nil) -> SavedInstance { |
| + SavedInstance(origin: origin, deviceToken: "tok", screenName: "Me", |
| + userPub: "pub", userPriv: "priv", addedAt: addedAt) |
| + } |
| + private func serverUrls(_ obj: [String: Any]) -> [String] { |
| + ((obj["servers"] as? [[String: Any]]) ?? []).compactMap { $0["url"] as? String } |
| + } |
| + |
| + func testPushSkipsTombstonedServerOlderThanRemoval() { |
| + // Device copy predates the removal (no addedAt = 0) → never re-pushed, |
| + // and the tombstone survives for other devices to see. |
| + let vault: [String: Any] = [ |
| + "v": 1, "servers": [], |
| + "removed": [["url": "https://gone.example", "ts": 1000.0]], |
| + ] |
| + let out = HomeSync.patchedVault(vault, instances: [inst("https://gone.example")]) |
| + XCTAssertEqual(serverUrls(out), [], "deleted server must not come back") |
| + XCTAssertEqual((out["removed"] as? [[String: Any]])?.count, 1, "tombstone kept") |
| + } |
| + |
| + func testPushReAddAfterRemovalWinsAndClearsTombstone() { |
| + let vault: [String: Any] = [ |
| + "v": 1, "servers": [], |
| + "removed": [["url": "https://back.example", "ts": 1000.0]], |
| + ] |
| + let out = HomeSync.patchedVault(vault, instances: [inst("https://back.example", addedAt: 2000)]) |
| + XCTAssertEqual(serverUrls(out), ["https://back.example"], "deliberate re-add wins") |
| + XCTAssertNil(out["removed"], "its tombstone is cleared (and the empty list dropped)") |
| + } |
| + |
| + func testPushHealsTombstonedVaultOnlyEntry() { |
| + // A stale client re-pushed a removed server this device doesn't hold: |
| + // the tail must not preserve it verbatim. |
| + let vault: [String: Any] = [ |
| + "v": 1, |
| + "servers": [["url": "https://zombie.example", "name": "Zombie"]], |
| + "removed": [["url": "https://zombie.example", "ts": 1000.0]], |
| + ] |
| + let out = HomeSync.patchedVault(vault, instances: [inst("https://kept.example", addedAt: 500)]) |
| + XCTAssertEqual(serverUrls(out), ["https://kept.example"]) |
| + XCTAssertEqual((out["removed"] as? [[String: Any]])?.count, 1, "tombstone kept for others") |
| + } |
| + |
| + func testPushWithoutTombstonesKeepsUpsertBehaviour() { |
| + let vault: [String: Any] = [ |
| + "v": 1, |
| + "servers": [["url": "https://other.example", "name": "Elsewhere"]], |
| + ] |
| + let out = HomeSync.patchedVault(vault, instances: [inst("https://mine.example", addedAt: 1)]) |
| + XCTAssertEqual(serverUrls(out), ["https://mine.example", "https://other.example"], |
| + "device order first, vault-only entries tail") |
| + XCTAssertNil(out["removed"]) |
| + } |
| +} |
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift| index 7678838..eb0274b 100644 |
| --- a/ios/LChatMac/LChatMacApp.swift |
| +++ b/ios/LChatMac/LChatMacApp.swift |
| @@ -281,9 +281,16 @@ final class MacAppState: ObservableObject { |
| // one silently reverts it on each page load (starred was cleared |
| // this way, un-starring the space everywhere via the vault sync). |
| inst.starred = instances[i].starred |
| + inst.archived = instances[i].archived |
| + // Especially addedAt: a page-load extraction is not a deliberate |
| + // add, so it must not re-arm the entry against a removal tombstone. |
| + inst.addedAt = instances[i].addedAt |
| guard instances[i] != inst else { return } |
| instances[i] = inst |
| } else { |
| + // A first extraction IS the join on this Mac — stamp it so the |
| + // entry beats any older home-page removal tombstone. |
| + inst.addedAt = Date().timeIntervalSince1970 * 1000 |
| instances.append(inst) |
| } |
| InstancesStore.save(instances) |
| @@ -390,7 +397,38 @@ final class MacAppState: ObservableObject { |
| /// is the same vault-wins merge sign-in uses. |
| func pullHome() { |
| Task { @MainActor in |
| - if let contents = await HomeSync.pull() { _ = applyVault(contents.servers) } |
| + if let contents = await HomeSync.pull() { |
| + _ = applyVault(contents.servers, removed: contents.removed) |
| + } |
| + } |
| + } |
| + |
| + /// Apply home-page removals: drop any instance whose vault tombstone is |
| + /// newer than when this Mac gained it — AND its rail entry, because a |
| + /// lingering SavedChat would re-extract the webview's identity on next |
| + /// open (noteIdentity stamps addedAt=now) and resurrect the space. Chats |
| + /// with no native identity fall to the same rule (no addedAt to defend |
| + /// them — the account says the space is gone). |
| + func dropTombstoned(_ removed: [HomeVault.Tombstone]?) { |
| + guard let removed, !removed.isEmpty else { return } |
| + var dropped = false |
| + instances.removeAll { inst in |
| + let gone = HomeVault.tombstonedAt(removed, url: inst.origin) > (inst.addedAt ?? 0) |
| + if gone { dropped = true } |
| + return gone |
| + } |
| + chats.removeAll { chat in |
| + guard let origin = chat.origin?.absoluteString, |
| + HomeVault.tombstonedAt(removed, url: origin) > 0, |
| + !instances.contains(where: { $0.origin == origin }) else { return false } |
| + dropped = true |
| + return true |
| + } |
| + guard dropped else { return } |
| + InstancesStore.save(instances) |
| + MacChatsStore.save(chats) |
| + if let sel = selection, !chats.contains(where: { $0.id == sel }) { |
| + selection = chats.first?.id |
| } |
| } |
| @@ -399,10 +437,15 @@ final class MacAppState: ObservableObject { |
| /// order to the rail (Mac-only chats tail). Returns how many servers were |
| /// NEW to this Mac. The caller pushes after, so the write-back reflects |
| /// the merged state (the iOS data-loss lesson). |
| - func applyVault(_ servers: [HomeVault.Server]) -> Int { |
| + func applyVault(_ servers: [HomeVault.Server], removed: [HomeVault.Tombstone]? = nil) -> Int { |
| + dropTombstoned(removed) |
| var added = 0 |
| for s in servers { |
| guard let creds = s.credentials else { continue } // bookmark entry |
| + // Entry + tombstone at once = a stale client re-pushed a removed |
| + // space; upserting it would re-arm it here (and a fresh addedAt |
| + // would clear the tombstone on push). Let the vault heal instead. |
| + guard HomeVault.tombstonedAt(removed, url: s.url) == 0 else { continue } |
| var inst = SavedInstance(origin: s.url, deviceToken: creds.token, |
| screenName: creds.name ?? s.name ?? "", |
| userPub: creds.userPub, userPriv: creds.userPriv, |
| @@ -412,8 +455,10 @@ final class MacAppState: ObservableObject { |
| if let i = instances.firstIndex(where: { $0.origin == s.url }) { |
| inst.myUserId = instances[i].myUserId |
| if inst.groupName.isEmpty { inst.groupName = instances[i].groupName } |
| + inst.addedAt = instances[i].addedAt |
| instances[i] = inst |
| } else { |
| + inst.addedAt = Date().timeIntervalSince1970 * 1000 |
| instances.append(inst) |
| } |
| if !chats.contains(where: { $0.origin?.absoluteString == s.url }) { |
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift| index 531aa54..a16605f 100644 |
| --- a/ios/LChatMac/MacHomeSignIn.swift |
| +++ b/ios/LChatMac/MacHomeSignIn.swift |
| @@ -174,7 +174,7 @@ final class MacHomeSignIn: ObservableObject { |
| let contents = try HomeVault.open(fetch.bytes, dek: dek) |
| HomeSessionStore.save(HomeSession(token: h.token, credentialID: h.credId, dek: dek)) |
| sessionExists = true |
| - let added = app.applyVault(contents.servers) |
| + let added = app.applyVault(contents.servers, removed: contents.removed) |
| app.syncHome() |
| phase = added > 0 ? .restored(added) : .emptyBackup |
| } catch { |
| @@ -221,7 +221,7 @@ final class MacHomeSignIn: ObservableObject { |
| HomeSessionStore.save(HomeSession(token: result.token, |
| credentialID: result.credentialID, |
| dek: opened.dek)) |
| - let added = app.applyVault(opened.contents.servers) |
| + let added = app.applyVault(opened.contents.servers, removed: opened.contents.removed) |
| app.syncHome() |
| return added > 0 ? .restored(added) : .emptyBackup |
| } |