aboutsummaryrefslogtreecommitdiffstats
path: root/src/marmot-client.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/marmot-client.js')
-rw-r--r--src/marmot-client.js74
1 files changed, 73 insertions, 1 deletions
diff --git a/src/marmot-client.js b/src/marmot-client.js
index 4a79b21..b514216 100644
--- a/src/marmot-client.js
+++ b/src/marmot-client.js
@@ -26,6 +26,7 @@ const DIRECTORY_RELAYS = ['wss://relay.eu.whitenoise.chat', 'wss://relay.us.whit
const DAY = 86400;
const KP_REFRESH = 30 * DAY; // republish well inside the 84-day lifetime
+const RETIRE_CHECK = 6 * 3600; // how often a device asks whether another one took the invitations
const REACH_TTL = 10 * 60_000;
const now = () => Math.floor(Date.now() / 1000);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -98,6 +99,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
}
async function ensure(opts) {
await load();
+ if (await checkRetired()) return false; // another device took the invitations: ours is not put back
const cur = dev && dev.kps && dev.kps.find((k) => k.eventId);
if (cur && cur.notAfter - now() > 14 * DAY && now() - cur.at < KP_REFRESH) return true;
const s = await signer(opts);
@@ -150,6 +152,76 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
return true;
}
+ // ---- which device gets the invitations ----
+ // Every device publishes its own KeyPackage, and a Marmot inviter (White
+ // Noise) picks ONE of them: the invitation lands on whichever device it
+ // chose, and the others never see the group (Adam, 2026-10-14: the
+ // phone's). "Only here" retires the account's other packages — a kind 5
+ // naming each, NIP-09 `a` for the addressable 30443s and `e` for an old
+ // 443 — and publishes a fresh one of ours; a device whose package was
+ // retired sees the deletion (newer than its package) and stops putting
+ // one back, until "only here" is pressed on it in turn.
+ const tagOf = (e, k) => (e.tags.find((t) => t[0] === k) || [])[1] || '';
+ async function devices() {
+ await load();
+ await checkRetired();
+ const own = await ownWriteRelays().catch(() => []);
+ const where = [...new Set([...own.slice(0, 6), ...PROFILE_RELAYS, ...DIRECTORY_RELAYS])];
+ const evs = await queryOn(where, { kinds: [M.KIND.KEY_PACKAGE, 443], authors: [pubkey] }, 3000).catch(() => []);
+ const bySlot = new Map();
+ for (const e of evs) {
+ if (e.pubkey !== pubkey) continue;
+ if (e.kind === 443) { bySlot.set('e:' + e.id, { id: e.id, slot: null, created_at: e.created_at, client: tagOf(e, 'client'), legacy: true }); continue; }
+ const d = tagOf(e, 'd');
+ if (!d) continue;
+ const cur = bySlot.get(d);
+ if (!cur || e.created_at > cur.created_at) bySlot.set(d, { id: e.id, slot: d, created_at: e.created_at, client: tagOf(e, 'client') });
+ }
+ const mine = dev ? dev.slot : null;
+ const list = [...bySlot.values()];
+ const ours = (x) => !!mine && x.slot === mine; // (an old 443 has no slot: never ours)
+ return {
+ slot: mine, retired: !!(dev && dev.retired), published: list.some(ours),
+ others: list.filter((x) => !ours(x)).sort((a, b) => b.created_at - a.created_at),
+ };
+ }
+ async function onlyHere(opts = {}) {
+ await load();
+ const s = await signer(opts);
+ if (!s) throw new Error('no signer');
+ if (!dev) { dev = { ...(await M.newDevice(s.sign, pubkey)), kps: [] }; await saveDev(); }
+ const d = await devices();
+ if (d.others.length) {
+ const tags = [['k', String(M.KIND.KEY_PACKAGE)], ['k', '443']];
+ for (const o of d.others) {
+ tags.push(['e', o.id]);
+ if (o.slot) tags.push(['a', M.KIND.KEY_PACKAGE + ':' + pubkey + ':' + o.slot]);
+ }
+ const ev = await s.sign({ kind: M.KIND.DELETE, created_at: now(), tags, content: 'group invitations go to one device' });
+ const own = await ownWriteRelays().catch(() => []);
+ if (!await publishOn([...new Set([...own.slice(0, 6), ...PROFILE_RELAYS, ...DIRECTORY_RELAYS])], ev)) throw new Error('not published');
+ }
+ // ours, fresh: newer than any deletion that ever named this slot
+ if (dev.retired) { dev.retired = false; await saveDev(); }
+ dev.retireCheckedAt = now();
+ if (!await publishKeyPackage(s)) throw new Error('not published');
+ return d.others.length;
+ }
+ // once in a while: was our package retired on the relays (onlyHere on another device)?
+ async function checkRetired() {
+ const cur = dev && dev.kps && dev.kps.find((k) => k.eventId);
+ if (!dev || dev.retired || !cur || now() - (dev.retireCheckedAt || 0) <= RETIRE_CHECK) return !!(dev && dev.retired);
+ dev.retireCheckedAt = now(); await saveDev();
+ if (await retiredOnRelays(cur)) { dev.retired = true; await saveDev(); }
+ return dev.retired === true;
+ }
+ async function retiredOnRelays(cur) {
+ const own = await ownWriteRelays().catch(() => []);
+ const evs = await queryOn([...new Set([...own.slice(0, 6), ...PROFILE_RELAYS])],
+ { kinds: [M.KIND.DELETE], authors: [pubkey], '#a': [M.KIND.KEY_PACKAGE + ':' + pubkey + ':' + dev.slot] }, 2500).catch(() => []);
+ return evs.some((e) => e.pubkey === pubkey && e.created_at > (cur.at || 0));
+ }
+
// Somebody's KeyPackages: one per device (a slot is one installation), the
// best first, at most ten. An invitation adds every one of them, so a person
// with a phone and a laptop gets the group on both — the rule Marmot's
@@ -440,7 +512,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY
}
return {
- pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, lookupAll, siblingAdds, welcome, accept,
+ pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, lookupAll, siblingAdds, welcome, accept, devices, onlyHere,
create, send, add, kick, rename, leave, forget,
event: (kind, content, tags) => M.appEvent(pubkey, kind, content, tags),
// attachments: seal for the epoch a message is about to go out in, and