Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
6 changes: 3 additions & 3 deletions scripts/probe-dm-offer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions scripts/probe-listing-registration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions scripts/probe-media-write.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
21 changes: 15 additions & 6 deletions scripts/probe-nexus-listing-ingest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 11 additions & 4 deletions src/core/controllers/auth/auth.single-approval-seams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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',
Expand Down Expand Up @@ -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 () =>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
32 changes: 30 additions & 2 deletions src/core/services/homeserver/error.utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,6 +9,7 @@ import type {
THandleTypedErrorParams,
TThrowHomeserverErrorParams,
TThrowInvalidInputErrorParams,
TThrowPkarrLookupErrorParams,
TThrowSessionExpiredErrorParams,
} from './homeserver.types';

Expand All @@ -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;

/**
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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 });
}
Expand Down
24 changes: 24 additions & 0 deletions src/core/services/homeserver/homeserver.ring-url.contract.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading
Loading