From 7724b848216380a5ad69119161e9eb095ca6e162 Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:15:43 +0100 Subject: [PATCH] fix(auth): move the Ring cookie path to @synonymdev/pubky 0.11 Pin @synonymdev/pubky 0.11.0 (and the pubky-shop override) and keep Ring on the cookie flow: startCookieAuthFlow, signinCookie, signupCookie. 0.11's Pubky.restoreSession parses exportLocalSecret() tokens, so the one-approval /session body, cookie reload and staging signup now hydrate with Session.restore. A staging signup whose restore attempts all fail after the invite is spent recovers through signinCookie before throwing. PkarrError from the SDK maps to a retryable network error. --- package-lock.json | 8 +- package.json | 4 +- scripts/probe-dm-offer.mjs | 6 +- scripts/probe-listing-registration.mjs | 8 +- scripts/probe-media-write.mjs | 6 +- scripts/probe-nexus-listing-ingest.mjs | 21 ++- .../auth/auth.single-approval-seams.test.ts | 15 +- src/core/services/homeserver/error.utils.ts | 32 ++++- .../homeserver.ring-url.contract.test.ts | 24 ++++ .../services/homeserver/homeserver.test.ts | 129 +++++++++++++++--- src/core/services/homeserver/homeserver.ts | 39 ++++-- .../services/homeserver/homeserver.types.ts | 5 + .../homeserver/sdk-call-sites.gate.test.ts | 50 +++++++ src/libs/identity/auth-token.ts | 2 +- .../auth/ring-signin-url.sdk-0.8.0.json | 7 + 15 files changed, 298 insertions(+), 58 deletions(-) create mode 100644 src/core/services/homeserver/homeserver.ring-url.contract.test.ts create mode 100644 src/core/services/homeserver/sdk-call-sites.gate.test.ts create mode 100644 src/test/fixtures/auth/ring-signin-url.sdk-0.8.0.json diff --git a/package-lock.json b/package-lock.json index 6d8b830266..01809e3646 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@prelude.so/js-sdk": "0.8.0", "@sentry/nextjs": "^10.53.1", "@serwist/next": "9.5.11", - "@synonymdev/pubky": "0.8.0", + "@synonymdev/pubky": "0.11.0", "@tanstack/react-query": "5.100.11", "bip39": "3.1.0", "canonicalize": "5.1.0", @@ -6775,9 +6775,9 @@ } }, "node_modules/@synonymdev/pubky": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@synonymdev/pubky/-/pubky-0.8.0.tgz", - "integrity": "sha512-U6BUGirleeJyDkYcl+hp4B7ImkOYLQZWu5dFBEiMBYX/teejJ1mAKICfIzqvO9qdVHccZqaMuhfBd1UKL/fq8A==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@synonymdev/pubky/-/pubky-0.11.0.tgz", + "integrity": "sha512-K9kOqG64XsBPJgGosh4CpCg7WucSautHGvU1xtObuW0VVOn3bxXk09wWYvEV+PyUHz/D2OY4JiZoEP5aEJKmRw==", "license": "MIT", "dependencies": { "fetch-cookie": "^3.0.1" diff --git a/package.json b/package.json index e9e5470372..27ac9826e8 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "@prelude.so/js-sdk": "0.8.0", "@sentry/nextjs": "^10.53.1", "@serwist/next": "9.5.11", - "@synonymdev/pubky": "0.8.0", + "@synonymdev/pubky": "0.11.0", "@tanstack/react-query": "5.100.11", "bip39": "3.1.0", "canonicalize": "5.1.0", @@ -119,7 +119,7 @@ "source-map@0.8.0-beta.0": "0.7.4", "glob": "13.0.1", "@bitcoinerrorlog/pubky-shop": { - "@synonymdev/pubky": "0.8.0" + "@synonymdev/pubky": "0.11.0" } }, "devDependencies": { diff --git a/scripts/probe-dm-offer.mjs b/scripts/probe-dm-offer.mjs index bbdcc0f969..4d82a52121 100644 --- a/scripts/probe-dm-offer.mjs +++ b/scripts/probe-dm-offer.mjs @@ -25,12 +25,12 @@ const keypair = Keypair.random(); const secretHex = Buffer.from(keypair.secret()).toString('hex'); const pubky = new Pubky(); const signer = pubky.signer(keypair); -await signer.signup(PublicKey.from(HOMESERVER_PUBKY), signupToken); +await signer.signupCookie(PublicKey.from(HOMESERVER_PUBKY), signupToken); const who = keypair.publicKey.z32(); console.log(`identity: ${who}`); console.log(`secret_hex: ${secretHex}`); -const flow = pubky.startAuthFlow('/pub/pubky.app/:rw,/pub/paykit/:rw', AuthFlowKind.signin(), HTTP_RELAY); +const flow = pubky.startCookieAuthFlow('/pub/pubky.app/:rw,/pub/paykit/:rw', AuthFlowKind.signin(), HTTP_RELAY); const approval = flow.awaitApproval(); await signer.approveAuthRequest(flow.authorizationUrl); const session = await approval; @@ -43,7 +43,7 @@ await session.storage.putJson('/pub/pubky.app/profile.json', { console.log('profile published'); // Transaction-service session (single-use AuthToken, empty caps — identity proof). -const tokenFlow = pubky.startAuthFlow('', AuthFlowKind.signin(), HTTP_RELAY); +const tokenFlow = pubky.startCookieAuthFlow('', AuthFlowKind.signin(), HTTP_RELAY); const tokenPromise = tokenFlow.awaitToken(); await signer.approveAuthRequest(tokenFlow.authorizationUrl); const authToken = await tokenPromise; diff --git a/scripts/probe-listing-registration.mjs b/scripts/probe-listing-registration.mjs index 2adcd5185d..a057088f71 100644 --- a/scripts/probe-listing-registration.mjs +++ b/scripts/probe-listing-registration.mjs @@ -36,14 +36,16 @@ const signer = pubky.signer(keypair); // this probe never touches the probe identity's homeserver anyway — the // marketplace session AuthToken travels over the HTTP relay. try { - await signer.signup(PublicKey.from(HOMESERVER_PUBKY), signupToken); + await signer.signupCookie(PublicKey.from(HOMESERVER_PUBKY), signupToken); } catch (error) { - console.warn(`signup skipped (${error?.message?.split('\n')[0] ?? error}); continuing with a homeserver-less identity`); + console.warn( + `signup skipped (${error?.message?.split('\n')[0] ?? error}); continuing with a homeserver-less identity`, + ); } console.log(`probe identity: ${keypair.publicKey.z32()}`); // Same shape as HomeserverService.generateAuthTokenFlow + signer approval. -const flow = pubky.startAuthFlow('', AuthFlowKind.signin(), HTTP_RELAY); +const flow = pubky.startCookieAuthFlow('', AuthFlowKind.signin(), HTTP_RELAY); const approval = flow.awaitToken(); await signer.approveAuthRequest(flow.authorizationUrl); const authToken = await approval; diff --git a/scripts/probe-media-write.mjs b/scripts/probe-media-write.mjs index 275f797d25..e9fdf542d6 100644 --- a/scripts/probe-media-write.mjs +++ b/scripts/probe-media-write.mjs @@ -15,13 +15,13 @@ const signupToken = (await tokenResponse.text()).trim(); const keypair = Keypair.random(); const signerPubky = new Pubky(); const signer = signerPubky.signer(keypair); -await signer.signup(PublicKey.from(HOMESERVER_PUBKY), signupToken); +await signer.signupCookie(PublicKey.from(HOMESERVER_PUBKY), signupToken); const who = keypair.publicKey.z32(); console.log(`identity: ${who}`); // Fresh client (separate cookie jar role): session via the app's exact grant. const appPubky = new Pubky(); -const flow = appPubky.startAuthFlow('/pub/pubky.app/:rw', AuthFlowKind.signin(), HTTP_RELAY); +const flow = appPubky.startCookieAuthFlow('/pub/pubky.app/:rw', AuthFlowKind.signin(), HTTP_RELAY); const approval = flow.awaitApproval(); await signer.approveAuthRequest(flow.authorizationUrl); const session = await approval; @@ -33,7 +33,7 @@ const writes = [ ]; // Simulate the browser: approve a SECOND session (the messaging grant) in the // same cookie jar, then retry the pubky.app write with the FIRST session. -const flow2 = appPubky.startAuthFlow('/pub/pubky.app/:rw,/pub/paykit/:rw', AuthFlowKind.signin(), HTTP_RELAY); +const flow2 = appPubky.startCookieAuthFlow('/pub/pubky.app/:rw,/pub/paykit/:rw', AuthFlowKind.signin(), HTTP_RELAY); const approval2 = flow2.awaitApproval(); await signer.approveAuthRequest(flow2.authorizationUrl); const messagingSession = await approval2; diff --git a/scripts/probe-nexus-listing-ingest.mjs b/scripts/probe-nexus-listing-ingest.mjs index b2ba60b7e5..f05e701120 100644 --- a/scripts/probe-nexus-listing-ingest.mjs +++ b/scripts/probe-nexus-listing-ingest.mjs @@ -17,22 +17,31 @@ const signupToken = (await tokenResponse.text()).trim(); const keypair = Keypair.random(); const pubky = new Pubky(); const signer = pubky.signer(keypair); -await signer.signup(PublicKey.from(HOMESERVER_PUBKY), signupToken); +await signer.signupCookie(PublicKey.from(HOMESERVER_PUBKY), signupToken); const who = keypair.publicKey.z32(); console.log(`identity: ${who}`); -const flow = pubky.startAuthFlow('/pub/pubky.app/:rw', AuthFlowKind.signin(), HTTP_RELAY); +const flow = pubky.startCookieAuthFlow('/pub/pubky.app/:rw', AuthFlowKind.signin(), HTTP_RELAY); const approval = flow.awaitApproval(); await signer.approveAuthRequest(flow.authorizationUrl); const session = await approval; // Profile first so the listing's seller dependency exists. -await session.storage.putJson('/pub/pubky.app/profile.json', { name: 'Nexus ingest probe', bio: '', image: '', links: [], status: '' }); +await session.storage.putJson('/pub/pubky.app/profile.json', { + name: 'Nexus ingest probe', + bio: '', + image: '', + links: [], + status: '', +}); // Clone the known-good canonical record, re-owned. -const source = await fetch(`https://homeserver.staging.pubky.app/pub/pubky.app/marketplace/v1/listings/${SOURCE_LISTING}`, { - headers: { 'pubky-host': SOURCE_SELLER }, -}); +const source = await fetch( + `https://homeserver.staging.pubky.app/pub/pubky.app/marketplace/v1/listings/${SOURCE_LISTING}`, + { + headers: { 'pubky-host': SOURCE_SELLER }, + }, +); const record = await source.json(); const newId = crypto.randomUUID().replaceAll('-', ''); record.listingId = newId; diff --git a/src/core/controllers/auth/auth.single-approval-seams.test.ts b/src/core/controllers/auth/auth.single-approval-seams.test.ts index 916b3e6795..9c0dd7ebc3 100644 --- a/src/core/controllers/auth/auth.single-approval-seams.test.ts +++ b/src/core/controllers/auth/auth.single-approval-seams.test.ts @@ -20,6 +20,7 @@ const BEARER = 'A'.repeat(43); const mockState = vi.hoisted(() => ({ clientFetch: vi.fn(), restoreSession: vi.fn(), + sessionRestore: vi.fn(), startAuthFlow: vi.fn(), authTokenFromBytes: vi.fn(), // Who the device is signed in as, read by the auth-store mock below. Null @@ -31,7 +32,7 @@ vi.mock('@synonymdev/pubky', () => { const createMockPubkyInstance = () => ({ getHomeserverOf: vi.fn(), restoreSession: (...args: unknown[]) => mockState.restoreSession(...args), - startAuthFlow: (...args: unknown[]) => mockState.startAuthFlow(...args), + startCookieAuthFlow: (...args: unknown[]) => mockState.startAuthFlow(...args), eventStreamForUser: vi.fn(), client: { fetch: (...args: unknown[]) => mockState.clientFetch(...args), @@ -57,6 +58,9 @@ vi.mock('@synonymdev/pubky', () => { Pubky: MockPubky, Client: MockClient, Address: MockAddress, + Session: { + restore: (...args: unknown[]) => mockState.sessionRestore(...args), + }, PublicKey: { from: vi.fn().mockReturnValue({ z32: () => 'homeserver-public-key-z32', @@ -119,7 +123,7 @@ describe('single-approval ceremony at the transport seams', () => { capabilities: CAPABILITIES.split(','), publicKey: { z32: () => PUBKY }, }); - mockState.restoreSession.mockResolvedValue(mockSession); + mockState.sessionRestore.mockResolvedValue(mockSession); mockState.startAuthFlow.mockReturnValue({ authorizationUrl: 'pubkyauth:///?relay=https%3A%2F%2Frelay.example.com%2Finbox&secret=s', awaitToken: async () => @@ -162,6 +166,9 @@ describe('single-approval ceremony at the transport seams', () => { // empty-capability second flow on the direct sign-in path. expect(mockState.startAuthFlow).toHaveBeenCalledTimes(1); expect(mockState.startAuthFlow).toHaveBeenCalledWith(CAPABILITIES, 'signin-kind', expect.any(String)); + // The /session body hydrates through Session.restore, never Pubky.restoreSession. + expect(mockState.sessionRestore).toHaveBeenCalledWith(expect.any(String), expect.anything()); + expect(mockState.restoreSession).not.toHaveBeenCalled(); // Identical bytes (same reference) on both POSTs. expect(mockState.clientFetch).toHaveBeenCalledTimes(1); @@ -196,7 +203,7 @@ describe('single-approval ceremony at the transport seams', () => { info: { publicKey: { z32: () => PUBKY } }, signout, }); - mockState.restoreSession.mockResolvedValue(wrongIdentitySession); + mockState.sessionRestore.mockResolvedValue(wrongIdentitySession); mockState.clientFetch.mockResolvedValue(new Response(SESSION_INFO_BODY, { status: 200 })); vi.mocked(fetch).mockResolvedValue( new Response( @@ -242,7 +249,7 @@ describe('single-approval ceremony at the transport seams', () => { info: { publicKey: { z32: () => PUBKY } }, signout, }); - mockState.restoreSession.mockResolvedValue(wrongIdentitySession); + mockState.sessionRestore.mockResolvedValue(wrongIdentitySession); mockState.clientFetch.mockResolvedValue(new Response(SESSION_INFO_BODY, { status: 200 })); vi.mocked(fetch).mockResolvedValue( new Response( diff --git a/src/core/services/homeserver/error.utils.ts b/src/core/services/homeserver/error.utils.ts index f89b6372b1..54b0ffb8c4 100644 --- a/src/core/services/homeserver/error.utils.ts +++ b/src/core/services/homeserver/error.utils.ts @@ -1,5 +1,5 @@ import { AppError } from '@/libs/error/error'; -import { AuthErrorCode, ServerErrorCode, ValidationErrorCode } from '@/libs/error/error.codes'; +import { AuthErrorCode, NetworkErrorCode, ServerErrorCode, ValidationErrorCode } from '@/libs/error/error.codes'; import { Err } from '@/libs/error/error.factories'; import { httpStatusCodeToError } from '@/libs/error/error.http'; import { ErrorService } from '@/libs/error/error.types'; @@ -9,6 +9,7 @@ import type { THandleTypedErrorParams, TThrowHomeserverErrorParams, TThrowInvalidInputErrorParams, + TThrowPkarrLookupErrorParams, TThrowSessionExpiredErrorParams, } from './homeserver.types'; @@ -18,6 +19,8 @@ export const AUTH_FLOW_CANCELED_ERROR_NAME = 'AuthFlowCanceled'; const PUBKY_ERROR_NAMES = { INVALID_INPUT: 'InvalidInput', AUTHENTICATION_ERROR: 'AuthenticationError', + /** PKARR lookup itself failed (relay/network error or malformed record) — absence NOT proven */ + PKARR_ERROR: 'PkarrError', } as const; /** @@ -97,6 +100,25 @@ const throwInvalidInputError = ({ errorMessage, additionalContext }: TThrowInval }); }; +/** + * Throws a retryable Network error for a failed PKARR lookup. + * + * The SDK rejects with `PkarrError` when the record could not be resolved (relay or + * network failure, malformed record). That is not proof the record is absent, so the + * error stays retryable and is never treated as a homeserver HTTP failure. + * + * @param errorMessage - The original error message + * @param additionalContext - Additional context to add to the error + * @returns Never (always throws) + */ +const throwPkarrLookupError = ({ errorMessage, additionalContext }: TThrowPkarrLookupErrorParams): never => { + throw Err.network(NetworkErrorCode.CONNECTION_FAILED, errorMessage || 'PKARR lookup failed', { + service: ErrorService.Homeserver, + operation: (additionalContext.operation as string | undefined) ?? 'unknown', + context: { originalError: errorMessage, ...additionalContext }, + }); +}; + /** * Throws a homeserver error with the provided context. * Uses httpStatusCodeToError for proper HTTP status code mapping. @@ -117,7 +139,7 @@ const throwHomeserverError = ({ statusCode, errorMessage, additionalContext }: T * Routes to specialized throwers based on error name and status code. * * @param errorMessage - The original error message - * @param errorName - The error name (e.g., 'InvalidInput', 'AuthenticationError') + * @param errorName - The error name (e.g., 'InvalidInput', 'AuthenticationError', 'PkarrError') * @param statusCode - The HTTP status code * @param additionalContext - Additional context to add to the error * @returns Never (always throws) @@ -128,6 +150,12 @@ const handleTypedError = ({ statusCode, additionalContext, }: THandleTypedErrorParams): never => { + // A PKARR failure carries no HTTP status, so it must be dispatched by name before + // the status-based fallbacks below turn it into a synthetic 500 homeserver error. + if (errorName === PUBKY_ERROR_NAMES.PKARR_ERROR) { + return throwPkarrLookupError({ errorMessage, additionalContext }); + } + if (errorName === PUBKY_ERROR_NAMES.INVALID_INPUT) { return throwInvalidInputError({ errorMessage, additionalContext }); } diff --git a/src/core/services/homeserver/homeserver.ring-url.contract.test.ts b/src/core/services/homeserver/homeserver.ring-url.contract.test.ts new file mode 100644 index 0000000000..9dfd069c10 --- /dev/null +++ b/src/core/services/homeserver/homeserver.ring-url.contract.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { CAPABILITIES } from '@/config/app'; +import captured from '@/test/fixtures/auth/ring-signin-url.sdk-0.8.0.json'; +import { HomeserverService } from './homeserver'; + +describe('Ring sign-in QR on @synonymdev/pubky 0.11 (real SDK)', () => { + it('ring qr matches captured 0.8.0 host and params', async () => { + const { authorizationUrl, awaitApproval, cancelAuthFlow } = await HomeserverService.generateAuthUrl(); + // Cancelling below rejects the pending approval; this test only reads the URL. + awaitApproval.catch(() => undefined); + try { + const url = new URL(authorizationUrl); + + expect(url.protocol).toBe(captured.scheme); + expect(url.host).toBe(captured.host); + expect([...url.searchParams.keys()]).toEqual(captured.params); + expect(url.searchParams.get('caps')).toBe(captured.caps); + expect(url.searchParams.get('caps')).toBe(CAPABILITIES); + } finally { + cancelAuthFlow(); + } + }); +}); diff --git a/src/core/services/homeserver/homeserver.test.ts b/src/core/services/homeserver/homeserver.test.ts index ae740146ce..64eb1bff53 100644 --- a/src/core/services/homeserver/homeserver.test.ts +++ b/src/core/services/homeserver/homeserver.test.ts @@ -2,7 +2,13 @@ import type { Keypair, PublicKey, Session } from '@synonymdev/pubky'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CAPABILITIES } from '@/config/app'; import { AppError } from '@/libs/error/error'; -import { AuthErrorCode, ClientErrorCode, ServerErrorCode, ValidationErrorCode } from '@/libs/error/error.codes'; +import { + AuthErrorCode, + ClientErrorCode, + NetworkErrorCode, + ServerErrorCode, + ValidationErrorCode, +} from '@/libs/error/error.codes'; import { ErrorCategory, ErrorService } from '@/libs/error/error.types'; import { HttpMethod } from '@/libs/http/http.types'; import { Logger } from '@/libs/logger/logger'; @@ -36,6 +42,7 @@ const mockState = vi.hoisted(() => ({ // Pubky methods getHomeserverOf: vi.fn(), restoreSession: vi.fn(), + sessionRestore: vi.fn(), startAuthFlow: vi.fn(), authFlowKindSignin: vi.fn(), authTokenFromBytes: vi.fn(), @@ -84,7 +91,7 @@ vi.mock('@synonymdev/pubky', () => { const createMockPubkyInstance = () => ({ getHomeserverOf: (...args: unknown[]) => mockState.getHomeserverOf(...args), restoreSession: (...args: unknown[]) => mockState.restoreSession(...args), - startAuthFlow: (...args: unknown[]) => mockState.startAuthFlow(...args), + startCookieAuthFlow: (...args: unknown[]) => mockState.startAuthFlow(...args), eventStreamForUser: (...args: unknown[]) => mockState.eventStreamForUser(...args), client: { fetch: (...args: unknown[]) => mockState.clientFetch(...args), @@ -95,8 +102,8 @@ vi.mock('@synonymdev/pubky', () => { list: (...args: unknown[]) => mockState.publicStorageList(...args), }, signer: () => ({ - signup: (...args: unknown[]) => mockState.signup(...args), - signin: (...args: unknown[]) => mockState.signin(...args), + signupCookie: (...args: unknown[]) => mockState.signup(...args), + signinCookie: (...args: unknown[]) => mockState.signin(...args), pkdns: { publishHomeserverForce: (...args: unknown[]) => mockState.publishHomeserverForce(...args), }, @@ -109,6 +116,9 @@ vi.mock('@synonymdev/pubky', () => { return { Pubky: MockPubky, + Session: { + restore: (...args: unknown[]) => mockState.sessionRestore(...args), + }, PublicKey: { from: vi.fn().mockReturnValue({ z32: () => 'homeserver-public-key-z32', @@ -356,7 +366,7 @@ describe('HomeserverService', () => { const keypair = createMockKeypair(); const expectedSession = createMockSession(); mockState.clientFetch.mockResolvedValue(new Response(sessionInfoBytes, { status: 200 })); - mockState.restoreSession.mockResolvedValue(expectedSession); + mockState.sessionRestore.mockResolvedValue(expectedSession); const result = await HomeserverService.signUp({ keypair, signupToken }); @@ -373,8 +383,13 @@ describe('HomeserverService', () => { expect(new Uint8Array(init.body).length).toBe(120); // Publishes the user's record so the staging guard and Nexus can resolve it expect(mockState.publishHomeserverForce).toHaveBeenCalled(); - // Session is restored from the base64 of the signup response body - expect(mockState.restoreSession).toHaveBeenCalledWith(btoa(String.fromCharCode(...sessionInfoBytes))); + // Session is restored from the base64 of the signup response body via + // the metadata API; Pubky.restoreSession parses secret tokens instead. + expect(mockState.sessionRestore).toHaveBeenCalledWith( + btoa(String.fromCharCode(...sessionInfoBytes)), + expect.anything(), + ); + expect(mockState.restoreSession).not.toHaveBeenCalled(); }); }); @@ -429,6 +444,7 @@ describe('HomeserverService', () => { category: ErrorCategory.Server, code: ServerErrorCode.SERVICE_UNAVAILABLE, }); + expect(mockState.sessionRestore).not.toHaveBeenCalled(); expect(mockState.restoreSession).not.toHaveBeenCalled(); }); }); @@ -438,14 +454,63 @@ describe('HomeserverService', () => { const keypair = createMockKeypair(); const expectedSession = createMockSession(); mockState.clientFetch.mockResolvedValue(new Response(sessionInfoBytes, { status: 200 })); - mockState.restoreSession + mockState.sessionRestore .mockRejectedValueOnce(new Error('record not propagated yet')) .mockResolvedValueOnce(expectedSession); const result = await HomeserverService.signUp({ keypair, signupToken }); expect(result).toEqual({ session: expectedSession }); - expect(mockState.restoreSession).toHaveBeenCalledTimes(2); + expect(mockState.sessionRestore).toHaveBeenCalledTimes(2); + expect(mockState.signin).not.toHaveBeenCalled(); + }); + }); + + it('recovers via signinCookie when every restore attempt fails after the invite is spent', async () => { + await withStagingHomeserverEnv(async () => { + vi.useFakeTimers(); + try { + const keypair = createMockKeypair(); + const recoveredSession = createMockSession(); + mockState.clientFetch.mockResolvedValue(new Response(sessionInfoBytes, { status: 200 })); + mockState.sessionRestore.mockRejectedValue(new Error('restore failed')); + mockState.signin.mockResolvedValue(recoveredSession); + + const pending = HomeserverService.signUp({ keypair, signupToken }); + await vi.runAllTimersAsync(); + const result = await pending; + + expect(result).toEqual({ session: recoveredSession }); + expect(mockState.sessionRestore).toHaveBeenCalledTimes(3); + expect(mockState.signin).toHaveBeenCalledTimes(1); + expect(mockState.restoreSession).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + }); + + it('throws a retryable error only when both restore and signinCookie fail', async () => { + await withStagingHomeserverEnv(async () => { + vi.useFakeTimers(); + try { + const keypair = createMockKeypair(); + mockState.clientFetch.mockResolvedValue(new Response(sessionInfoBytes, { status: 200 })); + mockState.sessionRestore.mockRejectedValue(new Error('restore failed')); + mockState.signin.mockRejectedValue(new Error('signin failed')); + + const pending = HomeserverService.signUp({ keypair, signupToken }).catch((error: unknown) => error); + await vi.runAllTimersAsync(); + + expect(await pending).toMatchObject({ + category: ErrorCategory.Server, + code: ServerErrorCode.SERVICE_UNAVAILABLE, + }); + expect(mockState.sessionRestore).toHaveBeenCalledTimes(3); + expect(mockState.signin).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } }); }); }); @@ -794,6 +859,19 @@ describe('HomeserverService', () => { }); }); + describe('restoreSession (cookie reload)', () => { + it('reload restores cookie session via Session.restore', async () => { + const restored = createMockSession(); + mockState.sessionRestore.mockResolvedValue(restored); + + const result = await HomeserverService.restoreSession({ sessionExport: 'c2Vzc2lvbi1leHBvcnQ=' }); + + expect(result).toBe(restored); + expect(mockState.sessionRestore).toHaveBeenCalledWith('c2Vzc2lvbi1leHBvcnQ=', expect.anything()); + expect(mockState.restoreSession).not.toHaveBeenCalled(); + }); + }); + describe('signInWithFullGrantAuthToken', () => { const z32 = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const fullCaps = CAPABILITIES.split(','); @@ -804,7 +882,7 @@ describe('HomeserverService', () => { capabilities: fullCaps, publicKey: { z32: () => z32 }, }); - mockState.restoreSession.mockResolvedValue(createMockSession()); + mockState.sessionRestore.mockResolvedValue(createMockSession()); }); it('POSTs to the session endpoint after a full-grant capability check', async () => { @@ -817,7 +895,8 @@ describe('HomeserverService', () => { `https://_pubky.${z32}/session`, expect.objectContaining({ method: 'POST', credentials: 'include', body: bytes }), ); - expect(mockState.restoreSession).toHaveBeenCalled(); + expect(mockState.sessionRestore).toHaveBeenCalledWith(expect.any(String), expect.anything()); + expect(mockState.restoreSession).not.toHaveBeenCalled(); }); it('hydrates with the same standard padded base64 alphabet session.export() uses', async () => { @@ -833,8 +912,8 @@ describe('HomeserverService', () => { await HomeserverService.signInWithFullGrantAuthToken(bytes); await HomeserverService.signInWithFullGrantAuthToken(bytes); - expect(mockState.restoreSession).toHaveBeenNthCalledWith(1, '+//+'); - expect(mockState.restoreSession).toHaveBeenNthCalledWith(2, '+w=='); + expect(mockState.sessionRestore).toHaveBeenNthCalledWith(1, '+//+', expect.anything()); + expect(mockState.sessionRestore).toHaveBeenNthCalledWith(2, '+w==', expect.anything()); }); it('accepts a reordered full grant (order-insensitive set equality)', async () => { @@ -891,13 +970,13 @@ describe('HomeserverService', () => { `https://_pubky.${z32}/session`, expect.objectContaining({ method: 'GET', credentials: 'include' }), ); - expect(mockState.restoreSession).toHaveBeenCalled(); + expect(mockState.sessionRestore).toHaveBeenCalled(); }); it('probes GET /session after any non-ok POST status, not just the recorded 400', async () => { for (const status of [401, 409, 500]) { mockState.clientFetch.mockReset(); - mockState.restoreSession.mockClear(); + mockState.sessionRestore.mockClear(); mockState.clientFetch .mockResolvedValueOnce(new Response('nope', { status })) .mockResolvedValueOnce(new Response(new Uint8Array([4, 5, 6]), { status: 200 })); @@ -909,13 +988,13 @@ describe('HomeserverService', () => { `https://_pubky.${z32}/session`, expect.objectContaining({ method: 'GET', credentials: 'include' }), ); - expect(mockState.restoreSession).toHaveBeenCalled(); + expect(mockState.sessionRestore).toHaveBeenCalled(); } }); it('fails sign-in when restore fails after a 2xx POST, without a fallback GET', async () => { mockState.clientFetch.mockResolvedValueOnce(new Response(new Uint8Array([1]), { status: 200 })); - mockState.restoreSession.mockRejectedValueOnce(new Error('restore failed')); + mockState.sessionRestore.mockRejectedValueOnce(new Error('restore failed')); await expect(HomeserverService.signInWithFullGrantAuthToken(bytes)).rejects.toMatchObject({ message: 'Sign-in failed. Scan again.', @@ -1494,6 +1573,22 @@ describe('HomeserverService', () => { }); }); + describe('PKARR lookup failure (SDK 0.11 PkarrError)', () => { + it('pkarr error maps to retryable network error', async () => { + const pkarrError = { name: 'PkarrError', message: 'PKARR lookup failed: relay timeout' }; + mockState.signup.mockRejectedValue(pkarrError); + + const error = await HomeserverService.signUp({ keypair: createMockKeypair(), signupToken: 'token' }).catch( + (caught: unknown) => caught, + ); + + expect(error).toMatchObject({ + category: ErrorCategory.Network, + code: NetworkErrorCode.CONNECTION_FAILED, + }); + }); + }); + describe('Session expiration handling', () => { it('should include endpoint in SESSION_EXPIRED error context', async () => { const testUrl = 'pubky://user/pub/data.json'; diff --git a/src/core/services/homeserver/homeserver.ts b/src/core/services/homeserver/homeserver.ts index f0b0ea52c2..43475fb696 100644 --- a/src/core/services/homeserver/homeserver.ts +++ b/src/core/services/homeserver/homeserver.ts @@ -189,7 +189,7 @@ export class HomeserverService { if (postResponse?.ok) { try { const body = new Uint8Array(await postResponse.arrayBuffer()); - return await this.restoreSession({ sessionExport: bytesToBase64(body) }); + return await Session.restore(bytesToBase64(body), client); } catch { throw Err.auth(AuthErrorCode.UNAUTHORIZED, 'Sign-in failed. Scan again.', { service: ErrorService.Homeserver, @@ -209,7 +209,7 @@ export class HomeserverService { if (getResponse?.ok) { try { const body = new Uint8Array(await getResponse.arrayBuffer()); - return await this.restoreSession({ sessionExport: bytesToBase64(body) }); + return await Session.restore(bytesToBase64(body), client); } catch { throw Err.auth(AuthErrorCode.UNAUTHORIZED, 'Sign-in failed. Scan again.', { service: ErrorService.Homeserver, @@ -308,7 +308,7 @@ export class HomeserverService { try { const homeserverPublicKey = PublicKey.from(getHomeserver()); const signer = this.getSigner(keypair); - const session = await signer.signup(homeserverPublicKey, signupToken); + const session = await signer.signupCookie(homeserverPublicKey, signupToken); Logger.debug('Signup successful', { session }); @@ -326,7 +326,7 @@ export class HomeserverService { /** * Staging signup that bypasses PKARR resolution of the homeserver key. * - * `signer.signup()` builds `https:///signup` and needs the + * `signer.signupCookie()` builds `https:///signup` and needs the * homeserver's OWN PKARR record to carry an HTTPS endpoint for that bare-key * hostname. The staging homeserver's record does not resolve that way (the * same reason {@link verifySignupToken} already uses {@link getHomeserverUrl} @@ -334,7 +334,7 @@ export class HomeserverService { * PKARR record" while everything else — which rides `_pubky.` URLs — * kept working. * - * This path replicates what `signer.signup()` does, without that lookup: + * This path replicates what `signer.signupCookie()` does, without that lookup: * 1. Sign a root-capability AuthToken locally (see `libs/identity/auth-token`) * and POST it to `{homeserverUrl}/signup?signup_token=…`. The response body * is the serialized SessionInfo and the session cookie is set by the browser. @@ -342,7 +342,8 @@ export class HomeserverService { * (required by {@link assertUserHomeserverAllowed} and by Nexus). * 3. Hydrate a Session from the signup response (retried briefly: the * hydration revalidates via `_pubky.`, which needs the record from - * step 2 to propagate to the relays). + * step 2 to propagate to the relays). If hydration still fails, sign in + * to the account the spent invite just created before giving up. * * Retry safety: the invite is consumed by a successful POST in step 1. If a * later step fails, the thrown error is retryable, and a retried call whose @@ -399,7 +400,7 @@ export class HomeserverService { }); } - const session = await this.restoreSignupSession(sessionInfoBytes, errorParams); + const session = await this.restoreSignupSession(keypair, sessionInfoBytes, errorParams); return { session }; } @@ -410,6 +411,7 @@ export class HomeserverService { * propagation of the just-published user record. */ private static async restoreSignupSession( + keypair: Keypair, sessionInfoBytes: Uint8Array, errorParams: { service: ErrorService; operation: string }, ): Promise { @@ -418,7 +420,7 @@ export class HomeserverService { let lastError: unknown; for (let attempt = 1; attempt <= SIGNUP_SESSION_RESTORE_ATTEMPTS; attempt++) { try { - return await this.getPubkySdk().restoreSession(sessionExport); + return await Session.restore(sessionExport, this.getPubkySdk().client); } catch (error) { lastError = error; Logger.warn('Signup session restore attempt failed', { attempt, error }); @@ -428,6 +430,14 @@ export class HomeserverService { } } + // The invite is already spent, so a retried signup would be rejected: + // recover the session from the account this signup just created. + const recovered = await this.trySignInExistingAccount(keypair); + if (recovered) { + Logger.info('Signup session restore failed; recovered via sign-in'); + return recovered.session; + } + throw Err.server(ServerErrorCode.SERVICE_UNAVAILABLE, 'Signed up, but could not establish your session yet.', { ...errorParams, cause: lastError, @@ -446,7 +456,7 @@ export class HomeserverService { try { const signer = this.getSigner(keypair); await signer.pkdns.publishHomeserverForce(PublicKey.from(getHomeserver())); - const session = await signer.signin(); + const session = await signer.signinCookie(); return { session }; } catch (error) { Logger.debug('No existing account to recover during signup', { error }); @@ -542,7 +552,7 @@ export class HomeserverService { } try { - const session = await signer.signin(); + const session = await signer.signinCookie(); return { session }; } catch (signinError) { return await this.republishConfiguredHomeserver({ signer, keypair, originalError: signinError }); @@ -582,7 +592,7 @@ export class HomeserverService { try { const pubkySdk = this.getPubkySdk(); - const flow = pubkySdk.startAuthFlow(capabilities, AuthFlowKind.signin(), getDefaultHttpRelay()); + const flow = pubkySdk.startCookieAuthFlow(capabilities, AuthFlowKind.signin(), getDefaultHttpRelay()); const approval = createCancelableAuthApproval(flow); return { @@ -615,7 +625,7 @@ export class HomeserverService { static generateAuthTokenFlow(capabilities: Capabilities = ''): TGenerateAuthTokenFlowResult { try { const pubkySdk = this.getPubkySdk(); - const flow = pubkySdk.startAuthFlow(capabilities, AuthFlowKind.signin(), getDefaultHttpRelay()); + const flow = pubkySdk.startCookieAuthFlow(capabilities, AuthFlowKind.signin(), getDefaultHttpRelay()); const authorizationUrl = flow.authorizationUrl; let freed = false; const free = () => { @@ -938,7 +948,10 @@ export class HomeserverService { static async restoreSession({ sessionExport }: THomeserverRestoreSessionParams): Promise { try { const pubkySdk = this.getPubkySdk(); - return await pubkySdk.restoreSession(sessionExport); + // `Pubky.restoreSession` parses secret tokens (`exportLocalSecret()`); + // `Session.restore` decodes `session.export()` metadata and revalidates + // it against the browser-held HttpOnly cookie. + return await Session.restore(sessionExport, pubkySdk.client); } catch (error) { return handleError({ error, diff --git a/src/core/services/homeserver/homeserver.types.ts b/src/core/services/homeserver/homeserver.types.ts index 083238c54c..3bd013a92f 100644 --- a/src/core/services/homeserver/homeserver.types.ts +++ b/src/core/services/homeserver/homeserver.types.ts @@ -148,6 +148,11 @@ export type TThrowInvalidInputErrorParams = { additionalContext: Record; }; +export type TThrowPkarrLookupErrorParams = { + errorMessage: string; + additionalContext: Record; +}; + export type TThrowHomeserverErrorParams = { statusCode: number; errorMessage: string; diff --git a/src/core/services/homeserver/sdk-call-sites.gate.test.ts b/src/core/services/homeserver/sdk-call-sites.gate.test.ts new file mode 100644 index 0000000000..5a6bb42075 --- /dev/null +++ b/src/core/services/homeserver/sdk-call-sites.gate.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment node +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const ROOT = join(__dirname, '../../../..'); +const SRC = join(ROOT, 'src'); + +function sourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + if (statSync(path).isDirectory()) return entry === 'node_modules' ? [] : sourceFiles(path); + if (!/\.(ts|tsx)$/.test(entry) || /\.(test|vrt\.test)\.(ts|tsx)$/.test(entry)) return []; + return [path]; + }); +} + +describe('@synonymdev/pubky 0.11 call sites', () => { + const files = sourceFiles(SRC).map((path) => ({ path: relative(ROOT, path), text: readFileSync(path, 'utf8') })); + + it('no Pubky.restoreSession call site in src', () => { + const offenders = files + .filter(({ text }) => /\b(pubkySdk|getPubkySdk\(\))\.restoreSession\(/.test(text)) + .map(({ path }) => path); + expect(offenders).toEqual([]); + }); + + it('no removed 0.8 cookie API names on the pubky SDK', () => { + const offenders = files + .filter(({ text }) => /\b(pubkySdk|getPubkySdk\(\))\.startAuthFlow\(|\bsigner\.(signin|signup)\(/.test(text)) + .map(({ path }) => path); + expect(offenders).toEqual([]); + }); + + it('one @synonymdev/pubky 0.11.0 in lockfile', () => { + const lock = JSON.parse(readFileSync(join(ROOT, 'package-lock.json'), 'utf8')) as { + packages: Record; + }; + const copies = Object.entries(lock.packages).filter(([path]) => path.endsWith('node_modules/@synonymdev/pubky')); + expect(copies.map(([path, entry]) => [path, entry.version])).toEqual([ + ['node_modules/@synonymdev/pubky', '0.11.0'], + ]); + const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { + dependencies: Record; + overrides: Record>; + }; + expect(pkg.dependencies['@synonymdev/pubky']).toBe('0.11.0'); + expect(pkg.overrides['@bitcoinerrorlog/pubky-shop']['@synonymdev/pubky']).toBe('0.11.0'); + }); +}); diff --git a/src/libs/identity/auth-token.ts b/src/libs/identity/auth-token.ts index 7216361cec..caf1b78eb6 100644 --- a/src/libs/identity/auth-token.ts +++ b/src/libs/identity/auth-token.ts @@ -4,7 +4,7 @@ import { ed25519 } from '@noble/curves/ed25519.js'; * Local (browser-side) signer for pubky-core `AuthToken` v0. * * The `@synonymdev/pubky` WASM SDK signs auth tokens internally for - * `signer.signin()` / `signer.signup()` but does not expose signing to JS + * `signer.signinCookie()` / `signer.signupCookie()` but does not expose signing to JS * (its `AuthToken` class is verify/fromBytes/toBytes only). Direct HTTPS * signup against the staging homeserver (`POST {homeserverUrl}/signup`) * needs the signed token as the request body, so this module reimplements diff --git a/src/test/fixtures/auth/ring-signin-url.sdk-0.8.0.json b/src/test/fixtures/auth/ring-signin-url.sdk-0.8.0.json new file mode 100644 index 0000000000..86a8787dce --- /dev/null +++ b/src/test/fixtures/auth/ring-signin-url.sdk-0.8.0.json @@ -0,0 +1,7 @@ +{ + "source": "@synonymdev/pubky 0.8.0 Pubky.startAuthFlow(CAPABILITIES, AuthFlowKind.signin(), relay), captured 2026-09-23; relay and secret values omitted", + "scheme": "pubkyauth:", + "host": "signin", + "params": ["caps", "relay", "secret"], + "caps": "/pub/pubky.app/:rw,/pub/paykit/:rw,/priv/pubky.app/:rw" +}