Skip to content

Feat/eid wallet v1 - #972

Merged
coodos merged 11 commits into
mainfrom
feat/eid-wallet-v1
May 27, 2026
Merged

coodos merged 11 commits into
mainfrom
feat/eid-wallet-v1

Conversation

@coodos

@coodos coodos commented May 27, 2026 •

Copy link
Copy Markdown
Contributor

Description of change

Finalize eID Wallet

Issue Number

closes #970
closes #969

Type of change

  • New (a change which implements a new feature)
  • Fix (a change which fixes an issue)

How the change has been tested

Change checklist

  • I have ensured that the CI Checks pass locally
  • I have removed any unnecessary logic
  • My code is well documented
  • I have signed my commits
  • My code follows the pattern of the application
  • I have self reviewed my code

Summary by CodeRabbit

  • New Features

    • Added biometric login settings page.
    • Added notifications settings page.
    • Introduced notary-based recovery code flow with QR scanning.
    • New LoadingSheet component for improved loading feedback.
    • Added character counters and limits to personal binding fields.
  • Improvements

    • Redesigned social bindings UI with contact grouping and role indicators.
    • Enhanced app settings layout with updated version display.
    • Improved back navigation handling across authentication flows.
    • Refined vault state persistence and key management.
    • Upgraded to version 1.0.0.

@coderabbitai

coderabbitai Bot commented May 27, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR introduces three major architectural shifts for version 1.0.0: a complete refactoring of cryptographic key management from multi-key/context-driven to a single-wallet model; a comprehensive notary-issued account recovery system with JWT signing, QR scanning, and claim validation; and modernized UI/settings infrastructure including notification permissions, biometric login setup, and improved social binding presentation. The changes consolidate the onboarding and recovery flows while adding passive data refresh and deferred notification prompting on the main page.

Changes

Single-Wallet Crypto Architecture

Layer / File(s) Summary
Type contracts and key alias constant
src/lib/crypto/types.ts
KeyManager interface removes all keyId parameters; introduces WALLET_KEY_ALIAS: "default" constant; simplifies SoftwareKeyPair and KeyManagerError constructor signatures.
Hardware key manager single-key implementation
src/lib/crypto/HardwareKeyManager.ts
All methods (exists, generate, getPublicKey, signPayload, verifySignature) remove keyId parameter and target WALLET_KEY_ALIAS directly; error handling standardized via KeyManagerError.
Software key manager single-key implementation with migration
src/lib/crypto/SoftwareKeyManager.ts
Fixed storage key based on WALLET_KEY_ALIAS; adds migrateLegacySoftwareKey() to copy legacy default-keyed entry to new slot; parameterless methods using single stored ECDSA P-256 key with SHA-256 signing/verification.
Key manager factory refactored to type-driven singletons
src/lib/crypto/KeyManagerFactory.ts
Replaces config/context selection with getHardware()/getSoftware()/get(type) methods backed by persistent singletons; isHardwareAvailable() uses one-shot hwExists(WALLET_KEY_ALIAS) probe.
Crypto module public exports
src/lib/crypto/index.ts
Exports KeyManagerError, KeyManagerErrorCodes, WALLET_KEY_ALIAS, and migrateLegacySoftwareKey alongside manager types.
Key service and eVault integration
src/lib/global/controllers/key.ts, src/lib/global/controllers/evault.ts, src/lib/global/state.ts
KeyService becomes single-wallet with parameterless methods; adds legacy migration on initialize; introduces #withEvaultSync() for automatic public-key recovery with resolver/sync handlers (now zero-argument). VaultController simplifies syncPublicKey() to take only eName and adds setVaultAndPersist() for vault persistence + background tasks.
Wallet SDK adapter refactored
src/lib/wallet-sdk-adapter.ts
Adapter now delegates to parameterless KeyService methods instead of passing keyId/context.
Route callers updated
src/routes/(app)/**/*.svelte, src/routes/(auth)/**/*.svelte, src/routes/(app)/scan-qr/scanLogic.ts, etc.
All pages switch from walletSdkAdapter.ensureKey(keyId, context) / signPayload(keyId, context, payload) to keyService.ensureKey() / keyService.sign(payload) and use vaultController.setVaultAndPersist().

Notary-Issued Recovery System

Layer / File(s) Summary
Recovery session store
platforms/enotary/src/lib/server/recoverySessions.ts
In-memory RecoverySession interface with pending/consumed states, fixed 15-minute TTL, background expiry sweeper; createSession/getSession/consumeSession with atomicity guarantees.
JWT signing with JWKS endpoint
platforms/enotary/src/lib/server/jwt.ts
Uses jose library for ES256 keypair management; supports ENOTARY_JWK env var for production, ephemeral dev fallback; exports generateInitialJWK, signRecoveryToken, getJWKS, and RecoveryTokenPayload schema.
Recovery audit binding
platforms/enotary/src/lib/server/recoveryAudit.ts
Writes "notary recovery attestation" binding documents to eVault post-claim; deterministic SHA-256 hashing for legacy ownerSignature compatibility, platform token fetching, GraphQL mutation submission.
Enotary server routes
platforms/enotary/src/**/*.ts, platforms/enotary/src/hooks.server.ts
GET /.well-known/jwks.json (JWKS endpoint), POST /api/recovery/issue (issue JWT + QR), POST /api/recovery/claim (consume + audit), GET /api/recovery/status (poll); CORS extended to /.well-known/ prefix.
Enotary user page recovery QR
platforms/enotary/src/routes/user/[ename]/+page.svelte
Replaces passphrase reset with recovery QR flow; issues code, displays QR, polls status with countdown, dismisses on claim/expiry.
Recovery page: unverified (security question) path
src/routes/(public)/recover/+page.svelte (partial)
eName → eVault resolution via registry, fetches security_question binding, validates user answer via GraphQL, enriches recovery profile from eVault bindings + provisioner decision endpoint fallback.
Recovery page: notary QR scanning path
src/routes/(public)/recover/+page.svelte (partial)
Full notary flow: camera permissions, QR decode, JWT verification with jose, registry notary whitelist validation, same-origin enforcement, claim endpoint call, profile enrichment.
Recovery page UI and overlays
src/routes/(public)/recover/+page.svelte, src/lib/ui/LoadingSheet/
LoadingSheet overlay, error-source-driven retry routing, loading phase states, step transitions, home screen copy for unverified/notary options, and error sheet "Try Again" routing.
Registry notaries endpoint
platforms/registry/api/src/index.ts
GET /notaries endpoint reads REGISTRY_NOTARIES env var, validates whitelist entry shapes, returns public notary list for wallet validation.

UI/UX Improvements and Settings Infrastructure

Layer / File(s) Summary
LoadingSheet component
src/lib/ui/LoadingSheet/LoadingSheet.svelte, src/lib/ui/index.ts
Non-dismissible BottomSheet with loading indicator, title/subtitle, optional cancel button; used in onboarding, login, and recovery flows.
Tauri terminal console mirroring
src/lib/utils/terminalConsole.ts, src-tauri/src/lib.rs
installTerminalConsoleBridge() forwards console.* to Tauri host via invoke("log_to_terminal", {level, message}); Tauri command routes to stderr (error/warn) or stdout (others).
Settings pages: biometrics and notifications
src/routes/(app)/settings/biometrics/+page.svelte, src/routes/(app)/settings/notifications/+page.svelte, src/routes/(app)/settings/+page.svelte
New biometric login toggle (queries checkStatus, persists to securityController); new notification permission toggle (queries isPermissionGranted, opens app settings); settings page displays reactive subtitles and navigation rows.
Onboarding biometrics step
src/routes/(auth)/onboarding/steps/BiometricsSetup.svelte, src/routes/(auth)/onboarding/+page.svelte
New explicit biometrics step checks availability on mount; enable/skip/back callbacks; recovery path can skip and continue to naming; loadingPhase overlay replaces old loading/checkingHardware UI.
Main page: module-level caching and UI enhancements
src/routes/(app)/main/+page.svelte
<script module> persists user data, display name, KYC/fake status, legal ID, social preview/count across navigations; one-shot notification prompt (deferred during tour, shown after); scan FAB resting position from viewport height; passive refresh interval + visibilitychange listener for bindings/personal data.
Social binding improvements
src/lib/utils/socialBinding.ts, src/routes/(app)/main/components/, src/routes/(app)/social-bindings/+page.svelte
fetchUnsignedSocialDocs deduplicates by signer (newest only); pruneDuplicateUnsignedDocs cleans duplicates post-accept; SocialBindingSummary includes role (sent/received); drawer idempotency checks existing signatures; new SocialBindingDetailsSheet per-contact display with role badge, timestamps; main page groups contacts by counterparty with combined role.
Minor UI updates
src/lib/ui/BottomSheet/, src/lib/ui/PlatformAppCard/, src/routes/(app)/settings/pin/, etc.
BottomSheet backdrop reduced dim + blur added; PlatformAppCard derives display name from hostname first subdomain; PIN success sheet icon changed to checkmark.

Version Bump and Housekeeping

Layer / File(s) Summary
Version to 1.0.0
infrastructure/eid-wallet/package.json, src-tauri/tauri.conf.json, src/routes/(app)/settings/+layout.svelte
Package, Tauri config, and settings VERSION constant updated to 1.0.0; Android versionCode incremented to 26.
Remove dev-only notification seeding
src/lib/stores/notifications.ts, src/routes/(app)/notifications/+page.svelte
seedDummyNotifications() export and fixture data deleted; dev-only seed UI and isDev checks removed from notifications page.
Global CSS overscroll and WebView transparency
src/app.css
Tailwind @layer base disables HTML/body overscroll bounce/glow and non-form text selection; adds body.custom-global-style mode for transparent WebView pass-through (camera scanner).
Personal binding max-length and GraphQL query
src/lib/utils/personalBinding.ts, src/routes/(app)/personal/components/*
PERSONAL_BINDING_MAX_LENGTH = 2048 constant added; PERSONAL_BINDING_BY_TYPE_QUERY exported; AddKnowledgeSheet/AddParametersSheet/AddPhotoSheet updated with maxlength and live character counters.
Route transitions and auth guard
src/routes/+layout.svelte, src/routes/+page.svelte
Navigation direction refactored to use navigation.type/delta; beforeNavigate hook prevents back navigation to auth routes after app routes visited; returning-user splash routing derives target from pinHash.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes


Possibly related PRs


Suggested labels

evault-refactor, notary-recovery, ui-modernization


Suggested reviewers

  • sosweetham
  • xPathin

🐰 A rabbit once mused on this grand refactor so fine,
"One key per wallet, no context design!
With notaries signing and LoadingSheets bright,
The recovery flows dance in OpenSSL light!"
Then hopped toward the PR with a cryptographic sigh. 🔐

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/eid-wallet-v1

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (1)
infrastructure/eid-wallet/src/routes/(public)/recover/+page.svelte (1)

896-915: 💤 Low value

Consider adding explicit type guards for the verified JWT payload.

The jwtVerify call validates the signature but the payload is cast directly to NotaryRecoveryPayload without runtime type validation. The subsequent checks (lines 907-915) partially validate required fields exist, but the type assertion on line 899 could mask malformed payloads.

The current sanity checks are likely sufficient for this use case, but a dedicated type guard would make the contract more explicit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infrastructure/eid-wallet/src/routes/`(public)/recover/+page.svelte around
lines 896 - 915, The JWT payload is being cast to NotaryRecoveryPayload without
runtime validation; add an explicit type guard (e.g.,
isNotaryRecoveryPayload(payload): payload is NotaryRecoveryPayload) and use it
after jwtVerify and before assigning to verifiedPayload so you only accept
payloads with required fields and proper types (check notaryEName, claim,
sessionId, targetEName types/shape). If the guard returns false, call failNotary
with the existing error message and return; keep using jwtVerify,
verifiedPayload, declaredNotary, declaredClaim and failNotary as the referenced
symbols.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@infrastructure/eid-wallet/src/lib/utils/socialBinding.ts`:
- Around line 466-492: In pruneDuplicateUnsignedDocs, normalize the signer
parameter the same way callerEname is normalized (ensure it starts with '@')
before using it in comparisons with sigs[0]?.signer and sigs.some(...); e.g.,
create a local normalizedSigner (or reuse the existing normalized name variable
if appropriate) and use that when computing sameSigner and callerAlreadySigned
so unprefixed eNames match correctly against parsed.signatures entries.

In `@infrastructure/eid-wallet/src/lib/utils/terminalConsole.ts`:
- Around line 53-69: The code registers global listeners using
window.addEventListener("error", ...) and
window.addEventListener("unhandledrejection", ...) unconditionally; wrap those
registrations in a browser guard (e.g., if (typeof window === "undefined")
return; or if (typeof window !== "undefined") { ... }) so the initializer no-ops
in SSR/non-browser runtimes, ensuring the error and unhandledrejection handlers
are only attached when window is defined.

In
`@infrastructure/eid-wallet/src/routes/`(app)/main/components/SocialBindingDetailsSheet.svelte:
- Around line 26-39: The formatTimestamp function currently calls new Date(iso)
and toLocaleString without validating the Date, which can render "Invalid Date";
update formatTimestamp to explicitly check the parsed date's validity (e.g.,
using isNaN(d.getTime()) or Number.isNaN(d.valueOf())) after creating d in
function formatTimestamp(iso: string) and return the original iso (or empty
string) when the date is invalid, otherwise proceed to call d.toLocaleString
with the existing options.

In `@infrastructure/eid-wallet/src/routes/`(app)/settings/biometrics/+page.svelte:
- Around line 45-47: The current check treats null (unknown) as unavailable
because it uses `!isAvailable`; change the condition in the block that uses `if
(next && !isAvailable)` to only treat a definite false as unavailable (e.g.,
`isAvailable === false`) so that `null`/undefined waits for `checkStatus()` to
resolve; update the error assignment path that sets `error = "Biometrics aren't
available on this device."` to run only when `isAvailable === false` and allow
the flow to continue (or await `checkStatus()`) when `isAvailable` is null so
users don't see the wrong error prematurely.

In `@infrastructure/eid-wallet/src/routes/`+layout.svelte:
- Around line 14-15: installTerminalConsoleBridge() is being called at module
scope which can run during prerender/SSR and crash because it uses browser
globals; move the call into a client-only lifecycle by importing Svelte's
onMount and calling installTerminalConsoleBridge() inside onMount (or
alternatively wrap the call with a typeof window !== 'undefined' guard) so it
only runs in the browser/Tauri runtime; reference the
installTerminalConsoleBridge() invocation and use onMount to defer execution to
the client.

In `@platforms/enotary/src/lib/server/jwt.ts`:
- Around line 44-45: Wrap the JSON.parse call that assigns jwk in jwt.ts in a
try/catch to safely handle malformed ENOTARY_JWK: when raw is present, try
JSON.parse(raw) into jwk and on SyntaxError catch log a clear error (including
the raw content or error message) and either throw a controlled error or skip
loading the JWK so the server doesn't crash on first request; update the block
around the raw/jwk assignment to use the try/catch and ensure downstream code
(e.g., any functions that use jwk) handles the absent/invalid jwk.

In `@platforms/enotary/src/lib/server/recoveryAudit.ts`:
- Around line 131-132: The current call to client.request(CREATE_BINDING_DOC, {
input }) in createBindingDocument ignores the GraphQL response so business
errors or a missing metaEnvelopeId are swallowed; update createBindingDocument
to capture the mutation response, check for response.errors and for a present
response.data.createBindingDocument.metaEnvelopeId (or equivalent field name),
and if errors exist or metaEnvelopeId is missing throw or return a descriptive
Error (and/or call the existing audit/logger) so upstream callers can handle
failure; reference the CREATE_BINDING_DOC constant, the client.request call and
the createBindingDocument function when applying the fix.

In `@platforms/enotary/src/lib/server/recoverySessions.ts`:
- Around line 34-37: The sweeper currently only removes sessions where
session.expiresAt < now and session.status === "pending", leaving consumed
sessions to accumulate; update the deletion condition in the sweeper loop that
iterates over sessions so it also removes consumed sessions (e.g., remove when
session.status === "consumed" or when status indicates it was consumed) in
addition to the existing expired/pending check; change the logic around the
sessions iteration that references sessions, session.expiresAt and
session.status (the same change should be applied to the other identical block
around the code that maps to lines 99-102) so consumed sessions are also deleted
to prevent unbounded in-memory growth.

---

Nitpick comments:
In `@infrastructure/eid-wallet/src/routes/`(public)/recover/+page.svelte:
- Around line 896-915: The JWT payload is being cast to NotaryRecoveryPayload
without runtime validation; add an explicit type guard (e.g.,
isNotaryRecoveryPayload(payload): payload is NotaryRecoveryPayload) and use it
after jwtVerify and before assigning to verifiedPayload so you only accept
payloads with required fields and proper types (check notaryEName, claim,
sessionId, targetEName types/shape). If the guard returns false, call failNotary
with the existing error message and return; keep using jwtVerify,
verifiedPayload, declaredNotary, declaredClaim and failNotary as the referenced
symbols.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7384a15d-6548-42bf-b7e5-36811bec7d2f

📥 Commits

Reviewing files that changed from the base of the PR and between 4dfada5 and 6fe3a97.

⛔ Files ignored due to path filters (2)
  • infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/foundation/metastate/eid_wallet/MainActivity.kt is excluded by !**/gen/**
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (62)
  • infrastructure/eid-wallet/package.json
  • infrastructure/eid-wallet/src-tauri/src/lib.rs
  • infrastructure/eid-wallet/src-tauri/tauri.conf.json
  • infrastructure/eid-wallet/src/app.css
  • infrastructure/eid-wallet/src/lib/crypto/HardwareKeyManager.ts
  • infrastructure/eid-wallet/src/lib/crypto/KeyManagerFactory.ts
  • infrastructure/eid-wallet/src/lib/crypto/SoftwareKeyManager.ts
  • infrastructure/eid-wallet/src/lib/crypto/index.ts
  • infrastructure/eid-wallet/src/lib/crypto/types.ts
  • infrastructure/eid-wallet/src/lib/global/controllers/evault.ts
  • infrastructure/eid-wallet/src/lib/global/controllers/key.ts
  • infrastructure/eid-wallet/src/lib/global/index.ts
  • infrastructure/eid-wallet/src/lib/global/state.ts
  • infrastructure/eid-wallet/src/lib/stores/notifications.ts
  • infrastructure/eid-wallet/src/lib/ui/BottomSheet/BottomSheet.svelte
  • infrastructure/eid-wallet/src/lib/ui/LoadingSheet/LoadingSheet.svelte
  • infrastructure/eid-wallet/src/lib/ui/PlatformAppCard/PlatformAppCard.svelte
  • infrastructure/eid-wallet/src/lib/ui/index.ts
  • infrastructure/eid-wallet/src/lib/utils/personalBinding.ts
  • infrastructure/eid-wallet/src/lib/utils/socialBinding.ts
  • infrastructure/eid-wallet/src/lib/utils/terminalConsole.ts
  • infrastructure/eid-wallet/src/lib/wallet-sdk-adapter.ts
  • infrastructure/eid-wallet/src/routes/(app)/+layout.svelte
  • infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte
  • infrastructure/eid-wallet/src/routes/(app)/main/+page.svelte
  • infrastructure/eid-wallet/src/routes/(app)/main/components/EVaultCard.svelte
  • infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingAccordion.svelte
  • infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelte
  • infrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDrawer.svelte
  • infrastructure/eid-wallet/src/routes/(app)/main/legacy/KycUpgradeOverlay.svelte
  • infrastructure/eid-wallet/src/routes/(app)/notifications/+page.svelte
  • infrastructure/eid-wallet/src/routes/(app)/personal/+page.svelte
  • infrastructure/eid-wallet/src/routes/(app)/personal/components/AddKnowledgeSheet.svelte
  • infrastructure/eid-wallet/src/routes/(app)/personal/components/AddParametersSheet.svelte
  • infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte
  • infrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.ts
  • infrastructure/eid-wallet/src/routes/(app)/settings/+layout.svelte
  • infrastructure/eid-wallet/src/routes/(app)/settings/+page.svelte
  • infrastructure/eid-wallet/src/routes/(app)/settings/biometrics/+page.svelte
  • infrastructure/eid-wallet/src/routes/(app)/settings/notifications/+page.svelte
  • infrastructure/eid-wallet/src/routes/(app)/settings/pin/+page.svelte
  • infrastructure/eid-wallet/src/routes/(app)/social-bindings/+page.svelte
  • infrastructure/eid-wallet/src/routes/(auth)/e-passport/+page.svelte
  • infrastructure/eid-wallet/src/routes/(auth)/login/+page.svelte
  • infrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelte
  • infrastructure/eid-wallet/src/routes/(auth)/onboarding/steps/BiometricsSetup.svelte
  • infrastructure/eid-wallet/src/routes/(auth)/register/+page.svelte
  • infrastructure/eid-wallet/src/routes/(auth)/review/+page.svelte
  • infrastructure/eid-wallet/src/routes/(public)/recover/+page.svelte
  • infrastructure/eid-wallet/src/routes/+layout.svelte
  • infrastructure/eid-wallet/src/routes/+page.svelte
  • platforms/enotary/package.json
  • platforms/enotary/src/hooks.server.ts
  • platforms/enotary/src/lib/server/jwt.ts
  • platforms/enotary/src/lib/server/recoveryAudit.ts
  • platforms/enotary/src/lib/server/recoverySessions.ts
  • platforms/enotary/src/routes/.well-known/jwks.json/+server.ts
  • platforms/enotary/src/routes/api/recovery/claim/+server.ts
  • platforms/enotary/src/routes/api/recovery/issue/+server.ts
  • platforms/enotary/src/routes/api/recovery/status/+server.ts
  • platforms/enotary/src/routes/user/[ename]/+page.svelte
  • platforms/registry/api/src/index.ts
💤 Files with no reviewable changes (5)
  • infrastructure/eid-wallet/src/routes/(auth)/review/+page.svelte
  • infrastructure/eid-wallet/src/lib/global/index.ts
  • infrastructure/eid-wallet/src/routes/(auth)/e-passport/+page.svelte
  • infrastructure/eid-wallet/src/routes/(auth)/register/+page.svelte
  • infrastructure/eid-wallet/src/lib/stores/notifications.ts

Comment on lines +466 to +492
export async function pruneDuplicateUnsignedDocs(
ownGqlUrl: string,
callerEname: string,
keepDocId: string,
signer: string,
): Promise<number> {
const normalized = callerEname.startsWith("@")
? callerEname
: `@${callerEname}`;

const data = await vaultGqlRequest<{
bindingDocuments: { edges: BindingDocEdge[] };
}>(ownGqlUrl, callerEname, SOCIAL_BINDING_DOCS_QUERY);

const dupes = (data.bindingDocuments?.edges ?? []).filter((edge) => {
if (edge.node.id === keepDocId) return false;
const parsed = edge.node.parsed;
if (!parsed || parsed.type !== "social_connection") return false;
if (parsed.subject !== normalized) return false;
const sigs = Array.isArray(parsed.signatures) ? parsed.signatures : [];
// Same signer, and the caller hasn't already countersigned this
// one either — i.e. it's a stale duplicate of the doc we just
// accepted.
const sameSigner = sigs[0]?.signer === signer;
const callerAlreadySigned = sigs.some((s) => s.signer === normalized);
return sameSigner && !callerAlreadySigned;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize signer parameter before comparison.

The signer parameter is compared directly against sigs[0]?.signer (Line 489), but unlike callerEname, it's not normalized with the @ prefix. If the caller passes an unnormalized eName, the duplicate filtering will fail to match.

🛠️ Proposed fix
 export async function pruneDuplicateUnsignedDocs(
     ownGqlUrl: string,
     callerEname: string,
     keepDocId: string,
     signer: string,
 ): Promise<number> {
     const normalized = callerEname.startsWith("@")
         ? callerEname
         : `@${callerEname}`;
+    const normalizedSigner = signer.startsWith("@") ? signer : `@${signer}`;
 
     const data = await vaultGqlRequest<{
         bindingDocuments: { edges: BindingDocEdge[] };
     }>(ownGqlUrl, callerEname, SOCIAL_BINDING_DOCS_QUERY);
 
     const dupes = (data.bindingDocuments?.edges ?? []).filter((edge) => {
         if (edge.node.id === keepDocId) return false;
         const parsed = edge.node.parsed;
         if (!parsed || parsed.type !== "social_connection") return false;
         if (parsed.subject !== normalized) return false;
         const sigs = Array.isArray(parsed.signatures) ? parsed.signatures : [];
-        const sameSigner = sigs[0]?.signer === signer;
+        const sameSigner = sigs[0]?.signer === normalizedSigner;
         const callerAlreadySigned = sigs.some((s) => s.signer === normalized);
         return sameSigner && !callerAlreadySigned;
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function pruneDuplicateUnsignedDocs(
ownGqlUrl: string,
callerEname: string,
keepDocId: string,
signer: string,
): Promise<number> {
const normalized = callerEname.startsWith("@")
? callerEname
: `@${callerEname}`;
const data = await vaultGqlRequest<{
bindingDocuments: { edges: BindingDocEdge[] };
}>(ownGqlUrl, callerEname, SOCIAL_BINDING_DOCS_QUERY);
const dupes = (data.bindingDocuments?.edges ?? []).filter((edge) => {
if (edge.node.id === keepDocId) return false;
const parsed = edge.node.parsed;
if (!parsed || parsed.type !== "social_connection") return false;
if (parsed.subject !== normalized) return false;
const sigs = Array.isArray(parsed.signatures) ? parsed.signatures : [];
// Same signer, and the caller hasn't already countersigned this
// one either — i.e. it's a stale duplicate of the doc we just
// accepted.
const sameSigner = sigs[0]?.signer === signer;
const callerAlreadySigned = sigs.some((s) => s.signer === normalized);
return sameSigner && !callerAlreadySigned;
});
export async function pruneDuplicateUnsignedDocs(
ownGqlUrl: string,
callerEname: string,
keepDocId: string,
signer: string,
): Promise<number> {
const normalized = callerEname.startsWith("@")
? callerEname
: `@${callerEname}`;
const normalizedSigner = signer.startsWith("@") ? signer : `@${signer}`;
const data = await vaultGqlRequest<{
bindingDocuments: { edges: BindingDocEdge[] };
}>(ownGqlUrl, callerEname, SOCIAL_BINDING_DOCS_QUERY);
const dupes = (data.bindingDocuments?.edges ?? []).filter((edge) => {
if (edge.node.id === keepDocId) return false;
const parsed = edge.node.parsed;
if (!parsed || parsed.type !== "social_connection") return false;
if (parsed.subject !== normalized) return false;
const sigs = Array.isArray(parsed.signatures) ? parsed.signatures : [];
// Same signer, and the caller hasn't already countersigned this
// one either — i.e. it's a stale duplicate of the doc we just
// accepted.
const sameSigner = sigs[0]?.signer === normalizedSigner;
const callerAlreadySigned = sigs.some((s) => s.signer === normalized);
return sameSigner && !callerAlreadySigned;
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infrastructure/eid-wallet/src/lib/utils/socialBinding.ts` around lines 466 -
492, In pruneDuplicateUnsignedDocs, normalize the signer parameter the same way
callerEname is normalized (ensure it starts with '@') before using it in
comparisons with sigs[0]?.signer and sigs.some(...); e.g., create a local
normalizedSigner (or reuse the existing normalized name variable if appropriate)
and use that when computing sameSigner and callerAlreadySigned so unprefixed
eNames match correctly against parsed.signatures entries.

Comment on lines +53 to +69
window.addEventListener("error", (event) => {
void invoke("log_to_terminal", {
level: "error",
message: `Unhandled error: ${event.message} @ ${event.filename}:${event.lineno}:${event.colno}`,
}).catch(() => {});
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
const msg =
reason instanceof Error
? `${reason.name}: ${reason.message}${reason.stack ? `\n${reason.stack}` : ""}`
: String(reason);
void invoke("log_to_terminal", {
level: "error",
message: `Unhandled rejection: ${msg}`,
}).catch(() => {});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard browser globals before registering window listeners.

window is used unconditionally here. If this initializer is called in SSR/non-browser runtime, it will throw and break execution. Add a browser guard before listener registration (or early in the function).

Suggested fix
 export function installTerminalConsoleBridge(): void {
     if (installed) return;
     installed = true;
+    const isBrowser = typeof window !== "undefined";
 
     const levels: Level[] = ["log", "info", "warn", "error", "debug"];
     for (const level of levels) {
@@
-    // Surface unhandled errors/rejections too.
-    window.addEventListener("error", (event) => {
+    // Surface unhandled errors/rejections too.
+    if (!isBrowser) return;
+    window.addEventListener("error", (event) => {
         void invoke("log_to_terminal", {
             level: "error",
             message: `Unhandled error: ${event.message} @ ${event.filename}:${event.lineno}:${event.colno}`,
         }).catch(() => {});
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
window.addEventListener("error", (event) => {
void invoke("log_to_terminal", {
level: "error",
message: `Unhandled error: ${event.message} @ ${event.filename}:${event.lineno}:${event.colno}`,
}).catch(() => {});
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
const msg =
reason instanceof Error
? `${reason.name}: ${reason.message}${reason.stack ? `\n${reason.stack}` : ""}`
: String(reason);
void invoke("log_to_terminal", {
level: "error",
message: `Unhandled rejection: ${msg}`,
}).catch(() => {});
});
if (!isBrowser) return;
window.addEventListener("error", (event) => {
void invoke("log_to_terminal", {
level: "error",
message: `Unhandled error: ${event.message} @ ${event.filename}:${event.lineno}:${event.colno}`,
}).catch(() => {});
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
const msg =
reason instanceof Error
? `${reason.name}: ${reason.message}${reason.stack ? `\n${reason.stack}` : ""}`
: String(reason);
void invoke("log_to_terminal", {
level: "error",
message: `Unhandled rejection: ${msg}`,
}).catch(() => {});
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infrastructure/eid-wallet/src/lib/utils/terminalConsole.ts` around lines 53 -
69, The code registers global listeners using window.addEventListener("error",
...) and window.addEventListener("unhandledrejection", ...) unconditionally;
wrap those registrations in a browser guard (e.g., if (typeof window ===
"undefined") return; or if (typeof window !== "undefined") { ... }) so the
initializer no-ops in SSR/non-browser runtimes, ensuring the error and
unhandledrejection handlers are only attached when window is defined.

Comment on lines +26 to +39
function formatTimestamp(iso: string): string {
if (!iso) return "";
try {
const d = new Date(iso);
return d.toLocaleString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return iso;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle invalid ISO values explicitly in timestamp formatting.

new Date(iso) won’t throw for invalid input, so this can render "Invalid Date" in UI. Add a validity check before toLocaleString.

Suggested fix
 function formatTimestamp(iso: string): string {
     if (!iso) return "";
-    try {
-        const d = new Date(iso);
-        return d.toLocaleString(undefined, {
-            day: "numeric",
-            month: "short",
-            year: "numeric",
-            hour: "numeric",
-            minute: "2-digit",
-        });
-    } catch {
-        return iso;
-    }
+    const d = new Date(iso);
+    if (Number.isNaN(d.getTime())) return iso;
+    return d.toLocaleString(undefined, {
+        day: "numeric",
+        month: "short",
+        year: "numeric",
+        hour: "numeric",
+        minute: "2-digit",
+    });
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function formatTimestamp(iso: string): string {
if (!iso) return "";
try {
const d = new Date(iso);
return d.toLocaleString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return iso;
}
function formatTimestamp(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(undefined, {
day: "numeric",
month: "short",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@infrastructure/eid-wallet/src/routes/`(app)/main/components/SocialBindingDetailsSheet.svelte
around lines 26 - 39, The formatTimestamp function currently calls new Date(iso)
and toLocaleString without validating the Date, which can render "Invalid Date";
update formatTimestamp to explicitly check the parsed date's validity (e.g.,
using isNaN(d.getTime()) or Number.isNaN(d.valueOf())) after creating d in
function formatTimestamp(iso: string) and return the original iso (or empty
string) when the date is invalid, otherwise proceed to call d.toLocaleString
with the existing options.

Comment on lines +45 to +47
if (next && !isAvailable) {
error = "Biometrics aren't available on this device.";
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not treat unknown availability (null) as unavailable.

At Line 45, !isAvailable is true for both false and null, so users can get a wrong “unavailable” error before checkStatus() resolves.

Suggested fix
-    if (next && !isAvailable) {
+    if (next && isAvailable === false) {
         error = "Biometrics aren't available on this device.";
         return;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infrastructure/eid-wallet/src/routes/`(app)/settings/biometrics/+page.svelte
around lines 45 - 47, The current check treats null (unknown) as unavailable
because it uses `!isAvailable`; change the condition in the block that uses `if
(next && !isAvailable)` to only treat a definite false as unavailable (e.g.,
`isAvailable === false`) so that `null`/undefined waits for `checkStatus()` to
resolve; update the error assignment path that sets `error = "Biometrics aren't
available on this device."` to run only when `isAvailable === false` and allow
the flow to continue (or await `checkStatus()`) when `isAvailable` is null so
users don't see the wrong error prematurely.

Comment on lines +14 to +15
// Mirror console.* to the Tauri host stdout so logs land in `pnpm tauri dev`.
installTerminalConsoleBridge();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Inspect bridge implementation for browser/Tauri globals:"
fd -a terminalConsole.ts
rg -n -C3 'installTerminalConsoleBridge|window|document|navigator|__TAURI__|globalThis' infrastructure/eid-wallet/src/lib/utils/terminalConsole.ts

echo "Check global/route SSR toggles:"
fd -a "svelte.config.*"
rg -n -C2 'ssr|prerender|adapter' svelte.config.* || true
rg -n -C2 'export const ssr' infrastructure/eid-wallet/src/routes || true

Repository: MetaState-Prototype-Project/prototype

Length of output: 2111


Defer installTerminalConsoleBridge() to the client lifecycle

installTerminalConsoleBridge() uses browser globals (window.addEventListener(...)), but it’s invoked at module scope in infrastructure/eid-wallet/src/routes/+layout.svelte (lines 14-15). Even though infrastructure/eid-wallet/src/routes/+layout.ts sets ssr = false, it also has prerender = true, so this call can run outside the browser/Tauri runtime and crash. Move the bridge installation into onMount (or guard with typeof window !== 'undefined').

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infrastructure/eid-wallet/src/routes/`+layout.svelte around lines 14 - 15,
installTerminalConsoleBridge() is being called at module scope which can run
during prerender/SSR and crash because it uses browser globals; move the call
into a client-only lifecycle by importing Svelte's onMount and calling
installTerminalConsoleBridge() inside onMount (or alternatively wrap the call
with a typeof window !== 'undefined' guard) so it only runs in the browser/Tauri
runtime; reference the installTerminalConsoleBridge() invocation and use onMount
to defer execution to the client.

Comment on lines +44 to +45
if (raw) {
jwk = JSON.parse(raw) as JWK;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Wrap JSON.parse in try/catch to handle malformed ENOTARY_JWK.

If ENOTARY_JWK contains invalid JSON, the server will crash on first request. This could happen from a typo in environment configuration.

🛡️ Proposed fix
     if (raw) {
-        jwk = JSON.parse(raw) as JWK;
+        try {
+            jwk = JSON.parse(raw) as JWK;
+        } catch (e) {
+            throw new Error(
+                `[enotary/jwt] ENOTARY_JWK contains invalid JSON: ${e instanceof Error ? e.message : e}`,
+            );
+        }
     } else {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platforms/enotary/src/lib/server/jwt.ts` around lines 44 - 45, Wrap the
JSON.parse call that assigns jwk in jwt.ts in a try/catch to safely handle
malformed ENOTARY_JWK: when raw is present, try JSON.parse(raw) into jwk and on
SyntaxError catch log a clear error (including the raw content or error message)
and either throw a controlled error or skip loading the JWK so the server
doesn't crash on first request; update the block around the raw/jwk assignment
to use the try/catch and ensure downstream code (e.g., any functions that use
jwk) handles the absent/invalid jwk.

Comment on lines +131 to +132
await client.request(CREATE_BINDING_DOC, { input });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate createBindingDocument business errors from GraphQL response.

The mutation response is currently ignored. If eVault returns errors (or no metaEnvelopeId), this path looks successful and the audit is silently dropped.

Suggested fix
-    await client.request(CREATE_BINDING_DOC, { input });
+    const result = await client.request<{
+        createBindingDocument: {
+            metaEnvelopeId: string | null;
+            errors?: Array<{ message: string; code?: string }>;
+        };
+    }>(CREATE_BINDING_DOC, { input });
+
+    const mutationErrors = result.createBindingDocument.errors ?? [];
+    if (mutationErrors.length > 0) {
+        throw new Error(
+            `createBindingDocument failed: ${mutationErrors
+                .map((e) => e.message)
+                .join("; ")}`,
+        );
+    }
+    if (!result.createBindingDocument.metaEnvelopeId) {
+        throw new Error("createBindingDocument returned no metaEnvelopeId");
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await client.request(CREATE_BINDING_DOC, { input });
}
const result = await client.request<{
createBindingDocument: {
metaEnvelopeId: string | null;
errors?: Array<{ message: string; code?: string }>;
};
}>(CREATE_BINDING_DOC, { input });
const mutationErrors = result.createBindingDocument.errors ?? [];
if (mutationErrors.length > 0) {
throw new Error(
`createBindingDocument failed: ${mutationErrors
.map((e) => e.message)
.join("; ")}`,
);
}
if (!result.createBindingDocument.metaEnvelopeId) {
throw new Error("createBindingDocument returned no metaEnvelopeId");
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platforms/enotary/src/lib/server/recoveryAudit.ts` around lines 131 - 132,
The current call to client.request(CREATE_BINDING_DOC, { input }) in
createBindingDocument ignores the GraphQL response so business errors or a
missing metaEnvelopeId are swallowed; update createBindingDocument to capture
the mutation response, check for response.errors and for a present
response.data.createBindingDocument.metaEnvelopeId (or equivalent field name),
and if errors exist or metaEnvelopeId is missing throw or return a descriptive
Error (and/or call the existing audit/logger) so upstream callers can handle
failure; reference the CREATE_BINDING_DOC constant, the client.request call and
the createBindingDocument function when applying the fix.

Comment on lines +34 to +37
for (const [id, session] of sessions) {
if (session.expiresAt < now && session.status === "pending") {
sessions.delete(id);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clean up consumed sessions to avoid unbounded in-memory growth.

The sweeper only deletes expired pending sessions, so consumed sessions are retained forever.

Proposed fix
-        for (const [id, session] of sessions) {
-            if (session.expiresAt < now && session.status === "pending") {
-                sessions.delete(id);
-            }
-        }
+        for (const [id, session] of sessions) {
+            if (session.expiresAt < now) {
+                sessions.delete(id);
+            }
+        }

Also applies to: 99-102

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@platforms/enotary/src/lib/server/recoverySessions.ts` around lines 34 - 37,
The sweeper currently only removes sessions where session.expiresAt < now and
session.status === "pending", leaving consumed sessions to accumulate; update
the deletion condition in the sweeper loop that iterates over sessions so it
also removes consumed sessions (e.g., remove when session.status === "consumed"
or when status indicates it was consumed) in addition to the existing
expired/pending check; change the logic around the sessions iteration that
references sessions, session.expiresAt and session.status (the same change
should be applied to the other identical block around the code that maps to
lines 99-102) so consumed sessions are also deleted to prevent unbounded
in-memory growth.

@coodos
coodos merged commit 4af8f4b into main May 27, 2026
5 checks passed
@coodos
coodos deleted the feat/eid-wallet-v1 branch May 27, 2026 12:03
Bekiboo added a commit that referenced this pull request Sep 23, 2026
…list

Accept and Decline lived only inside the invite drawer — the sheet that
shows your own QR code — and only surfaced there through a poll that runs
every three seconds while that sheet is open. The bindings list showed the
same pending request as "Awaiting confirmation" with nothing to press, and
nothing anywhere pointed at the drawer, so a request could only be found
by opening your own QR and waiting.

Put the action where the request is already visible. Each row in the
details sheet now carries what fits its state: Accept and Decline on a
received request, Cancel invite on a sent one, nothing on a completed
binding. Accepting and declining share one implementation with the drawer
and the ePassport page, which had drifted — the ePassport copy never ran
the duplicate prune. Signing is passed in as a function so the utils
module stays free of any GlobalState dependency.

"View on full list" is now rendered only when a caller supplies the
callback. The sheet is opened from the full list and nowhere else, so the
button had been a second Close since #972.

Two defects in the same path broke re-binding with a contact you are
already bound to, and both are fixed here:

- pruneBoundSignerDocs deleted any unsigned envelope from a bound signer,
  including a deliberate new invite. It now compares against the timestamp
  of your counter-signature: envelopes that predate your acceptance are
  leftovers from the same burst of repeat scans, anything newer is a real
  request and survives.
- The sent-mirror reconcile matched on data.parties alone, so one confirmed
  binding marked every pending invite to that person confirmed. It now
  matches invites per relation description, from one scan of the
  counterparty's vault however many mirrors point at them, and skips docs
  the counterparty originated — their own mirrors were being counted as
  invites we sent, which kept a declined invite looking pending forever.

Duplicate pruning is scoped to the same relation description too, so
accepting one invite no longer deletes a different one from the same
person.

Closes #1146
Bekiboo added a commit that referenced this pull request Sep 24, 2026
…list

Accept and Decline lived only inside the invite drawer — the sheet that
shows your own QR code — and only surfaced there through a poll that runs
every three seconds while that sheet is open. The bindings list showed the
same pending request as "Awaiting confirmation" with nothing to press, and
nothing anywhere pointed at the drawer, so a request could only be found
by opening your own QR and waiting.

Put the action where the request is already visible. Each row in the
details sheet now carries what fits its state: Accept and Decline on a
received request, Cancel invite on a sent one, nothing on a completed
binding. Accepting and declining share one implementation with the drawer
and the ePassport page, which had drifted — the ePassport copy never ran
the duplicate prune. Signing is passed in as a function so the utils
module stays free of any GlobalState dependency.

"View on full list" is now rendered only when a caller supplies the
callback. The sheet is opened from the full list and nowhere else, so the
button had been a second Close since #972.

Two defects in the same path broke re-binding with a contact you are
already bound to, and both are fixed here:

- pruneBoundSignerDocs deleted any unsigned envelope from a bound signer,
  including a deliberate new invite. It now compares against the timestamp
  of your counter-signature: envelopes that predate your acceptance are
  leftovers from the same burst of repeat scans, anything newer is a real
  request and survives.
- The sent-mirror reconcile matched on data.parties alone, so one confirmed
  binding marked every pending invite to that person confirmed. It now
  matches invites per relation description, from one scan of the
  counterparty's vault however many mirrors point at them, and skips docs
  the counterparty originated — their own mirrors were being counted as
  invites we sent, which kept a declined invite looking pending forever.

Duplicate pruning is scoped to the same relation description too, so
accepting one invite no longer deletes a different one from the same
person.

Closes #1146
Sahil2004 pushed a commit that referenced this pull request Sep 25, 2026
…list (#1147)

* fix(eid-wallet): accept, decline or cancel a social binding from the list

Accept and Decline lived only inside the invite drawer — the sheet that
shows your own QR code — and only surfaced there through a poll that runs
every three seconds while that sheet is open. The bindings list showed the
same pending request as "Awaiting confirmation" with nothing to press, and
nothing anywhere pointed at the drawer, so a request could only be found
by opening your own QR and waiting.

Put the action where the request is already visible. Each row in the
details sheet now carries what fits its state: Accept and Decline on a
received request, Cancel invite on a sent one, nothing on a completed
binding. Accepting and declining share one implementation with the drawer
and the ePassport page, which had drifted — the ePassport copy never ran
the duplicate prune. Signing is passed in as a function so the utils
module stays free of any GlobalState dependency.

"View on full list" is now rendered only when a caller supplies the
callback. The sheet is opened from the full list and nowhere else, so the
button had been a second Close since #972.

Two defects in the same path broke re-binding with a contact you are
already bound to, and both are fixed here:

- pruneBoundSignerDocs deleted any unsigned envelope from a bound signer,
  including a deliberate new invite. It now compares against the timestamp
  of your counter-signature: envelopes that predate your acceptance are
  leftovers from the same burst of repeat scans, anything newer is a real
  request and survives.
- The sent-mirror reconcile matched on data.parties alone, so one confirmed
  binding marked every pending invite to that person confirmed. It now
  matches invites per relation description, from one scan of the
  counterparty's vault however many mirrors point at them, and skips docs
  the counterparty originated — their own mirrors were being counted as
  invites we sent, which kept a declined invite looking pending forever.

Duplicate pruning is scoped to the same relation description too, so
accepting one invite no longer deletes a different one from the same
person.

Closes #1146

* fix(eid-wallet): scope the accepted-invite cutoff to one relation description

Accepting an invite records the time of the counter-signature, and any older
unsigned envelope from that signer was treated as a leftover from the same
burst of repeat scans. Keyed on the signer alone, that swallowed the person's
other pending invites: send "coffee" then "work", accept "coffee", and "work"
stopped surfacing in the invite drawer and was deleted the next time it opened.
The sender's reconcile then read it as declined and dropped their copy too.

The accept-time prune was already scoped to the relation description; the
cutoff now matches it, so an invite is only ever superseded by an acceptance
of that same invite.

This is the situation #1146 reports, and it survived the first round because
acting from the list never opens the drawer. The spec missed it because its
placeholder timestamps ("t1") sort after any ISO date, so no cutoff check ever
fired. They are real ISO strings now, and a test covers accepting one of two
invites with different descriptions.

Two smaller fixes in the same path:

- Cancel claimed "just confirmed" whenever any doc with that description
  remained on the counterparty's side. An older confirmed binding — usually the
  empty-description one — matched too, so a declined invite reported the
  opposite of what happened. With only the description to match on, the two
  cannot be told apart, so the message no longer guesses.
- A failed action left its error on screen for whichever contact was opened
  next. The error is now tied to the contact it belongs to and cleared when the
  sheet closes, and the list re-reads after a failure as well as a success,
  since a failure usually means the binding moved on without us.

* fix(eid-wallet): word the cancel refusal through the message catalog

The cancel path's one user-facing refusal was an English sentence thrown from
socialBinding.ts. That module deliberately carries no i18n — it is the one the
spec drives directly, without the app's module aliases — so it now throws a
code and the details sheet words it.

Adds the four keys this branch introduces in all three locales, and rebuilds
the published catalog.

* fix(eid-wallet): keep the reload from stealing the sheet back

refreshAfterAction captured the open contact, awaited a reload that resolves a
name per contact over the network, then pointed the sheet at the captured one
without checking it was still the selection. Close the sheet and open someone
else while that runs and the sheet snaps back to the first contact, with its
Accept, Decline and Cancel buttons now acting on them.

Also route the four remaining inlined reads of relation_description through
relationOf. It is the matching key for pruning, cutoffs, reconcile and cancel
now, and this branch has already been bitten once by two copies of one rule
drifting apart.

* fix(eid-wallet): stop deleting invites on a cross-device timestamp

pruneBoundSignerDocs ran on every invite-drawer open and deleted unsigned
envelopes whose signature predated the caller's acceptance of the same invite.
Those two timestamps are written by two different phones — the envelope's by
the scanner, the cutoff by the acceptor — so a clock a few minutes behind makes
a genuine new invite look like a leftover, and it was destroyed before the
recipient ever saw it. The sender's mirror then reconciled to "declined".

There is no way to tell the two apart from here: a server-assigned time is not
exposed, and a tolerance window wide enough to absorb clock skew also shields
the real leftovers it exists to clear.

So stop guessing, and stop deleting. The same check still keeps a leftover out
of the drawer's poll, which is what stops it re-prompting; the envelope now
stays in the bindings list, where this branch has just put Accept and Decline,
so the user settles it. Repeat scans are already collapsed at accept time by
pruneDuplicateUnsignedDocs, which is scoped to the relation description, so
what this deletion still caught was a narrow race and legacy envelopes.

Costs a leftover showing as a row instead of being cleared silently, which
partially reopens #1001. Showing one row too many beats destroying a real
invite.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

eNotary: Notary signs new key binding certificate Chinese phones get stuck on finish page during account recovery

1 participant