Feat/eid wallet v1 - #972
Conversation
📝 WalkthroughWalkthroughThis 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. ChangesSingle-Wallet Crypto Architecture
Notary-Issued Recovery System
UI/UX Improvements and Settings Infrastructure
Version Bump and Housekeeping
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Suggested reviewers
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (1)
infrastructure/eid-wallet/src/routes/(public)/recover/+page.svelte (1)
896-915: 💤 Low valueConsider adding explicit type guards for the verified JWT payload.
The
jwtVerifycall validates the signature but the payload is cast directly toNotaryRecoveryPayloadwithout 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
⛔ Files ignored due to path filters (2)
infrastructure/eid-wallet/src-tauri/gen/android/app/src/main/java/foundation/metastate/eid_wallet/MainActivity.ktis excluded by!**/gen/**pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (62)
infrastructure/eid-wallet/package.jsoninfrastructure/eid-wallet/src-tauri/src/lib.rsinfrastructure/eid-wallet/src-tauri/tauri.conf.jsoninfrastructure/eid-wallet/src/app.cssinfrastructure/eid-wallet/src/lib/crypto/HardwareKeyManager.tsinfrastructure/eid-wallet/src/lib/crypto/KeyManagerFactory.tsinfrastructure/eid-wallet/src/lib/crypto/SoftwareKeyManager.tsinfrastructure/eid-wallet/src/lib/crypto/index.tsinfrastructure/eid-wallet/src/lib/crypto/types.tsinfrastructure/eid-wallet/src/lib/global/controllers/evault.tsinfrastructure/eid-wallet/src/lib/global/controllers/key.tsinfrastructure/eid-wallet/src/lib/global/index.tsinfrastructure/eid-wallet/src/lib/global/state.tsinfrastructure/eid-wallet/src/lib/stores/notifications.tsinfrastructure/eid-wallet/src/lib/ui/BottomSheet/BottomSheet.svelteinfrastructure/eid-wallet/src/lib/ui/LoadingSheet/LoadingSheet.svelteinfrastructure/eid-wallet/src/lib/ui/PlatformAppCard/PlatformAppCard.svelteinfrastructure/eid-wallet/src/lib/ui/index.tsinfrastructure/eid-wallet/src/lib/utils/personalBinding.tsinfrastructure/eid-wallet/src/lib/utils/socialBinding.tsinfrastructure/eid-wallet/src/lib/utils/terminalConsole.tsinfrastructure/eid-wallet/src/lib/wallet-sdk-adapter.tsinfrastructure/eid-wallet/src/routes/(app)/+layout.svelteinfrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelteinfrastructure/eid-wallet/src/routes/(app)/main/+page.svelteinfrastructure/eid-wallet/src/routes/(app)/main/components/EVaultCard.svelteinfrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingAccordion.svelteinfrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDetailsSheet.svelteinfrastructure/eid-wallet/src/routes/(app)/main/components/SocialBindingDrawer.svelteinfrastructure/eid-wallet/src/routes/(app)/main/legacy/KycUpgradeOverlay.svelteinfrastructure/eid-wallet/src/routes/(app)/notifications/+page.svelteinfrastructure/eid-wallet/src/routes/(app)/personal/+page.svelteinfrastructure/eid-wallet/src/routes/(app)/personal/components/AddKnowledgeSheet.svelteinfrastructure/eid-wallet/src/routes/(app)/personal/components/AddParametersSheet.svelteinfrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelteinfrastructure/eid-wallet/src/routes/(app)/scan-qr/scanLogic.tsinfrastructure/eid-wallet/src/routes/(app)/settings/+layout.svelteinfrastructure/eid-wallet/src/routes/(app)/settings/+page.svelteinfrastructure/eid-wallet/src/routes/(app)/settings/biometrics/+page.svelteinfrastructure/eid-wallet/src/routes/(app)/settings/notifications/+page.svelteinfrastructure/eid-wallet/src/routes/(app)/settings/pin/+page.svelteinfrastructure/eid-wallet/src/routes/(app)/social-bindings/+page.svelteinfrastructure/eid-wallet/src/routes/(auth)/e-passport/+page.svelteinfrastructure/eid-wallet/src/routes/(auth)/login/+page.svelteinfrastructure/eid-wallet/src/routes/(auth)/onboarding/+page.svelteinfrastructure/eid-wallet/src/routes/(auth)/onboarding/steps/BiometricsSetup.svelteinfrastructure/eid-wallet/src/routes/(auth)/register/+page.svelteinfrastructure/eid-wallet/src/routes/(auth)/review/+page.svelteinfrastructure/eid-wallet/src/routes/(public)/recover/+page.svelteinfrastructure/eid-wallet/src/routes/+layout.svelteinfrastructure/eid-wallet/src/routes/+page.svelteplatforms/enotary/package.jsonplatforms/enotary/src/hooks.server.tsplatforms/enotary/src/lib/server/jwt.tsplatforms/enotary/src/lib/server/recoveryAudit.tsplatforms/enotary/src/lib/server/recoverySessions.tsplatforms/enotary/src/routes/.well-known/jwks.json/+server.tsplatforms/enotary/src/routes/api/recovery/claim/+server.tsplatforms/enotary/src/routes/api/recovery/issue/+server.tsplatforms/enotary/src/routes/api/recovery/status/+server.tsplatforms/enotary/src/routes/user/[ename]/+page.svelteplatforms/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
| 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; | ||
| }); |
There was a problem hiding this comment.
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.
| 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.
| 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(() => {}); | ||
| }); |
There was a problem hiding this comment.
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.
| 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.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| if (next && !isAvailable) { | ||
| error = "Biometrics aren't available on this device."; | ||
| return; |
There was a problem hiding this comment.
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.
| // Mirror console.* to the Tauri host stdout so logs land in `pnpm tauri dev`. | ||
| installTerminalConsoleBridge(); |
There was a problem hiding this comment.
🧩 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 || trueRepository: 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.
| if (raw) { | ||
| jwk = JSON.parse(raw) as JWK; |
There was a problem hiding this comment.
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.
| await client.request(CREATE_BINDING_DOC, { input }); | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| for (const [id, session] of sessions) { | ||
| if (session.expiresAt < now && session.status === "pending") { | ||
| sessions.delete(id); | ||
| } |
There was a problem hiding this comment.
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.
…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
…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
…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.
Description of change
Finalize eID Wallet
Issue Number
closes #970
closes #969
Type of change
How the change has been tested
Change checklist
Summary by CodeRabbit
New Features
Improvements