diff --git a/docs/static/translations.json b/docs/static/translations.json index aee9f522d..bce36cfcc 100644 --- a/docs/static/translations.json +++ b/docs/static/translations.json @@ -505,8 +505,12 @@ "social_bindings_empty_body": "Invite a contact from your eName card.", "social_bindings_empty_title": "No social bindings yet", "social_bindings_page_title": "Social bindings", + "social_cancel_not_pending": "This request is no longer pending — reopen the list to see where it landed.", "social_details_awaiting": "Awaiting confirmation", "social_details_awaiting_suffix": "· Awaiting confirmation", + "social_details_awaiting_you_suffix": "· Awaiting your confirmation", + "social_details_cancel_invite": "Cancel invite", + "social_details_not_ready": "Wallet not ready.", "social_details_view_full_list": "View on full list", "social_drawer_counter_signing": "Completing mutual binding…", "social_drawer_error_fallback": "Failed to complete the binding.", @@ -1041,8 +1045,12 @@ "social_bindings_empty_body": "Пригласите контакт с карточки вашего eName.", "social_bindings_empty_title": "Социальных связей пока нет", "social_bindings_page_title": "Социальные связи", + "social_cancel_not_pending": "Этот запрос больше не ожидает ответа — откройте список заново, чтобы увидеть результат.", "social_details_awaiting": "Ожидает подтверждения", "social_details_awaiting_suffix": "· Ожидает подтверждения", + "social_details_awaiting_you_suffix": "· Ожидает вашего подтверждения", + "social_details_cancel_invite": "Отменить приглашение", + "social_details_not_ready": "Кошелёк не готов.", "social_details_view_full_list": "Открыть полный список", "social_drawer_counter_signing": "Завершаем взаимную связь…", "social_drawer_error_fallback": "Не удалось завершить создание связи.", @@ -1577,8 +1585,12 @@ "social_bindings_empty_body": "Запросіть контакт із картки вашого eName.", "social_bindings_empty_title": "Соціальних зв’язків поки немає", "social_bindings_page_title": "Соціальні зв’язки", + "social_cancel_not_pending": "Цей запит більше не очікує відповіді — відкрийте список знову, щоб побачити результат.", "social_details_awaiting": "Очікує підтвердження", "social_details_awaiting_suffix": "· Очікує підтвердження", + "social_details_awaiting_you_suffix": "· Очікує вашого підтвердження", + "social_details_cancel_invite": "Скасувати запрошення", + "social_details_not_ready": "Гаманець не готовий.", "social_details_view_full_list": "Відкрити повний список", "social_drawer_counter_signing": "Завершуємо взаємний зв’язок…", "social_drawer_error_fallback": "Не вдалося завершити створення зв’язку.", diff --git a/infrastructure/eid-wallet/messages/en.json b/infrastructure/eid-wallet/messages/en.json index b9dae82c9..02041cec7 100644 --- a/infrastructure/eid-wallet/messages/en.json +++ b/infrastructure/eid-wallet/messages/en.json @@ -655,8 +655,12 @@ "social_bindings_empty_body": "Invite a contact from your eName card.", "social_bindings_empty_title": "No social bindings yet", "social_bindings_page_title": "Social bindings", + "social_cancel_not_pending": "This request is no longer pending — reopen the list to see where it landed.", "social_details_awaiting": "Awaiting confirmation", "social_details_awaiting_suffix": "· Awaiting confirmation", + "social_details_awaiting_you_suffix": "· Awaiting your confirmation", + "social_details_cancel_invite": "Cancel invite", + "social_details_not_ready": "Wallet not ready.", "social_details_view_full_list": "View on full list", "social_drawer_counter_signing": "Completing mutual binding…", "social_drawer_error_fallback": "Failed to complete the binding.", diff --git a/infrastructure/eid-wallet/messages/ru.json b/infrastructure/eid-wallet/messages/ru.json index 6674e5a14..8f37761cf 100644 --- a/infrastructure/eid-wallet/messages/ru.json +++ b/infrastructure/eid-wallet/messages/ru.json @@ -675,8 +675,12 @@ "social_bindings_empty_body": "Пригласите контакт с карточки вашего eName.", "social_bindings_empty_title": "Социальных связей пока нет", "social_bindings_page_title": "Социальные связи", + "social_cancel_not_pending": "Этот запрос больше не ожидает ответа — откройте список заново, чтобы увидеть результат.", "social_details_awaiting": "Ожидает подтверждения", "social_details_awaiting_suffix": "· Ожидает подтверждения", + "social_details_awaiting_you_suffix": "· Ожидает вашего подтверждения", + "social_details_cancel_invite": "Отменить приглашение", + "social_details_not_ready": "Кошелёк не готов.", "social_details_view_full_list": "Открыть полный список", "social_drawer_counter_signing": "Завершаем взаимную связь…", "social_drawer_error_fallback": "Не удалось завершить создание связи.", diff --git a/infrastructure/eid-wallet/messages/uk.json b/infrastructure/eid-wallet/messages/uk.json index 4f1b711d6..75bde60e0 100644 --- a/infrastructure/eid-wallet/messages/uk.json +++ b/infrastructure/eid-wallet/messages/uk.json @@ -675,8 +675,12 @@ "social_bindings_empty_body": "Запросіть контакт із картки вашого eName.", "social_bindings_empty_title": "Соціальних зв’язків поки немає", "social_bindings_page_title": "Соціальні зв’язки", + "social_cancel_not_pending": "Цей запит більше не очікує відповіді — відкрийте список знову, щоб побачити результат.", "social_details_awaiting": "Очікує підтвердження", "social_details_awaiting_suffix": "· Очікує підтвердження", + "social_details_awaiting_you_suffix": "· Очікує вашого підтвердження", + "social_details_cancel_invite": "Скасувати запрошення", + "social_details_not_ready": "Гаманець не готовий.", "social_details_view_full_list": "Відкрити повний список", "social_drawer_counter_signing": "Завершуємо взаємний зв’язок…", "social_drawer_error_fallback": "Не вдалося завершити створення зв’язку.", diff --git a/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts b/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts new file mode 100644 index 000000000..e0827905a --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts @@ -0,0 +1,438 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("$env/static/public", () => ({ + PUBLIC_EID_WALLET_TOKEN: "test-token", + PUBLIC_REGISTRY_URL: "https://registry.test/", +})); + +import { + CANCEL_NOT_PENDING, + acceptSocialBinding, + cancelSentSocialBinding, + declineSocialBinding, + fetchReconciledSocialBindings, + fetchSocialBindings, + fetchUnsignedSocialDocs, +} from "./socialBinding"; + +const ME = "@me"; +const BOB = "@bob"; + +interface Sig { + signer: string; + timestamp: string; +} +interface Doc { + id: string; + subject: string; + parties: [string, string]; + relation: string; + sigs: Sig[]; +} + +/** eName → the docs that vault holds. */ +let vaults: Map; +/** Every id passed to deleteMetaEnvelope, in order. */ +let deletes: string[]; + +function doc( + id: string, + subject: string, + parties: [string, string], + sigs: Sig[], + relation = "", +): Doc { + return { id, subject, parties, relation, sigs }; +} + +/** + * Signature timestamps as real ISO strings, `minutes` before now. The code under + * test stamps its own signatures with new Date().toISOString(), and these are + * compared as strings — a placeholder like "t1" sorts after any ISO date and + * would quietly disable every cutoff check below. + */ +function at(minutesAgo: number): string { + return new Date(Date.now() - minutesAgo * 60_000).toISOString(); +} + +function gql(ename: string): string { + return `https://vault.test/${ename.slice(1)}/graphql`; +} + +function edgeOf(d: Doc) { + return { + node: { + id: d.id, + parsed: { + subject: d.subject, + type: "social_connection", + data: { + kind: "social_connection", + name: "Someone", + parties: d.parties, + relation_description: d.relation, + }, + signatures: d.sigs.map((s) => ({ + signer: s.signer, + signature: `sig-${s.signer}`, + timestamp: s.timestamp, + })), + }, + }, + }; +} + +/** + * Stands in for the registry plus every eVault: a GET resolves an eName, a POST + * is answered from the vault the X-ENAME header names. + */ +function vaultHandler() { + return async (input: string, init?: RequestInit) => { + const url = String(input); + if (!init || init.method !== "POST") { + const ename = decodeURIComponent( + new URL(url).searchParams.get("w3id") ?? "", + ); + return jsonResponse({ uri: gql(ename) }); + } + + const ename = (init.headers as Record)["X-ENAME"]; + const body = JSON.parse(String(init.body)) as { + query: string; + variables?: Record; + }; + const docs = vaults.get(ename) ?? []; + + if (body.query.includes("deleteMetaEnvelope")) { + const id = String(body.variables?.id); + deletes.push(id); + vaults.set( + ename, + docs.filter((d) => d.id !== id), + ); + return jsonResponse({ data: { deleteMetaEnvelope: true } }); + } + + if (body.query.includes("createBindingDocumentSignature")) { + const input = body.variables?.input as { + bindingDocumentId: string; + signature: Sig; + }; + const target = docs.find((d) => d.id === input.bindingDocumentId); + target?.sigs.push({ + signer: input.signature.signer, + timestamp: input.signature.timestamp, + }); + return jsonResponse({ + data: { + createBindingDocumentSignature: { + bindingDocument: {}, + errors: [], + }, + }, + }); + } + + return jsonResponse({ + data: { + bindingDocuments: { + edges: docs.map(edgeOf), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + }; +} + +function jsonResponse(payload: unknown) { + return { ok: true, json: async () => payload } as Response; +} + +beforeEach(() => { + vaults = new Map(); + deletes = []; + vi.stubGlobal("fetch", vi.fn(vaultHandler())); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("leftovers from an already-bound signer", () => { + // D1 is a completed binding accepted at t20; D2 predates that acceptance, + // D3 was sent after it. + beforeEach(() => { + vaults.set(ME, [ + doc( + "D1", + ME, + [BOB, ME], + [ + { signer: BOB, timestamp: at(190) }, + { signer: ME, timestamp: at(180) }, + ], + ), + doc("D2", ME, [BOB, ME], [{ signer: BOB, timestamp: at(185) }]), + doc("D3", ME, [BOB, ME], [{ signer: BOB, timestamp: at(170) }]), + ]); + }); + + it("surfaces a request sent after the earlier binding was accepted", async () => { + const unsigned = await fetchUnsignedSocialDocs(gql(ME), ME); + expect(unsigned.map((e) => e.node.id)).toEqual(["D3"]); + }); + + it("hides the leftover from the poll without deleting it", async () => { + // The cutoff compares timestamps written by two different phones, so a + // genuine new invite can look older than the acceptance. Hiding it from + // the drawer is recoverable — the bindings list still shows it — while + // deleting it is not. + await fetchUnsignedSocialDocs(gql(ME), ME); + expect(deletes).toEqual([]); + expect((vaults.get(ME) as Doc[]).map((d) => d.id)).toEqual([ + "D1", + "D2", + "D3", + ]); + }); +}); + +describe("acting on one request of several", () => { + beforeEach(() => { + vaults.set(ME, [ + doc( + "P1", + ME, + [BOB, ME], + [{ signer: BOB, timestamp: at(199) }], + "coffee", + ), + doc( + "P2", + ME, + [BOB, ME], + [{ signer: BOB, timestamp: at(198) }], + "coffee", + ), + doc( + "P3", + ME, + [BOB, ME], + [{ signer: BOB, timestamp: at(197) }], + "work", + ), + ]); + }); + + it("accepting signs the doc and drops only its repeat-scan duplicate", async () => { + const parsed = edgeOf((vaults.get(ME) as Doc[])[0]).node.parsed; + await acceptSocialBinding(gql(ME), ME, "P1", parsed, async () => "sig"); + + expect(deletes).toEqual(["P2"]); + const remaining = vaults.get(ME) as Doc[]; + expect(remaining.map((d) => d.id)).toEqual(["P1", "P3"]); + expect(remaining[0].sigs.map((s) => s.signer)).toEqual([BOB, ME]); + }); + + it("accepting one does not make the other a stale leftover afterwards", async () => { + // The accept records a cutoff for this signer. Keyed on the signer + // alone, that cutoff swallowed every older invite from them whatever + // its description — the situation #1146 reports. + const parsed = edgeOf((vaults.get(ME) as Doc[])[0]).node.parsed; + await acceptSocialBinding(gql(ME), ME, "P1", parsed, async () => "sig"); + + const unsigned = await fetchUnsignedSocialDocs(gql(ME), ME); + expect( + unsigned.map((e) => e.node.parsed?.data.relation_description), + ).toEqual(["work"]); + // Only the same-description duplicate went; "work" is untouched. + expect(deletes).toEqual(["P2"]); + }); + + it("declining removes the request and its duplicate, not the other invite", async () => { + const parsed = edgeOf((vaults.get(ME) as Doc[])[0]).node.parsed; + await declineSocialBinding(gql(ME), ME, "P1", parsed); + + expect(deletes).toEqual(["P1", "P2"]); + expect((vaults.get(ME) as Doc[]).map((d) => d.id)).toEqual(["P3"]); + }); +}); + +describe("reconciling sent mirrors", () => { + it("keeps a second invite pending when an earlier one with the same contact is confirmed", async () => { + vaults.set(ME, [ + doc( + "M1", + ME, + [ME, BOB], + [{ signer: ME, timestamp: at(199) }], + "coffee", + ), + doc( + "M2", + ME, + [ME, BOB], + [{ signer: ME, timestamp: at(198) }], + "work", + ), + ]); + vaults.set(BOB, [ + doc( + "R1", + BOB, + [ME, BOB], + [ + { signer: ME, timestamp: at(199) }, + { signer: BOB, timestamp: at(195) }, + ], + "coffee", + ), + doc( + "R2", + BOB, + [ME, BOB], + [{ signer: ME, timestamp: at(198) }], + "work", + ), + ]); + + const out = await fetchReconciledSocialBindings(gql(ME), ME); + expect( + Object.fromEntries(out.map((s) => [s.docId, s.mutuallySigned])), + ).toEqual({ M1: true, M2: false }); + expect(deletes).toEqual([]); + }); + + it("drops a mirror the counterparty declined, ignoring their own mirror", async () => { + vaults.set(ME, [ + doc("M1", ME, [ME, BOB], [{ signer: ME, timestamp: at(199) }], "x"), + ]); + // Bob's own mirror: same parties and subject=@bob, but he originated it, + // so it says nothing about the invite we sent. + vaults.set(BOB, [ + doc( + "B1", + BOB, + [BOB, ME], + [{ signer: BOB, timestamp: at(191) }], + "x", + ), + ]); + + const out = await fetchReconciledSocialBindings(gql(ME), ME); + expect(out).toEqual([]); + expect(deletes).toEqual(["M1"]); + }); + + it("keeps everything when the counterparty vault can't be reached", async () => { + vaults.set(ME, [ + doc("M1", ME, [ME, BOB], [{ signer: ME, timestamp: at(199) }], "x"), + ]); + const handler = vaultHandler(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string, init?: RequestInit) => { + const ename = (init?.headers as Record)?.[ + "X-ENAME" + ]; + if (ename === BOB) throw new Error("offline"); + return handler(input, init); + }), + ); + + const out = await fetchReconciledSocialBindings(gql(ME), ME); + expect(out.map((s) => s.docId)).toEqual(["M1"]); + expect(deletes).toEqual([]); + }); +}); + +describe("cancelling a sent invite", () => { + it("deletes the pending doc over there, then the local mirror", async () => { + vaults.set(ME, [ + doc("M1", ME, [ME, BOB], [{ signer: ME, timestamp: at(199) }], "x"), + ]); + vaults.set(BOB, [ + doc( + "R1", + BOB, + [ME, BOB], + [{ signer: ME, timestamp: at(199) }], + "x", + ), + ]); + + await cancelSentSocialBinding(gql(ME), ME, "M1", BOB, "x"); + + expect(deletes).toEqual(["R1", "M1"]); + }); + + it("does not claim a declined invite was confirmed", async () => { + // Bound to Bob already, then a second invite with the same (empty) + // description that he declined. The only doc left over there is the + // older confirmed one, which must not be read as this invite's fate. + vaults.set(ME, [ + doc("M1", ME, [ME, BOB], [{ signer: ME, timestamp: at(120) }], ""), + doc("M2", ME, [ME, BOB], [{ signer: ME, timestamp: at(5) }], ""), + ]); + vaults.set(BOB, [ + doc( + "R1", + BOB, + [ME, BOB], + [ + { signer: ME, timestamp: at(120) }, + { signer: BOB, timestamp: at(118) }, + ], + "", + ), + ]); + + await expect( + cancelSentSocialBinding(gql(ME), ME, "M2", BOB, ""), + ).rejects.toThrow(CANCEL_NOT_PENDING); + expect(deletes).toEqual([]); + }); + + it("refuses to withdraw a request the counterparty already confirmed", async () => { + vaults.set(ME, [ + doc("M1", ME, [ME, BOB], [{ signer: ME, timestamp: at(199) }], "x"), + ]); + vaults.set(BOB, [ + doc( + "R1", + BOB, + [ME, BOB], + [ + { signer: ME, timestamp: at(199) }, + { signer: BOB, timestamp: at(195) }, + ], + "x", + ), + ]); + + await expect( + cancelSentSocialBinding(gql(ME), ME, "M1", BOB, "x"), + ).rejects.toThrow(CANCEL_NOT_PENDING); + expect(deletes).toEqual([]); + }); +}); + +describe("fetchSocialBindings", () => { + it("carries the parsed doc so a pending request can be signed from the list", async () => { + vaults.set(ME, [ + doc( + "P1", + ME, + [BOB, ME], + [{ signer: BOB, timestamp: at(199) }], + "hi", + ), + ]); + + const [summary] = await fetchSocialBindings(gql(ME), ME); + expect(summary.role).toBe("received"); + expect(summary.mutuallySigned).toBe(false); + expect(summary.parsed.subject).toBe(ME); + expect(summary.parsed.data.relation_description).toBe("hi"); + }); +}); diff --git a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts index 4c3ee74ca..bc5481428 100644 --- a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts @@ -10,6 +10,7 @@ import { PUBLIC_EID_WALLET_TOKEN, PUBLIC_REGISTRY_URL, } from "$env/static/public"; +import { getCanonicalBindingDocString } from "./bindingDocHash"; export interface BindingDocParsed { subject: string; @@ -472,31 +473,73 @@ export async function deleteSocialBindingDoc( ); } +function relationOf(parsed: BindingDocParsed): string { + return typeof parsed.data?.relation_description === "string" + ? parsed.data.relation_description + : ""; +} + +/** Cutoffs are per (signer, relation description) — see collectAcceptanceCutoffs. */ +function cutoffKey(signer: string, relationDescription: string): string { + return `${signer}\u0000${relationDescription}`; +} + /** - * Signers the caller has ALREADY completed a binding with. A completed binding - * is a doc subject=@caller that the caller has counter-signed; its originator is - * signatures[0].signer. Once bound, any *other* unsigned envelope from that same - * signer is a stale leftover — a repeat scan of the caller's QR (before or after - * acceptance) or a duplicate the accept-time prune never reached — and must not - * re-surface as a fresh "Social Connection Request". + * For each invite the caller has ALREADY accepted, the timestamp of their + * counter-signature. A completed binding is a doc subject=@caller that the + * caller has counter-signed; its originator is signatures[0].signer. + * + * The timestamp is the cutoff that separates the two kinds of unsigned envelope + * an accepted invite leaves behind: one created *before* the caller accepted is + * a leftover from the same burst of repeat scans and must not re-surface as a + * fresh request; one created *after* is a deliberate new invite and must be kept. + * + * Keyed by relation description as well as signer, because the same person can + * send several invites before the caller acts on any of them. Keying on the + * signer alone made accepting one of those wipe the rest, whatever their + * description — which is the situation issue #1146 reports. + * + * Only ever used to hide an envelope from the drawer's poll, never to delete + * one: the timestamps being compared are written by two different devices. */ -function collectBoundSigners( +function collectAcceptanceCutoffs( edges: BindingDocEdge[], normalizedCaller: string, -): Set { - const boundSigners = new Set(); +): Map { + const cutoffs = new Map(); for (const edge of edges) { const parsed = edge.node.parsed; if (!parsed || parsed.type !== "social_connection") continue; if (parsed.subject !== normalizedCaller) continue; const sigs = Array.isArray(parsed.signatures) ? parsed.signatures : []; - const callerSigned = sigs.some((s) => s.signer === normalizedCaller); + const callerSig = sigs.find((s) => s.signer === normalizedCaller); const originator = sigs[0]?.signer; - if (callerSigned && originator && originator !== normalizedCaller) { - boundSigners.add(originator); + if (!callerSig || !originator || originator === normalizedCaller) + continue; + const key = cutoffKey(originator, relationOf(parsed)); + const acceptedAt = callerSig.timestamp ?? ""; + const previous = cutoffs.get(key); + if (previous === undefined || acceptedAt > previous) { + cutoffs.set(key, acceptedAt); } } - return boundSigners; + return cutoffs; +} + +/** + * True when an unsigned envelope predates the caller's acceptance of the same + * invite from the same person — see collectAcceptanceCutoffs. + */ +function isStaleLeftover( + parsed: BindingDocParsed, + cutoffs: Map, +): boolean { + const sigs = Array.isArray(parsed.signatures) ? parsed.signatures : []; + const originator = sigs[0]?.signer; + if (!originator) return false; + const acceptedAt = cutoffs.get(cutoffKey(originator, relationOf(parsed))); + if (acceptedAt === undefined) return false; + return (sigs[0]?.timestamp ?? "") <= acceptedAt; } /** @@ -518,7 +561,7 @@ export async function fetchUnsignedSocialDocs( }>(ownGqlUrl, callerEname, SOCIAL_BINDING_DOCS_QUERY); const edges = data.bindingDocuments?.edges ?? []; - const boundSigners = collectBoundSigners(edges, normalized); + const cutoffs = collectAcceptanceCutoffs(edges, normalized); const unsigned = edges.filter((edge) => { const parsed = edge.node.parsed; @@ -532,18 +575,21 @@ export async function fetchUnsignedSocialDocs( : []; const alreadySigned = signatures.some((s) => s.signer === normalized); if (alreadySigned) return false; - // Skip leftover envelopes from a signer the caller is already bound to. - const originator = signatures[0]?.signer; - if (originator && boundSigners.has(originator)) return false; + // Hide, never delete: an envelope that predates the caller's acceptance + // of the same invite is almost certainly a repeat-scan leftover, but the + // two timestamps being compared come from two different phones, so a + // genuine new invite can look older than it is. Keeping it out of this + // poll stops the drawer re-prompting; it stays in the bindings list, + // where the caller can accept or decline it themselves. + if (isStaleLeftover(parsed, cutoffs)) return false; return true; }); - // Dedupe by signer: each scan of the requester's QR creates a fresh - // envelope. When a scanner scans more than once (the usual reason — - // they thought it didn't work) the requester ends up with several - // identical pending docs, all needing acceptance. Surface just the - // newest from each signer; the dupes are pruned by - // pruneDuplicateUnsignedDocs() below at consent time. + // One request at a time per signer: each scan of the requester's QR creates + // a fresh envelope, and a scanner who scans twice (thinking it didn't work) + // leaves several. Surface the newest; accepting or declining it clears its + // duplicates, and any genuinely different invite from the same person comes + // up on the next poll. const newestBySigner = new Map(); for (const edge of unsigned) { const signer = edge.node.parsed?.signatures?.[0]?.signer ?? null; @@ -562,17 +608,22 @@ export async function fetchUnsignedSocialDocs( } /** - * After successfully counter-signing one pending binding doc from a given - * signer, look up every OTHER unsigned doc with the same signer on the - * caller's vault and delete them. These are duplicate envelopes from - * repeat scans of the same QR; collapsing them here stops the drawer from - * re-prompting the user to accept the "same" binding over and over. + * After acting on one pending binding doc, look up every OTHER unsigned doc from + * the same signer carrying the same relation description and delete them. Those + * are the envelopes a repeat scan of the same QR leaves behind; collapsing them + * stops the drawer re-prompting the user to accept the "same" binding over and + * over. + * + * The relation description is what keeps this from eating deliberate second + * invites: the same person can bind twice with different descriptions, and + * acting on one of those must leave the other standing. */ export async function pruneDuplicateUnsignedDocs( ownGqlUrl: string, callerEname: string, keepDocId: string, signer: string, + relationDescription: string, ): Promise { const normalized = callerEname.startsWith("@") ? callerEname @@ -587,10 +638,11 @@ export async function pruneDuplicateUnsignedDocs( const parsed = edge.node.parsed; if (!parsed || parsed.type !== "social_connection") return false; if (parsed.subject !== normalized) return false; + if (relationOf(parsed) !== relationDescription) return false; const sigs = Array.isArray(parsed.signatures) ? parsed.signatures : []; // Same signer, and the caller hasn't already countersigned this // one either — i.e. it's a stale duplicate of the doc we just - // accepted. + // acted on. const sameSigner = sigs[0]?.signer === signer; const callerAlreadySigned = sigs.some((s) => s.signer === normalized); return sameSigner && !callerAlreadySigned; @@ -612,59 +664,163 @@ export async function pruneDuplicateUnsignedDocs( return deleted; } +// --------------------------------------------------------------------------- +// Acting on a pending request +// --------------------------------------------------------------------------- + /** - * Delete leftover unsigned social_connection envelopes addressed to the caller - * from signers the caller is ALREADY bound to. These pile up from repeat scans - * of the caller's QR (before or after acceptance) and would otherwise re-surface - * as duplicate "Social Connection Request" prompts for a contact already added. + * Counter-sign a pending social binding request on the caller's own vault, then + * drop the duplicate envelopes left by repeat scans of the same QR. * - * Safe to delete: a completed (caller-counter-signed) binding with the same - * signer already exists, so accepting a leftover would only mint a redundant - * second binding to the same person. The fully-signed doc is never touched. + * Shared by every entry point that can accept a request (the invite drawer's + * poll and the bindings list), so they can't drift apart. * - * Intended as a one-time cleanup when the invite drawer opens. Returns the - * number of envelopes deleted. + * @param sign - signs the doc's canonical form; supplied by the caller so this + * module stays free of any GlobalState dependency. */ -export async function pruneBoundSignerDocs( +export async function acceptSocialBinding( ownGqlUrl: string, callerEname: string, -): Promise { + docId: string, + parsed: BindingDocParsed, + sign: (payload: string) => Promise, +): Promise { const normalized = callerEname.startsWith("@") ? callerEname : `@${callerEname}`; - const data = await vaultGqlRequest<{ - bindingDocuments: { edges: BindingDocEdge[] }; - }>(ownGqlUrl, callerEname, SOCIAL_BINDING_DOCS_QUERY); + const signatures = Array.isArray(parsed.signatures) + ? parsed.signatures + : []; + + // Idempotency: if the doc already carries our signature (a stale poll result + // re-surfaced after we just signed it), treat as already-done. + if (!signatures.some((s) => s.signer === normalized)) { + const canonical = getCanonicalBindingDocString({ + subject: parsed.subject, + type: parsed.type, + data: parsed.data, + }); + const signature = await sign(canonical); + await addCounterpartySignature( + ownGqlUrl, + normalized, + normalized, + docId, + signature, + ); + } - const edges = data.bindingDocuments?.edges ?? []; - const boundSigners = collectBoundSigners(edges, normalized); + await pruneDuplicatesOf(ownGqlUrl, normalized, docId, parsed); +} - const stale = edges.filter((edge) => { - const parsed = edge.node.parsed; - if (!parsed || parsed.type !== "social_connection") return false; - if (parsed.subject !== normalized) return false; - const sigs = Array.isArray(parsed.signatures) ? parsed.signatures : []; - // Keep the completed binding itself — only leftovers are stale. - if (sigs.some((s) => s.signer === normalized)) return false; - const originator = sigs[0]?.signer; - return !!originator && boundSigners.has(originator); - }); +/** Shared tail of accept and decline — see pruneDuplicateUnsignedDocs. */ +async function pruneDuplicatesOf( + ownGqlUrl: string, + normalizedCaller: string, + docId: string, + parsed: BindingDocParsed, +): Promise { + const signer = parsed.signatures?.[0]?.signer; + if (!signer) return; + try { + await pruneDuplicateUnsignedDocs( + ownGqlUrl, + normalizedCaller, + docId, + signer, + relationOf(parsed), + ); + } catch (err) { + console.warn("[socialBinding] duplicate prune failed:", err); + } +} - let deleted = 0; - for (const edge of stale) { - try { - await deleteSocialBindingDoc(ownGqlUrl, callerEname, edge.node.id); - deleted += 1; - } catch (err) { - console.warn( - "[socialBinding] failed to prune bound-signer doc", - edge.node.id, - err, - ); +/** + * Reject a pending social binding request: delete the envelope, then the + * duplicates queued behind it. Without that second step the next refresh + * re-prompts with what looks like the request the user just declined (#1082). + */ +export async function declineSocialBinding( + ownGqlUrl: string, + callerEname: string, + docId: string, + parsed: BindingDocParsed | null, +): Promise { + const normalized = callerEname.startsWith("@") + ? callerEname + : `@${callerEname}`; + + await deleteSocialBindingDoc(ownGqlUrl, normalized, docId); + + // docId is already gone, so nothing is actually kept — the prune clears + // whatever duplicates of it remain. + if (parsed) await pruneDuplicatesOf(ownGqlUrl, normalized, docId, parsed); +} + +/** + * The one refusal cancelSentSocialBinding surfaces to the user, as a code rather + * than a sentence: this module has no i18n, so the caller renders the wording. + */ +export const CANCEL_NOT_PENDING = "social-binding/cancel-not-pending"; + +/** + * Withdraw an invite the caller sent by scanning: delete the pending doc from + * the counterparty's vault first, then the caller's local mirror. + * + * Remote first, and only on success — if the delete over there fails, the + * counterparty can still accept, and dropping the mirror would leave the caller + * blind to a binding that then completes. + * + * Throws if the counterparty has already counter-signed; a completed binding is + * not something to withdraw silently. + */ +export async function cancelSentSocialBinding( + ownGqlUrl: string, + callerEname: string, + mirrorDocId: string, + counterpartyEname: string, + relationDescription: string, +): Promise { + const normalized = callerEname.startsWith("@") + ? callerEname + : `@${callerEname}`; + const normalizedCounter = counterpartyEname.startsWith("@") + ? counterpartyEname + : `@${counterpartyEname}`; + + const remote = await fetchRemoteDocsWithSelf(normalized, normalizedCounter); + const matching = remote.docs.filter( + (d) => d.relationDescription === relationDescription, + ); + const pending = matching.filter((d) => d.signatureCount < 2); + + if (pending.length === 0) { + if (matching.length > 0) { + // Some doc with this description is on their side but none of them + // is pending. Either they counter-signed this invite or they + // declined it and an older binding with the same description (very + // often the empty one) is what we are seeing. The description is + // all we have to match on, so don't guess which: leave the mirror + // alone and let the reconcile settle it on the next read. + throw new Error(CANCEL_NOT_PENDING); } + // Nothing with this description on their side at all: they declined and + // deleted it, so only the orphaned mirror is left. + } else { + // One mirror, one invite: drop the newest match, the one this mirror + // most plausibly created. + const newest = pending.reduce((a, b) => + b.timestamp > a.timestamp ? b : a, + ); + await deleteSocialBindingDoc( + remote.gqlUrl, + normalizedCounter, + newest.id, + ); } - return deleted; + + await deleteSocialBindingDoc(ownGqlUrl, normalized, mirrorDocId); } // --------------------------------------------------------------------------- @@ -686,6 +842,12 @@ export interface SocialBindingSummary { * are no signatures (shouldn't happen post-fetch but stay defensive). */ role: "sent" | "received"; + /** + * The doc as stored. Kept so a pending request can be counter-signed from + * the list without re-fetching — signing needs subject/type/data to rebuild + * the canonical form. + */ + parsed: BindingDocParsed; } // All social_connection docs on the caller's own vault, newest first. @@ -729,12 +891,10 @@ export async function fetchSocialBindings( docId: edge.node.id, counterpartyEname: counterparty, completedAt, - relationDescription: - typeof parsed.data?.relation_description === "string" - ? (parsed.data.relation_description as string) - : "", + relationDescription: relationOf(parsed), mutuallySigned: sigs.length >= 2, role, + parsed, }); } @@ -747,10 +907,9 @@ export async function fetchSocialBindings( // --------------------------------------------------------------------------- /** - * True status of a scanner-initiated ("sent") binding, determined by reading - * the primary doc in the counterparty's vault — the source of truth. The - * scanner only holds a single-signature mirror; the real doc lives in the - * counterparty's vault. + * True status of a scanner-initiated ("sent") binding, determined by reading the + * primary doc in the counterparty's vault — the source of truth. The scanner + * only holds a single-signature mirror; the real doc lives over there. * * - `confirmed`: the counterparty counter-signed (doc has 2 signatures). * - `pending`: the counterparty hasn't acted yet (doc has 1 signature). @@ -758,37 +917,41 @@ export async function fetchSocialBindings( */ export type SentBindingStatus = "confirmed" | "pending" | "declined"; +/** One social_connection doc in a counterparty's vault that involves the caller. */ +interface RemoteSocialDoc { + id: string; + relationDescription: string; + signatureCount: number; + /** Originator's signature timestamp — when the invite was sent. */ + timestamp: string; +} + /** - * Read the counterparty's vault to determine the true status of a binding the - * caller initiated by scanning. Reuses the same cross-vault read path as + * Every social_connection doc the caller created in the counterparty's vault, + * walked across all pages. Reuses the same cross-vault read path as * fetchNameFromVault (X-ENAME scopes the query to the counterparty's data). * + * The originator check matters: the counterparty's own mirrors (from them + * scanning the caller) also carry subject=@them and both parties, and counting + * those as invites the caller sent would leave a declined invite looking pending + * forever. + * * Throws if the counterparty vault can't be resolved or reached — callers MUST - * treat a throw as "unknown" and leave the local mirror untouched, so a - * transient network error never deletes a still-valid binding. + * treat a throw as "unknown" and leave local mirrors untouched, so a transient + * network error never deletes a still-valid binding. */ -export async function fetchSentBindingStatus( - selfEname: string, - counterpartyEname: string, -): Promise { - const normalizedSelf = selfEname.startsWith("@") - ? selfEname - : `@${selfEname}`; - const normalizedCounter = counterpartyEname.startsWith("@") - ? counterpartyEname - : `@${counterpartyEname}`; +async function fetchRemoteDocsWithSelf( + normalizedSelf: string, + normalizedCounter: string, +): Promise<{ gqlUrl: string; docs: RemoteSocialDoc[] }> { + const gqlUrl = await resolveVaultUri(normalizedCounter); - const foreignGqlUrl = await resolveVaultUri(normalizedCounter); - - // The primary doc has subject=@counterparty and lists both parties; any - // 2-signature match means confirmed (repeat scans can leave several). Only - // conclude "declined" — which deletes the mirror — after all pages are checked. + const docs: RemoteSocialDoc[] = []; let after: string | null = null; - let sawMatch = false; do { const data: SocialBindingDocsPage = await vaultGqlRequest( - foreignGqlUrl, + gqlUrl, normalizedCounter, SOCIAL_BINDING_DOCS_PAGE_QUERY, { after: after ?? undefined }, @@ -804,19 +967,83 @@ export async function fetchSentBindingStatus( : []; if (!parties.includes(normalizedSelf)) continue; - sawMatch = true; - // A 2-signature match is terminal — the counterparty counter-signed. - const sigs = parsed.signatures; - if (Array.isArray(sigs) && sigs.length >= 2) return "confirmed"; + const sigs = Array.isArray(parsed.signatures) + ? parsed.signatures + : []; + if (sigs[0]?.signer !== normalizedSelf) continue; + + docs.push({ + id: edge.node.id, + relationDescription: relationOf(parsed), + signatureCount: sigs.length, + timestamp: sigs[0]?.timestamp ?? "", + }); } const pageInfo = connection?.pageInfo; after = pageInfo?.hasNextPage ? (pageInfo?.endCursor ?? null) : null; } while (after !== null); - // No matching doc on any page → the counterparty deleted it (declined). - // Otherwise we only ever saw single-signature matches → still pending. - return sawMatch ? "pending" : "declined"; + return { gqlUrl, docs }; +} + +/** + * Resolve the status of every pending mirror the caller holds for one + * counterparty, from a single read of that counterparty's vault. + * + * The mirror carries no pointer to the doc it created over there, so the two + * sides are matched on relation_description — the only field that distinguishes + * one invite to the same person from another. Within a description, confirmed + * docs claim the oldest mirrors and pending docs the next; a mirror left with + * nothing to claim is one the counterparty declined and deleted. + * + * Matching on parties alone (what this used to do) marked every pending mirror + * confirmed as soon as *any* binding with that person was — so a second invite + * showed as accepted the moment it was sent. + */ +function resolveSentStatuses( + mirrors: SocialBindingSummary[], + remote: RemoteSocialDoc[], +): Map { + const pools = new Map(); + for (const doc of remote) { + const pool = pools.get(doc.relationDescription) ?? { + confirmed: 0, + pending: 0, + }; + if (doc.signatureCount >= 2) pool.confirmed += 1; + else pool.pending += 1; + pools.set(doc.relationDescription, pool); + } + + const byDescription = new Map(); + for (const mirror of mirrors) { + const group = byDescription.get(mirror.relationDescription); + if (group) group.push(mirror); + else byDescription.set(mirror.relationDescription, [mirror]); + } + + const statuses = new Map(); + for (const [description, group] of byDescription) { + const pool = pools.get(description) ?? { confirmed: 0, pending: 0 }; + // Oldest first, so a confirmation lands on the invite that has been + // waiting longest rather than on whichever one sorted first. + const ordered = [...group].sort((a, b) => + a.completedAt.localeCompare(b.completedAt), + ); + for (const mirror of ordered) { + if (pool.confirmed > 0) { + pool.confirmed -= 1; + statuses.set(mirror.docId, "confirmed"); + } else if (pool.pending > 0) { + pool.pending -= 1; + statuses.set(mirror.docId, "pending"); + } else { + statuses.set(mirror.docId, "declined"); + } + } + } + return statuses; } /** @@ -830,62 +1057,82 @@ export async function fetchSentBindingStatus( * (the whole point of this reconcile — see issue #990). * - still pending / unreachable → keep it as an unconfirmed (pending) binding. * - * A confirmed or already-mutually-signed binding needs no remote read. + * A confirmed or already-mutually-signed binding needs no remote read, and each + * counterparty is read once however many mirrors point at them. */ export async function fetchReconciledSocialBindings( ownGqlUrl: string, callerEname: string, ): Promise { const summaries = await fetchSocialBindings(ownGqlUrl, callerEname); + const normalized = callerEname.startsWith("@") + ? callerEname + : `@${callerEname}`; - const reconciled = await Promise.all( - summaries.map(async (summary) => { - // Only scanner-initiated mirrors that aren't yet mutually signed - // need a remote check; everything else is already authoritative. - if (summary.role !== "sent" || summary.mutuallySigned) { - return summary; - } + const byCounterparty = new Map(); + for (const summary of summaries) { + // Only scanner-initiated mirrors that aren't yet mutually signed need a + // remote check; everything else is already authoritative. + if (summary.role !== "sent" || summary.mutuallySigned) continue; + const group = byCounterparty.get(summary.counterpartyEname); + if (group) group.push(summary); + else byCounterparty.set(summary.counterpartyEname, [summary]); + } + if (byCounterparty.size === 0) return summaries; + + const statuses = new Map(); + await Promise.all( + Array.from(byCounterparty, async ([counterparty, mirrors]) => { try { - const status = await fetchSentBindingStatus( - callerEname, - summary.counterpartyEname, + const remote = await fetchRemoteDocsWithSelf( + normalized, + counterparty.startsWith("@") + ? counterparty + : `@${counterparty}`, ); - if (status === "confirmed") { - return { ...summary, mutuallySigned: true }; + for (const [docId, status] of resolveSentStatuses( + mirrors, + remote.docs, + )) { + statuses.set(docId, status); } - if (status === "declined") { - // The counterparty rejected the request and deleted their - // copy — remove our orphaned mirror so it stops showing as - // a successful binding, then drop it from this list. - void deleteSocialBindingDoc( - ownGqlUrl, - callerEname, - summary.docId, - ).catch((err) => - console.warn( - "[socialBinding] failed to delete declined mirror", - summary.docId, - err, - ), - ); - return null; - } - // pending — keep it as an unconfirmed binding. - return summary; } catch (err) { - // Couldn't reach the counterparty vault — treat as unknown and - // keep the mirror; never delete on a transient failure. + // Couldn't reach the counterparty vault — leave these mirrors + // unresolved and keep them; never delete on a transient failure. console.warn( - "[socialBinding] could not reconcile sent binding with", - summary.counterpartyEname, + "[socialBinding] could not reconcile sent bindings with", + counterparty, err, ); - return summary; } }), ); - return reconciled.filter((s): s is SocialBindingSummary => s !== null); + const out: SocialBindingSummary[] = []; + for (const summary of summaries) { + const status = statuses.get(summary.docId); + if (status === "confirmed") { + out.push({ ...summary, mutuallySigned: true }); + } else if (status === "declined") { + // The counterparty rejected the request and deleted their copy — + // remove our orphaned mirror so it stops showing as a successful + // binding, then drop it from this list. + void deleteSocialBindingDoc( + ownGqlUrl, + callerEname, + summary.docId, + ).catch((err) => + console.warn( + "[socialBinding] failed to delete declined mirror", + summary.docId, + err, + ), + ); + } else { + out.push(summary); + } + } + return out; } // --------------------------------------------------------------------------- diff --git a/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte index 42a222d48..bb0878c76 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte @@ -9,12 +9,12 @@ import type { GlobalState } from "$lib/global"; import { m } from "$lib/i18n"; import { ButtonAction, CopyableEName } from "$lib/ui"; import { - addCounterpartySignature, + type BindingDocParsed, + acceptSocialBinding, capitalize, - deleteSocialBindingDoc, + declineSocialBinding, fetchNameFromVault, fetchUnsignedSocialDocs, - getCanonicalBindingDocString, identityFieldLabel, identityFieldValue, resolveVaultUri, @@ -402,11 +402,7 @@ let socialBindingSuccess = $state(false); let socialBindingSignerName = $state(null); let socialBindingSignerEname = $state(null); let socialBindingPendingDocId = $state(null); -let socialBindingPendingDocParsed = $state<{ - subject: string; - type: string; - data: Record; -} | null>(null); +let socialBindingPendingDocParsed = $state(null); let socialBindingAwaitingConsent = $state(false); let socialBindingError = $state(null); let socialBindingCounterSigning = $state(false); @@ -501,7 +497,7 @@ async function runSocialBindingPoll() { } } -async function confirmSocialBinding() { +async function confirmSocialBindingRequest() { if (!socialBindingSignerEname) return; socialBindingAwaitingConsent = false; socialBindingError = null; @@ -533,18 +529,12 @@ async function confirmSocialBinding() { // Counter-sign the doc in the requester's OWN vault. // The doc has subject=@requester (=callerEname) so the requester is the valid counterparty. - const payload = getCanonicalBindingDocString({ - subject: socialBindingPendingDocParsed.subject, - type: socialBindingPendingDocParsed.type, - data: socialBindingPendingDocParsed.data, - }); - const sig = await globalState.keyService.sign(payload); - await addCounterpartySignature( + await acceptSocialBinding( gqlUrl, callerEname, - callerEname, socialBindingPendingDocId, - sig, + socialBindingPendingDocParsed, + (payload) => globalState.keyService.sign(payload), ); socialBindingSuccess = true; @@ -561,8 +551,9 @@ async function confirmSocialBinding() { } } -async function declineSocialBinding() { +async function declineSocialBindingRequest() { const docId = socialBindingPendingDocId; + const declinedDoc = socialBindingPendingDocParsed; socialBindingAwaitingConsent = false; socialBindingPendingDocId = null; socialBindingPendingDocParsed = null; @@ -577,7 +568,12 @@ async function declineSocialBinding() { ? vault.ename : `@${vault.ename}`; const gqlUrl = new URL("/graphql", vault.uri).toString(); - await deleteSocialBindingDoc(gqlUrl, callerEname, docId); + await declineSocialBinding( + gqlUrl, + callerEname, + docId, + declinedDoc, + ); } } catch (err) { console.error( @@ -746,13 +742,16 @@ onMount(async () => {

{socialBindingError}

{/if}
- {m.common_accept()} {m.common_decline()}{m.common_decline()}
{:else} diff --git a/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelte b/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelte index aba64e891..43eebf5fe 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelte @@ -1,24 +1,49 @@ @@ -70,15 +176,29 @@ function close() { + {#if visibleError} +

{visibleError}

+ {/if} +
{#each contact.bindings as binding (binding.docId)} + {@const needsMyConfirmation = + binding.role === "received" && !binding.mutuallySigned} + {@const awaitingThem = + binding.role === "sent" && !binding.mutuallySigned}

- {binding.role === "sent" ? m.social_role_sent() : m.social_role_received()} - {#if !binding.mutuallySigned} + {binding.role === "sent" + ? m.social_role_sent() + : m.social_role_received()} + {#if needsMyConfirmation} + {m.social_details_awaiting_you_suffix()} + {:else if awaitingThem} {m.social_details_awaiting_suffix()} @@ -97,20 +217,57 @@ function close() {

{/if}
+ + {#if needsMyConfirmation} +
+ decline(binding)} + > + {m.common_decline()} + + accept(binding)} + > + {m.common_accept()} + +
+ {:else if awaitingThem} + cancel(binding)} + > + {m.social_details_cancel_invite()} + + {/if}
{/each}
- { - close(); - onfulllist?.(); - }} - > - {m.social_details_view_full_list()} - + {#if onfulllist} + { + close(); + onfulllist(); + }} + > + {m.social_details_view_full_list()} + + {/if} s.signer === callerEname, + await acceptSocialBinding( + gqlUrl, + callerEname, + pendingDocId, + pendingDocParsed, + (payload) => gs.keyService.sign(payload), ); - const signerEname = existingSigs[0]?.signer ?? null; - - if (!alreadySignedByUs) { - const canonical = getCanonicalBindingDocString({ - subject: pendingDocParsed.subject, - type: pendingDocParsed.type, - data: pendingDocParsed.data, - }); - const sig = await globalState.keyService.sign(canonical); - await addCounterpartySignature( - gqlUrl, - callerEname, - callerEname, - pendingDocId, - sig, - ); - } - - // After accepting, remove any duplicate envelopes left over from - // repeat scans of the same QR by the same counterparty — otherwise - // the polling loop will show them on the next drawer open and - // re-prompt the user to "accept" what they just accepted. - if (signerEname) { - try { - await pruneDuplicateUnsignedDocs( - gqlUrl, - callerEname, - pendingDocId, - signerEname, - ); - } catch (err) { - console.warn( - "[SocialBindingDrawer] duplicate prune failed:", - err, - ); - } - } - phase = "success"; onbound?.(); } catch (err) { @@ -178,6 +136,7 @@ async function confirm() { async function decline() { const docId = pendingDocId; + const declinedDoc = pendingDocParsed; pendingDocId = null; pendingDocParsed = null; signerEname = null; @@ -191,7 +150,12 @@ async function decline() { ? vault.ename : `@${vault.ename}`; const gqlUrl = new URL("/graphql", vault.uri).toString(); - await deleteSocialBindingDoc(gqlUrl, callerEname, docId); + await declineSocialBinding( + gqlUrl, + callerEname, + docId, + declinedDoc, + ); // The declined request was counted as a (pending) binding on // the home screen; now that it's gone, tell the parent to @@ -238,21 +202,6 @@ async function initFromVault() { const ename = vault.ename.startsWith("@") ? vault.ename : `@${vault.ename}`; qrValue = `w3ds://social_binding?ename=${encodeURIComponent(ename)}`; phase = "qr"; - - // One-time cleanup: drop leftover unsigned envelopes from contacts the user - // is already bound to, so the poll below never re-prompts for them. Runs - // fire-and-forget — the QR shows immediately and the filter in - // fetchUnsignedSocialDocs still guards the poll until this lands. - if (vault.uri) { - const gqlUrl = new URL("/graphql", vault.uri).toString(); - void pruneBoundSignerDocs(gqlUrl, ename).catch((err) => - console.warn( - "[SocialBindingDrawer] bound-signer prune failed:", - err, - ), - ); - } - startPolling(); } diff --git a/infrastructure/eid-wallet/src/routes/(app)/social-bindings/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/social-bindings/+page.svelte index d34ba2ad0..7a789f3b6 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/social-bindings/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/social-bindings/+page.svelte @@ -40,7 +40,10 @@ async function init() { return; } globalState = gs; + await load(gs); +} +async function load(gs: GlobalState) { try { const vault = await gs.vaultController.vault; if (!vault?.uri || !vault?.ename) { @@ -111,6 +114,28 @@ async function init() { } } +/** + * Re-read after an accept, decline or cancel, then re-point the open sheet at + * the refreshed contact so it shows the new state instead of what was on screen + * when the action started. A contact whose last binding just went away closes + * the sheet with it. + */ +async function refreshAfterAction() { + if (!globalState) return; + const openFor = detailsContact?.counterpartyEname; + await load(globalState); + // load() resolves a name per contact, so it can run for seconds. If the user + // switched contact meanwhile, leave their selection alone. + if (!openFor || detailsContact?.counterpartyEname !== openFor) return; + const updated = contacts.find((c) => c.counterpartyEname === openFor); + if (updated) { + detailsContact = updated; + } else { + detailsContact = null; + detailsOpen = false; + } +} + function roleLabel(role: SocialBindingDisplay["role"]): string { if (role === "both") return m.social_role_sent_received(); if (role === "sent") return m.social_role_sent(); @@ -178,4 +203,6 @@ const subtitle = $derived(