Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"test": "vitest run --project eid-wallet",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
128 changes: 128 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { loadPersonalBindings } from "./personalBinding";

const GQL_URL = "http://vault.test:4000/graphql";
const ENAME = "@ada-0000-0000";

type StubResponse = {
ok: boolean;
status?: number;
json: () => Promise<unknown>;
};

function docs(edges: unknown[]): StubResponse {
return {
ok: true,
json: async () => ({ data: { bindingDocuments: { edges } } }),
};
}

const PHOTO_EDGES = [{ node: { id: "photo-1", parsed: null } }];
// pickLatestEdge orders by signatures[0].timestamp and ignores anything without
// one, so these fixtures must carry a signature to be seen at all.
const PARAM_EDGES = [
{
node: {
id: "param-1",
parsed: {
type: "personal_parameters",
data: { text: "5'9\", brown eyes" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];
const SECURITY_EDGES = [
{
node: {
id: "sec-1",
parsed: {
type: "security_question",
data: { question: "First pet?" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];

/** Route each stubbed request by the `type` variable the caller sent. */
function stubVault(byType: (type: string) => StubResponse) {
const mock = vi.fn(async (_url: string, init: { body: string }) => {
const body = JSON.parse(init.body) as {
variables?: { type?: string };
};
return byType(body.variables?.type ?? "");
});
vi.stubGlobal("fetch", mock);
return mock;
}

const allGood = (type: string): StubResponse => {
if (type === "photograph") return docs(PHOTO_EDGES);
if (type === "personal_parameters") return docs(PARAM_EDGES);
return docs(SECURITY_EDGES);
};

afterEach(() => {
vi.unstubAllGlobals();
});

describe("loadPersonalBindings — count-only path (home screen)", () => {
it("returns every mark when all queries succeed", async () => {
stubVault(allGood);

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(1);
expect(loaded.parameters?.text).toBe("5'9\", brown eyes");
expect(loaded.securityQuestion?.question).toBe("First pet?");
});

// The #1086 regression: these used to resolve with an empty result, which
// the caller then wrote over the store as a full replace — wiping marks the
// user still had. Rejecting is what lets the caller keep the last good state.
it("rejects when a query fails at the HTTP level, instead of reporting no marks", async () => {
stubVault((type) =>
type === "photograph"
? { ok: false, status: 503, json: async () => ({}) }
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/503/);
});

it("rejects on a GraphQL-level error (HTTP 200 + errors)", async () => {
stubVault((type) =>
type === "security_question"
? {
ok: true,
json: async () => ({
errors: [{ message: "token expired" }],
data: null,
}),
}
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/token expired/);
});

it("still reports genuinely absent marks as empty, not as a failure", async () => {
stubVault(() => docs([]));

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(0);
expect(loaded.parameters).toBeNull();
expect(loaded.securityQuestion).toBeNull();
});
});
68 changes: 23 additions & 45 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -466,52 +466,30 @@ async function _loadPersonalBindingsCountOnly(
ownerEname: string,
): Promise<LoadedPersonalBindings> {
type Resp = { bindingDocuments: { edges: BindingDocEdge[] } };
const empty: Resp = { bindingDocuments: { edges: [] } };

const [photosResult, paramsResult, securityResult] =
await Promise.allSettled([
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_PHOTO_IDS_QUERY,
{ type: "photograph" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

if (photosResult.status === "rejected")
console.warn(
"[personalBinding] photograph count failed:",
photosResult.reason,
);
if (paramsResult.status === "rejected")
console.warn(
"[personalBinding] personal_parameters fetch failed:",
paramsResult.reason,
);
if (securityResult.status === "rejected")
console.warn(
"[personalBinding] security_question fetch failed:",
securityResult.reason,
);

const photosResp =
photosResult.status === "fulfilled" ? photosResult.value : empty;
const paramsResp =
paramsResult.status === "fulfilled" ? paramsResult.value : empty;
const securityResp =
securityResult.status === "fulfilled" ? securityResult.value : empty;
// Promise.all, not allSettled: callers feed this straight into a full-store
// replace, so substituting an empty result for a failed query is not
// graceful degradation — it erases marks the user still has. A partial
// result is no safer than none, since the replace overwrites either way.
// Rejecting lets the caller keep the last good state. Matches the sibling
// path in loadPersonalBindings above.
const [photosResp, paramsResp, securityResp] = await Promise.all([
vaultGqlRequest<Resp>(gqlUrl, ownerEname, PERSONAL_PHOTO_IDS_QUERY, {
type: "photograph",
}),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

const photographs: LoadedPhotograph[] = (
photosResp.bindingDocuments?.edges ?? []
Expand Down
96 changes: 96 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { clearVaultUriCache, resolveVaultUri } from "./socialBinding";

const ENAME = "@ada-0000-0000";
const OTHER = "@grace-1111-1111";
const VAULT_URI = "http://vault.test:4000";
const EXPECTED = "http://vault.test:4000/graphql";

/** Registry responds with a vault URI; counts how often it was actually hit. */
function stubRegistry(uri = VAULT_URI) {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ uri }),
}));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

beforeEach(() => {
clearVaultUriCache();
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("resolveVaultUri — registry lookup memoisation", () => {
it("hits the registry once for repeated sequential lookups", async () => {
const fetchMock = stubRegistry();

expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("coalesces concurrent lookups of the same eName into one request", async () => {
const fetchMock = stubRegistry();

// This is the real shape of the bug: the binding reconcile and the name
// lookup resolve the same counterparty at the same time, via Promise.all.
const results = await Promise.all([
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
]);

expect(results).toEqual([EXPECTED, EXPECTED, EXPECTED, EXPECTED]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("treats a bare eName and an @-prefixed one as the same cache entry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri("ada-0000-0000");
await resolveVaultUri("@ada-0000-0000");

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("keeps distinct eNames independent", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
await resolveVaultUri(OTHER);

expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("does not cache failures — a later lookup retries the registry", async () => {
const failing = vi.fn(async () => ({ ok: false, status: 503 }));
vi.stubGlobal("fetch", failing);

await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);
await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);

expect(failing).toHaveBeenCalledTimes(2);
});

it("clearVaultUriCache forces the next lookup back to the registry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
clearVaultUriCache();
await resolveVaultUri(ENAME);

expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
47 changes: 42 additions & 5 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,11 +29,17 @@ export interface BindingDocEdge {
// Registry resolution
// ---------------------------------------------------------------------------

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;
const VAULT_URI_TTL_MS = 5 * 60_000;
const vaultUriCache = new Map<string, { uri: string; at: number }>();
const vaultUriInFlight = new Map<string, Promise<string>>();

/** Drop the memoised registry lookups (sign-out, tests). */
export function clearVaultUriCache(): void {
vaultUriCache.clear();
vaultUriInFlight.clear();
}

async function fetchVaultUri(normalized: string): Promise<string> {
const url = new URL(
`resolve?w3id=${encodeURIComponent(normalized)}`,
PUBLIC_REGISTRY_URL,
Expand All@@ -52,6 +58,37 @@ export async function resolveVaultUri(ename: string): Promise<string> {
: new URL("/graphql", base).toString();
}

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*
* Memoised: an eName→URI mapping only changes when a vault is re-provisioned,
* but several layers resolve the same counterparty independently on one screen
* load (the binding reconcile and the name lookup, for a start), so the same
* round trip was being paid 3-4× per contact. Concurrent callers share one
* in-flight request; failures are not cached.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;

const cached = vaultUriCache.get(normalized);
if (cached && Date.now() - cached.at < VAULT_URI_TTL_MS) return cached.uri;

const pending = vaultUriInFlight.get(normalized);
if (pending) return pending;

const request = fetchVaultUri(normalized)
.then((uri) => {
vaultUriCache.set(normalized, { uri, at: Date.now() });
return uri;
})
.finally(() => {
vaultUriInFlight.delete(normalized);
});

vaultUriInFlight.set(normalized, request);
return request;
}

// ---------------------------------------------------------------------------
// Generic cross-vault GraphQL request
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"test": "vitest run --project eid-wallet",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
128 changes: 128 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { loadPersonalBindings } from "./personalBinding";

const GQL_URL = "http://vault.test:4000/graphql";
const ENAME = "@ada-0000-0000";

type StubResponse = {
ok: boolean;
status?: number;
json: () => Promise<unknown>;
};

function docs(edges: unknown[]): StubResponse {
return {
ok: true,
json: async () => ({ data: { bindingDocuments: { edges } } }),
};
}

const PHOTO_EDGES = [{ node: { id: "photo-1", parsed: null } }];
// pickLatestEdge orders by signatures[0].timestamp and ignores anything without
// one, so these fixtures must carry a signature to be seen at all.
const PARAM_EDGES = [
{
node: {
id: "param-1",
parsed: {
type: "personal_parameters",
data: { text: "5'9\", brown eyes" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];
const SECURITY_EDGES = [
{
node: {
id: "sec-1",
parsed: {
type: "security_question",
data: { question: "First pet?" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];

/** Route each stubbed request by the `type` variable the caller sent. */
function stubVault(byType: (type: string) => StubResponse) {
const mock = vi.fn(async (_url: string, init: { body: string }) => {
const body = JSON.parse(init.body) as {
variables?: { type?: string };
};
return byType(body.variables?.type ?? "");
});
vi.stubGlobal("fetch", mock);
return mock;
}

const allGood = (type: string): StubResponse => {
if (type === "photograph") return docs(PHOTO_EDGES);
if (type === "personal_parameters") return docs(PARAM_EDGES);
return docs(SECURITY_EDGES);
};

afterEach(() => {
vi.unstubAllGlobals();
});

describe("loadPersonalBindings — count-only path (home screen)", () => {
it("returns every mark when all queries succeed", async () => {
stubVault(allGood);

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(1);
expect(loaded.parameters?.text).toBe("5'9\", brown eyes");
expect(loaded.securityQuestion?.question).toBe("First pet?");
});

// The #1086 regression: these used to resolve with an empty result, which
// the caller then wrote over the store as a full replace — wiping marks the
// user still had. Rejecting is what lets the caller keep the last good state.
it("rejects when a query fails at the HTTP level, instead of reporting no marks", async () => {
stubVault((type) =>
type === "photograph"
? { ok: false, status: 503, json: async () => ({}) }
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/503/);
});

it("rejects on a GraphQL-level error (HTTP 200 + errors)", async () => {
stubVault((type) =>
type === "security_question"
? {
ok: true,
json: async () => ({
errors: [{ message: "token expired" }],
data: null,
}),
}
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/token expired/);
});

it("still reports genuinely absent marks as empty, not as a failure", async () => {
stubVault(() => docs([]));

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(0);
expect(loaded.parameters).toBeNull();
expect(loaded.securityQuestion).toBeNull();
});
});
68 changes: 23 additions & 45 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -466,52 +466,30 @@ async function _loadPersonalBindingsCountOnly(
ownerEname: string,
): Promise<LoadedPersonalBindings> {
type Resp = { bindingDocuments: { edges: BindingDocEdge[] } };
const empty: Resp = { bindingDocuments: { edges: [] } };

const [photosResult, paramsResult, securityResult] =
await Promise.allSettled([
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_PHOTO_IDS_QUERY,
{ type: "photograph" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

if (photosResult.status === "rejected")
console.warn(
"[personalBinding] photograph count failed:",
photosResult.reason,
);
if (paramsResult.status === "rejected")
console.warn(
"[personalBinding] personal_parameters fetch failed:",
paramsResult.reason,
);
if (securityResult.status === "rejected")
console.warn(
"[personalBinding] security_question fetch failed:",
securityResult.reason,
);

const photosResp =
photosResult.status === "fulfilled" ? photosResult.value : empty;
const paramsResp =
paramsResult.status === "fulfilled" ? paramsResult.value : empty;
const securityResp =
securityResult.status === "fulfilled" ? securityResult.value : empty;
// Promise.all, not allSettled: callers feed this straight into a full-store
// replace, so substituting an empty result for a failed query is not
// graceful degradation — it erases marks the user still has. A partial
// result is no safer than none, since the replace overwrites either way.
// Rejecting lets the caller keep the last good state. Matches the sibling
// path in loadPersonalBindings above.
const [photosResp, paramsResp, securityResp] = await Promise.all([
vaultGqlRequest<Resp>(gqlUrl, ownerEname, PERSONAL_PHOTO_IDS_QUERY, {
type: "photograph",
}),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

const photographs: LoadedPhotograph[] = (
photosResp.bindingDocuments?.edges ?? []
Expand Down
96 changes: 96 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { clearVaultUriCache, resolveVaultUri } from "./socialBinding";

const ENAME = "@ada-0000-0000";
const OTHER = "@grace-1111-1111";
const VAULT_URI = "http://vault.test:4000";
const EXPECTED = "http://vault.test:4000/graphql";

/** Registry responds with a vault URI; counts how often it was actually hit. */
function stubRegistry(uri = VAULT_URI) {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ uri }),
}));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

beforeEach(() => {
clearVaultUriCache();
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("resolveVaultUri — registry lookup memoisation", () => {
it("hits the registry once for repeated sequential lookups", async () => {
const fetchMock = stubRegistry();

expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("coalesces concurrent lookups of the same eName into one request", async () => {
const fetchMock = stubRegistry();

// This is the real shape of the bug: the binding reconcile and the name
// lookup resolve the same counterparty at the same time, via Promise.all.
const results = await Promise.all([
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
]);

expect(results).toEqual([EXPECTED, EXPECTED, EXPECTED, EXPECTED]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("treats a bare eName and an @-prefixed one as the same cache entry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri("ada-0000-0000");
await resolveVaultUri("@ada-0000-0000");

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("keeps distinct eNames independent", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
await resolveVaultUri(OTHER);

expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("does not cache failures — a later lookup retries the registry", async () => {
const failing = vi.fn(async () => ({ ok: false, status: 503 }));
vi.stubGlobal("fetch", failing);

await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);
await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);

expect(failing).toHaveBeenCalledTimes(2);
});

it("clearVaultUriCache forces the next lookup back to the registry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
clearVaultUriCache();
await resolveVaultUri(ENAME);

expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
47 changes: 42 additions & 5 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,11 +29,17 @@ export interface BindingDocEdge {
// Registry resolution
// ---------------------------------------------------------------------------

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;
const VAULT_URI_TTL_MS = 5 * 60_000;
const vaultUriCache = new Map<string, { uri: string; at: number }>();
const vaultUriInFlight = new Map<string, Promise<string>>();

/** Drop the memoised registry lookups (sign-out, tests). */
export function clearVaultUriCache(): void {
vaultUriCache.clear();
vaultUriInFlight.clear();
}

async function fetchVaultUri(normalized: string): Promise<string> {
const url = new URL(
`resolve?w3id=${encodeURIComponent(normalized)}`,
PUBLIC_REGISTRY_URL,
Expand All@@ -52,6 +58,37 @@ export async function resolveVaultUri(ename: string): Promise<string> {
: new URL("/graphql", base).toString();
}

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*
* Memoised: an eName→URI mapping only changes when a vault is re-provisioned,
* but several layers resolve the same counterparty independently on one screen
* load (the binding reconcile and the name lookup, for a start), so the same
* round trip was being paid 3-4× per contact. Concurrent callers share one
* in-flight request; failures are not cached.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;

const cached = vaultUriCache.get(normalized);
if (cached && Date.now() - cached.at < VAULT_URI_TTL_MS) return cached.uri;

const pending = vaultUriInFlight.get(normalized);
if (pending) return pending;

const request = fetchVaultUri(normalized)
.then((uri) => {
vaultUriCache.set(normalized, { uri, at: Date.now() });
return uri;
})
.finally(() => {
vaultUriInFlight.delete(normalized);
});

vaultUriInFlight.set(normalized, request);
return request;
}

// ---------------------------------------------------------------------------
// Generic cross-vault GraphQL request
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"test": "vitest run --project eid-wallet",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
128 changes: 128 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { loadPersonalBindings } from "./personalBinding";

const GQL_URL = "http://vault.test:4000/graphql";
const ENAME = "@ada-0000-0000";

type StubResponse = {
ok: boolean;
status?: number;
json: () => Promise<unknown>;
};

function docs(edges: unknown[]): StubResponse {
return {
ok: true,
json: async () => ({ data: { bindingDocuments: { edges } } }),
};
}

const PHOTO_EDGES = [{ node: { id: "photo-1", parsed: null } }];
// pickLatestEdge orders by signatures[0].timestamp and ignores anything without
// one, so these fixtures must carry a signature to be seen at all.
const PARAM_EDGES = [
{
node: {
id: "param-1",
parsed: {
type: "personal_parameters",
data: { text: "5'9\", brown eyes" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];
const SECURITY_EDGES = [
{
node: {
id: "sec-1",
parsed: {
type: "security_question",
data: { question: "First pet?" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];

/** Route each stubbed request by the `type` variable the caller sent. */
function stubVault(byType: (type: string) => StubResponse) {
const mock = vi.fn(async (_url: string, init: { body: string }) => {
const body = JSON.parse(init.body) as {
variables?: { type?: string };
};
return byType(body.variables?.type ?? "");
});
vi.stubGlobal("fetch", mock);
return mock;
}

const allGood = (type: string): StubResponse => {
if (type === "photograph") return docs(PHOTO_EDGES);
if (type === "personal_parameters") return docs(PARAM_EDGES);
return docs(SECURITY_EDGES);
};

afterEach(() => {
vi.unstubAllGlobals();
});

describe("loadPersonalBindings — count-only path (home screen)", () => {
it("returns every mark when all queries succeed", async () => {
stubVault(allGood);

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(1);
expect(loaded.parameters?.text).toBe("5'9\", brown eyes");
expect(loaded.securityQuestion?.question).toBe("First pet?");
});

// The #1086 regression: these used to resolve with an empty result, which
// the caller then wrote over the store as a full replace — wiping marks the
// user still had. Rejecting is what lets the caller keep the last good state.
it("rejects when a query fails at the HTTP level, instead of reporting no marks", async () => {
stubVault((type) =>
type === "photograph"
? { ok: false, status: 503, json: async () => ({}) }
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/503/);
});

it("rejects on a GraphQL-level error (HTTP 200 + errors)", async () => {
stubVault((type) =>
type === "security_question"
? {
ok: true,
json: async () => ({
errors: [{ message: "token expired" }],
data: null,
}),
}
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/token expired/);
});

it("still reports genuinely absent marks as empty, not as a failure", async () => {
stubVault(() => docs([]));

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(0);
expect(loaded.parameters).toBeNull();
expect(loaded.securityQuestion).toBeNull();
});
});
68 changes: 23 additions & 45 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -466,52 +466,30 @@ async function _loadPersonalBindingsCountOnly(
ownerEname: string,
): Promise<LoadedPersonalBindings> {
type Resp = { bindingDocuments: { edges: BindingDocEdge[] } };
const empty: Resp = { bindingDocuments: { edges: [] } };

const [photosResult, paramsResult, securityResult] =
await Promise.allSettled([
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_PHOTO_IDS_QUERY,
{ type: "photograph" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

if (photosResult.status === "rejected")
console.warn(
"[personalBinding] photograph count failed:",
photosResult.reason,
);
if (paramsResult.status === "rejected")
console.warn(
"[personalBinding] personal_parameters fetch failed:",
paramsResult.reason,
);
if (securityResult.status === "rejected")
console.warn(
"[personalBinding] security_question fetch failed:",
securityResult.reason,
);

const photosResp =
photosResult.status === "fulfilled" ? photosResult.value : empty;
const paramsResp =
paramsResult.status === "fulfilled" ? paramsResult.value : empty;
const securityResp =
securityResult.status === "fulfilled" ? securityResult.value : empty;
// Promise.all, not allSettled: callers feed this straight into a full-store
// replace, so substituting an empty result for a failed query is not
// graceful degradation — it erases marks the user still has. A partial
// result is no safer than none, since the replace overwrites either way.
// Rejecting lets the caller keep the last good state. Matches the sibling
// path in loadPersonalBindings above.
const [photosResp, paramsResp, securityResp] = await Promise.all([
vaultGqlRequest<Resp>(gqlUrl, ownerEname, PERSONAL_PHOTO_IDS_QUERY, {
type: "photograph",
}),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

const photographs: LoadedPhotograph[] = (
photosResp.bindingDocuments?.edges ?? []
Expand Down
96 changes: 96 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { clearVaultUriCache, resolveVaultUri } from "./socialBinding";

const ENAME = "@ada-0000-0000";
const OTHER = "@grace-1111-1111";
const VAULT_URI = "http://vault.test:4000";
const EXPECTED = "http://vault.test:4000/graphql";

/** Registry responds with a vault URI; counts how often it was actually hit. */
function stubRegistry(uri = VAULT_URI) {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ uri }),
}));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

beforeEach(() => {
clearVaultUriCache();
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("resolveVaultUri — registry lookup memoisation", () => {
it("hits the registry once for repeated sequential lookups", async () => {
const fetchMock = stubRegistry();

expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("coalesces concurrent lookups of the same eName into one request", async () => {
const fetchMock = stubRegistry();

// This is the real shape of the bug: the binding reconcile and the name
// lookup resolve the same counterparty at the same time, via Promise.all.
const results = await Promise.all([
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
]);

expect(results).toEqual([EXPECTED, EXPECTED, EXPECTED, EXPECTED]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("treats a bare eName and an @-prefixed one as the same cache entry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri("ada-0000-0000");
await resolveVaultUri("@ada-0000-0000");

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("keeps distinct eNames independent", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
await resolveVaultUri(OTHER);

expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("does not cache failures — a later lookup retries the registry", async () => {
const failing = vi.fn(async () => ({ ok: false, status: 503 }));
vi.stubGlobal("fetch", failing);

await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);
await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);

expect(failing).toHaveBeenCalledTimes(2);
});

it("clearVaultUriCache forces the next lookup back to the registry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
clearVaultUriCache();
await resolveVaultUri(ENAME);

expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
47 changes: 42 additions & 5 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,11 +29,17 @@ export interface BindingDocEdge {
// Registry resolution
// ---------------------------------------------------------------------------

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;
const VAULT_URI_TTL_MS = 5 * 60_000;
const vaultUriCache = new Map<string, { uri: string; at: number }>();
const vaultUriInFlight = new Map<string, Promise<string>>();

/** Drop the memoised registry lookups (sign-out, tests). */
export function clearVaultUriCache(): void {
vaultUriCache.clear();
vaultUriInFlight.clear();
}

async function fetchVaultUri(normalized: string): Promise<string> {
const url = new URL(
`resolve?w3id=${encodeURIComponent(normalized)}`,
PUBLIC_REGISTRY_URL,
Expand All@@ -52,6 +58,37 @@ export async function resolveVaultUri(ename: string): Promise<string> {
: new URL("/graphql", base).toString();
}

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*
* Memoised: an eName→URI mapping only changes when a vault is re-provisioned,
* but several layers resolve the same counterparty independently on one screen
* load (the binding reconcile and the name lookup, for a start), so the same
* round trip was being paid 3-4× per contact. Concurrent callers share one
* in-flight request; failures are not cached.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;

const cached = vaultUriCache.get(normalized);
if (cached && Date.now() - cached.at < VAULT_URI_TTL_MS) return cached.uri;

const pending = vaultUriInFlight.get(normalized);
if (pending) return pending;

const request = fetchVaultUri(normalized)
.then((uri) => {
vaultUriCache.set(normalized, { uri, at: Date.now() });
return uri;
})
.finally(() => {
vaultUriInFlight.delete(normalized);
});

vaultUriInFlight.set(normalized, request);
return request;
}

// ---------------------------------------------------------------------------
// Generic cross-vault GraphQL request
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"test": "vitest run --project eid-wallet",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
128 changes: 128 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { loadPersonalBindings } from "./personalBinding";

const GQL_URL = "http://vault.test:4000/graphql";
const ENAME = "@ada-0000-0000";

type StubResponse = {
ok: boolean;
status?: number;
json: () => Promise<unknown>;
};

function docs(edges: unknown[]): StubResponse {
return {
ok: true,
json: async () => ({ data: { bindingDocuments: { edges } } }),
};
}

const PHOTO_EDGES = [{ node: { id: "photo-1", parsed: null } }];
// pickLatestEdge orders by signatures[0].timestamp and ignores anything without
// one, so these fixtures must carry a signature to be seen at all.
const PARAM_EDGES = [
{
node: {
id: "param-1",
parsed: {
type: "personal_parameters",
data: { text: "5'9\", brown eyes" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];
const SECURITY_EDGES = [
{
node: {
id: "sec-1",
parsed: {
type: "security_question",
data: { question: "First pet?" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];

/** Route each stubbed request by the `type` variable the caller sent. */
function stubVault(byType: (type: string) => StubResponse) {
const mock = vi.fn(async (_url: string, init: { body: string }) => {
const body = JSON.parse(init.body) as {
variables?: { type?: string };
};
return byType(body.variables?.type ?? "");
});
vi.stubGlobal("fetch", mock);
return mock;
}

const allGood = (type: string): StubResponse => {
if (type === "photograph") return docs(PHOTO_EDGES);
if (type === "personal_parameters") return docs(PARAM_EDGES);
return docs(SECURITY_EDGES);
};

afterEach(() => {
vi.unstubAllGlobals();
});

describe("loadPersonalBindings — count-only path (home screen)", () => {
it("returns every mark when all queries succeed", async () => {
stubVault(allGood);

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(1);
expect(loaded.parameters?.text).toBe("5'9\", brown eyes");
expect(loaded.securityQuestion?.question).toBe("First pet?");
});

// The #1086 regression: these used to resolve with an empty result, which
// the caller then wrote over the store as a full replace — wiping marks the
// user still had. Rejecting is what lets the caller keep the last good state.
it("rejects when a query fails at the HTTP level, instead of reporting no marks", async () => {
stubVault((type) =>
type === "photograph"
? { ok: false, status: 503, json: async () => ({}) }
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/503/);
});

it("rejects on a GraphQL-level error (HTTP 200 + errors)", async () => {
stubVault((type) =>
type === "security_question"
? {
ok: true,
json: async () => ({
errors: [{ message: "token expired" }],
data: null,
}),
}
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/token expired/);
});

it("still reports genuinely absent marks as empty, not as a failure", async () => {
stubVault(() => docs([]));

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(0);
expect(loaded.parameters).toBeNull();
expect(loaded.securityQuestion).toBeNull();
});
});
68 changes: 23 additions & 45 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -466,52 +466,30 @@ async function _loadPersonalBindingsCountOnly(
ownerEname: string,
): Promise<LoadedPersonalBindings> {
type Resp = { bindingDocuments: { edges: BindingDocEdge[] } };
const empty: Resp = { bindingDocuments: { edges: [] } };

const [photosResult, paramsResult, securityResult] =
await Promise.allSettled([
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_PHOTO_IDS_QUERY,
{ type: "photograph" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

if (photosResult.status === "rejected")
console.warn(
"[personalBinding] photograph count failed:",
photosResult.reason,
);
if (paramsResult.status === "rejected")
console.warn(
"[personalBinding] personal_parameters fetch failed:",
paramsResult.reason,
);
if (securityResult.status === "rejected")
console.warn(
"[personalBinding] security_question fetch failed:",
securityResult.reason,
);

const photosResp =
photosResult.status === "fulfilled" ? photosResult.value : empty;
const paramsResp =
paramsResult.status === "fulfilled" ? paramsResult.value : empty;
const securityResp =
securityResult.status === "fulfilled" ? securityResult.value : empty;
// Promise.all, not allSettled: callers feed this straight into a full-store
// replace, so substituting an empty result for a failed query is not
// graceful degradation — it erases marks the user still has. A partial
// result is no safer than none, since the replace overwrites either way.
// Rejecting lets the caller keep the last good state. Matches the sibling
// path in loadPersonalBindings above.
const [photosResp, paramsResp, securityResp] = await Promise.all([
vaultGqlRequest<Resp>(gqlUrl, ownerEname, PERSONAL_PHOTO_IDS_QUERY, {
type: "photograph",
}),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

const photographs: LoadedPhotograph[] = (
photosResp.bindingDocuments?.edges ?? []
Expand Down
96 changes: 96 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { clearVaultUriCache, resolveVaultUri } from "./socialBinding";

const ENAME = "@ada-0000-0000";
const OTHER = "@grace-1111-1111";
const VAULT_URI = "http://vault.test:4000";
const EXPECTED = "http://vault.test:4000/graphql";

/** Registry responds with a vault URI; counts how often it was actually hit. */
function stubRegistry(uri = VAULT_URI) {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ uri }),
}));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

beforeEach(() => {
clearVaultUriCache();
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("resolveVaultUri — registry lookup memoisation", () => {
it("hits the registry once for repeated sequential lookups", async () => {
const fetchMock = stubRegistry();

expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("coalesces concurrent lookups of the same eName into one request", async () => {
const fetchMock = stubRegistry();

// This is the real shape of the bug: the binding reconcile and the name
// lookup resolve the same counterparty at the same time, via Promise.all.
const results = await Promise.all([
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
]);

expect(results).toEqual([EXPECTED, EXPECTED, EXPECTED, EXPECTED]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("treats a bare eName and an @-prefixed one as the same cache entry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri("ada-0000-0000");
await resolveVaultUri("@ada-0000-0000");

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("keeps distinct eNames independent", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
await resolveVaultUri(OTHER);

expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("does not cache failures — a later lookup retries the registry", async () => {
const failing = vi.fn(async () => ({ ok: false, status: 503 }));
vi.stubGlobal("fetch", failing);

await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);
await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);

expect(failing).toHaveBeenCalledTimes(2);
});

it("clearVaultUriCache forces the next lookup back to the registry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
clearVaultUriCache();
await resolveVaultUri(ENAME);

expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
47 changes: 42 additions & 5 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,11 +29,17 @@ export interface BindingDocEdge {
// Registry resolution
// ---------------------------------------------------------------------------

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;
const VAULT_URI_TTL_MS = 5 * 60_000;
const vaultUriCache = new Map<string, { uri: string; at: number }>();
const vaultUriInFlight = new Map<string, Promise<string>>();

/** Drop the memoised registry lookups (sign-out, tests). */
export function clearVaultUriCache(): void {
vaultUriCache.clear();
vaultUriInFlight.clear();
}

async function fetchVaultUri(normalized: string): Promise<string> {
const url = new URL(
`resolve?w3id=${encodeURIComponent(normalized)}`,
PUBLIC_REGISTRY_URL,
Expand All@@ -52,6 +58,37 @@ export async function resolveVaultUri(ename: string): Promise<string> {
: new URL("/graphql", base).toString();
}

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*
* Memoised: an eName→URI mapping only changes when a vault is re-provisioned,
* but several layers resolve the same counterparty independently on one screen
* load (the binding reconcile and the name lookup, for a start), so the same
* round trip was being paid 3-4× per contact. Concurrent callers share one
* in-flight request; failures are not cached.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;

const cached = vaultUriCache.get(normalized);
if (cached && Date.now() - cached.at < VAULT_URI_TTL_MS) return cached.uri;

const pending = vaultUriInFlight.get(normalized);
if (pending) return pending;

const request = fetchVaultUri(normalized)
.then((uri) => {
vaultUriCache.set(normalized, { uri, at: Date.now() });
return uri;
})
.finally(() => {
vaultUriInFlight.delete(normalized);
});

vaultUriInFlight.set(normalized, request);
return request;
}

// ---------------------------------------------------------------------------
// Generic cross-vault GraphQL request
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"test": "vitest run --project eid-wallet",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
128 changes: 128 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { loadPersonalBindings } from "./personalBinding";

const GQL_URL = "http://vault.test:4000/graphql";
const ENAME = "@ada-0000-0000";

type StubResponse = {
ok: boolean;
status?: number;
json: () => Promise<unknown>;
};

function docs(edges: unknown[]): StubResponse {
return {
ok: true,
json: async () => ({ data: { bindingDocuments: { edges } } }),
};
}

const PHOTO_EDGES = [{ node: { id: "photo-1", parsed: null } }];
// pickLatestEdge orders by signatures[0].timestamp and ignores anything without
// one, so these fixtures must carry a signature to be seen at all.
const PARAM_EDGES = [
{
node: {
id: "param-1",
parsed: {
type: "personal_parameters",
data: { text: "5'9\", brown eyes" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];
const SECURITY_EDGES = [
{
node: {
id: "sec-1",
parsed: {
type: "security_question",
data: { question: "First pet?" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];

/** Route each stubbed request by the `type` variable the caller sent. */
function stubVault(byType: (type: string) => StubResponse) {
const mock = vi.fn(async (_url: string, init: { body: string }) => {
const body = JSON.parse(init.body) as {
variables?: { type?: string };
};
return byType(body.variables?.type ?? "");
});
vi.stubGlobal("fetch", mock);
return mock;
}

const allGood = (type: string): StubResponse => {
if (type === "photograph") return docs(PHOTO_EDGES);
if (type === "personal_parameters") return docs(PARAM_EDGES);
return docs(SECURITY_EDGES);
};

afterEach(() => {
vi.unstubAllGlobals();
});

describe("loadPersonalBindings — count-only path (home screen)", () => {
it("returns every mark when all queries succeed", async () => {
stubVault(allGood);

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(1);
expect(loaded.parameters?.text).toBe("5'9\", brown eyes");
expect(loaded.securityQuestion?.question).toBe("First pet?");
});

// The #1086 regression: these used to resolve with an empty result, which
// the caller then wrote over the store as a full replace — wiping marks the
// user still had. Rejecting is what lets the caller keep the last good state.
it("rejects when a query fails at the HTTP level, instead of reporting no marks", async () => {
stubVault((type) =>
type === "photograph"
? { ok: false, status: 503, json: async () => ({}) }
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/503/);
});

it("rejects on a GraphQL-level error (HTTP 200 + errors)", async () => {
stubVault((type) =>
type === "security_question"
? {
ok: true,
json: async () => ({
errors: [{ message: "token expired" }],
data: null,
}),
}
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/token expired/);
});

it("still reports genuinely absent marks as empty, not as a failure", async () => {
stubVault(() => docs([]));

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(0);
expect(loaded.parameters).toBeNull();
expect(loaded.securityQuestion).toBeNull();
});
});
68 changes: 23 additions & 45 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -466,52 +466,30 @@ async function _loadPersonalBindingsCountOnly(
ownerEname: string,
): Promise<LoadedPersonalBindings> {
type Resp = { bindingDocuments: { edges: BindingDocEdge[] } };
const empty: Resp = { bindingDocuments: { edges: [] } };

const [photosResult, paramsResult, securityResult] =
await Promise.allSettled([
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_PHOTO_IDS_QUERY,
{ type: "photograph" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

if (photosResult.status === "rejected")
console.warn(
"[personalBinding] photograph count failed:",
photosResult.reason,
);
if (paramsResult.status === "rejected")
console.warn(
"[personalBinding] personal_parameters fetch failed:",
paramsResult.reason,
);
if (securityResult.status === "rejected")
console.warn(
"[personalBinding] security_question fetch failed:",
securityResult.reason,
);

const photosResp =
photosResult.status === "fulfilled" ? photosResult.value : empty;
const paramsResp =
paramsResult.status === "fulfilled" ? paramsResult.value : empty;
const securityResp =
securityResult.status === "fulfilled" ? securityResult.value : empty;
// Promise.all, not allSettled: callers feed this straight into a full-store
// replace, so substituting an empty result for a failed query is not
// graceful degradation — it erases marks the user still has. A partial
// result is no safer than none, since the replace overwrites either way.
// Rejecting lets the caller keep the last good state. Matches the sibling
// path in loadPersonalBindings above.
const [photosResp, paramsResp, securityResp] = await Promise.all([
vaultGqlRequest<Resp>(gqlUrl, ownerEname, PERSONAL_PHOTO_IDS_QUERY, {
type: "photograph",
}),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

const photographs: LoadedPhotograph[] = (
photosResp.bindingDocuments?.edges ?? []
Expand Down
96 changes: 96 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { clearVaultUriCache, resolveVaultUri } from "./socialBinding";

const ENAME = "@ada-0000-0000";
const OTHER = "@grace-1111-1111";
const VAULT_URI = "http://vault.test:4000";
const EXPECTED = "http://vault.test:4000/graphql";

/** Registry responds with a vault URI; counts how often it was actually hit. */
function stubRegistry(uri = VAULT_URI) {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ uri }),
}));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

beforeEach(() => {
clearVaultUriCache();
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("resolveVaultUri — registry lookup memoisation", () => {
it("hits the registry once for repeated sequential lookups", async () => {
const fetchMock = stubRegistry();

expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("coalesces concurrent lookups of the same eName into one request", async () => {
const fetchMock = stubRegistry();

// This is the real shape of the bug: the binding reconcile and the name
// lookup resolve the same counterparty at the same time, via Promise.all.
const results = await Promise.all([
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
]);

expect(results).toEqual([EXPECTED, EXPECTED, EXPECTED, EXPECTED]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("treats a bare eName and an @-prefixed one as the same cache entry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri("ada-0000-0000");
await resolveVaultUri("@ada-0000-0000");

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("keeps distinct eNames independent", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
await resolveVaultUri(OTHER);

expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("does not cache failures — a later lookup retries the registry", async () => {
const failing = vi.fn(async () => ({ ok: false, status: 503 }));
vi.stubGlobal("fetch", failing);

await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);
await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);

expect(failing).toHaveBeenCalledTimes(2);
});

it("clearVaultUriCache forces the next lookup back to the registry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
clearVaultUriCache();
await resolveVaultUri(ENAME);

expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
47 changes: 42 additions & 5 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,11 +29,17 @@ export interface BindingDocEdge {
// Registry resolution
// ---------------------------------------------------------------------------

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;
const VAULT_URI_TTL_MS = 5 * 60_000;
const vaultUriCache = new Map<string, { uri: string; at: number }>();
const vaultUriInFlight = new Map<string, Promise<string>>();

/** Drop the memoised registry lookups (sign-out, tests). */
export function clearVaultUriCache(): void {
vaultUriCache.clear();
vaultUriInFlight.clear();
}

async function fetchVaultUri(normalized: string): Promise<string> {
const url = new URL(
`resolve?w3id=${encodeURIComponent(normalized)}`,
PUBLIC_REGISTRY_URL,
Expand All@@ -52,6 +58,37 @@ export async function resolveVaultUri(ename: string): Promise<string> {
: new URL("/graphql", base).toString();
}

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*
* Memoised: an eName→URI mapping only changes when a vault is re-provisioned,
* but several layers resolve the same counterparty independently on one screen
* load (the binding reconcile and the name lookup, for a start), so the same
* round trip was being paid 3-4× per contact. Concurrent callers share one
* in-flight request; failures are not cached.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;

const cached = vaultUriCache.get(normalized);
if (cached && Date.now() - cached.at < VAULT_URI_TTL_MS) return cached.uri;

const pending = vaultUriInFlight.get(normalized);
if (pending) return pending;

const request = fetchVaultUri(normalized)
.then((uri) => {
vaultUriCache.set(normalized, { uri, at: Date.now() });
return uri;
})
.finally(() => {
vaultUriInFlight.delete(normalized);
});

vaultUriInFlight.set(normalized, request);
return request;
}

// ---------------------------------------------------------------------------
// Generic cross-vault GraphQL request
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"test": "vitest run --project eid-wallet",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
128 changes: 128 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { loadPersonalBindings } from "./personalBinding";

const GQL_URL = "http://vault.test:4000/graphql";
const ENAME = "@ada-0000-0000";

type StubResponse = {
ok: boolean;
status?: number;
json: () => Promise<unknown>;
};

function docs(edges: unknown[]): StubResponse {
return {
ok: true,
json: async () => ({ data: { bindingDocuments: { edges } } }),
};
}

const PHOTO_EDGES = [{ node: { id: "photo-1", parsed: null } }];
// pickLatestEdge orders by signatures[0].timestamp and ignores anything without
// one, so these fixtures must carry a signature to be seen at all.
const PARAM_EDGES = [
{
node: {
id: "param-1",
parsed: {
type: "personal_parameters",
data: { text: "5'9\", brown eyes" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];
const SECURITY_EDGES = [
{
node: {
id: "sec-1",
parsed: {
type: "security_question",
data: { question: "First pet?" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];

/** Route each stubbed request by the `type` variable the caller sent. */
function stubVault(byType: (type: string) => StubResponse) {
const mock = vi.fn(async (_url: string, init: { body: string }) => {
const body = JSON.parse(init.body) as {
variables?: { type?: string };
};
return byType(body.variables?.type ?? "");
});
vi.stubGlobal("fetch", mock);
return mock;
}

const allGood = (type: string): StubResponse => {
if (type === "photograph") return docs(PHOTO_EDGES);
if (type === "personal_parameters") return docs(PARAM_EDGES);
return docs(SECURITY_EDGES);
};

afterEach(() => {
vi.unstubAllGlobals();
});

describe("loadPersonalBindings — count-only path (home screen)", () => {
it("returns every mark when all queries succeed", async () => {
stubVault(allGood);

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(1);
expect(loaded.parameters?.text).toBe("5'9\", brown eyes");
expect(loaded.securityQuestion?.question).toBe("First pet?");
});

// The #1086 regression: these used to resolve with an empty result, which
// the caller then wrote over the store as a full replace — wiping marks the
// user still had. Rejecting is what lets the caller keep the last good state.
it("rejects when a query fails at the HTTP level, instead of reporting no marks", async () => {
stubVault((type) =>
type === "photograph"
? { ok: false, status: 503, json: async () => ({}) }
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/503/);
});

it("rejects on a GraphQL-level error (HTTP 200 + errors)", async () => {
stubVault((type) =>
type === "security_question"
? {
ok: true,
json: async () => ({
errors: [{ message: "token expired" }],
data: null,
}),
}
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/token expired/);
});

it("still reports genuinely absent marks as empty, not as a failure", async () => {
stubVault(() => docs([]));

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(0);
expect(loaded.parameters).toBeNull();
expect(loaded.securityQuestion).toBeNull();
});
});
68 changes: 23 additions & 45 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -466,52 +466,30 @@ async function _loadPersonalBindingsCountOnly(
ownerEname: string,
): Promise<LoadedPersonalBindings> {
type Resp = { bindingDocuments: { edges: BindingDocEdge[] } };
const empty: Resp = { bindingDocuments: { edges: [] } };

const [photosResult, paramsResult, securityResult] =
await Promise.allSettled([
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_PHOTO_IDS_QUERY,
{ type: "photograph" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

if (photosResult.status === "rejected")
console.warn(
"[personalBinding] photograph count failed:",
photosResult.reason,
);
if (paramsResult.status === "rejected")
console.warn(
"[personalBinding] personal_parameters fetch failed:",
paramsResult.reason,
);
if (securityResult.status === "rejected")
console.warn(
"[personalBinding] security_question fetch failed:",
securityResult.reason,
);

const photosResp =
photosResult.status === "fulfilled" ? photosResult.value : empty;
const paramsResp =
paramsResult.status === "fulfilled" ? paramsResult.value : empty;
const securityResp =
securityResult.status === "fulfilled" ? securityResult.value : empty;
// Promise.all, not allSettled: callers feed this straight into a full-store
// replace, so substituting an empty result for a failed query is not
// graceful degradation — it erases marks the user still has. A partial
// result is no safer than none, since the replace overwrites either way.
// Rejecting lets the caller keep the last good state. Matches the sibling
// path in loadPersonalBindings above.
const [photosResp, paramsResp, securityResp] = await Promise.all([
vaultGqlRequest<Resp>(gqlUrl, ownerEname, PERSONAL_PHOTO_IDS_QUERY, {
type: "photograph",
}),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

const photographs: LoadedPhotograph[] = (
photosResp.bindingDocuments?.edges ?? []
Expand Down
96 changes: 96 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { clearVaultUriCache, resolveVaultUri } from "./socialBinding";

const ENAME = "@ada-0000-0000";
const OTHER = "@grace-1111-1111";
const VAULT_URI = "http://vault.test:4000";
const EXPECTED = "http://vault.test:4000/graphql";

/** Registry responds with a vault URI; counts how often it was actually hit. */
function stubRegistry(uri = VAULT_URI) {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ uri }),
}));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

beforeEach(() => {
clearVaultUriCache();
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("resolveVaultUri — registry lookup memoisation", () => {
it("hits the registry once for repeated sequential lookups", async () => {
const fetchMock = stubRegistry();

expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("coalesces concurrent lookups of the same eName into one request", async () => {
const fetchMock = stubRegistry();

// This is the real shape of the bug: the binding reconcile and the name
// lookup resolve the same counterparty at the same time, via Promise.all.
const results = await Promise.all([
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
]);

expect(results).toEqual([EXPECTED, EXPECTED, EXPECTED, EXPECTED]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("treats a bare eName and an @-prefixed one as the same cache entry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri("ada-0000-0000");
await resolveVaultUri("@ada-0000-0000");

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("keeps distinct eNames independent", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
await resolveVaultUri(OTHER);

expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("does not cache failures — a later lookup retries the registry", async () => {
const failing = vi.fn(async () => ({ ok: false, status: 503 }));
vi.stubGlobal("fetch", failing);

await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);
await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);

expect(failing).toHaveBeenCalledTimes(2);
});

it("clearVaultUriCache forces the next lookup back to the registry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
clearVaultUriCache();
await resolveVaultUri(ENAME);

expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
47 changes: 42 additions & 5 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,11 +29,17 @@ export interface BindingDocEdge {
// Registry resolution
// ---------------------------------------------------------------------------

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;
const VAULT_URI_TTL_MS = 5 * 60_000;
const vaultUriCache = new Map<string, { uri: string; at: number }>();
const vaultUriInFlight = new Map<string, Promise<string>>();

/** Drop the memoised registry lookups (sign-out, tests). */
export function clearVaultUriCache(): void {
vaultUriCache.clear();
vaultUriInFlight.clear();
}

async function fetchVaultUri(normalized: string): Promise<string> {
const url = new URL(
`resolve?w3id=${encodeURIComponent(normalized)}`,
PUBLIC_REGISTRY_URL,
Expand All@@ -52,6 +58,37 @@ export async function resolveVaultUri(ename: string): Promise<string> {
: new URL("/graphql", base).toString();
}

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*
* Memoised: an eName→URI mapping only changes when a vault is re-provisioned,
* but several layers resolve the same counterparty independently on one screen
* load (the binding reconcile and the name lookup, for a start), so the same
* round trip was being paid 3-4× per contact. Concurrent callers share one
* in-flight request; failures are not cached.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;

const cached = vaultUriCache.get(normalized);
if (cached && Date.now() - cached.at < VAULT_URI_TTL_MS) return cached.uri;

const pending = vaultUriInFlight.get(normalized);
if (pending) return pending;

const request = fetchVaultUri(normalized)
.then((uri) => {
vaultUriCache.set(normalized, { uri, at: Date.now() });
return uri;
})
.finally(() => {
vaultUriInFlight.delete(normalized);
});

vaultUriInFlight.set(normalized, request);
return request;
}

// ---------------------------------------------------------------------------
// Generic cross-vault GraphQL request
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"test": "vitest run --project eid-wallet",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
128 changes: 128 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { loadPersonalBindings } from "./personalBinding";

const GQL_URL = "http://vault.test:4000/graphql";
const ENAME = "@ada-0000-0000";

type StubResponse = {
ok: boolean;
status?: number;
json: () => Promise<unknown>;
};

function docs(edges: unknown[]): StubResponse {
return {
ok: true,
json: async () => ({ data: { bindingDocuments: { edges } } }),
};
}

const PHOTO_EDGES = [{ node: { id: "photo-1", parsed: null } }];
// pickLatestEdge orders by signatures[0].timestamp and ignores anything without
// one, so these fixtures must carry a signature to be seen at all.
const PARAM_EDGES = [
{
node: {
id: "param-1",
parsed: {
type: "personal_parameters",
data: { text: "5'9\", brown eyes" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];
const SECURITY_EDGES = [
{
node: {
id: "sec-1",
parsed: {
type: "security_question",
data: { question: "First pet?" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];

/** Route each stubbed request by the `type` variable the caller sent. */
function stubVault(byType: (type: string) => StubResponse) {
const mock = vi.fn(async (_url: string, init: { body: string }) => {
const body = JSON.parse(init.body) as {
variables?: { type?: string };
};
return byType(body.variables?.type ?? "");
});
vi.stubGlobal("fetch", mock);
return mock;
}

const allGood = (type: string): StubResponse => {
if (type === "photograph") return docs(PHOTO_EDGES);
if (type === "personal_parameters") return docs(PARAM_EDGES);
return docs(SECURITY_EDGES);
};

afterEach(() => {
vi.unstubAllGlobals();
});

describe("loadPersonalBindings — count-only path (home screen)", () => {
it("returns every mark when all queries succeed", async () => {
stubVault(allGood);

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(1);
expect(loaded.parameters?.text).toBe("5'9\", brown eyes");
expect(loaded.securityQuestion?.question).toBe("First pet?");
});

// The #1086 regression: these used to resolve with an empty result, which
// the caller then wrote over the store as a full replace — wiping marks the
// user still had. Rejecting is what lets the caller keep the last good state.
it("rejects when a query fails at the HTTP level, instead of reporting no marks", async () => {
stubVault((type) =>
type === "photograph"
? { ok: false, status: 503, json: async () => ({}) }
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/503/);
});

it("rejects on a GraphQL-level error (HTTP 200 + errors)", async () => {
stubVault((type) =>
type === "security_question"
? {
ok: true,
json: async () => ({
errors: [{ message: "token expired" }],
data: null,
}),
}
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/token expired/);
});

it("still reports genuinely absent marks as empty, not as a failure", async () => {
stubVault(() => docs([]));

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(0);
expect(loaded.parameters).toBeNull();
expect(loaded.securityQuestion).toBeNull();
});
});
68 changes: 23 additions & 45 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -466,52 +466,30 @@ async function _loadPersonalBindingsCountOnly(
ownerEname: string,
): Promise<LoadedPersonalBindings> {
type Resp = { bindingDocuments: { edges: BindingDocEdge[] } };
const empty: Resp = { bindingDocuments: { edges: [] } };

const [photosResult, paramsResult, securityResult] =
await Promise.allSettled([
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_PHOTO_IDS_QUERY,
{ type: "photograph" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

if (photosResult.status === "rejected")
console.warn(
"[personalBinding] photograph count failed:",
photosResult.reason,
);
if (paramsResult.status === "rejected")
console.warn(
"[personalBinding] personal_parameters fetch failed:",
paramsResult.reason,
);
if (securityResult.status === "rejected")
console.warn(
"[personalBinding] security_question fetch failed:",
securityResult.reason,
);

const photosResp =
photosResult.status === "fulfilled" ? photosResult.value : empty;
const paramsResp =
paramsResult.status === "fulfilled" ? paramsResult.value : empty;
const securityResp =
securityResult.status === "fulfilled" ? securityResult.value : empty;
// Promise.all, not allSettled: callers feed this straight into a full-store
// replace, so substituting an empty result for a failed query is not
// graceful degradation — it erases marks the user still has. A partial
// result is no safer than none, since the replace overwrites either way.
// Rejecting lets the caller keep the last good state. Matches the sibling
// path in loadPersonalBindings above.
const [photosResp, paramsResp, securityResp] = await Promise.all([
vaultGqlRequest<Resp>(gqlUrl, ownerEname, PERSONAL_PHOTO_IDS_QUERY, {
type: "photograph",
}),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

const photographs: LoadedPhotograph[] = (
photosResp.bindingDocuments?.edges ?? []
Expand Down
96 changes: 96 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { clearVaultUriCache, resolveVaultUri } from "./socialBinding";

const ENAME = "@ada-0000-0000";
const OTHER = "@grace-1111-1111";
const VAULT_URI = "http://vault.test:4000";
const EXPECTED = "http://vault.test:4000/graphql";

/** Registry responds with a vault URI; counts how often it was actually hit. */
function stubRegistry(uri = VAULT_URI) {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ uri }),
}));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

beforeEach(() => {
clearVaultUriCache();
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("resolveVaultUri — registry lookup memoisation", () => {
it("hits the registry once for repeated sequential lookups", async () => {
const fetchMock = stubRegistry();

expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("coalesces concurrent lookups of the same eName into one request", async () => {
const fetchMock = stubRegistry();

// This is the real shape of the bug: the binding reconcile and the name
// lookup resolve the same counterparty at the same time, via Promise.all.
const results = await Promise.all([
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
]);

expect(results).toEqual([EXPECTED, EXPECTED, EXPECTED, EXPECTED]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("treats a bare eName and an @-prefixed one as the same cache entry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri("ada-0000-0000");
await resolveVaultUri("@ada-0000-0000");

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("keeps distinct eNames independent", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
await resolveVaultUri(OTHER);

expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("does not cache failures — a later lookup retries the registry", async () => {
const failing = vi.fn(async () => ({ ok: false, status: 503 }));
vi.stubGlobal("fetch", failing);

await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);
await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);

expect(failing).toHaveBeenCalledTimes(2);
});

it("clearVaultUriCache forces the next lookup back to the registry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
clearVaultUriCache();
await resolveVaultUri(ENAME);

expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
47 changes: 42 additions & 5 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,11 +29,17 @@ export interface BindingDocEdge {
// Registry resolution
// ---------------------------------------------------------------------------

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;
const VAULT_URI_TTL_MS = 5 * 60_000;
const vaultUriCache = new Map<string, { uri: string; at: number }>();
const vaultUriInFlight = new Map<string, Promise<string>>();

/** Drop the memoised registry lookups (sign-out, tests). */
export function clearVaultUriCache(): void {
vaultUriCache.clear();
vaultUriInFlight.clear();
}

async function fetchVaultUri(normalized: string): Promise<string> {
const url = new URL(
`resolve?w3id=${encodeURIComponent(normalized)}`,
PUBLIC_REGISTRY_URL,
Expand All@@ -52,6 +58,37 @@ export async function resolveVaultUri(ename: string): Promise<string> {
: new URL("/graphql", base).toString();
}

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*
* Memoised: an eName→URI mapping only changes when a vault is re-provisioned,
* but several layers resolve the same counterparty independently on one screen
* load (the binding reconcile and the name lookup, for a start), so the same
* round trip was being paid 3-4× per contact. Concurrent callers share one
* in-flight request; failures are not cached.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;

const cached = vaultUriCache.get(normalized);
if (cached && Date.now() - cached.at < VAULT_URI_TTL_MS) return cached.uri;

const pending = vaultUriInFlight.get(normalized);
if (pending) return pending;

const request = fetchVaultUri(normalized)
.then((uri) => {
vaultUriCache.set(normalized, { uri, at: Date.now() });
return uri;
})
.finally(() => {
vaultUriInFlight.delete(normalized);
});

vaultUriInFlight.set(normalized, request);
return request;
}

// ---------------------------------------------------------------------------
// Generic cross-vault GraphQL request
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infrastructure/eid-wallet/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,7 +14,7 @@
"lint": "npx @biomejs/biome lint --write ./src",
"check-lint": "npx @biomejs/biome lint ./src",
"tauri": "tauri",
"test": "vitest run",
"test": "vitest run --project eid-wallet",
"storybook": "svelte-kit sync && storybook dev -p 6006",
"build-storybook": "storybook build",
"build:apk": "npm run tauri android build -- --apk --target aarch64 --target armv7",
Expand Down
128 changes: 128 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { loadPersonalBindings } from "./personalBinding";

const GQL_URL = "http://vault.test:4000/graphql";
const ENAME = "@ada-0000-0000";

type StubResponse = {
ok: boolean;
status?: number;
json: () => Promise<unknown>;
};

function docs(edges: unknown[]): StubResponse {
return {
ok: true,
json: async () => ({ data: { bindingDocuments: { edges } } }),
};
}

const PHOTO_EDGES = [{ node: { id: "photo-1", parsed: null } }];
// pickLatestEdge orders by signatures[0].timestamp and ignores anything without
// one, so these fixtures must carry a signature to be seen at all.
const PARAM_EDGES = [
{
node: {
id: "param-1",
parsed: {
type: "personal_parameters",
data: { text: "5'9\", brown eyes" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];
const SECURITY_EDGES = [
{
node: {
id: "sec-1",
parsed: {
type: "security_question",
data: { question: "First pet?" },
signatures: [{ timestamp: "2026-07-15T10:00:00.000Z" }],
},
},
},
];

/** Route each stubbed request by the `type` variable the caller sent. */
function stubVault(byType: (type: string) => StubResponse) {
const mock = vi.fn(async (_url: string, init: { body: string }) => {
const body = JSON.parse(init.body) as {
variables?: { type?: string };
};
return byType(body.variables?.type ?? "");
});
vi.stubGlobal("fetch", mock);
return mock;
}

const allGood = (type: string): StubResponse => {
if (type === "photograph") return docs(PHOTO_EDGES);
if (type === "personal_parameters") return docs(PARAM_EDGES);
return docs(SECURITY_EDGES);
};

afterEach(() => {
vi.unstubAllGlobals();
});

describe("loadPersonalBindings — count-only path (home screen)", () => {
it("returns every mark when all queries succeed", async () => {
stubVault(allGood);

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(1);
expect(loaded.parameters?.text).toBe("5'9\", brown eyes");
expect(loaded.securityQuestion?.question).toBe("First pet?");
});

// The #1086 regression: these used to resolve with an empty result, which
// the caller then wrote over the store as a full replace — wiping marks the
// user still had. Rejecting is what lets the caller keep the last good state.
it("rejects when a query fails at the HTTP level, instead of reporting no marks", async () => {
stubVault((type) =>
type === "photograph"
? { ok: false, status: 503, json: async () => ({}) }
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/503/);
});

it("rejects on a GraphQL-level error (HTTP 200 + errors)", async () => {
stubVault((type) =>
type === "security_question"
? {
ok: true,
json: async () => ({
errors: [{ message: "token expired" }],
data: null,
}),
}
: allGood(type),
);

await expect(
loadPersonalBindings(GQL_URL, ENAME, { skipPhotoBlobs: true }),
).rejects.toThrow(/token expired/);
});

it("still reports genuinely absent marks as empty, not as a failure", async () => {
stubVault(() => docs([]));

const loaded = await loadPersonalBindings(GQL_URL, ENAME, {
skipPhotoBlobs: true,
});

expect(loaded.photographs).toHaveLength(0);
expect(loaded.parameters).toBeNull();
expect(loaded.securityQuestion).toBeNull();
});
});
68 changes: 23 additions & 45 deletions infrastructure/eid-wallet/src/lib/utils/personalBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -466,52 +466,30 @@ async function _loadPersonalBindingsCountOnly(
ownerEname: string,
): Promise<LoadedPersonalBindings> {
type Resp = { bindingDocuments: { edges: BindingDocEdge[] } };
const empty: Resp = { bindingDocuments: { edges: [] } };

const [photosResult, paramsResult, securityResult] =
await Promise.allSettled([
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_PHOTO_IDS_QUERY,
{ type: "photograph" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

if (photosResult.status === "rejected")
console.warn(
"[personalBinding] photograph count failed:",
photosResult.reason,
);
if (paramsResult.status === "rejected")
console.warn(
"[personalBinding] personal_parameters fetch failed:",
paramsResult.reason,
);
if (securityResult.status === "rejected")
console.warn(
"[personalBinding] security_question fetch failed:",
securityResult.reason,
);

const photosResp =
photosResult.status === "fulfilled" ? photosResult.value : empty;
const paramsResp =
paramsResult.status === "fulfilled" ? paramsResult.value : empty;
const securityResp =
securityResult.status === "fulfilled" ? securityResult.value : empty;
// Promise.all, not allSettled: callers feed this straight into a full-store
// replace, so substituting an empty result for a failed query is not
// graceful degradation — it erases marks the user still has. A partial
// result is no safer than none, since the replace overwrites either way.
// Rejecting lets the caller keep the last good state. Matches the sibling
// path in loadPersonalBindings above.
const [photosResp, paramsResp, securityResp] = await Promise.all([
vaultGqlRequest<Resp>(gqlUrl, ownerEname, PERSONAL_PHOTO_IDS_QUERY, {
type: "photograph",
}),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "personal_parameters" },
),
vaultGqlRequest<Resp>(
gqlUrl,
ownerEname,
PERSONAL_BINDING_BY_TYPE_QUERY,
{ type: "security_question" },
),
]);

const photographs: LoadedPhotograph[] = (
photosResp.bindingDocuments?.edges ?? []
Expand Down
96 changes: 96 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { clearVaultUriCache, resolveVaultUri } from "./socialBinding";

const ENAME = "@ada-0000-0000";
const OTHER = "@grace-1111-1111";
const VAULT_URI = "http://vault.test:4000";
const EXPECTED = "http://vault.test:4000/graphql";

/** Registry responds with a vault URI; counts how often it was actually hit. */
function stubRegistry(uri = VAULT_URI) {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ uri }),
}));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

beforeEach(() => {
clearVaultUriCache();
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("resolveVaultUri — registry lookup memoisation", () => {
it("hits the registry once for repeated sequential lookups", async () => {
const fetchMock = stubRegistry();

expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);
expect(await resolveVaultUri(ENAME)).toBe(EXPECTED);

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("coalesces concurrent lookups of the same eName into one request", async () => {
const fetchMock = stubRegistry();

// This is the real shape of the bug: the binding reconcile and the name
// lookup resolve the same counterparty at the same time, via Promise.all.
const results = await Promise.all([
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
resolveVaultUri(ENAME),
]);

expect(results).toEqual([EXPECTED, EXPECTED, EXPECTED, EXPECTED]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("treats a bare eName and an @-prefixed one as the same cache entry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri("ada-0000-0000");
await resolveVaultUri("@ada-0000-0000");

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("keeps distinct eNames independent", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
await resolveVaultUri(OTHER);

expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("does not cache failures — a later lookup retries the registry", async () => {
const failing = vi.fn(async () => ({ ok: false, status: 503 }));
vi.stubGlobal("fetch", failing);

await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);
await expect(resolveVaultUri(ENAME)).rejects.toThrow(
/could not resolve/i,
);

expect(failing).toHaveBeenCalledTimes(2);
});

it("clearVaultUriCache forces the next lookup back to the registry", async () => {
const fetchMock = stubRegistry();

await resolveVaultUri(ENAME);
clearVaultUriCache();
await resolveVaultUri(ENAME);

expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
47 changes: 42 additions & 5 deletions infrastructure/eid-wallet/src/lib/utils/socialBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,11 +29,17 @@ export interface BindingDocEdge {
// Registry resolution
// ---------------------------------------------------------------------------

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;
const VAULT_URI_TTL_MS = 5 * 60_000;
const vaultUriCache = new Map<string, { uri: string; at: number }>();
const vaultUriInFlight = new Map<string, Promise<string>>();

/** Drop the memoised registry lookups (sign-out, tests). */
export function clearVaultUriCache(): void {
vaultUriCache.clear();
vaultUriInFlight.clear();
}

async function fetchVaultUri(normalized: string): Promise<string> {
const url = new URL(
`resolve?w3id=${encodeURIComponent(normalized)}`,
PUBLIC_REGISTRY_URL,
Expand All@@ -52,6 +58,37 @@ export async function resolveVaultUri(ename: string): Promise<string> {
: new URL("/graphql", base).toString();
}

/**
* Resolve an eName to its eVault GraphQL endpoint via the registry.
*
* Memoised: an eName→URI mapping only changes when a vault is re-provisioned,
* but several layers resolve the same counterparty independently on one screen
* load (the binding reconcile and the name lookup, for a start), so the same
* round trip was being paid 3-4× per contact. Concurrent callers share one
* in-flight request; failures are not cached.
*/
export async function resolveVaultUri(ename: string): Promise<string> {
const normalized = ename.startsWith("@") ? ename : `@${ename}`;

const cached = vaultUriCache.get(normalized);
if (cached && Date.now() - cached.at < VAULT_URI_TTL_MS) return cached.uri;

const pending = vaultUriInFlight.get(normalized);
if (pending) return pending;

const request = fetchVaultUri(normalized)
.then((uri) => {
vaultUriCache.set(normalized, { uri, at: Date.now() });
return uri;
})
.finally(() => {
vaultUriInFlight.delete(normalized);
});

vaultUriInFlight.set(normalized, request);
return request;
}

// ---------------------------------------------------------------------------
// Generic cross-vault GraphQL request
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading