From 60f3b88ce6361d525e357f08f3264e31cd4614c0 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Tue, 22 Sep 2026 20:01:42 +0300 Subject: [PATCH 1/5] fix(eid-wallet): accept, decline or cancel a social binding from the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept and Decline lived only inside the invite drawer — the sheet that shows your own QR code — and only surfaced there through a poll that runs every three seconds while that sheet is open. The bindings list showed the same pending request as "Awaiting confirmation" with nothing to press, and nothing anywhere pointed at the drawer, so a request could only be found by opening your own QR and waiting. Put the action where the request is already visible. Each row in the details sheet now carries what fits its state: Accept and Decline on a received request, Cancel invite on a sent one, nothing on a completed binding. Accepting and declining share one implementation with the drawer and the ePassport page, which had drifted — the ePassport copy never ran the duplicate prune. Signing is passed in as a function so the utils module stays free of any GlobalState dependency. "View on full list" is now rendered only when a caller supplies the callback. The sheet is opened from the full list and nowhere else, so the button had been a second Close since #972. Two defects in the same path broke re-binding with a contact you are already bound to, and both are fixed here: - pruneBoundSignerDocs deleted any unsigned envelope from a bound signer, including a deliberate new invite. It now compares against the timestamp of your counter-signature: envelopes that predate your acceptance are leftovers from the same burst of repeat scans, anything newer is a real request and survives. - The sent-mirror reconcile matched on data.parties alone, so one confirmed binding marked every pending invite to that person confirmed. It now matches invites per relation description, from one scan of the counterparty's vault however many mirrors point at them, and skips docs the counterparty originated — their own mirrors were being counted as invites we sent, which kept a declined invite looking pending forever. Duplicate pruning is scoped to the same relation description too, so accepting one invite no longer deletes a different one from the same person. Closes #1146 --- infrastructure/eid-wallet/messages/en.json | 3 + infrastructure/eid-wallet/messages/ru.json | 3 + infrastructure/eid-wallet/messages/uk.json | 3 + .../src/lib/utils/socialBinding.spec.ts | 353 +++++++++++++ .../eid-wallet/src/lib/utils/socialBinding.ts | 484 ++++++++++++++---- .../src/routes/(app)/ePassport/+page.svelte | 43 +- .../SocialBindingDetailsSheet.svelte | 157 +++++- .../components/SocialBindingDrawer.svelte | 71 +-- .../routes/(app)/social-bindings/+page.svelte | 25 + 9 files changed, 953 insertions(+), 189 deletions(-) create mode 100644 infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts diff --git a/infrastructure/eid-wallet/messages/en.json b/infrastructure/eid-wallet/messages/en.json index b9dae82c9..5cbce6209 100644 --- a/infrastructure/eid-wallet/messages/en.json +++ b/infrastructure/eid-wallet/messages/en.json @@ -657,6 +657,9 @@ "social_bindings_page_title": "Social bindings", "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..6d931de65 100644 --- a/infrastructure/eid-wallet/messages/ru.json +++ b/infrastructure/eid-wallet/messages/ru.json @@ -677,6 +677,9 @@ "social_bindings_page_title": "Социальные связи", "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..30f40eb1c 100644 --- a/infrastructure/eid-wallet/messages/uk.json +++ b/infrastructure/eid-wallet/messages/uk.json @@ -677,6 +677,9 @@ "social_bindings_page_title": "Соціальні зв’язки", "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..f87055d8f --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts @@ -0,0 +1,353 @@ +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 { + acceptSocialBinding, + cancelSentSocialBinding, + declineSocialBinding, + fetchReconciledSocialBindings, + fetchSocialBindings, + fetchUnsignedSocialDocs, + pruneBoundSignerDocs, +} 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 }; +} + +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: "t10" }, + { signer: ME, timestamp: "t20" }, + ], + ), + doc("D2", ME, [BOB, ME], [{ signer: BOB, timestamp: "t15" }]), + doc("D3", ME, [BOB, ME], [{ signer: BOB, timestamp: "t30" }]), + ]); + }); + + 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("prunes only the envelope that predates acceptance", async () => { + await expect(pruneBoundSignerDocs(gql(ME), ME)).resolves.toBe(1); + expect(deletes).toEqual(["D2"]); + }); +}); + +describe("acting on one request of several", () => { + beforeEach(() => { + vaults.set(ME, [ + doc( + "P1", + ME, + [BOB, ME], + [{ signer: BOB, timestamp: "t1" }], + "coffee", + ), + doc( + "P2", + ME, + [BOB, ME], + [{ signer: BOB, timestamp: "t2" }], + "coffee", + ), + doc( + "P3", + ME, + [BOB, ME], + [{ signer: BOB, timestamp: "t3" }], + "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("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: "t1" }], + "coffee", + ), + doc("M2", ME, [ME, BOB], [{ signer: ME, timestamp: "t2" }], "work"), + ]); + vaults.set(BOB, [ + doc( + "R1", + BOB, + [ME, BOB], + [ + { signer: ME, timestamp: "t1" }, + { signer: BOB, timestamp: "t5" }, + ], + "coffee", + ), + doc( + "R2", + BOB, + [ME, BOB], + [{ signer: ME, timestamp: "t2" }], + "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: "t1" }], "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: "t9" }], "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: "t1" }], "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: "t1" }], "x"), + ]); + vaults.set(BOB, [ + doc("R1", BOB, [ME, BOB], [{ signer: ME, timestamp: "t1" }], "x"), + ]); + + await cancelSentSocialBinding(gql(ME), ME, "M1", BOB, "x"); + + expect(deletes).toEqual(["R1", "M1"]); + }); + + it("refuses to withdraw a request the counterparty already confirmed", async () => { + vaults.set(ME, [ + doc("M1", ME, [ME, BOB], [{ signer: ME, timestamp: "t1" }], "x"), + ]); + vaults.set(BOB, [ + doc( + "R1", + BOB, + [ME, BOB], + [ + { signer: ME, timestamp: "t1" }, + { signer: BOB, timestamp: "t5" }, + ], + "x", + ), + ]); + + await expect( + cancelSentSocialBinding(gql(ME), ME, "M1", BOB, "x"), + ).rejects.toThrow(/just confirmed/); + 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: "t1" }], "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..130d79e19 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; @@ -473,32 +474,56 @@ export async function deleteSocialBindingDoc( } /** - * 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". + * Signers the caller has ALREADY completed a binding with, mapped to the + * timestamp of the caller's most recent counter-signature with them. 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 + * a bound signer can leave 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 (a second + * binding with a different relation description, say) and must be kept. */ function collectBoundSigners( edges: BindingDocEdge[], normalizedCaller: string, -): Set { - const boundSigners = new Set(); +): Map { + const boundSigners = 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 acceptedAt = callerSig.timestamp ?? ""; + const previous = boundSigners.get(originator); + if (previous === undefined || acceptedAt > previous) { + boundSigners.set(originator, acceptedAt); } } return boundSigners; } +/** + * True when an unsigned envelope predates the caller's acceptance of an earlier + * binding with the same signer — see collectBoundSigners. + */ +function isStaleLeftover( + parsed: BindingDocParsed, + boundSigners: Map, +): boolean { + const sigs = Array.isArray(parsed.signatures) ? parsed.signatures : []; + const originator = sigs[0]?.signer; + if (!originator) return false; + const acceptedAt = boundSigners.get(originator); + if (acceptedAt === undefined) return false; + return (sigs[0]?.timestamp ?? "") <= acceptedAt; +} + /** * Poll the caller's own eVault for social_connection binding documents * that were created by someone else (i.e. the signer wrote a doc about themselves @@ -533,17 +558,15 @@ 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; + if (isStaleLeftover(parsed, boundSigners)) 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 +585,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 +615,15 @@ export async function pruneDuplicateUnsignedDocs( const parsed = edge.node.parsed; if (!parsed || parsed.type !== "social_connection") return false; if (parsed.subject !== normalized) return false; + const description = + typeof parsed.data?.relation_description === "string" + ? parsed.data.relation_description + : ""; + if (description !== 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; @@ -615,12 +648,13 @@ export async function pruneDuplicateUnsignedDocs( /** * 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. + * of the caller's QR around the time of the original binding and would otherwise + * re-surface as duplicate "Social Connection Request" prompts for a contact + * already added. * - * 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. + * Only envelopes that predate the caller's acceptance are removed; a newer one + * is a deliberate new invite from that contact and is left alone. The + * fully-signed doc is never touched. * * Intended as a one-time cleanup when the invite drawer opens. Returns the * number of envelopes deleted. @@ -647,8 +681,7 @@ export async function pruneBoundSignerDocs( 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); + return isStaleLeftover(parsed, boundSigners); }); let deleted = 0; @@ -667,6 +700,156 @@ export async function pruneBoundSignerDocs( return deleted; } +// --------------------------------------------------------------------------- +// Acting on a pending request +// --------------------------------------------------------------------------- + +/** + * 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. + * + * 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. + * + * @param sign - signs the doc's canonical form; supplied by the caller so this + * module stays free of any GlobalState dependency. + */ +export async function acceptSocialBinding( + ownGqlUrl: string, + callerEname: string, + docId: string, + parsed: BindingDocParsed, + sign: (payload: string) => Promise, +): Promise { + const normalized = callerEname.startsWith("@") + ? callerEname + : `@${callerEname}`; + + 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, + ); + } + + await pruneDuplicatesOf(ownGqlUrl, normalized, docId, parsed); +} + +/** 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, + typeof parsed.data?.relation_description === "string" + ? parsed.data.relation_description + : "", + ); + } catch (err) { + console.warn("[socialBinding] duplicate prune failed:", 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); +} + +/** + * 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) { + throw new Error( + "This request was just confirmed — reopen the list to see it.", + ); + } + // Already declined over there; 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, + ); + } + + await deleteSocialBindingDoc(ownGqlUrl, normalized, mirrorDocId); +} + // --------------------------------------------------------------------------- // Listing completed social bindings // --------------------------------------------------------------------------- @@ -686,6 +869,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. @@ -735,6 +924,7 @@ export async function fetchSocialBindings( : "", mutuallySigned: sigs.length >= 2, role, + parsed, }); } @@ -747,10 +937,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 +947,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}`; - - const foreignGqlUrl = await resolveVaultUri(normalizedCounter); +async function fetchRemoteDocsWithSelf( + normalizedSelf: string, + normalizedCounter: string, +): Promise<{ gqlUrl: string; docs: RemoteSocialDoc[] }> { + const gqlUrl = 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 +997,86 @@ 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: + typeof parsed.data?.relation_description === "string" + ? (parsed.data.relation_description as string) + : "", + 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 +1090,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 }; - } - 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; + for (const [docId, status] of resolveSentStatuses( + mirrors, + remote.docs, + )) { + statuses.set(docId, status); } - // 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..114e3eb31 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,40 @@ @@ -70,15 +152,29 @@ function close() { + {#if actionError} +

