From fc61789a87050ae5fe9e20eaa18b97b9e2e5cd0e Mon Sep 17 00:00:00 2001
From: mjkatgithub
Date: Fri, 8 May 2026 20:40:01 +0200
Subject: [PATCH 1/4] Add Account Verification Panel and Recovery Key Bootstrap
Functionality
- Introduced a new `AccountVerificationPanel.vue` component for managing device verification processes, including emoji comparison and recovery key input.
- Implemented recovery key bootstrap logic in `recoveryKeyBootstrap.ts`, allowing users to restore cross-signing secrets using their recovery keys.
- Enhanced internationalization support in `useAppI18n.ts` with new messages related to verification and recovery key processes in both English and German.
- Updated the account settings page to integrate the new verification panel, improving user experience during the verification process.
- Added unit tests for the recovery key bootstrap functionality to ensure robust error handling and verification logic.
---
.../settings/AccountVerificationPanel.vue | 673 ++++++++++++++++++
.../matrix/recoveryKeyBootstrap.ts | 228 ++++++
app/composables/useAppI18n.ts | 74 +-
app/pages/settings/account.vue | 539 +-------------
.../composables/recoveryKeyBootstrap.spec.ts | 182 +++++
5 files changed, 1157 insertions(+), 539 deletions(-)
create mode 100644 app/components/settings/AccountVerificationPanel.vue
create mode 100644 app/composables/matrix/recoveryKeyBootstrap.ts
create mode 100644 tests/unit/composables/recoveryKeyBootstrap.spec.ts
diff --git a/app/components/settings/AccountVerificationPanel.vue b/app/components/settings/AccountVerificationPanel.vue
new file mode 100644
index 0000000..ea41a39
--- /dev/null
+++ b/app/components/settings/AccountVerificationPanel.vue
@@ -0,0 +1,673 @@
+
+
+
+
+
+
+ {{ translateText('settings.verificationPanelTitle') }}
+
+
+ {{ translateText('settings.verificationPanelIntro') }}
+
+
+
+
+
+ {{ translateText('settings.verificationEmojiTitle') }}
+
+
+ {{ translateText('settings.verificationEmojiDescription') }}
+
+
+ {{ translateText('settings.verificationDeviceId') }}:
+ {{ ownDeviceId || '-' }}
+
+
+ {{ verificationErrorText ? '' : verificationStatusText }}
+
+
+ {{ verificationErrorText }}
+
+
+ {{ translateText('settings.verificationPending') }}
+
+
+ {{ translateText('settings.verificationCrossSigningHint') }}
+
+
+
+
+ {{ emoji.symbol }}
+ {{ emoji.name }}
+
+
+
+ {{ sasDecimal[0] }} - {{ sasDecimal[1] }} - {{ sasDecimal[2] }}
+
+
+
+
+ {{ translateText('settings.verificationStart') }}
+
+
+ {{ translateText('settings.verificationRefresh') }}
+
+
+ {{ translateText('settings.verificationConfirm') }}
+
+
+ {{ translateText('settings.verificationMismatchAction') }}
+
+
+ {{ translateText('settings.verificationCancel') }}
+
+
+
+
+
+
+ {{ translateText('settings.verificationRecoveryTitle') }}
+
+
+ {{ translateText('settings.verificationRecoveryDescription') }}
+
+
+ {{ translateText('settings.verificationRecoveryHint') }}
+
+
+
+ {{ translateText('settings.verificationRecoveryKeyLabel') }}
+
+
+
+
+ {{ translateText('settings.verificationRecoverySubmit') }}
+
+
+ {{ recoveryKeyMessageText }}
+
+
+ {{ recoveryKeyErrorText }}
+
+
+
+
diff --git a/app/composables/matrix/recoveryKeyBootstrap.ts b/app/composables/matrix/recoveryKeyBootstrap.ts
new file mode 100644
index 0000000..45b4ca5
--- /dev/null
+++ b/app/composables/matrix/recoveryKeyBootstrap.ts
@@ -0,0 +1,228 @@
+import type { MatrixClient } from 'matrix-js-sdk'
+import type { CryptoCallbacks } from 'matrix-js-sdk/lib/crypto-api'
+import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto-api/recovery-key'
+
+import type { MatrixApiErrorShape } from './matrixClientShared'
+import {
+ isLikelyBrowserNetworkOrCorsError,
+ readMatrixErrorCode
+} from './matrixClientShared'
+
+/** Outcome of {@link bootstrapCrossSigningWithRecoveryKeyString}. */
+export type RecoveryKeyBootstrapResult =
+ | {
+ success: true
+ crossSigningReady: boolean
+ }
+ | {
+ success: false
+ failureReason: RecoveryKeyBootstrapFailureReason
+ }
+
+export type RecoveryKeyBootstrapFailureReason =
+ | 'invalid_input'
+ | 'invalid_key'
+ | 'no_client'
+ | 'crypto_unavailable'
+ | 'ssss_missing'
+ | 'uia_required'
+ | 'network'
+ | 'unknown'
+
+type SecretStorageCallbacksHolder = {
+ callbacks: CryptoCallbacks
+}
+
+/**
+ * Reads the live callback bag used by {@link MatrixClient.secretStorage}.
+ * When {@link ICreateClientOpts.cryptoCallbacks} was omitted, the SDK may use
+ * a different object than {@link MatrixClient.cryptoCallbacks}; both must be
+ * patched for Rust crypto + secret storage to see the recovery key.
+ */
+function getSecretStorageCallbackBags(
+ matrixClient: MatrixClient
+): [CryptoCallbacks, CryptoCallbacks] {
+ const secretHolder = matrixClient.secretStorage as unknown as
+ SecretStorageCallbacksHolder
+ const secretCallbacks = secretHolder.callbacks
+ const clientCallbacks = matrixClient.cryptoCallbacks
+ return [secretCallbacks, clientCallbacks]
+}
+
+/**
+ * Maps thrown values from bootstrap / network to a stable failure reason.
+ * Never include secret material in logs or error strings.
+ */
+function errorLooksLikeInteractiveAuth(thrownError: unknown): boolean {
+ if (!thrownError || typeof thrownError !== 'object') {
+ return false
+ }
+ const shaped = thrownError as MatrixApiErrorShape
+ const session = shaped.session ?? shaped.data?.session
+ const flows = shaped.flows ?? shaped.data?.flows
+ return Boolean(session && Array.isArray(flows) && flows.length > 0)
+}
+
+export function mapThrownErrorToRecoveryFailureReason(
+ thrownError: unknown
+): RecoveryKeyBootstrapFailureReason {
+ const message =
+ thrownError instanceof Error
+ ? thrownError.message
+ : String(thrownError)
+ const lower = message.toLowerCase()
+
+ if (
+ lower.includes('getsecretstoragekey callback returned falsey') ||
+ lower.includes('incorrect parity') ||
+ lower.includes('incorrect prefix') ||
+ lower.includes('incorrect length') ||
+ lower.includes('importcrosssigningkeys failed')
+ ) {
+ return 'invalid_key'
+ }
+
+ if (
+ lower.includes('interactive authentication') ||
+ lower.includes('interactive auth') ||
+ errorLooksLikeInteractiveAuth(thrownError)
+ ) {
+ return 'uia_required'
+ }
+
+ const matrixCode = readMatrixErrorCode(thrownError)
+ if (matrixCode === 'M_FORBIDDEN' || matrixCode === 'M_UNAUTHORIZED') {
+ return 'uia_required'
+ }
+
+ if (isLikelyBrowserNetworkOrCorsError(thrownError)) {
+ return 'network'
+ }
+
+ if (
+ thrownError &&
+ typeof thrownError === 'object' &&
+ 'httpStatus' in thrownError &&
+ typeof (thrownError as { httpStatus: unknown }).httpStatus === 'number'
+ ) {
+ const status = (thrownError as { httpStatus: number }).httpStatus
+ if (status === 401 || status === 403) {
+ return 'uia_required'
+ }
+ }
+
+ return 'unknown'
+}
+
+function installMatchingSecretStorageGetter(
+ matrixClient: MatrixClient,
+ decodedPrivateKey: Uint8Array
+): () => void {
+ const [secretCallbacks, clientCallbacks] =
+ getSecretStorageCallbackBags(matrixClient)
+ const previousSecret = secretCallbacks.getSecretStorageKey
+ const previousClient = clientCallbacks.getSecretStorageKey
+
+ const getter: CryptoCallbacks['getSecretStorageKey'] = async (opts) => {
+ for (const keyId of Object.keys(opts.keys)) {
+ const keyInfo = opts.keys[keyId]
+ const matches = await matrixClient.secretStorage.checkKey(
+ decodedPrivateKey,
+ keyInfo
+ )
+ if (matches) {
+ return [keyId, decodedPrivateKey]
+ }
+ }
+ return null
+ }
+
+ secretCallbacks.getSecretStorageKey = getter
+ if (clientCallbacks !== secretCallbacks) {
+ clientCallbacks.getSecretStorageKey = getter
+ }
+
+ return () => {
+ secretCallbacks.getSecretStorageKey = previousSecret
+ if (clientCallbacks !== secretCallbacks) {
+ clientCallbacks.getSecretStorageKey = previousClient
+ }
+ }
+}
+
+/**
+ * Hydrate cross-signing (and optionally the key backup key) from secret
+ * storage using the user's recovery / security key.
+ *
+ * Does not persist the recovery key. Do not log the raw key.
+ */
+export async function bootstrapCrossSigningWithRecoveryKeyString(
+ matrixClient: MatrixClient | null | undefined,
+ recoveryKeyRaw: string,
+ ensureCryptoReady: () => Promise
+): Promise {
+ if (!matrixClient) {
+ return { success: false, failureReason: 'no_client' }
+ }
+
+ const trimmedKey = recoveryKeyRaw.trim()
+ if (!trimmedKey) {
+ return { success: false, failureReason: 'invalid_input' }
+ }
+
+ let decodedPrivateKey: Uint8Array
+ try {
+ decodedPrivateKey = decodeRecoveryKey(trimmedKey)
+ } catch {
+ return { success: false, failureReason: 'invalid_key' }
+ }
+
+ const cryptoReady = await ensureCryptoReady()
+ if (!cryptoReady) {
+ return { success: false, failureReason: 'crypto_unavailable' }
+ }
+
+ const cryptoApi = matrixClient.getCrypto?.()
+ if (!cryptoApi) {
+ return { success: false, failureReason: 'crypto_unavailable' }
+ }
+
+ try {
+ const crossSigningStatus = await cryptoApi.getCrossSigningStatus()
+ const defaultKeyId = await matrixClient.secretStorage.getDefaultKeyId()
+ if (
+ !crossSigningStatus.privateKeysInSecretStorage ||
+ !defaultKeyId
+ ) {
+ return { success: false, failureReason: 'ssss_missing' }
+ }
+ } catch (thrownError) {
+ return {
+ success: false,
+ failureReason: mapThrownErrorToRecoveryFailureReason(thrownError)
+ }
+ }
+
+ const uninstallGetter = installMatchingSecretStorageGetter(
+ matrixClient,
+ decodedPrivateKey
+ )
+
+ try {
+ await cryptoApi.bootstrapCrossSigning({})
+ try {
+ await cryptoApi.loadSessionBackupPrivateKeyFromSecretStorage()
+ } catch {
+ /* optional; backup may be absent */
+ }
+ const crossSigningReady = await cryptoApi.isCrossSigningReady()
+ return { success: true, crossSigningReady }
+ } catch (thrownError) {
+ return {
+ success: false,
+ failureReason: mapThrownErrorToRecoveryFailureReason(thrownError)
+ }
+ } finally {
+ uninstallGetter()
+ }
+}
diff --git a/app/composables/useAppI18n.ts b/app/composables/useAppI18n.ts
index ce9ef06..4637226 100644
--- a/app/composables/useAppI18n.ts
+++ b/app/composables/useAppI18n.ts
@@ -157,8 +157,13 @@ const messages: Record> = {
'settings.applyPresence': 'Apply presence',
'settings.presenceSaved': 'Presence updated',
'settings.busyUnsupported': 'Busy presence is not supported by this homeserver',
- 'settings.verificationTitle': 'Device verification',
- 'settings.verificationDescription': 'Verify this session with another client of the same account.',
+ 'settings.verificationPanelTitle': 'Verification',
+ 'settings.verificationPanelIntro':
+ 'You can verify this session in two ways: with another signed-in client ' +
+ '(emoji comparison) or with your recovery / security key.',
+ 'settings.verificationEmojiTitle': 'Verification with another client (emoji)',
+ 'settings.verificationEmojiDescription':
+ 'Verify this session with another client of the same account by comparing emojis.',
'settings.verificationDeviceId': 'Device ID',
'settings.verificationStart': 'Start verification',
'settings.verificationRefresh': 'Refresh status',
@@ -188,6 +193,33 @@ const messages: Record> = {
'online and tap Refresh status, then try again.',
'settings.verificationProtocolError':
'Verification could not continue — try cancelling and starting again.',
+ 'settings.verificationRecoveryTitle': 'Verification with recovery key',
+ 'settings.verificationRecoveryDescription':
+ 'If you cannot verify with another client, enter your Matrix ' +
+ 'recovery / security key to restore cross-signing secrets from ' +
+ 'secret storage on this device.',
+ 'settings.verificationRecoveryHint':
+ 'Prefer emoji verification when you have a second trusted client; use ' +
+ 'the recovery key only when that is not possible.',
+ 'settings.verificationRecoveryKeyLabel': 'Recovery or security key',
+ 'settings.verificationRecoverySubmit': 'Restore encryption',
+ 'settings.verificationRecoverySuccess':
+ 'Encryption secrets were restored. Status below should update shortly.',
+ 'settings.verificationRecoveryErrorInvalidInput': 'Enter your recovery key.',
+ 'settings.verificationRecoveryErrorInvalidKey':
+ 'That key does not match your secret storage. Check the key and try again.',
+ 'settings.verificationRecoveryErrorNoSecretStorage':
+ 'This account has no cross-signing keys in secret storage yet. Set up ' +
+ 'secret storage in another Matrix client first.',
+ 'settings.verificationRecoveryErrorCryptoUnavailable':
+ 'Encryption is not ready on this device. Try signing out and in, then retry.',
+ 'settings.verificationRecoveryErrorUiaRequired':
+ 'The homeserver needs extra authentication to finish this step. Try ' +
+ 'again from a session that can complete interactive auth, or use another client.',
+ 'settings.verificationRecoveryErrorNetwork':
+ 'Network error talking to the homeserver. Check your connection and retry.',
+ 'settings.verificationRecoveryErrorUnknown':
+ 'Could not restore encryption. Try again or use another client.',
'settings.backToChat': 'Back to chat',
'settings.spaceTitle': 'Space settings',
'settings.spaceDescription': 'Basic settings for this space.',
@@ -425,8 +457,13 @@ const messages: Record> = {
'settings.applyPresence': 'Status setzen',
'settings.presenceSaved': 'Status aktualisiert',
'settings.busyUnsupported': 'Busy-Status wird vom Homeserver nicht unterstuetzt',
- 'settings.verificationTitle': 'Geraet verifizieren',
- 'settings.verificationDescription': 'Verifiziere diese Session mit einem anderen Client desselben Accounts.',
+ 'settings.verificationPanelTitle': 'Verifizierung',
+ 'settings.verificationPanelIntro':
+ 'Du kannst diese Session auf zwei Arten verifizieren: mit einem anderen ' +
+ 'angemeldeten Client (Emoji-Vergleich) oder mit deinem Recovery- bzw. Security-Key.',
+ 'settings.verificationEmojiTitle': 'Verifizierung mit anderem Client (Emoji)',
+ 'settings.verificationEmojiDescription':
+ 'Verifiziere diese Session mit einem anderen Client desselben Accounts per Emoji-Vergleich.',
'settings.verificationDeviceId': 'Geraete-ID',
'settings.verificationStart': 'Verifizierung starten',
'settings.verificationRefresh': 'Status aktualisieren',
@@ -457,6 +494,35 @@ const messages: Record> = {
'und versuche es erneut.',
'settings.verificationProtocolError':
'Die Verifizierung konnte nicht fortgesetzt werden — abbrechen und erneut starten.',
+ 'settings.verificationRecoveryTitle': 'Verifizierung mit Recovery-Key',
+ 'settings.verificationRecoveryDescription':
+ 'Wenn du keinen zweiten Client zur Verifizierung hast, gib deinen ' +
+ 'Matrix-Wiederherstellungsschluessel (Security Key) ein, um Cross-Signing ' +
+ 'aus dem Secret Storage auf diesem Geraet wiederherzustellen.',
+ 'settings.verificationRecoveryHint':
+ 'Nutze zuerst die Emoji-Verifizierung, wenn du einen zweiten vertrauenswuerdigen ' +
+ 'Client hast; den Recovery-Key nur, wenn das nicht moeglich ist.',
+ 'settings.verificationRecoveryKeyLabel': 'Recovery- oder Security-Key',
+ 'settings.verificationRecoverySubmit': 'Verschluesselung wiederherstellen',
+ 'settings.verificationRecoverySuccess':
+ 'Schluessel wurden wiederhergestellt. Der Status unten aktualisiert sich in Kuerze.',
+ 'settings.verificationRecoveryErrorInvalidInput':
+ 'Bitte den Wiederherstellungsschluessel eingeben.',
+ 'settings.verificationRecoveryErrorInvalidKey':
+ 'Dieser Schluessel passt nicht zu deinem Secret Storage. Bitte pruefen und erneut versuchen.',
+ 'settings.verificationRecoveryErrorNoSecretStorage':
+ 'Fuer dieses Konto liegt kein Cross-Signing im Secret Storage. Richte ' +
+ 'Secret Storage zuerst in einem anderen Matrix-Client ein.',
+ 'settings.verificationRecoveryErrorCryptoUnavailable':
+ 'Verschluesselung ist auf diesem Geraet nicht bereit. Ab- und wieder anmelden, dann erneut versuchen.',
+ 'settings.verificationRecoveryErrorUiaRequired':
+ 'Der Homeserver verlangt zusaetzliche Authentifizierung. Von einer Session ' +
+ 'mit Interactive Auth versuchen oder einen anderen Client nutzen.',
+ 'settings.verificationRecoveryErrorNetwork':
+ 'Netzwerkfehler zum Homeserver. Verbindung pruefen und erneut versuchen.',
+ 'settings.verificationRecoveryErrorUnknown':
+ 'Verschluesselung konnte nicht wiederhergestellt werden. Erneut versuchen ' +
+ 'oder anderen Client nutzen.',
'settings.backToChat': 'Zurück zum Chat',
'settings.spaceTitle': 'Space-Einstellungen',
'settings.spaceDescription': 'Basis-Einstellungen für diesen Space.',
diff --git a/app/pages/settings/account.vue b/app/pages/settings/account.vue
index e2b708f..56105a3 100644
--- a/app/pages/settings/account.vue
+++ b/app/pages/settings/account.vue
@@ -1,36 +1,8 @@
@@ -658,106 +226,7 @@ function cancelVerification() {
{{ translateText('settings.busyUnsupported') }}
-
-
- {{ translateText('settings.verificationTitle') }}
-
-
- {{ translateText('settings.verificationDescription') }}
-
-
- {{ translateText('settings.verificationDeviceId') }}:
- {{ ownDeviceId || '-' }}
-
-
- {{ verificationErrorText ? '' : verificationStatusText }}
-
-
- {{ verificationErrorText }}
-
-
- {{ translateText('settings.verificationPending') }}
-
-
- {{ translateText('settings.verificationCrossSigningHint') }}
-
-
-
-
- {{ emoji.symbol }}
- {{ emoji.name }}
-
-
-
- {{ sasDecimal[0] }} - {{ sasDecimal[1] }} - {{ sasDecimal[2] }}
-
-
-
-
- {{ translateText('settings.verificationStart') }}
-
-
- {{ translateText('settings.verificationRefresh') }}
-
-
- {{ translateText('settings.verificationConfirm') }}
-
-
- {{ translateText('settings.verificationMismatchAction') }}
-
-
- {{ translateText('settings.verificationCancel') }}
-
-
-
+
diff --git a/tests/unit/composables/recoveryKeyBootstrap.spec.ts b/tests/unit/composables/recoveryKeyBootstrap.spec.ts
new file mode 100644
index 0000000..f523838
--- /dev/null
+++ b/tests/unit/composables/recoveryKeyBootstrap.spec.ts
@@ -0,0 +1,182 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { MatrixClient } from 'matrix-js-sdk'
+import { encodeRecoveryKey } from 'matrix-js-sdk/lib/crypto-api/recovery-key'
+
+import {
+ bootstrapCrossSigningWithRecoveryKeyString,
+ mapThrownErrorToRecoveryFailureReason
+} from '~/composables/matrix/recoveryKeyBootstrap'
+
+function asMatrixClient(stub: unknown): MatrixClient {
+ return stub as MatrixClient
+}
+
+describe('mapThrownErrorToRecoveryFailureReason', () => {
+ it('maps falsey callback message to invalid_key', () => {
+ expect(
+ mapThrownErrorToRecoveryFailureReason(
+ new Error('getSecretStorageKey callback returned falsey')
+ )
+ ).toBe('invalid_key')
+ })
+
+ it('maps parity error to invalid_key', () => {
+ expect(
+ mapThrownErrorToRecoveryFailureReason(new Error('Incorrect parity'))
+ ).toBe('invalid_key')
+ })
+
+ it('maps import failure to invalid_key', () => {
+ expect(
+ mapThrownErrorToRecoveryFailureReason(
+ new Error('importCrossSigningKeys failed to import the keys')
+ )
+ ).toBe('invalid_key')
+ })
+
+ it('maps interactive auth message to uia_required', () => {
+ expect(
+ mapThrownErrorToRecoveryFailureReason(
+ new Error('Interactive authentication required')
+ )
+ ).toBe('uia_required')
+ })
+
+ it('maps session+flows shape to uia_required', () => {
+ expect(
+ mapThrownErrorToRecoveryFailureReason({
+ session: 'abc',
+ flows: [{ stages: ['m.login.password'] }]
+ })
+ ).toBe('uia_required')
+ })
+
+ it('maps M_FORBIDDEN errcode to uia_required', () => {
+ expect(
+ mapThrownErrorToRecoveryFailureReason({ errcode: 'M_FORBIDDEN' })
+ ).toBe('uia_required')
+ })
+
+ it('maps unknown errors to unknown', () => {
+ expect(
+ mapThrownErrorToRecoveryFailureReason(new Error('Something else'))
+ ).toBe('unknown')
+ })
+})
+
+describe('bootstrapCrossSigningWithRecoveryKeyString', () => {
+ it('returns no_client when client is null', async () => {
+ const outcome = await bootstrapCrossSigningWithRecoveryKeyString(
+ null,
+ 'EsTs RSeQ 7dR3 jKEC qMVw 3yss Qjd6 BfX4 wGmz VQ9k Fnoe A5Gq',
+ async () => true
+ )
+ expect(outcome).toEqual({
+ success: false,
+ failureReason: 'no_client'
+ })
+ })
+
+ it('returns invalid_input for whitespace-only input', async () => {
+ const outcome = await bootstrapCrossSigningWithRecoveryKeyString(
+ asMatrixClient({}),
+ ' \n',
+ async () => true
+ )
+ expect(outcome).toEqual({
+ success: false,
+ failureReason: 'invalid_input'
+ })
+ })
+
+ it('returns invalid_key for malformed recovery key', async () => {
+ const outcome = await bootstrapCrossSigningWithRecoveryKeyString(
+ asMatrixClient({}),
+ 'not-a-valid-matrix-recovery-key',
+ async () => true
+ )
+ expect(outcome).toEqual({
+ success: false,
+ failureReason: 'invalid_key'
+ })
+ })
+
+ it('returns crypto_unavailable when ensureCryptoReady is false', async () => {
+ const fakeKey = new Uint8Array(32)
+ fakeKey[0] = 2
+ const encoded = encodeRecoveryKey(fakeKey)
+ if (!encoded) {
+ throw new Error('encodeRecoveryKey returned empty')
+ }
+ const matrixClientStub = asMatrixClient({})
+ const outcome = await bootstrapCrossSigningWithRecoveryKeyString(
+ matrixClientStub,
+ encoded,
+ async () => false
+ )
+ expect(outcome).toEqual({
+ success: false,
+ failureReason: 'crypto_unavailable'
+ })
+ })
+
+ it('calls bootstrapCrossSigning and restores callbacks', async () => {
+ const fakeKey = new Uint8Array(32)
+ fakeKey[1] = 9
+ const encoded = encodeRecoveryKey(fakeKey)
+ if (!encoded) {
+ throw new Error('encodeRecoveryKey returned empty')
+ }
+
+ const secretCallbacks: { getSecretStorageKey?: unknown } = {}
+ const clientCallbacks: { getSecretStorageKey?: unknown } = {}
+
+ const bootstrapCrossSigning = vi.fn(async () => undefined)
+ const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {
+ return undefined
+ })
+ const isCrossSigningReady = vi.fn(async () => true)
+ const getCrossSigningStatus = vi.fn(async () => ({
+ publicKeysOnDevice: true,
+ privateKeysInSecretStorage: true,
+ privateKeysCachedLocally: {
+ masterKey: false,
+ selfSigningKey: false,
+ userSigningKey: false
+ }
+ }))
+
+ const matrixClientStub = {
+ getCrypto: () => ({
+ bootstrapCrossSigning,
+ getCrossSigningStatus,
+ loadSessionBackupPrivateKeyFromSecretStorage,
+ isCrossSigningReady
+ }),
+ secretStorage: {
+ getDefaultKeyId: vi.fn(async () => 'defaultKey'),
+ checkKey: vi.fn(async () => true)
+ },
+ cryptoCallbacks: clientCallbacks
+ }
+
+ Object.assign(matrixClientStub.secretStorage, {
+ callbacks: secretCallbacks
+ })
+
+ const outcome = await bootstrapCrossSigningWithRecoveryKeyString(
+ asMatrixClient(matrixClientStub),
+ encoded,
+ async () => true
+ )
+
+ expect(outcome).toEqual({
+ success: true,
+ crossSigningReady: true
+ })
+ expect(bootstrapCrossSigning).toHaveBeenCalledWith({})
+ expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalled()
+ expect(secretCallbacks.getSecretStorageKey).toBeUndefined()
+ expect(clientCallbacks.getSecretStorageKey).toBeUndefined()
+ })
+})
From c352e82088b519b12f09df98f6fb979922ae7446 Mon Sep 17 00:00:00 2001
From: mjkatgithub
Date: Sat, 9 May 2026 16:31:41 +0200
Subject: [PATCH 2/4] Add optional remote logging functionality and update
environment configuration
- Introduced a new `.env.example` file to provide examples for optional remote debug logging configuration.
- Updated `nuxt.config.ts` to include new settings for remote debug logging, allowing for better diagnostics.
- Enhanced `README.md` with instructions on enabling remote logging via environment variables.
- Implemented a `logOptionalRemote` function in `optionalRemoteLogger.ts` to handle logging to a remote endpoint.
- Integrated remote logging into various components, including `AccountVerificationPanel.vue` and `recoveryKeyBootstrap.ts`, to capture detailed debug information during verification processes.
---
.env.example | 24 +++
README.md | 16 ++
.../settings/AccountVerificationPanel.vue | 150 +++++++++++++++++-
app/composables/debug/optionalRemoteLogger.ts | 50 ++++++
.../matrix/recoveryKeyBootstrap.ts | 81 +++++++++-
app/composables/useAppI18n.ts | 9 +-
nuxt.config.ts | 18 ++-
7 files changed, 334 insertions(+), 14 deletions(-)
create mode 100644 .env.example
create mode 100644 app/composables/debug/optionalRemoteLogger.ts
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..d62906f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,24 @@
+#
+# Decentra – local environment variables (example)
+#
+# Copy to `.env.local` (recommended) or `.env` and fill values.
+# Files matching `.env*` are gitignored by default (except `.env.example`).
+#
+# Nuxt exposes variables prefixed with `NUXT_PUBLIC_` to the browser.
+#
+
+#
+# Optional remote debug logging (default: console-only)
+#
+# When empty/unset: Decentra only logs to the browser console.
+# When set: Decentra will POST debug payloads to this URL.
+#
+# Example (Cursor debug session ingest):
+# NUXT_PUBLIC_DEBUG_LOG_INGEST_URL=http://127.0.0.1:7476/ingest/
+# NUXT_PUBLIC_DEBUG_LOG_SESSION_ID=
+# NUXT_PUBLIC_DEBUG_LOG_SESSION_HEADER=X-Debug-Session-Id
+#
+NUXT_PUBLIC_DEBUG_LOG_INGEST_URL=
+NUXT_PUBLIC_DEBUG_LOG_SESSION_ID=
+NUXT_PUBLIC_DEBUG_LOG_SESSION_HEADER=
+
diff --git a/README.md b/README.md
index 8674282..965c4e9 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,22 @@ npm run dev
Open [http://localhost:3000](http://localhost:3000).
+### Optional debug logging (console vs. remote)
+
+By default, Decentra logs debug traces only to the **browser console**.
+You can optionally forward those traces to a remote ingest endpoint by
+setting environment variables (recommended via a local untracked file):
+
+```bash
+cp .env.example .env.local
+```
+
+Then set one or more of:
+
+- `NUXT_PUBLIC_DEBUG_LOG_INGEST_URL` (enables remote logging)
+- `NUXT_PUBLIC_DEBUG_LOG_SESSION_ID` (optional)
+- `NUXT_PUBLIC_DEBUG_LOG_SESSION_HEADER` (optional, e.g. `X-Debug-Session-Id`)
+
## Build & Preview
```bash
diff --git a/app/components/settings/AccountVerificationPanel.vue b/app/components/settings/AccountVerificationPanel.vue
index ea41a39..7110e5f 100644
--- a/app/components/settings/AccountVerificationPanel.vue
+++ b/app/components/settings/AccountVerificationPanel.vue
@@ -5,6 +5,7 @@ import {
} from '~/composables/matrix/recoveryKeyBootstrap'
import { useAppI18n } from '~/composables/useAppI18n'
import { useMatrixClient } from '~/composables/useMatrixClient'
+import { logOptionalRemote } from '~/composables/debug/optionalRemoteLogger'
/**
* matrix-js-sdk `VerificationPhase` values (avoid importing sdk subpaths:
@@ -222,21 +223,53 @@ async function refreshVerificationState() {
verificationStatusText.value = ownDeviceVerified
? translateText('settings.verificationVerified')
: translateText('settings.verificationNotVerified')
- } catch {
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H1,H2,H3',
+ location: 'AccountVerificationPanel.vue:refreshVerificationState',
+ message: 'refresh result',
+ data: {
+ ownCrossSigningReady: ownCrossSigningReady.value,
+ ownDeviceVerified,
+ openRequestCount: openRequests.length,
+ pending: pendingVerificationRequest.value,
+ deviceId: matrixDeviceId
+ },
+ timestamp: Date.now()
+ })
+ // #endregion
+ } catch (thrownError) {
verificationStatusText.value = translateText('settings.verificationFailed')
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H2',
+ location: 'AccountVerificationPanel.vue:refreshVerificationState:catch',
+ message: 'refresh threw',
+ data: {
+ errorMessage:
+ thrownError instanceof Error ? thrownError.message : String(thrownError)
+ },
+ timestamp: Date.now()
+ })
+ // #endregion
}
}
function getSelfVerificationRequest(
- cryptoApi: {
- getVerificationRequestsToDeviceInProgress?: (
- userId: string
- ) => MatrixDeviceVerificationRequest[]
- },
+ cryptoApi: unknown,
matrixUserId: string
): MatrixDeviceVerificationRequest | undefined {
+ const cryptoApiWithRequests = cryptoApi as {
+ getVerificationRequestsToDeviceInProgress?: (
+ userId: string
+ ) => unknown[]
+ }
const openRequests =
- cryptoApi.getVerificationRequestsToDeviceInProgress?.(matrixUserId) ?? []
+ (cryptoApiWithRequests.getVerificationRequestsToDeviceInProgress?.(
+ matrixUserId
+ ) ?? []) as MatrixDeviceVerificationRequest[]
return openRequests.find((request: MatrixDeviceVerificationRequest) => {
return request.isSelfVerification && request.pending
})
@@ -285,6 +318,20 @@ function resolveVerificationThrownMessage(thrownError: unknown): string {
}
async function startDeviceVerification() {
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H4,H6',
+ location: 'AccountVerificationPanel.vue:startDeviceVerification:entry',
+ message: 'function entered',
+ data: {
+ hasClient: Boolean(client.value),
+ pendingNow: pendingVerificationRequest.value,
+ verificationBusy: verificationBusy.value
+ },
+ timestamp: Date.now()
+ })
+ // #endregion
verificationBusy.value = true
verificationErrorText.value = ''
clearVerificationUi()
@@ -292,6 +339,16 @@ async function startDeviceVerification() {
const matrixClient = client.value
const matrixUserId = matrixClient?.getUserId?.()
if (!matrixClient || !matrixUserId) {
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H6',
+ location: 'AccountVerificationPanel.vue:startDeviceVerification:earlyExit',
+ message: 'no client/userId',
+ data: { hasClient: Boolean(matrixClient), hasUserId: Boolean(matrixUserId) },
+ timestamp: Date.now()
+ })
+ // #endregion
verificationStatusText.value = translateText(
'settings.verificationUnavailable'
)
@@ -304,6 +361,16 @@ async function startDeviceVerification() {
'Crypto initialization timeout'
)
if (!cryptoReady) {
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H6',
+ location: 'AccountVerificationPanel.vue:startDeviceVerification:cryptoNotReady',
+ message: 'cryptoReady=false',
+ data: {},
+ timestamp: Date.now()
+ })
+ // #endregion
verificationStatusText.value = translateText(
'settings.verificationUnavailable'
)
@@ -328,6 +395,19 @@ async function startDeviceVerification() {
cryptoApi,
matrixUserId
)
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H4,H6',
+ location: 'AccountVerificationPanel.vue:startDeviceVerification:existingCheck',
+ message: 'existing request lookup',
+ data: {
+ hasExisting: Boolean(existingIncomingRequest),
+ phase: existingIncomingRequest?.phase ?? null
+ },
+ timestamp: Date.now()
+ })
+ // #endregion
const verificationRequest = (
existingIncomingRequest ??
@@ -341,6 +421,25 @@ async function startDeviceVerification() {
verificationStatusText.value = translateText(
'settings.verificationRequestSent'
)
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H4',
+ location: 'AccountVerificationPanel.vue:startDeviceVerification:requestObtained',
+ message: 'have verificationRequest',
+ data: {
+ usedExisting: Boolean(existingIncomingRequest),
+ phase: verificationRequest.phase,
+ initiatedByMe: verificationRequest.initiatedByMe,
+ isSelfVerification: verificationRequest.isSelfVerification,
+ accepting: verificationRequest.accepting,
+ pending: verificationRequest.pending,
+ methods: (verificationRequest as unknown as { methods?: unknown }).methods ?? null,
+ otherDeviceId: (verificationRequest as unknown as { otherDeviceId?: unknown }).otherDeviceId ?? null
+ },
+ timestamp: Date.now()
+ })
+ // #endregion
const responderShouldSendReady =
existingIncomingRequest !== undefined ||
@@ -358,6 +457,20 @@ async function startDeviceVerification() {
verificationRequest,
90_000
)
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H4',
+ location: 'AccountVerificationPanel.vue:startDeviceVerification:waitOutcome',
+ message: 'wait outcome',
+ data: {
+ waitOutcome,
+ phase: verificationRequest.phase,
+ methods: (verificationRequest as unknown as { methods?: unknown }).methods ?? null
+ },
+ timestamp: Date.now()
+ })
+ // #endregion
if (waitOutcome === 'timeout') {
verificationErrorText.value = translateText(
'settings.verificationReadyTimeout'
@@ -430,6 +543,19 @@ async function startDeviceVerification() {
verificationBusy.value = false
} catch (thrownError: unknown) {
const message = resolveVerificationThrownMessage(thrownError)
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H4,H6',
+ location: 'AccountVerificationPanel.vue:startDeviceVerification:catch',
+ message: 'outer catch',
+ data: {
+ errorMessage: message,
+ errorName: thrownError instanceof Error ? thrownError.name : '(non-error)'
+ },
+ timestamp: Date.now()
+ })
+ // #endregion
if (message.includes('no existing cross-signing key')) {
verificationErrorText.value = translateText(
'settings.verificationNeedCrossSigning'
@@ -498,6 +624,16 @@ async function submitRecoveryKey() {
recoveryKeyInput.value,
ensureCryptoReady
)
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H1,H2,H5',
+ location: 'AccountVerificationPanel.vue:submitRecoveryKey',
+ message: 'bootstrap returned',
+ data: { outcome },
+ timestamp: Date.now()
+ })
+ // #endregion
if (outcome.success) {
recoveryKeyMessageText.value = translateText(
'settings.verificationRecoverySuccess'
diff --git a/app/composables/debug/optionalRemoteLogger.ts b/app/composables/debug/optionalRemoteLogger.ts
new file mode 100644
index 0000000..196e61f
--- /dev/null
+++ b/app/composables/debug/optionalRemoteLogger.ts
@@ -0,0 +1,50 @@
+import { useRuntimeConfig } from '#imports'
+
+type OptionalRemoteLoggerPayload = {
+ sessionId?: string
+ runId?: string
+ hypothesisId?: string
+ location: string
+ message: string
+ data?: unknown
+ timestamp: number
+}
+
+function safeConsoleWarn(payload: OptionalRemoteLoggerPayload) {
+ try {
+ // Always log locally; never include secrets in `data`.
+ console.warn('[DECENTRA_DBG]', payload)
+ } catch {
+ /* ignore */
+ }
+}
+
+export function logOptionalRemote(payload: OptionalRemoteLoggerPayload) {
+ safeConsoleWarn(payload)
+
+ const runtimePublic = useRuntimeConfig().public as {
+ debugLogIngestUrl?: string
+ debugLogSessionId?: string
+ debugLogSessionHeader?: string
+ }
+
+ const ingestUrl = String(runtimePublic.debugLogIngestUrl ?? '').trim()
+ if (!ingestUrl) {
+ return
+ }
+
+ const sessionId = String(runtimePublic.debugLogSessionId ?? '').trim()
+ const sessionHeader = String(runtimePublic.debugLogSessionHeader ?? '').trim()
+
+ const headers: Record = { 'Content-Type': 'application/json' }
+ if (sessionId && sessionHeader) {
+ headers[sessionHeader] = sessionId
+ }
+
+ fetch(ingestUrl, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ ...payload, sessionId: sessionId || payload.sessionId })
+ }).catch(() => {})
+}
+
diff --git a/app/composables/matrix/recoveryKeyBootstrap.ts b/app/composables/matrix/recoveryKeyBootstrap.ts
index 45b4ca5..da0f054 100644
--- a/app/composables/matrix/recoveryKeyBootstrap.ts
+++ b/app/composables/matrix/recoveryKeyBootstrap.ts
@@ -7,6 +7,7 @@ import {
isLikelyBrowserNetworkOrCorsError,
readMatrixErrorCode
} from './matrixClientShared'
+import { logOptionalRemote } from '../debug/optionalRemoteLogger'
/** Outcome of {@link bootstrapCrossSigningWithRecoveryKeyString}. */
export type RecoveryKeyBootstrapResult =
@@ -126,6 +127,9 @@ function installMatchingSecretStorageGetter(
const getter: CryptoCallbacks['getSecretStorageKey'] = async (opts) => {
for (const keyId of Object.keys(opts.keys)) {
const keyInfo = opts.keys[keyId]
+ if (!keyInfo) {
+ continue
+ }
const matches = await matrixClient.secretStorage.checkKey(
decodedPrivateKey,
keyInfo
@@ -187,13 +191,25 @@ export async function bootstrapCrossSigningWithRecoveryKeyString(
return { success: false, failureReason: 'crypto_unavailable' }
}
+ let crossSigningStatusBefore: unknown = null
try {
const crossSigningStatus = await cryptoApi.getCrossSigningStatus()
+ crossSigningStatusBefore = crossSigningStatus
const defaultKeyId = await matrixClient.secretStorage.getDefaultKeyId()
if (
!crossSigningStatus.privateKeysInSecretStorage ||
!defaultKeyId
) {
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H1,H3',
+ location: 'recoveryKeyBootstrap.ts:precheck',
+ message: 'pre-bootstrap ssss_missing branch',
+ data: { crossSigningStatus, defaultKeyId },
+ timestamp: Date.now()
+ })
+ // #endregion
return { success: false, failureReason: 'ssss_missing' }
}
} catch (thrownError) {
@@ -209,6 +225,16 @@ export async function bootstrapCrossSigningWithRecoveryKeyString(
)
try {
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H1,H3',
+ location: 'recoveryKeyBootstrap.ts:before-bootstrap',
+ message: 'about to call bootstrapCrossSigning',
+ data: { crossSigningStatusBefore },
+ timestamp: Date.now()
+ })
+ // #endregion
await cryptoApi.bootstrapCrossSigning({})
try {
await cryptoApi.loadSessionBackupPrivateKeyFromSecretStorage()
@@ -216,12 +242,59 @@ export async function bootstrapCrossSigningWithRecoveryKeyString(
/* optional; backup may be absent */
}
const crossSigningReady = await cryptoApi.isCrossSigningReady()
+ let crossSigningStatusAfter: unknown = null
+ try {
+ crossSigningStatusAfter = await cryptoApi.getCrossSigningStatus()
+ } catch {
+ /* best-effort only for diagnostics */
+ }
+ let deviceVerifiedAfter: boolean | null = null
+ try {
+ const matrixUserId = matrixClient.getUserId?.() ?? ''
+ const matrixDeviceId = matrixClient.getDeviceId?.() ?? ''
+ const status = matrixUserId && matrixDeviceId
+ ? await cryptoApi.getDeviceVerificationStatus(
+ matrixUserId,
+ matrixDeviceId
+ )
+ : null
+ deviceVerifiedAfter = status?.isVerified() ?? null
+ } catch {
+ /* diagnostics */
+ }
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H1,H3',
+ location: 'recoveryKeyBootstrap.ts:after-bootstrap',
+ message: 'bootstrapCrossSigning returned',
+ data: {
+ crossSigningReady,
+ crossSigningStatusBefore,
+ crossSigningStatusAfter,
+ deviceVerifiedAfter
+ },
+ timestamp: Date.now()
+ })
+ // #endregion
return { success: true, crossSigningReady }
} catch (thrownError) {
- return {
- success: false,
- failureReason: mapThrownErrorToRecoveryFailureReason(thrownError)
- }
+ const failureReason = mapThrownErrorToRecoveryFailureReason(thrownError)
+ // #region agent log
+ logOptionalRemote({
+ sessionId: '4f7064',
+ hypothesisId: 'H1,H3,H5',
+ location: 'recoveryKeyBootstrap.ts:catch',
+ message: 'bootstrapCrossSigning threw',
+ data: {
+ failureReason,
+ errorMessage:
+ thrownError instanceof Error ? thrownError.message : String(thrownError)
+ },
+ timestamp: Date.now()
+ })
+ // #endregion
+ return { success: false, failureReason }
} finally {
uninstallGetter()
}
diff --git a/app/composables/useAppI18n.ts b/app/composables/useAppI18n.ts
index 4637226..7682be9 100644
--- a/app/composables/useAppI18n.ts
+++ b/app/composables/useAppI18n.ts
@@ -204,7 +204,9 @@ const messages: Record> = {
'settings.verificationRecoveryKeyLabel': 'Recovery or security key',
'settings.verificationRecoverySubmit': 'Restore encryption',
'settings.verificationRecoverySuccess':
- 'Encryption secrets were restored. Status below should update shortly.',
+ 'Encryption secrets were restored and this session is now verified. ' +
+ 'Other clients (Element, Thunderbird, ...) may need a few minutes ' +
+ 'to refresh their device list before they show this session as verified.',
'settings.verificationRecoveryErrorInvalidInput': 'Enter your recovery key.',
'settings.verificationRecoveryErrorInvalidKey':
'That key does not match your secret storage. Check the key and try again.',
@@ -505,7 +507,10 @@ const messages: Record> = {
'settings.verificationRecoveryKeyLabel': 'Recovery- oder Security-Key',
'settings.verificationRecoverySubmit': 'Verschluesselung wiederherstellen',
'settings.verificationRecoverySuccess':
- 'Schluessel wurden wiederhergestellt. Der Status unten aktualisiert sich in Kuerze.',
+ 'Schluessel wurden wiederhergestellt und diese Session ist jetzt ' +
+ 'verifiziert. Andere Clients (Element, Thunderbird, ...) benoetigen ' +
+ 'eventuell ein paar Minuten, bis sie diese Session als verifiziert ' +
+ 'anzeigen.',
'settings.verificationRecoveryErrorInvalidInput':
'Bitte den Wiederherstellungsschluessel eingeben.',
'settings.verificationRecoveryErrorInvalidKey':
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 8e3ce55..cfcac6a 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -16,7 +16,23 @@ export default defineNuxtConfig({
* Example: https://YOUR-NGROK.app — required for matrix.org OAuth.
*/
siteUrl: '',
- matrixOidcClientId: ''
+ matrixOidcClientId: '',
+ /**
+ * Optional remote debug log sink. When unset, Decentra only logs to the
+ * browser console. When set, Decentra will POST NDJSON-ish payloads.
+ *
+ * Example: http://127.0.0.1:7476/ingest/ (local), or your Graylog.
+ */
+ debugLogIngestUrl: '',
+ /**
+ * Optional session id for remote log sink. Only sent when both this and
+ * `debugLogSessionHeader` are configured.
+ */
+ debugLogSessionId: '',
+ /**
+ * Optional header name for session id, e.g. X-Debug-Session-Id.
+ */
+ debugLogSessionHeader: ''
}
},
app: {
From 4fa017d3a3ed0c365c613568701acea7e076feaf Mon Sep 17 00:00:00 2001
From: mjkatgithub
Date: Sat, 9 May 2026 17:11:51 +0200
Subject: [PATCH 3/4] Refactor optional remote logger to enhance configuration
handling
- Introduced a new type for public configuration in `optionalRemoteLogger.ts` to streamline the retrieval of remote logging settings.
- Updated the `getOptionalRemoteLoggerPublicConfig` function to merge environment variables with Nuxt public configuration, improving flexibility.
- Refactored the `logOptionalRemote` function to utilize the new configuration structure, ensuring cleaner code and better maintainability.
---
app/composables/debug/optionalRemoteLogger.ts | 39 +++++++++++++------
1 file changed, 28 insertions(+), 11 deletions(-)
diff --git a/app/composables/debug/optionalRemoteLogger.ts b/app/composables/debug/optionalRemoteLogger.ts
index 196e61f..3b48dc7 100644
--- a/app/composables/debug/optionalRemoteLogger.ts
+++ b/app/composables/debug/optionalRemoteLogger.ts
@@ -1,5 +1,3 @@
-import { useRuntimeConfig } from '#imports'
-
type OptionalRemoteLoggerPayload = {
sessionId?: string
runId?: string
@@ -10,6 +8,12 @@ type OptionalRemoteLoggerPayload = {
timestamp: number
}
+type OptionalRemoteLoggerPublicConfig = {
+ debugLogIngestUrl?: string
+ debugLogSessionId?: string
+ debugLogSessionHeader?: string
+}
+
function safeConsoleWarn(payload: OptionalRemoteLoggerPayload) {
try {
// Always log locally; never include secrets in `data`.
@@ -19,22 +23,35 @@ function safeConsoleWarn(payload: OptionalRemoteLoggerPayload) {
}
}
-export function logOptionalRemote(payload: OptionalRemoteLoggerPayload) {
- safeConsoleWarn(payload)
+function getOptionalRemoteLoggerPublicConfig(): OptionalRemoteLoggerPublicConfig {
+ const metaEnv = (import.meta as unknown as { env?: Record }).env
- const runtimePublic = useRuntimeConfig().public as {
- debugLogIngestUrl?: string
- debugLogSessionId?: string
- debugLogSessionHeader?: string
+ const envConfig: OptionalRemoteLoggerPublicConfig = {
+ debugLogIngestUrl: metaEnv?.NUXT_PUBLIC_DEBUG_LOG_INGEST_URL?.trim(),
+ debugLogSessionId: metaEnv?.NUXT_PUBLIC_DEBUG_LOG_SESSION_ID?.trim(),
+ debugLogSessionHeader: metaEnv?.NUXT_PUBLIC_DEBUG_LOG_SESSION_HEADER?.trim()
}
- const ingestUrl = String(runtimePublic.debugLogIngestUrl ?? '').trim()
+ const nuxtPublicConfig = (
+ globalThis as unknown as {
+ __NUXT__?: { config?: { public?: OptionalRemoteLoggerPublicConfig } }
+ }
+ ).__NUXT__?.config?.public
+
+ return { ...envConfig, ...(nuxtPublicConfig ?? {}) }
+}
+
+export function logOptionalRemote(payload: OptionalRemoteLoggerPayload) {
+ safeConsoleWarn(payload)
+
+ const config = getOptionalRemoteLoggerPublicConfig()
+ const ingestUrl = String(config.debugLogIngestUrl ?? '').trim()
if (!ingestUrl) {
return
}
- const sessionId = String(runtimePublic.debugLogSessionId ?? '').trim()
- const sessionHeader = String(runtimePublic.debugLogSessionHeader ?? '').trim()
+ const sessionId = String(config.debugLogSessionId ?? '').trim()
+ const sessionHeader = String(config.debugLogSessionHeader ?? '').trim()
const headers: Record = { 'Content-Type': 'application/json' }
if (sessionId && sessionHeader) {
From 93d8e939335e31ddfa6fa73482a84de681559236 Mon Sep 17 00:00:00 2001
From: mjkatgithub
Date: Sat, 9 May 2026 18:13:17 +0200
Subject: [PATCH 4/4] Update CHANGELOG to include new features for account
verification and remote logging
- Added an account verification panel in settings with device verification (SAS) and recovery key bootstrap for cross-signing.
- Introduced optional remote debug logging via `NUXT_PUBLIC_DEBUG_LOG_*` environment variables and updated `.env.example` template.
---
CHANGELOG.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a23921a..5c1b0e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -97,6 +97,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Sign-up entry point for delegated browser OAuth; runtime
`nuxt.public.siteUrl` / `matrixOidcClientId`; EN/DE i18n for callback and
error paths; README notes on HTTPS redirect requirements for matrix.org
+- Account verification panel in settings with device verification (SAS)
+ and recovery key bootstrap for cross-signing (#69)
+- Optional remote debug logging via `NUXT_PUBLIC_DEBUG_LOG_*` env vars and
+ `.env.example` template (#69)
### Changed