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
1 change: 1 addition & 0 deletions changelog.d/next/55-locks-connection-kept.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bitcoin Step 1 stays Connected after the Lock Server sign-in in this browser expires (24 hours), after signing out, or in another browser, when the Lock Server still holds the connection. If the Lock Server cannot confirm it, Step 1 says why it asks for one more approval and that approving again does not reset setup. If the status check itself fails, Step 1 says it could not check the connection and to try again, instead of showing Not set up.
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type LocksConnectView = {
connectedCreator: string | null;
isExchanging: boolean;
error: string | null;
reapproveNotice?: string | null;
connectOpen?: boolean;
connectUrl?: string | null;
setConnectIframe?: (element: HTMLIFrameElement | null) => void;
Expand Down Expand Up @@ -130,6 +131,7 @@ export function MarketplaceGetPaidSettings({ locksConnect, onSaved }: Marketplac
connectedCreator,
isExchanging,
error: locksError,
reapproveNotice,
connectOpen,
connectUrl,
setConnectIframe,
Expand Down Expand Up @@ -364,6 +366,11 @@ export function MarketplaceGetPaidSettings({ locksConnect, onSaved }: Marketplac
{locksError}
</Typography>
)}
{!step1Connected && !locksError && reapproveNotice && (
<Typography as="p" className="mt-2 text-sm text-muted-foreground" data-testid="locks-reapprove-notice">
{reapproveNotice}
</Typography>
)}
</div>
{step1Connected ? (
<Badge variant="secondary" className="justify-self-start sm:justify-self-auto">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CommerceController } from '@/controllers/commerce/commerce';
import { LOCKS_STATUS_CHECK_ERROR } from '@/hooks/useMarketplaceLocksConnect/useMarketplaceLocksConnect';
import type { SellerPaymentConfigOwnView } from '@/libs/commerce/payment-methods';
import { toast } from '@/molecules/Toaster/use-toast';
import { useAuthStore } from '@/stores/auth/auth.store';
Expand All @@ -10,11 +11,18 @@ import { MarketplacePaymentSettings } from './MarketplacePaymentSettings';

const view = vi.hoisted(() => ({
locksConnect: {
connectedCreator: null as string | null,
connectedCreator: null,
isExchanging: false,
error: null as string | null,
error: null,
connectOpen: false,
connectUrl: null as string | null,
connectUrl: null,
} as {
connectedCreator: string | null;
isExchanging: boolean;
error: string | null;
reapproveNotice?: string | null;
connectOpen: boolean;
connectUrl: string | null;
},
}));
const navigation = vi.hoisted(() => ({
Expand Down Expand Up @@ -50,7 +58,10 @@ vi.mock('@/molecules/Toaster/use-toast', () => ({
toast: vi.fn(),
}));

vi.mock('@/hooks/useMarketplaceLocksConnect/useMarketplaceLocksConnect', () => ({
vi.mock('@/hooks/useMarketplaceLocksConnect/useMarketplaceLocksConnect', async (importOriginal) => ({
LOCKS_STATUS_CHECK_ERROR: (
await importOriginal<typeof import('@/hooks/useMarketplaceLocksConnect/useMarketplaceLocksConnect')>()
).LOCKS_STATUS_CHECK_ERROR,
useMarketplaceLocksConnect: () => ({
...view.locksConnect,
setConnectIframe: vi.fn(),
Expand Down Expand Up @@ -298,6 +309,54 @@ describe('MarketplacePaymentSettings', () => {
);
});

it('tells a seller why Step 1 asks for a fresh approval, beside Open Locks connect', async () => {
const notice = 'Connected before? Approve once more in Pubky Ring or Bitkit.';
view.locksConnect = {
connectedCreator: null,
isExchanging: false,
error: null,
reapproveNotice: notice,
connectOpen: false,
connectUrl: null,
};

await renderSettings();

expect(screen.getByTestId('locks-reapprove-notice')).toHaveTextContent(notice);
expect(screen.getByRole('button', { name: /Open Locks connect/ })).toBeInTheDocument();
});

it('shows a failed Lock Server status check as Needs attention with the reason, never Not set up', async () => {
view.locksConnect = {
connectedCreator: null,
isExchanging: false,
error: LOCKS_STATUS_CHECK_ERROR,
connectOpen: false,
connectUrl: null,
};

await renderSettings();

expect(screen.getByRole('alert')).toHaveTextContent(LOCKS_STATUS_CHECK_ERROR);
expect(screen.getByTestId('payment-method-status-bitcoin')).toHaveTextContent('Needs attention');
expect(screen.getByTestId('payment-method-status-bitcoin')).not.toHaveTextContent('Not set up');
});

it('hides the fresh-approval notice once Step 1 is connected', async () => {
view.locksConnect = {
connectedCreator: 'gy1wnkhfwezwdnawnur1bc3kw1x3jf5ggjj3cm37e31i5ntq3pco',
isExchanging: false,
error: null,
reapproveNotice: 'Connected before? Approve once more in Pubky Ring or Bitkit.',
connectOpen: false,
connectUrl: null,
};

await renderSettings();

expect(screen.queryByTestId('locks-reapprove-notice')).not.toBeInTheDocument();
});

it('preserves server bitcoin when Step 1 names a different Lock Server creator', async () => {
const user = userEvent.setup();
view.locksConnect = {
Expand Down
4 changes: 4 additions & 0 deletions src/core/application/commerce/commerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,10 @@ export class CommerceApplication {
return await LocksGatewayService.getCreatorAuthorityStatus(sessionToken);
}

static async getLocksPublicCreatorAuthorityStatus(accountPubky: string) {
return await LocksGatewayService.getPublicCreatorAuthorityStatus(accountPubky);
}

static restoreLocksFrontendSession(accountPubky: string) {
return LocksFrontendSessionStore.restore(accountPubky);
}
Expand Down
13 changes: 12 additions & 1 deletion src/core/controllers/commerce/commerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { CommerceDigitalLock } from '@/libs/commerce/marketplace-records';
import type { PaymentMethodKind } from '@/libs/commerce/payment-methods';
import type { ShipFromAddress, ShippingParcel } from '@/libs/commerce/shipping';
import { buildMarketplaceListingAggregateId } from '@/libs/commerce/transaction-commands';
import { commercePositiveMoneySchema } from '@/libs/commerce/transaction-contracts';
import { commercePositiveMoneySchema, commercePubkySchema } from '@/libs/commerce/transaction-contracts';
import { ValidationErrorCode } from '@/libs/error/error.codes';
import { Err } from '@/libs/error/error.factories';
import { ErrorService } from '@/libs/error/error.types';
Expand Down Expand Up @@ -1045,6 +1045,17 @@ export class CommerceController {
return await CommerceApplication.getLocksCreatorAuthorityStatus(sessionToken);
}

static async getLocksPublicCreatorAuthorityStatus(accountPubky: unknown) {
const parsed = commercePubkySchema.safeParse(accountPubky);
if (!parsed.success) {
throw Err.validation(ValidationErrorCode.INVALID_INPUT, 'Shop account is invalid.', {
service: ErrorService.Local,
operation: 'getLocksPublicCreatorAuthorityStatus',
});
}
return await CommerceApplication.getLocksPublicCreatorAuthorityStatus(parsed.data);
}

static restoreLocksFrontendSession(accountPubky: string) {
return CommerceApplication.restoreLocksFrontendSession(accountPubky);
}
Expand Down
32 changes: 32 additions & 0 deletions src/core/services/locks/locks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,38 @@ describe('LocksGatewayService', () => {
loggerError.mockRestore();
});

it('reads the creator-keyed authority status without a bearer', async () => {
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse({ creator: `pubky${CREATOR}`, authorized: true }));

await expect(LocksGatewayService.getPublicCreatorAuthorityStatus(CREATOR)).resolves.toEqual({
creator: `pubky${CREATOR}`,
authorized: true,
});
expect(fetch).toHaveBeenCalledWith(
`https://locks.example.com/creators/pubky${CREATOR}/authority-status`,
expect.objectContaining({ method: 'GET' }),
);
const init = vi.mocked(fetch).mock.calls[0]?.[1] as RequestInit;
expect(init.headers).toBeUndefined();
});

it('resolves null when the Lock Server does not serve the creator-keyed status', async () => {
vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 404 }));

await expect(LocksGatewayService.getPublicCreatorAuthorityStatus(CREATOR)).resolves.toBeNull();
});

it('rejects a creator-keyed status error instead of reading it as not connected', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ error: { code: 'internal_error', message: 'internal error' } }), {
status: 500,
headers: { 'content-type': 'application/json' },
}),
);

await expect(LocksGatewayService.getPublicCreatorAuthorityStatus(CREATOR)).rejects.toBeTruthy();
});

it('builds the legacy connect URL with postmessage delivery', () => {
expect(
LocksGatewayService.buildLegacyConnectUrl('https://shop.pubky.app/marketplace/settings', 'opaque-state'),
Expand Down
36 changes: 34 additions & 2 deletions src/core/services/locks/locks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,8 @@ export class LocksGatewayService {
* Exchanges the hosted legacy-connect completion (`code` + the caller's own
* `state`, delivered to `return_to`) for a creator frontend session. This is
* the client's proof that the Lock Server actually holds creator authority
* for the signed-in seller — the setup UI must not claim "connected" from
* anything weaker.
* for the signed-in seller. The setup UI claims "connected" only from the
* Lock Server's own answer: this exchange, or an authority-status read.
*/
static async createFrontendSession(code: string, state: string): Promise<LocksFrontendSession> {
const url = `${getLocksUrl()}/frontend-sessions`;
Expand Down Expand Up @@ -276,6 +276,38 @@ export class LocksGatewayService {
return parsed.data;
}

/**
* Creator-keyed status (`GET /creators/{creator}/authority-status`): whether
* the Lock Server holds creator authority for `creatorPubky`, with no
* frontend session. It reads the same stored authority record as
* {@link getCreatorAuthorityStatus}, so Step 1 stays Connected after the
* 24-hour frontend session expires or sign-out wipes it. Resolves `null`
* when the Lock Server does not serve the route (404), so callers can fall
* back to asking for a fresh approval.
*/
static async getPublicCreatorAuthorityStatus(creatorPubky: string): Promise<LocksCreatorAuthorityStatus | null> {
const creator = `pubky${creatorPubky.replace(/^pubky/i, '')}`;
const url = `${getLocksUrl()}/creators/${encodeURIComponent(creator)}/authority-status`;
const response = await safeFetch(url, { method: 'GET' }, ErrorService.Locks, 'getPublicCreatorAuthorityStatus');
if (response.status === 404) return null;
if (!response.ok) throw httpResponseToError(response, ErrorService.Locks, 'getPublicCreatorAuthorityStatus', url);
const raw = await parseResponseOrThrow<unknown>(
response,
ErrorService.Locks,
'getPublicCreatorAuthorityStatus',
url,
);
const parsed = creatorAuthorityStatusSchema.safeParse(raw);
if (!parsed.success) {
throw Err.server(ServerErrorCode.INVALID_RESPONSE, 'Locks returned an invalid authority-status response.', {
service: ErrorService.Locks,
operation: 'getPublicCreatorAuthorityStatus',
context: { statusCode: response.status },
});
}
return parsed.data;
}

/**
* Hosted legacy-connect URL for the seller Bitcoin Step 1 iframe.
* `delivery=postmessage` starts the Lock Server poller immediately and posts
Expand Down
Loading
Loading