{actionError}

+ {/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 +193,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 +137,7 @@ async function confirm() { async function decline() { const docId = pendingDocId; + const declinedDoc = pendingDocParsed; pendingDocId = null; pendingDocParsed = null; signerEname = null; @@ -191,7 +151,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 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..7205b7c15 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,26 @@ 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); + if (!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 +201,6 @@ const subtitle = $derived( From 2de283198c913d7c2770892b72e0b2f088208871 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Wed, 23 Sep 2026 12:18:10 +0300 Subject: [PATCH 2/5] fix(eid-wallet): scope the accepted-invite cutoff to one relation description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepting an invite records the time of the counter-signature, and any older unsigned envelope from that signer was treated as a leftover from the same burst of repeat scans. Keyed on the signer alone, that swallowed the person's other pending invites: send "coffee" then "work", accept "coffee", and "work" stopped surfacing in the invite drawer and was deleted the next time it opened. The sender's reconcile then read it as declined and dropped their copy too. The accept-time prune was already scoped to the relation description; the cutoff now matches it, so an invite is only ever superseded by an acceptance of that same invite. This is the situation #1146 reports, and it survived the first round because acting from the list never opens the drawer. The spec missed it because its placeholder timestamps ("t1") sort after any ISO date, so no cutoff check ever fired. They are real ISO strings now, and a test covers accepting one of two invites with different descriptions. Two smaller fixes in the same path: - Cancel claimed "just confirmed" whenever any doc with that description remained on the counterparty's side. An older confirmed binding — usually the empty-description one — matched too, so a declined invite reported the opposite of what happened. With only the description to match on, the two cannot be told apart, so the message no longer guesses. - A failed action left its error on screen for whichever contact was opened next. The error is now tied to the contact it belongs to and cleared when the sheet closes, and the list re-reads after a failure as well as a success, since a failure usually means the binding moved on without us. --- .../src/lib/utils/socialBinding.spec.ts | 119 ++++++++++++++---- .../eid-wallet/src/lib/utils/socialBinding.ts | 68 ++++++---- .../SocialBindingDetailsSheet.svelte | 19 ++- 3 files changed, 158 insertions(+), 48 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts b/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts index f87055d8f..15c60f78a 100644 --- a/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts @@ -45,6 +45,16 @@ function 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`; } @@ -158,12 +168,12 @@ describe("leftovers from an already-bound signer", () => { ME, [BOB, ME], [ - { signer: BOB, timestamp: "t10" }, - { signer: ME, timestamp: "t20" }, + { signer: BOB, timestamp: at(190) }, + { signer: ME, timestamp: at(180) }, ], ), - doc("D2", ME, [BOB, ME], [{ signer: BOB, timestamp: "t15" }]), - doc("D3", ME, [BOB, ME], [{ signer: BOB, timestamp: "t30" }]), + doc("D2", ME, [BOB, ME], [{ signer: BOB, timestamp: at(185) }]), + doc("D3", ME, [BOB, ME], [{ signer: BOB, timestamp: at(170) }]), ]); }); @@ -185,21 +195,21 @@ describe("acting on one request of several", () => { "P1", ME, [BOB, ME], - [{ signer: BOB, timestamp: "t1" }], + [{ signer: BOB, timestamp: at(199) }], "coffee", ), doc( "P2", ME, [BOB, ME], - [{ signer: BOB, timestamp: "t2" }], + [{ signer: BOB, timestamp: at(198) }], "coffee", ), doc( "P3", ME, [BOB, ME], - [{ signer: BOB, timestamp: "t3" }], + [{ signer: BOB, timestamp: at(197) }], "work", ), ]); @@ -215,6 +225,20 @@ describe("acting on one request of several", () => { 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 — P3 in the drawer, and the situation #1146 reports. + const parsed = edgeOf((vaults.get(ME) as Doc[])[0]).node.parsed; + await acceptSocialBinding(gql(ME), ME, "P1", parsed, async () => "sig"); + + await expect(pruneBoundSignerDocs(gql(ME), ME)).resolves.toBe(0); + const unsigned = await fetchUnsignedSocialDocs(gql(ME), ME); + expect( + unsigned.map((e) => e.node.parsed?.data.relation_description), + ).toEqual(["work"]); + }); + 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); @@ -231,10 +255,16 @@ describe("reconciling sent mirrors", () => { "M1", ME, [ME, BOB], - [{ signer: ME, timestamp: "t1" }], + [{ signer: ME, timestamp: at(199) }], "coffee", ), - doc("M2", ME, [ME, BOB], [{ signer: ME, timestamp: "t2" }], "work"), + doc( + "M2", + ME, + [ME, BOB], + [{ signer: ME, timestamp: at(198) }], + "work", + ), ]); vaults.set(BOB, [ doc( @@ -242,8 +272,8 @@ describe("reconciling sent mirrors", () => { BOB, [ME, BOB], [ - { signer: ME, timestamp: "t1" }, - { signer: BOB, timestamp: "t5" }, + { signer: ME, timestamp: at(199) }, + { signer: BOB, timestamp: at(195) }, ], "coffee", ), @@ -251,7 +281,7 @@ describe("reconciling sent mirrors", () => { "R2", BOB, [ME, BOB], - [{ signer: ME, timestamp: "t2" }], + [{ signer: ME, timestamp: at(198) }], "work", ), ]); @@ -265,12 +295,18 @@ describe("reconciling sent mirrors", () => { it("drops a mirror the counterparty declined, ignoring their own mirror", async () => { vaults.set(ME, [ - doc("M1", ME, [ME, BOB], [{ signer: ME, timestamp: "t1" }], "x"), + 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: "t9" }], "x"), + doc( + "B1", + BOB, + [BOB, ME], + [{ signer: BOB, timestamp: at(191) }], + "x", + ), ]); const out = await fetchReconciledSocialBindings(gql(ME), ME); @@ -280,7 +316,7 @@ describe("reconciling sent mirrors", () => { it("keeps everything when the counterparty vault can't be reached", async () => { vaults.set(ME, [ - doc("M1", ME, [ME, BOB], [{ signer: ME, timestamp: "t1" }], "x"), + doc("M1", ME, [ME, BOB], [{ signer: ME, timestamp: at(199) }], "x"), ]); const handler = vaultHandler(); vi.stubGlobal( @@ -303,10 +339,16 @@ describe("reconciling sent mirrors", () => { 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: "t1" }], "x"), + doc("M1", ME, [ME, BOB], [{ signer: ME, timestamp: at(199) }], "x"), ]); vaults.set(BOB, [ - doc("R1", BOB, [ME, BOB], [{ signer: ME, timestamp: "t1" }], "x"), + doc( + "R1", + BOB, + [ME, BOB], + [{ signer: ME, timestamp: at(199) }], + "x", + ), ]); await cancelSentSocialBinding(gql(ME), ME, "M1", BOB, "x"); @@ -314,9 +356,36 @@ describe("cancelling a sent invite", () => { 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(/no longer 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: "t1" }], "x"), + doc("M1", ME, [ME, BOB], [{ signer: ME, timestamp: at(199) }], "x"), ]); vaults.set(BOB, [ doc( @@ -324,8 +393,8 @@ describe("cancelling a sent invite", () => { BOB, [ME, BOB], [ - { signer: ME, timestamp: "t1" }, - { signer: BOB, timestamp: "t5" }, + { signer: ME, timestamp: at(199) }, + { signer: BOB, timestamp: at(195) }, ], "x", ), @@ -333,7 +402,7 @@ describe("cancelling a sent invite", () => { await expect( cancelSentSocialBinding(gql(ME), ME, "M1", BOB, "x"), - ).rejects.toThrow(/just confirmed/); + ).rejects.toThrow(/no longer pending/); expect(deletes).toEqual([]); }); }); @@ -341,7 +410,13 @@ describe("cancelling a sent invite", () => { 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: "t1" }], "hi"), + doc( + "P1", + ME, + [BOB, ME], + [{ signer: BOB, timestamp: at(199) }], + "hi", + ), ]); const [summary] = await fetchSocialBindings(gql(ME), ME); diff --git a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts index 130d79e19..e6634af4e 100644 --- a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts @@ -473,23 +473,37 @@ 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, mapped to the - * timestamp of the caller's most recent counter-signature with them. A - * completed binding is a doc subject=@caller that the caller has counter-signed; - * its originator is signatures[0].signer. + * 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 - * a bound signer can leave 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 (a second - * binding with a different relation description, say) and must be kept. + * 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. */ -function collectBoundSigners( +function collectAcceptanceCutoffs( edges: BindingDocEdge[], normalizedCaller: string, ): Map { - const boundSigners = new Map(); + const cutoffs = new Map(); for (const edge of edges) { const parsed = edge.node.parsed; if (!parsed || parsed.type !== "social_connection") continue; @@ -499,27 +513,28 @@ function collectBoundSigners( const originator = sigs[0]?.signer; if (!callerSig || !originator || originator === normalizedCaller) continue; + const key = cutoffKey(originator, relationOf(parsed)); const acceptedAt = callerSig.timestamp ?? ""; - const previous = boundSigners.get(originator); + const previous = cutoffs.get(key); if (previous === undefined || acceptedAt > previous) { - boundSigners.set(originator, acceptedAt); + cutoffs.set(key, acceptedAt); } } - return boundSigners; + return cutoffs; } /** - * True when an unsigned envelope predates the caller's acceptance of an earlier - * binding with the same signer — see collectBoundSigners. + * True when an unsigned envelope predates the caller's acceptance of the same + * invite from the same person — see collectAcceptanceCutoffs. */ function isStaleLeftover( parsed: BindingDocParsed, - boundSigners: Map, + cutoffs: Map, ): boolean { const sigs = Array.isArray(parsed.signatures) ? parsed.signatures : []; const originator = sigs[0]?.signer; if (!originator) return false; - const acceptedAt = boundSigners.get(originator); + const acceptedAt = cutoffs.get(cutoffKey(originator, relationOf(parsed))); if (acceptedAt === undefined) return false; return (sigs[0]?.timestamp ?? "") <= acceptedAt; } @@ -543,7 +558,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; @@ -558,7 +573,7 @@ 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. - if (isStaleLeftover(parsed, boundSigners)) return false; + if (isStaleLeftover(parsed, cutoffs)) return false; return true; }); @@ -672,7 +687,7 @@ export async function pruneBoundSignerDocs( }>(ownGqlUrl, callerEname, SOCIAL_BINDING_DOCS_QUERY); const edges = data.bindingDocuments?.edges ?? []; - const boundSigners = collectBoundSigners(edges, normalized); + const cutoffs = collectAcceptanceCutoffs(edges, normalized); const stale = edges.filter((edge) => { const parsed = edge.node.parsed; @@ -681,7 +696,7 @@ export async function pruneBoundSignerDocs( 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; - return isStaleLeftover(parsed, boundSigners); + return isStaleLeftover(parsed, cutoffs); }); let deleted = 0; @@ -829,11 +844,18 @@ export async function cancelSentSocialBinding( 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( - "This request was just confirmed — reopen the list to see it.", + "This request is no longer pending — reopen the list to see where it landed.", ); } - // Already declined over there; only the orphaned mirror is left. + // 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. 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 114e3eb31..f14647782 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelte @@ -34,6 +34,14 @@ let { /** docId of the binding whose action is in flight, if any. */ let busyDocId = $state(null); let actionError = $state(null); +/** Whose sheet the error belongs to, so it can't leak onto the next contact. */ +let errorFor = $state(null); + +const visibleError = $derived( + actionError !== null && errorFor === (contact?.counterpartyEname ?? null) + ? actionError + : null, +); function roleLabel(role: "sent" | "received" | "both"): string { if (role === "both") return m.social_role_sent_received(); @@ -58,6 +66,8 @@ function formatTimestamp(iso: string): string { } function close() { + actionError = null; + errorFor = null; isOpen = false; onOpenChange?.(false); } @@ -90,9 +100,9 @@ async function runAction( if (busyDocId) return; busyDocId = binding.docId; actionError = null; + errorFor = contact?.counterpartyEname ?? null; try { await action(await callerContext()); - onchanged?.(); } catch (err) { console.error("[SocialBindingDetailsSheet] action failed:", err); actionError = @@ -102,6 +112,9 @@ async function runAction( } finally { busyDocId = null; } + // Re-read either way: a failure usually means the binding moved on without + // us, and the list is what shows where it actually landed. + onchanged?.(); } function accept(binding: SocialBindingSummary) { @@ -152,8 +165,8 @@ function cancel(binding: SocialBindingSummary) {
- {#if actionError} -

{actionError}

+ {#if visibleError} +

{visibleError}

{/if}
From 1c0ef5d0dcb678e2d22c5213f117f4e91581eccd Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Wed, 23 Sep 2026 16:09:00 +0300 Subject: [PATCH 3/5] fix(eid-wallet): word the cancel refusal through the message catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cancel path's one user-facing refusal was an English sentence thrown from socialBinding.ts. That module deliberately carries no i18n — it is the one the spec drives directly, without the app's module aliases — so it now throws a code and the details sheet words it. Adds the four keys this branch introduces in all three locales, and rebuilds the published catalog. --- docs/static/translations.json | 12 ++++++++++++ infrastructure/eid-wallet/messages/en.json | 1 + infrastructure/eid-wallet/messages/ru.json | 1 + infrastructure/eid-wallet/messages/uk.json | 1 + .../eid-wallet/src/lib/utils/socialBinding.spec.ts | 5 +++-- .../eid-wallet/src/lib/utils/socialBinding.ts | 10 +++++++--- .../components/SocialBindingDetailsSheet.svelte | 13 ++++++++++++- 7 files changed, 37 insertions(+), 6 deletions(-) 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 5cbce6209..02041cec7 100644 --- a/infrastructure/eid-wallet/messages/en.json +++ b/infrastructure/eid-wallet/messages/en.json @@ -655,6 +655,7 @@ "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", diff --git a/infrastructure/eid-wallet/messages/ru.json b/infrastructure/eid-wallet/messages/ru.json index 6d931de65..8f37761cf 100644 --- a/infrastructure/eid-wallet/messages/ru.json +++ b/infrastructure/eid-wallet/messages/ru.json @@ -675,6 +675,7 @@ "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": "· Ожидает вашего подтверждения", diff --git a/infrastructure/eid-wallet/messages/uk.json b/infrastructure/eid-wallet/messages/uk.json index 30f40eb1c..75bde60e0 100644 --- a/infrastructure/eid-wallet/messages/uk.json +++ b/infrastructure/eid-wallet/messages/uk.json @@ -675,6 +675,7 @@ "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": "· Очікує вашого підтвердження", diff --git a/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts b/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts index 15c60f78a..f3d074275 100644 --- a/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts @@ -6,6 +6,7 @@ vi.mock("$env/static/public", () => ({ })); import { + CANCEL_NOT_PENDING, acceptSocialBinding, cancelSentSocialBinding, declineSocialBinding, @@ -379,7 +380,7 @@ describe("cancelling a sent invite", () => { await expect( cancelSentSocialBinding(gql(ME), ME, "M2", BOB, ""), - ).rejects.toThrow(/no longer pending/); + ).rejects.toThrow(CANCEL_NOT_PENDING); expect(deletes).toEqual([]); }); @@ -402,7 +403,7 @@ describe("cancelling a sent invite", () => { await expect( cancelSentSocialBinding(gql(ME), ME, "M1", BOB, "x"), - ).rejects.toThrow(/no longer pending/); + ).rejects.toThrow(CANCEL_NOT_PENDING); expect(deletes).toEqual([]); }); }); diff --git a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts index e6634af4e..87fccda02 100644 --- a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts @@ -811,6 +811,12 @@ export async function declineSocialBinding( 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. @@ -850,9 +856,7 @@ export async function cancelSentSocialBinding( // 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( - "This request is no longer pending — reopen the list to see where it landed.", - ); + 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. 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 f14647782..43eebf5fe 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelte @@ -4,6 +4,7 @@ import { m } from "$lib/i18n"; import { getLocale } from "$lib/paraglide/runtime"; import { BottomSheet, ButtonAction } from "$lib/ui"; import { + CANCEL_NOT_PENDING, type SocialBindingSummary, acceptSocialBinding, cancelSentSocialBinding, @@ -93,6 +94,16 @@ async function callerContext(): Promise { }; } +/** + * socialBinding.ts carries no i18n so it stays testable without the app's module + * aliases; its one user-facing refusal arrives as a code and is worded here. + */ +function messageFor(err: Error): string { + return err.message === CANCEL_NOT_PENDING + ? m.social_cancel_not_pending() + : err.message; +} + async function runAction( binding: SocialBindingSummary, action: (ctx: CallerContext) => Promise, @@ -107,7 +118,7 @@ async function runAction( console.error("[SocialBindingDetailsSheet] action failed:", err); actionError = err instanceof Error - ? err.message + ? messageFor(err) : m.social_drawer_error_generic(); } finally { busyDocId = null; From f78f72877131fad441bba03bace90f2fe7449a70 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Thu, 24 Sep 2026 21:10:06 +0300 Subject: [PATCH 4/5] fix(eid-wallet): keep the reload from stealing the sheet back refreshAfterAction captured the open contact, awaited a reload that resolves a name per contact over the network, then pointed the sheet at the captured one without checking it was still the selection. Close the sheet and open someone else while that runs and the sheet snaps back to the first contact, with its Accept, Decline and Cancel buttons now acting on them. Also route the four remaining inlined reads of relation_description through relationOf. It is the matching key for pruning, cutoffs, reconcile and cancel now, and this branch has already been bitten once by two copies of one rule drifting apart. --- .../eid-wallet/src/lib/utils/socialBinding.ts | 20 ++++--------------- .../routes/(app)/social-bindings/+page.svelte | 4 +++- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts index 87fccda02..f17bff98a 100644 --- a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts @@ -630,11 +630,7 @@ export async function pruneDuplicateUnsignedDocs( const parsed = edge.node.parsed; if (!parsed || parsed.type !== "social_connection") return false; if (parsed.subject !== normalized) return false; - const description = - typeof parsed.data?.relation_description === "string" - ? parsed.data.relation_description - : ""; - if (description !== relationDescription) 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 @@ -780,9 +776,7 @@ async function pruneDuplicatesOf( normalizedCaller, docId, signer, - typeof parsed.data?.relation_description === "string" - ? parsed.data.relation_description - : "", + relationOf(parsed), ); } catch (err) { console.warn("[socialBinding] duplicate prune failed:", err); @@ -944,10 +938,7 @@ 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, @@ -1030,10 +1021,7 @@ async function fetchRemoteDocsWithSelf( docs.push({ id: edge.node.id, - relationDescription: - typeof parsed.data?.relation_description === "string" - ? (parsed.data.relation_description as string) - : "", + relationDescription: relationOf(parsed), signatureCount: sigs.length, timestamp: sigs[0]?.timestamp ?? "", }); 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 7205b7c15..7a789f3b6 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/social-bindings/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/social-bindings/+page.svelte @@ -124,7 +124,9 @@ async function refreshAfterAction() { if (!globalState) return; const openFor = detailsContact?.counterpartyEname; await load(globalState); - if (!openFor) return; + // 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; From 0daf191d5e8eee271ef58a151f20d56f96764774 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Fri, 25 Sep 2026 09:29:06 +0300 Subject: [PATCH 5/5] fix(eid-wallet): stop deleting invites on a cross-device timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pruneBoundSignerDocs ran on every invite-drawer open and deleted unsigned envelopes whose signature predated the caller's acceptance of the same invite. Those two timestamps are written by two different phones — the envelope's by the scanner, the cutoff by the acceptor — so a clock a few minutes behind makes a genuine new invite look like a leftover, and it was destroyed before the recipient ever saw it. The sender's mirror then reconciled to "declined". There is no way to tell the two apart from here: a server-assigned time is not exposed, and a tolerance window wide enough to absorb clock skew also shields the real leftovers it exists to clear. So stop guessing, and stop deleting. The same check still keeps a leftover out of the drawer's poll, which is what stops it re-prompting; the envelope now stays in the bindings list, where this branch has just put Accept and Decline, so the user settles it. Repeat scans are already collapsed at accept time by pruneDuplicateUnsignedDocs, which is scoped to the relation description, so what this deletion still caught was a narrow race and legacy envelopes. Costs a leftover showing as a row instead of being cleared silently, which partially reopens #1001. Showing one row too many beats destroying a real invite. --- .../src/lib/utils/socialBinding.spec.ts | 21 ++++-- .../eid-wallet/src/lib/utils/socialBinding.ts | 65 +++---------------- .../components/SocialBindingDrawer.svelte | 16 ----- 3 files changed, 24 insertions(+), 78 deletions(-) diff --git a/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts b/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts index f3d074275..e0827905a 100644 --- a/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts @@ -13,7 +13,6 @@ import { fetchReconciledSocialBindings, fetchSocialBindings, fetchUnsignedSocialDocs, - pruneBoundSignerDocs, } from "./socialBinding"; const ME = "@me"; @@ -183,9 +182,18 @@ describe("leftovers from an already-bound signer", () => { expect(unsigned.map((e) => e.node.id)).toEqual(["D3"]); }); - it("prunes only the envelope that predates acceptance", async () => { - await expect(pruneBoundSignerDocs(gql(ME), ME)).resolves.toBe(1); - expect(deletes).toEqual(["D2"]); + 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", + ]); }); }); @@ -229,15 +237,16 @@ describe("acting on one request of several", () => { 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 — P3 in the drawer, and the situation #1146 reports. + // 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"); - await expect(pruneBoundSignerDocs(gql(ME), ME)).resolves.toBe(0); 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 () => { diff --git a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts index f17bff98a..bc5481428 100644 --- a/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.ts @@ -498,6 +498,9 @@ function cutoffKey(signer: string, relationDescription: string): string { * 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 collectAcceptanceCutoffs( edges: BindingDocEdge[], @@ -572,7 +575,12 @@ 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. + // 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; }); @@ -656,61 +664,6 @@ export async function pruneDuplicateUnsignedDocs( return deleted; } -/** - * 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 around the time of the original binding and would otherwise - * re-surface as duplicate "Social Connection Request" prompts for a contact - * already added. - * - * Only envelopes that predate the caller's acceptance are removed; a newer one - * is a deliberate new invite from that contact and is left alone. The - * fully-signed doc is never touched. - * - * Intended as a one-time cleanup when the invite drawer opens. Returns the - * number of envelopes deleted. - */ -export async function pruneBoundSignerDocs( - ownGqlUrl: string, - callerEname: string, -): Promise { - const normalized = callerEname.startsWith("@") - ? callerEname - : `@${callerEname}`; - - const data = await vaultGqlRequest<{ - bindingDocuments: { edges: BindingDocEdge[] }; - }>(ownGqlUrl, callerEname, SOCIAL_BINDING_DOCS_QUERY); - - const edges = data.bindingDocuments?.edges ?? []; - const cutoffs = collectAcceptanceCutoffs(edges, normalized); - - 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; - return isStaleLeftover(parsed, cutoffs); - }); - - 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, - ); - } - } - return deleted; -} - // --------------------------------------------------------------------------- // Acting on a pending request // --------------------------------------------------------------------------- diff --git a/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDrawer.svelte b/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDrawer.svelte index c9b8f2db8..d63137452 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDrawer.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDrawer.svelte @@ -8,7 +8,6 @@ import { declineSocialBinding, fetchNameFromVault, fetchUnsignedSocialDocs, - pruneBoundSignerDocs, resolveVaultUri, } from "$lib/utils"; import { onDestroy } from "svelte"; @@ -203,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(); }