From 0d53eca0a95132c51fcda60d92bd72357c6b7222 Mon Sep 17 00:00:00 2001
From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com>
Date: Thu, 24 Sep 2026 12:54:59 +0100
Subject: [PATCH 01/15] fix(auth): sign out and drop the keys of a grant
session that cannot be saved
A failed BrowserSessionStore save left the approved grant live on the homeserver and a partial
record plus the flow key in IndexedDB. The session is now signed out, then every grant key is
removed under the finalization lock, and the save error reaches the caller.
---
src/core/controllers/auth/auth.test.ts | 21 +++++++++++++++++++++
src/core/controllers/auth/auth.ts | 17 ++++++++++++++++-
2 files changed, 37 insertions(+), 1 deletion(-)
diff --git a/src/core/controllers/auth/auth.test.ts b/src/core/controllers/auth/auth.test.ts
index 8e3067659c..bfc2f591f1 100644
--- a/src/core/controllers/auth/auth.test.ts
+++ b/src/core/controllers/auth/auth.test.ts
@@ -2448,6 +2448,27 @@ describe('AuthController', () => {
);
});
+ it('a failed save signs the grant out, removes its keys and surfaces the failure', async () => {
+ const order: string[] = [];
+ const session = grantSession();
+ const authStore = grantAuthStore();
+ vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore);
+ const saveFailure = new Error('IndexedDB write failed');
+ vi.spyOn(AuthApplication, 'saveGrantSession').mockRejectedValue(saveFailure);
+ vi.spyOn(AuthApplication, 'logout').mockImplementation(async () => {
+ order.push('signout');
+ });
+ vi.spyOn(AuthApplication, 'clearGrantSessions').mockImplementation(async () => {
+ order.push('clearAll');
+ });
+
+ const approved = await approveGrantSignIn(session);
+ await expect(AuthController.initializeAuthenticatedSession({ session: approved })).rejects.toBe(saveFailure);
+
+ expect(order).toEqual(['signout', 'clearAll']);
+ expect(authStore.init).not.toHaveBeenCalled();
+ });
+
it('save aborts after a sign-out since QR start', async () => {
const session = grantSession();
const authStore = grantAuthStore();
diff --git a/src/core/controllers/auth/auth.ts b/src/core/controllers/auth/auth.ts
index bfb2d1e3b3..35f6da1ca5 100644
--- a/src/core/controllers/auth/auth.ts
+++ b/src/core/controllers/auth/auth.ts
@@ -527,6 +527,7 @@ export class AuthController {
const authStore = useAuthStore.getState();
let persistAborted = false;
+ let grantSaveError: unknown = null;
try {
this.cancelAllAuthFlows();
@@ -551,7 +552,12 @@ export class AuthController {
if (epochAtStart === undefined || epochAtStart !== readAuthEpoch()) {
return false;
}
- grantSessionRecordId = await AuthApplication.saveGrantSession(session);
+ try {
+ grantSessionRecordId = await AuthApplication.saveGrantSession(session);
+ } catch (error) {
+ grantSaveError = error;
+ return false;
+ }
}
authStore.init({
session,
@@ -566,6 +572,15 @@ export class AuthController {
await AuthApplication.logout({ session }).catch((logoutError) => {
Logger.warn('Failed to sign out a session that lost the local-state race', { logoutError });
});
+ if (grantSaveError !== null) {
+ // A failed save can leave a partial record and this flow's delegated
+ // key in IndexedDB. The grant is signed out above (that needs the
+ // key), so the key goes now. Sign-out uses the same order.
+ await withAuthFinalizationLock(() => AuthApplication.clearGrantSessions()).catch((clearError) => {
+ Logger.error('Grant keys left by a failed save could not be removed', { clearError });
+ });
+ throw grantSaveError;
+ }
throw createCanceledError();
}
From 2b3a7d0d74a1412fc1f7da3e377194e00b192f53 Mon Sep 17 00:00:00 2001
From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com>
Date: Thu, 24 Sep 2026 12:54:59 +0100
Subject: [PATCH 02/15] fix(auth): drop a pending #s= export after a grant
session restore
The grant leg returned before the discard that bounds the cookie leg, so a hand-off captured on
the same load could survive into a later restore after the grant signed out.
---
src/core/application/auth/auth.test.ts | 19 +++++++++++++++++++
src/core/application/auth/auth.ts | 3 +++
2 files changed, 22 insertions(+)
diff --git a/src/core/application/auth/auth.test.ts b/src/core/application/auth/auth.test.ts
index f60ce8e23f..e1418b7fa1 100644
--- a/src/core/application/auth/auth.test.ts
+++ b/src/core/application/auth/auth.test.ts
@@ -292,6 +292,25 @@ describe('AuthApplication', () => {
expect(cookieRestoreSpy).not.toHaveBeenCalled();
});
+ it('grant restore drops a #s= export captured on the same load', async () => {
+ vi.spyOn(HomeserverService, 'restoreGrantSession').mockRejectedValue(createAuthError());
+ vi.spyOn(HomeserverService, 'removeGrantSession').mockResolvedValue(undefined);
+ vibeSessionFragment.resetFragmentSessionExportCache();
+ window.history.replaceState(null, '', '/marketplace#s=handoff-export');
+ try {
+ vibeSessionFragment.consumeFragmentSessionExport();
+ expect(vibeSessionFragment.hasPendingFragmentSessionExport()).toBe(true);
+
+ await AuthApplication.restorePersistedSession({ authStore: grantStore() });
+
+ expect(vibeSessionFragment.hasPendingFragmentSessionExport()).toBe(false);
+ expect(vibeSessionFragment.takeFragmentSessionExport()).toBeNull();
+ } finally {
+ vibeSessionFragment.resetFragmentSessionExportCache();
+ window.history.replaceState(null, '', '/');
+ }
+ });
+
it('restore never calls save', async () => {
vi.spyOn(HomeserverService, 'restoreGrantSession').mockResolvedValue(grantSession());
vi.spyOn(HomeserverService, 'assertUserHomeserverAllowed').mockResolvedValue(undefined);
diff --git a/src/core/application/auth/auth.ts b/src/core/application/auth/auth.ts
index e1a603e4a2..450c9579c7 100644
--- a/src/core/application/auth/auth.ts
+++ b/src/core/application/auth/auth.ts
@@ -95,6 +95,9 @@ export class AuthApplication {
try {
return await this.restoreGrantSession(grantRecordId);
} finally {
+ // Same bound as the cookie leg: a `#s=` captured on this load must
+ // not survive into a later restore after this one signs out.
+ discardFragmentSessionExport();
this.restoreSessionPromise = null;
}
})();
From 7d8e29aeb4804a1072f465c118129aa7ae170193 Mon Sep 17 00:00:00 2001
From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com>
Date: Thu, 24 Sep 2026 12:55:00 +0100
Subject: [PATCH 03/15] fix(auth): keep the authorization URL out of the QR
slot markup
The URL carries the relay channel secret. It is drawn into the QR only; the data-auth-url
attribute is gone, so a CSS attribute selector cannot read it. The launch-critical suite reads
the URL through the copy button instead.
---
.../QrCodeSlot/QrCodeSlot.dom.test.tsx | 29 +++++++++++++++++++
.../molecules/QrCodeSlot/QrCodeSlot.test.tsx | 3 +-
.../QrCodeSlot/QrCodeSlot.test.tsx.snap | 3 --
.../molecules/QrCodeSlot/QrCodeSlot.tsx | 4 ++-
.../organisms/Scan/Scan.test.tsx.snap | 1 -
.../organisms/SignIn/SignIn.test.tsx.snap | 1 -
6 files changed, 34 insertions(+), 7 deletions(-)
create mode 100644 src/components/molecules/QrCodeSlot/QrCodeSlot.dom.test.tsx
diff --git a/src/components/molecules/QrCodeSlot/QrCodeSlot.dom.test.tsx b/src/components/molecules/QrCodeSlot/QrCodeSlot.dom.test.tsx
new file mode 100644
index 0000000000..bf9880f0fa
--- /dev/null
+++ b/src/components/molecules/QrCodeSlot/QrCodeSlot.dom.test.tsx
@@ -0,0 +1,29 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { QrCodeSlot } from './QrCodeSlot';
+
+vi.mock('next/image', () => ({
+ __esModule: true,
+ default: ({ src, alt }: { src: string; alt: string }) =>
,
+}));
+
+const RELAY_SECRET = 'c2VjcmV0LWNoYW5uZWwta2V5LWZvci10aGlzLWZsb3c';
+const AUTH_URL = `pubkyauth://signin_grant?caps=%2Fpub%2Fpubky.app%2F%3Arw&relay=https%3A%2F%2Frelay.example%2Finbox&secret=${RELAY_SECRET}&cid=shop.pubky.app`;
+
+describe('QrCodeSlot with the real QR renderer', () => {
+ it('keeps the relay secret out of the DOM markup', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector('svg')).not.toBeNull();
+ expect(container.innerHTML).not.toContain(RELAY_SECRET);
+ expect(container.innerHTML).not.toContain('pubkyauth://');
+ });
+});
diff --git a/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx b/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx
index 0dfd5f8527..f2fc279d98 100644
--- a/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx
+++ b/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx
@@ -77,7 +77,8 @@ describe('QrCodeSlot', () => {
const qr = screen.getByTestId('qrcode-svg');
expect(qr).toHaveAttribute('data-value', 'auth-url');
expect(qr).toHaveAttribute('width', '176');
- expect(screen.getByTestId('qr-auth-url')).toHaveAttribute('data-auth-url', 'auth-url');
+ const slotAttributeValues = Array.from(screen.getByTestId('qr-auth-url').attributes, (attribute) => attribute.value);
+ expect(slotAttributeValues).not.toContain('auth-url');
const ringLogo = screen.getByAltText('Pubky Ring');
expect(ringLogo).toHaveAttribute('src', '/images/ring-logo.svg');
diff --git a/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx.snap b/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx.snap
index 1e7fc491aa..27fd1b036f 100644
--- a/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx.snap
+++ b/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx.snap
@@ -4,7 +4,6 @@ exports[`QrCodeSlot - Snapshots > matches snapshot for a non-default size 1`] =