eleven / messenger Public

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>
Paul Campbell 3f8d2a11f16490525d937c91bb97c77f7fd12dce parent 6e4f6ef
12 files changed, +420 −50
  • home/test/serverlist.test.mjs +57 −0
  • home/web/static/enroll.js +6 −1
  • home/web/static/home.js +18 −1
  • home/web/static/serverlist.js +62 −0
  • ios/LChat/LChatApp.swift +37 −11
  • ios/LChat/Views/SignInWithEleven.swift +3 −0
  • ios/LChatCore/Sources/LChatCore/HomeSync.swift +74 −31
  • ios/LChatCore/Sources/LChatCore/HomeVault.swift +28 −1
  • ios/LChatCore/Sources/LChatCore/Models.swift +9 −1
  • ios/LChatCore/Tests/LChatCoreTests/HomeVaultTests.swift +77 −0
  • ios/LChatMac/LChatMacApp.swift +47 −2
  • ios/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
}