From 953a3d437a16549cd7e69233f6065b33ef34441c Mon Sep 17 00:00:00 2001
From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com>
Date: Wed, 23 Sep 2026 17:44:27 +0100
Subject: [PATCH 1/4] feat(marketplace): Bitkit purchase bootstrap for grant
sign-ins
A Bitkit (grant) sign-in has no AuthToken to redeem, so its purchase
session comes from a browser bootstrap: the grant session writes a
single-use proof document, the Shop BFF runs the CLI verifier's checks and
opens a marketplace signin_grant, and after the Bitkit approval the BFF
claims the bearer.
The result PoP seed is derived from the BFF state key and the challenge
id (HKDF key with its own salt and info, two labelled HMACs), so no
migration is added and a key rotation inside the challenge window fails
closed. The flow is bound to the __Host-shop-marketplace-grant cookie and
sealed as a version 2 browser context; CLI routes refuse it, and the CLI
verifier refuses a browser challenge row before consume.
The connect dialog shows Bitkit copy for the bootstrap, reason codes get
static copy, and the inventory, messaging and manual-claim dialogs name
their Pubky Ring requirement.
---
.../[challengeId]/verify/route.ts | 17 +
.../marketplace/bootstrap-challenges/route.ts | 13 +
.../bootstrap-flows/[stateId]/cancel/route.ts | 17 +
.../bootstrap-flows/[stateId]/status/route.ts | 14 +
.../GrantSessionRefusal.tsx | 10 +-
.../MarketplaceGrantSessionRefusal.test.tsx | 25 +-
.../MarketplaceInventoryGrantDialog.tsx | 8 +-
.../MarketplaceMessagingEnableDialog.tsx | 4 +-
.../MarketplaceSessionConnectDialog.test.tsx | 45 +-
.../MarketplaceSessionConnectDialog.tsx | 54 +-
.../marketplace-bootstrap-client.test.ts | 160 ++++
.../marketplace-bootstrap-client.ts | 125 +++
.../useMarketplaceSessionConnect.test.ts | 151 ++++
.../useMarketplaceSessionConnect.ts | 64 +-
.../useMarketplaceSessionConnect.types.ts | 6 +
src/libs/commerce/failure-messages.test.ts | 39 +
src/libs/commerce/failure-messages.ts | 36 +
.../marketplace-grant/browser-bff.test.ts | 779 ++++++++++++++++++
src/server/marketplace-grant/browser-bff.ts | 398 +++++++++
src/server/marketplace-grant/cli-bff.ts | 20 +-
src/server/marketplace-grant/config.ts | 40 +-
src/server/marketplace-grant/crypto.ts | 110 +++
.../MarketplaceSessionConnect.vrt.test.tsx | 23 +-
23 files changed, 2099 insertions(+), 59 deletions(-)
create mode 100644 src/app/api/marketplace/bootstrap-challenges/[challengeId]/verify/route.ts
create mode 100644 src/app/api/marketplace/bootstrap-challenges/route.ts
create mode 100644 src/app/api/marketplace/bootstrap-flows/[stateId]/cancel/route.ts
create mode 100644 src/app/api/marketplace/bootstrap-flows/[stateId]/status/route.ts
create mode 100644 src/core/services/marketplace/marketplace-bootstrap-client.test.ts
create mode 100644 src/core/services/marketplace/marketplace-bootstrap-client.ts
create mode 100644 src/server/marketplace-grant/browser-bff.test.ts
create mode 100644 src/server/marketplace-grant/browser-bff.ts
diff --git a/src/app/api/marketplace/bootstrap-challenges/[challengeId]/verify/route.ts b/src/app/api/marketplace/bootstrap-challenges/[challengeId]/verify/route.ts
new file mode 100644
index 000000000..4aa95c8ad
--- /dev/null
+++ b/src/app/api/marketplace/bootstrap-challenges/[challengeId]/verify/route.ts
@@ -0,0 +1,17 @@
+import type { NextRequest } from 'next/server';
+import { BOOTSTRAP_FLOW_COOKIE, verifyBrowserChallenge } from '@/server/marketplace-grant/browser-bff';
+import { grantCookieOptions, grantError, noStoreJson } from '@/server/marketplace-grant/http';
+
+export const runtime = 'nodejs';
+
+export async function POST(request: NextRequest, context: { params: Promise<{ challengeId: string }> }) {
+ try {
+ const { challengeId } = await context.params;
+ const verified = await verifyBrowserChallenge(request, challengeId);
+ const response = noStoreJson(verified.response, 201);
+ response.cookies.set(BOOTSTRAP_FLOW_COOKIE, verified.cookie, { ...grantCookieOptions, maxAge: verified.maxAge });
+ return response;
+ } catch (error) {
+ return grantError(error);
+ }
+}
diff --git a/src/app/api/marketplace/bootstrap-challenges/route.ts b/src/app/api/marketplace/bootstrap-challenges/route.ts
new file mode 100644
index 000000000..8d4b50649
--- /dev/null
+++ b/src/app/api/marketplace/bootstrap-challenges/route.ts
@@ -0,0 +1,13 @@
+import type { NextRequest } from 'next/server';
+import { createBrowserChallenge } from '@/server/marketplace-grant/browser-bff';
+import { grantError, noStoreJson } from '@/server/marketplace-grant/http';
+
+export const runtime = 'nodejs';
+
+export async function POST(request: NextRequest) {
+ try {
+ return noStoreJson(await createBrowserChallenge(request), 201);
+ } catch (error) {
+ return grantError(error);
+ }
+}
diff --git a/src/app/api/marketplace/bootstrap-flows/[stateId]/cancel/route.ts b/src/app/api/marketplace/bootstrap-flows/[stateId]/cancel/route.ts
new file mode 100644
index 000000000..d4e03799d
--- /dev/null
+++ b/src/app/api/marketplace/bootstrap-flows/[stateId]/cancel/route.ts
@@ -0,0 +1,17 @@
+import type { NextRequest } from 'next/server';
+import { BOOTSTRAP_FLOW_COOKIE, cancelBrowserFlow } from '@/server/marketplace-grant/browser-bff';
+import { grantError, noStoreEmpty } from '@/server/marketplace-grant/http';
+
+export const runtime = 'nodejs';
+
+export async function POST(request: NextRequest, context: { params: Promise<{ stateId: string }> }) {
+ try {
+ const { stateId } = await context.params;
+ await cancelBrowserFlow(request, request.cookies.get(BOOTSTRAP_FLOW_COOKIE)?.value, stateId);
+ const response = noStoreEmpty(204);
+ response.cookies.delete(BOOTSTRAP_FLOW_COOKIE);
+ return response;
+ } catch (error) {
+ return grantError(error);
+ }
+}
diff --git a/src/app/api/marketplace/bootstrap-flows/[stateId]/status/route.ts b/src/app/api/marketplace/bootstrap-flows/[stateId]/status/route.ts
new file mode 100644
index 000000000..ca904b778
--- /dev/null
+++ b/src/app/api/marketplace/bootstrap-flows/[stateId]/status/route.ts
@@ -0,0 +1,14 @@
+import type { NextRequest } from 'next/server';
+import { BOOTSTRAP_FLOW_COOKIE, pollBrowserFlow } from '@/server/marketplace-grant/browser-bff';
+import { grantError, noStoreJson } from '@/server/marketplace-grant/http';
+
+export const runtime = 'nodejs';
+
+export async function POST(request: NextRequest, context: { params: Promise<{ stateId: string }> }) {
+ try {
+ const { stateId } = await context.params;
+ return noStoreJson(await pollBrowserFlow(request, request.cookies.get(BOOTSTRAP_FLOW_COOKIE)?.value, stateId));
+ } catch (error) {
+ return grantError(error);
+ }
+}
diff --git a/src/components/molecules/GrantSessionRefusal/GrantSessionRefusal.tsx b/src/components/molecules/GrantSessionRefusal/GrantSessionRefusal.tsx
index f35defe4b..fc90fca63 100644
--- a/src/components/molecules/GrantSessionRefusal/GrantSessionRefusal.tsx
+++ b/src/components/molecules/GrantSessionRefusal/GrantSessionRefusal.tsx
@@ -1,7 +1,13 @@
import { Typography } from '@/atoms/Typography/Typography';
+export const GRANT_SESSION_REFUSAL_COPY = {
+ default: 'Bitkit sign-in does not cover this step yet. Sign in with Pubky Ring to continue.',
+ inventory: 'Inventory edits need a Pubky Ring sign-in for now.',
+ messaging: 'Messages need a Pubky Ring sign-in for now.',
+} as const;
+
/** Shown instead of a Pubky Ring approval QR when the Shop session came from a Bitkit sign-in. */
-export function GrantSessionRefusal() {
+export function GrantSessionRefusal({ message = GRANT_SESSION_REFUSAL_COPY.default }: { message?: string }) {
return (
- {'Bitkit sign-in does not cover this step yet. Sign in with Pubky Ring to continue.'}
+ {message}
);
diff --git a/src/components/organisms/Marketplace/MarketplaceGrantSessionRefusal.test.tsx b/src/components/organisms/Marketplace/MarketplaceGrantSessionRefusal.test.tsx
index 3cbc40fa4..dc62bcdf3 100644
--- a/src/components/organisms/Marketplace/MarketplaceGrantSessionRefusal.test.tsx
+++ b/src/components/organisms/Marketplace/MarketplaceGrantSessionRefusal.test.tsx
@@ -55,17 +55,34 @@ describe('classic Pubky Ring approvals refuse a grant session', () => {
state.messagingStart.mockClear();
});
- it('grant session sees refusal not classic qr (inventory grant)', () => {
+ it('grant session sees inventory refusal', () => {
render( );
- expect(screen.getByTestId('grant-session-refusal')).toBeInTheDocument();
+ expect(screen.getByTestId('grant-session-refusal')).toHaveTextContent(
+ 'Inventory edits need a Pubky Ring sign-in for now.',
+ );
+ expect(screen.queryByTestId('qr-auth-url')).not.toBeInTheDocument();
expect(state.inventoryStart).not.toHaveBeenCalled();
});
- it('grant session sees refusal not classic qr (messaging enable)', () => {
+ it('inventory copy names Ring only', () => {
+ state.isGrantSession = false;
+ render( );
+
+ expect(
+ screen.getByText('Approve this grant in Pubky Ring; it does not replace your purchase session.'),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/Bitkit/)).not.toBeInTheDocument();
+ expect(state.inventoryStart).toHaveBeenCalled();
+ });
+
+ it('grant session sees messaging refusal without qr', () => {
render( );
- expect(screen.getByTestId('grant-session-refusal')).toBeInTheDocument();
+ expect(screen.getByTestId('grant-session-refusal')).toHaveTextContent(
+ 'Messages need a Pubky Ring sign-in for now.',
+ );
+ expect(screen.queryByTestId('qr-auth-url')).not.toBeInTheDocument();
expect(state.messagingStart).not.toHaveBeenCalled();
});
diff --git a/src/components/organisms/Marketplace/MarketplaceInventoryGrantDialog.tsx b/src/components/organisms/Marketplace/MarketplaceInventoryGrantDialog.tsx
index 6805c14a7..03cb1f9f8 100644
--- a/src/components/organisms/Marketplace/MarketplaceInventoryGrantDialog.tsx
+++ b/src/components/organisms/Marketplace/MarketplaceInventoryGrantDialog.tsx
@@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogT
import { Typography } from '@/atoms/Typography/Typography';
import { useIsGrantSession } from '@/hooks/useIsGrantSession/useIsGrantSession';
import { useMarketplaceInventoryGrantConnect } from '@/hooks/useMarketplaceInventoryGrantConnect/useMarketplaceInventoryGrantConnect';
-import { GrantSessionRefusal } from '@/molecules/GrantSessionRefusal/GrantSessionRefusal';
+import { GRANT_SESSION_REFUSAL_COPY, GrantSessionRefusal } from '@/molecules/GrantSessionRefusal/GrantSessionRefusal';
import { QrCodeSlot } from '@/molecules/QrCodeSlot/QrCodeSlot';
import { toast } from '@/molecules/Toaster/use-toast';
@@ -68,10 +68,10 @@ export function MarketplaceInventoryGrantDialog({
Approve inventory access
- Approve this grant in Bitkit or Pubky Ring; it does not replace your purchase session.
+ Approve this grant in Pubky Ring; it does not replace your purchase session.
{isGrantSession ? (
-
+
) : grant.status === 'error' ? (
{grant.errorMessage}
@@ -108,7 +108,7 @@ export function MarketplaceInventoryGrantDialog({
disabled={!grant.authorizationUrl || grant.isOpeningSigner}
>
- Open in Bitkit / Ring
+ Open in Pubky Ring
{isGrantSession ? (
-
+
) : enable.status === 'error' ? (
diff --git a/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.test.tsx b/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.test.tsx
index be85fc907..f68100da7 100644
--- a/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.test.tsx
+++ b/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.test.tsx
@@ -16,9 +16,15 @@ const view = vi.hoisted(() => ({
isOpeningRing: false,
requestsFullGrant: true,
requestsGrantReconnect: false,
+ requestsGrantBootstrap: false,
isGrantSession: false,
+ grantEnabled: false,
start: vi.fn(),
}));
+vi.mock('@/libs/runtime-config/runtime-config', async (importOriginal) => ({
+ ...(await importOriginal
()),
+ getMarketplaceGrantFlowEnabled: () => view.grantEnabled,
+}));
vi.mock('@/hooks/useIsGrantSession/useIsGrantSession', () => ({
useIsGrantSession: () => view.isGrantSession,
}));
@@ -30,6 +36,7 @@ vi.mock('@/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect', ()
errorMessage: view.errorMessage,
requestsFullGrant: view.requestsFullGrant,
requestsGrantReconnect: view.requestsGrantReconnect,
+ requestsGrantBootstrap: view.requestsGrantBootstrap,
start: view.start,
cancel: vi.fn(),
copyAuthUrl: vi.fn(async () => {}),
@@ -61,11 +68,13 @@ describe('MarketplaceSessionConnectDialog', () => {
view.isOpeningRing = false;
view.requestsFullGrant = true;
view.requestsGrantReconnect = false;
+ view.requestsGrantBootstrap = false;
view.isGrantSession = false;
+ view.grantEnabled = false;
view.start.mockClear();
});
- it('grant session sees refusal not classic qr', () => {
+ it('grant session sees refusal not classic qr (grant flow off)', () => {
view.status = 'awaiting';
view.authorizationUrl = 'pubkyauth:///?relay=https%3A%2F%2Frelay.example.com%2Finbox&secret=x';
view.isGrantSession = true;
@@ -78,6 +87,40 @@ describe('MarketplaceSessionConnectDialog', () => {
expect(view.start).not.toHaveBeenCalled();
});
+ it('grant session connect uses bootstrap (Bitkit copy, no refusal, flow starts)', () => {
+ view.status = 'awaiting';
+ view.authorizationUrl = 'pubkyauth://signin_grant?caps=%2Fpub%2Fpubky.app%2Fmarketplace-service%2Fv1%2F%3Arw';
+ view.isGrantSession = true;
+ view.grantEnabled = true;
+ view.requestsGrantBootstrap = true;
+ view.requestsFullGrant = false;
+
+ render( );
+
+ expect(view.start).toHaveBeenCalled();
+ expect(screen.queryByTestId('grant-session-refusal')).not.toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Approve purchases in Bitkit' })).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'Approve with Bitkit to connect purchases for the identity signed in to Shop. Nothing is charged until you pay.',
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Open in Bitkit' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /open in pubky ring/i })).not.toBeInTheDocument();
+ expect(screen.getByText('Waiting for approval in Bitkit…')).toBeInTheDocument();
+ });
+
+ it('bootstrap creating state confirms with the homeserver', () => {
+ view.status = 'creating';
+ view.isGrantSession = true;
+ view.grantEnabled = true;
+ view.requestsGrantBootstrap = true;
+
+ render( );
+
+ expect(screen.getByText('Confirming with your homeserver…')).toBeInTheDocument();
+ });
+
it('a cookie session still starts the classic approval when opened', () => {
view.status = 'awaiting';
render( );
diff --git a/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.tsx b/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.tsx
index d470b7cce..6cdf720bd 100644
--- a/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.tsx
+++ b/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.tsx
@@ -53,14 +53,16 @@ export function MarketplaceSessionConnectDialog({
if (autoOpen) setOpen(true);
}, [autoOpen]);
- const isGrantSession = useIsGrantSession();
+ // A Bitkit (grant) sign-in has no AuthToken to redeem; it connects through
+ // the grant bootstrap, so it is refused only where that flow is off.
+ const refusesGrantSession = useIsGrantSession() && !grantFlowEnabled;
useEffect(() => {
if (open) {
- if (!isGrantSession) start();
+ if (!refusesGrantSession) start();
return;
}
cancel();
- }, [open, start, cancel, isGrantSession]);
+ }, [open, start, cancel, refusesGrantSession]);
const copyUrl = async () => {
try {
@@ -77,6 +79,7 @@ export function MarketplaceSessionConnectDialog({
// copy could describe a different approval than the QR requests.
const requestsFullGrant = session.requestsFullGrant;
const requestsGrantReconnect = session.requestsGrantReconnect;
+ const requestsGrantBootstrap = session.requestsGrantBootstrap;
return (
@@ -88,18 +91,26 @@ export function MarketplaceSessionConnectDialog({
- {requestsGrantReconnect ? 'Approve purchases' : 'Approve purchases in Pubky Ring'}
+
+ {requestsGrantBootstrap
+ ? 'Approve purchases in Bitkit'
+ : requestsGrantReconnect
+ ? 'Approve purchases'
+ : 'Approve purchases in Pubky Ring'}
+
- {requestsGrantReconnect
- ? 'Approve with Bitkit or Pubky Ring to reconnect the marketplace session for the identity already signed in to Shop. Nothing is charged until you pay.'
- : requestsFullGrant && !grantFlowEnabled
- ? 'Sign in to Pubky Shop.'
- : 'Approve purchases for this device.'}
+ {requestsGrantBootstrap
+ ? 'Approve with Bitkit to connect purchases for the identity signed in to Shop. Nothing is charged until you pay.'
+ : requestsGrantReconnect
+ ? 'Approve with Bitkit or Pubky Ring to reconnect the marketplace session for the identity already signed in to Shop. Nothing is charged until you pay.'
+ : requestsFullGrant && !grantFlowEnabled
+ ? 'Sign in to Pubky Shop.'
+ : 'Approve purchases for this device.'}
- {isGrantSession ? (
+ {refusesGrantSession ? (
) : ['error', 'mismatch', 'expired', 'cancelled'].includes(session.status) ? (
@@ -141,20 +152,23 @@ export function MarketplaceSessionConnectDialog({
generatingLabel="Generating QR Code..."
clickToReloadLabel="Click to reload"
activeQrHasHoverEffect
+ showRingLogo={!requestsGrantBootstrap}
/>
{session.status === 'awaiting' && (
- Waiting for approval on your signer…
+ {requestsGrantBootstrap ? 'Waiting for approval in Bitkit…' : 'Waiting for approval on your signer…'}
)}
{['creating', 'verifying', 'claiming'].includes(session.status) && (
{session.status === 'creating'
- ? 'Preparing secure approval…'
+ ? requestsGrantBootstrap
+ ? 'Confirming with your homeserver…'
+ : 'Preparing secure approval…'
: session.status === 'verifying'
? 'Verifying approval…'
: 'Connecting marketplace…'}
@@ -175,12 +189,16 @@ export function MarketplaceSessionConnectDialog({
)}
{session.isOpeningRing
- ? requestsGrantReconnect
- ? 'Opening signer...'
- : 'Opening Pubky Ring...'
- : requestsGrantReconnect
- ? 'Open in signer'
- : 'Open in Pubky Ring'}
+ ? requestsGrantBootstrap
+ ? 'Opening Bitkit...'
+ : requestsGrantReconnect
+ ? 'Opening signer...'
+ : 'Opening Pubky Ring...'
+ : requestsGrantBootstrap
+ ? 'Open in Bitkit'
+ : requestsGrantReconnect
+ ? 'Open in signer'
+ : 'Open in Pubky Ring'}
({
+ HomeserverService: { request: vi.fn(), delete: vi.fn() },
+}));
+
+vi.mock('@/libs/logger/logger', () => ({
+ Logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
+}));
+
+vi.mock('@/libs/utils/utils', async (importOriginal) => ({
+ ...(await importOriginal()),
+ sleep: vi.fn().mockResolvedValue(undefined),
+}));
+
+const PUBKY = 'y'.repeat(52);
+const CHALLENGE_ID = '018f4f36-7a61-7d4e-8f22-3e31ed45d2af';
+const STATE_ID = '11111111-1111-4111-8111-111111111111';
+const PROOF_URI = `pubky://${PUBKY}/pub/pubky.app/marketplace/v1/cli-grant-proofs/${CHALLENGE_ID}`;
+const PROOF_DOCUMENT = { aud: 'https://shop.example', challenge_id: CHALLENGE_ID, pubky: PUBKY };
+
+function challenge(overrides: Record = {}) {
+ return {
+ challenge_id: CHALLENGE_ID,
+ expires_at: new Date(Date.now() + 60_000).toISOString(),
+ nonce: 'n'.repeat(43),
+ proof_document: PROOF_DOCUMENT,
+ proof_uri: PROOF_URI,
+ result_cpk: 'c'.repeat(52),
+ result_delivery_id: 'd'.repeat(43),
+ ...overrides,
+ };
+}
+
+const verified = {
+ authorization_url: 'pubkyauth://signin_grant?caps=x',
+ expires_at: new Date(Date.now() + 120_000).toISOString(),
+ state_id: STATE_ID,
+ status: 'awaiting',
+};
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+}
+
+describe('marketplace purchase bootstrap client', () => {
+ const fetchMock = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.stubGlobal('fetch', fetchMock);
+ vi.mocked(HomeserverService.request).mockResolvedValue(undefined as never);
+ vi.mocked(HomeserverService.delete).mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('grant session writes proof document', async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse(challenge(), 201)).mockResolvedValueOnce(jsonResponse(verified));
+
+ const flow = await beginMarketplaceBootstrapFlow({ pubky: PUBKY });
+
+ expect(fetchMock.mock.calls[0][0]).toBe('/api/marketplace/bootstrap-challenges');
+ expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({ pubky: PUBKY });
+ expect(HomeserverService.request).toHaveBeenCalledWith({
+ method: HttpMethod.PUT,
+ url: PROOF_URI,
+ bodyJson: PROOF_DOCUMENT,
+ });
+ expect(fetchMock.mock.calls[1][0]).toBe(`/api/marketplace/bootstrap-challenges/${CHALLENGE_ID}/verify`);
+ expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({ nonce: 'n'.repeat(43) });
+ expect(flow.authorizationUrl).toBe(verified.authorization_url);
+ });
+
+ it('proof deleted after verify', async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse(challenge(), 201)).mockResolvedValueOnce(jsonResponse(verified));
+ await beginMarketplaceBootstrapFlow({ pubky: PUBKY });
+ expect(HomeserverService.delete).toHaveBeenCalledWith(PROOF_URI);
+
+ vi.mocked(HomeserverService.delete).mockClear();
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse(challenge(), 201))
+ .mockResolvedValueOnce(jsonResponse({ error: 'homeserver_proof_invalid' }, 401));
+ await expect(beginMarketplaceBootstrapFlow({ pubky: PUBKY })).rejects.toThrow('homeserver_proof_invalid');
+ expect(HomeserverService.delete).toHaveBeenCalledWith(PROOF_URI);
+ });
+
+ it('proof for a pubky the session cannot write fails', async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse(challenge({ proof_uri: `pubky://${'o'.repeat(52)}/pub/pubky.app/x` }), 201),
+ );
+
+ await expect(beginMarketplaceBootstrapFlow({ pubky: PUBKY })).rejects.toThrow('invalid_request');
+ expect(HomeserverService.request).not.toHaveBeenCalled();
+ });
+
+ it('maps a refused homeserver write to shop_session_expired and never verifies', async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse(challenge(), 201));
+ vi.mocked(HomeserverService.request).mockRejectedValue(
+ new AppError({
+ category: ErrorCategory.Auth,
+ code: AuthErrorCode.SESSION_EXPIRED,
+ message: 'Session expired',
+ service: ErrorService.Homeserver,
+ operation: 'request',
+ }),
+ );
+
+ await expect(beginMarketplaceBootstrapFlow({ pubky: PUBKY })).rejects.toThrow('shop_session_expired');
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(HomeserverService.delete).toHaveBeenCalledWith(PROOF_URI);
+ });
+
+ it('keeps polling while another poll holds the claim, then returns the claimed session', async () => {
+ const claimed = {
+ capabilities: '/pub/pubky.app/marketplace-service/v1/:rw',
+ expires_at: new Date(Date.now() + 3_600_000).toISOString(),
+ pubky: PUBKY,
+ state_id: STATE_ID,
+ status: 'connected',
+ token: 'bearer',
+ };
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse(challenge(), 201))
+ .mockResolvedValueOnce(jsonResponse(verified))
+ .mockResolvedValueOnce(jsonResponse({ state_id: STATE_ID, status: 'awaiting', expires_at: verified.expires_at }))
+ .mockResolvedValueOnce(jsonResponse({ error: 'claim_in_progress' }, 409))
+ .mockResolvedValueOnce(jsonResponse(claimed));
+
+ const flow = await beginMarketplaceBootstrapFlow({ pubky: PUBKY });
+ await expect(flow.awaitResult()).resolves.toMatchObject({ status: 'connected', pubky: PUBKY, token: 'bearer' });
+ expect(fetchMock.mock.calls.slice(2).map((call) => call[0])).toEqual([
+ `/api/marketplace/bootstrap-flows/${STATE_ID}/status`,
+ `/api/marketplace/bootstrap-flows/${STATE_ID}/status`,
+ `/api/marketplace/bootstrap-flows/${STATE_ID}/status`,
+ ]);
+ });
+
+ it('cancel posts to the bootstrap cancel route once', async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse(challenge(), 201))
+ .mockResolvedValueOnce(jsonResponse(verified))
+ .mockResolvedValue(new Response(null, { status: 204 }));
+
+ const flow = await beginMarketplaceBootstrapFlow({ pubky: PUBKY });
+ await flow.cancel();
+ await flow.cancel();
+
+ const cancels = fetchMock.mock.calls.filter((call) => String(call[0]).endsWith('/cancel'));
+ expect(cancels.map((call) => call[0])).toEqual([`/api/marketplace/bootstrap-flows/${STATE_ID}/cancel`]);
+ });
+});
diff --git a/src/core/services/marketplace/marketplace-bootstrap-client.ts b/src/core/services/marketplace/marketplace-bootstrap-client.ts
new file mode 100644
index 000000000..79c846ae0
--- /dev/null
+++ b/src/core/services/marketplace/marketplace-bootstrap-client.ts
@@ -0,0 +1,125 @@
+import { z } from 'zod';
+import { isAppError } from '@/libs/error/error';
+import { ServerErrorCode } from '@/libs/error/error.codes';
+import { Err } from '@/libs/error/error.factories';
+import { ErrorCategory, ErrorService } from '@/libs/error/error.types';
+import { HttpMethod } from '@/libs/http/http.types';
+import { Logger } from '@/libs/logger/logger';
+import { getMarketplaceGrantPollMilliseconds } from '@/libs/runtime-config/runtime-config';
+import { sleep } from '@/libs/utils/utils';
+import { HomeserverService } from '@/services/homeserver/homeserver';
+import type { MarketplaceGrantFlow } from './marketplace-grant-client';
+
+/**
+ * Browser purchase bootstrap for a Bitkit (grant) sign-in. The grant session
+ * proves write access to its own homeserver with a single-use proof document,
+ * the Shop BFF runs the CLI verifier's checks and opens a marketplace
+ * `signin_grant`, and after the Bitkit approval the BFF claims the bearer.
+ */
+const challengeSchema = z.object({
+ challenge_id: z.uuid(),
+ expires_at: z.iso.datetime({ offset: true }),
+ nonce: z.string().min(1),
+ proof_document: z.record(z.string(), z.unknown()),
+ proof_uri: z.string().startsWith('pubky://'),
+ result_cpk: z.string(),
+ result_delivery_id: z.string(),
+});
+const verifySchema = z.object({
+ authorization_url: z.string().startsWith('pubkyauth://signin_grant'),
+ expires_at: z.iso.datetime({ offset: true }),
+ state_id: z.uuid(),
+ status: z.literal('awaiting'),
+});
+const pollSchema = z.object({
+ status: z.enum(['awaiting', 'verifying', 'connected', 'mismatch', 'expired', 'cancelled', 'failed']),
+ state_id: z.uuid().optional(),
+ token: z.string().optional(),
+ pubky: z.string().optional(),
+ capabilities: z.string().optional(),
+ expires_at: z.string().optional(),
+});
+
+function bootstrapFailure(code: string) {
+ return Err.server(ServerErrorCode.SERVICE_UNAVAILABLE, code, {
+ service: ErrorService.Marketplace,
+ operation: 'marketplaceBootstrap',
+ });
+}
+
+async function readJson(response: Response): Promise {
+ const body = (await response.json().catch(() => null)) as unknown;
+ if (!response.ok) {
+ const code =
+ typeof body === 'object' && body !== null && typeof (body as { error?: unknown }).error === 'string'
+ ? (body as { error: string }).error
+ : 'grant_unavailable';
+ throw bootstrapFailure(/^[a-z_]{1,64}$/.test(code) ? code : 'grant_unavailable');
+ }
+ return body;
+}
+
+async function post(path: string, body: unknown): Promise {
+ return await readJson(
+ await fetch(path, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ }),
+ );
+}
+
+export async function beginMarketplaceBootstrapFlow({ pubky }: { pubky: string }): Promise {
+ const challenge = challengeSchema.parse(await post('/api/marketplace/bootstrap-challenges', { pubky }));
+ if (!challenge.proof_uri.startsWith(`pubky://${pubky}/`)) throw bootstrapFailure('invalid_request');
+
+ let verified: z.infer;
+ try {
+ try {
+ await HomeserverService.request({
+ method: HttpMethod.PUT,
+ url: challenge.proof_uri,
+ bodyJson: challenge.proof_document,
+ });
+ } catch (error) {
+ // The grant session could not write its own homeserver: it expired or was revoked.
+ if (isAppError(error) && error.category === ErrorCategory.Auth) throw bootstrapFailure('shop_session_expired');
+ throw error;
+ }
+ verified = verifySchema.parse(
+ await post(`/api/marketplace/bootstrap-challenges/${challenge.challenge_id}/verify`, { nonce: challenge.nonce }),
+ );
+ } finally {
+ // Single-use either way: the verify consumed the challenge or refused it.
+ await HomeserverService.delete(challenge.proof_uri).catch(() => {
+ Logger.warn('Could not delete the purchase bootstrap proof document');
+ });
+ }
+
+ let cancelled = false;
+ return {
+ authorizationUrl: verified.authorization_url,
+ awaitResult: async () => {
+ while (!cancelled && Date.now() < Date.parse(verified.expires_at)) {
+ let result: z.infer;
+ try {
+ result = pollSchema.parse(await post(`/api/marketplace/bootstrap-flows/${verified.state_id}/status`, {}));
+ } catch (error) {
+ // Another poll holds the claim lease; it finishes the approval.
+ if (!(error instanceof Error && error.message === 'claim_in_progress')) throw error;
+ await sleep(getMarketplaceGrantPollMilliseconds());
+ continue;
+ }
+ if (!['awaiting', 'verifying'].includes(result.status)) return result;
+ await sleep(getMarketplaceGrantPollMilliseconds());
+ }
+ throw bootstrapFailure(cancelled ? 'flow_cancelled' : 'flow_expired');
+ },
+ cancel: async () => {
+ if (cancelled) return;
+ cancelled = true;
+ await post(`/api/marketplace/bootstrap-flows/${verified.state_id}/cancel`, {}).catch(() => undefined);
+ },
+ };
+}
diff --git a/src/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect.test.ts b/src/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect.test.ts
index 0dd7ab680..5fca2c788 100644
--- a/src/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect.test.ts
+++ b/src/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect.test.ts
@@ -7,10 +7,12 @@ import { AppError } from '@/libs/error/error';
import { AuthErrorCode } from '@/libs/error/error.codes';
import { ErrorCategory, ErrorService } from '@/libs/error/error.types';
import { copyToClipboard } from '@/libs/utils/utils';
+import { beginMarketplaceBootstrapFlow } from '@/services/marketplace/marketplace-bootstrap-client';
import { beginMarketplaceGrantFlow } from '@/services/marketplace/marketplace-grant-client';
import { MarketplaceSessionService } from '@/services/marketplace/marketplace-session';
import { useAuthStore } from '@/stores/auth/auth.store';
import type { CommerceMarketplaceSession } from '@/stores/commerce/commerce.types';
+import { asOpaque } from '@/test-utils/type-assertions';
import { useMarketplaceSessionConnect } from './useMarketplaceSessionConnect';
vi.mock('@/libs/logger/logger', () => ({
@@ -36,6 +38,10 @@ vi.mock('@/services/marketplace/marketplace-grant-client', () => ({
beginMarketplaceGrantFlow: vi.fn(),
}));
+vi.mock('@/services/marketplace/marketplace-bootstrap-client', () => ({
+ beginMarketplaceBootstrapFlow: vi.fn(),
+}));
+
vi.mock('@/controllers/auth/auth', () => ({
AuthController: {
beginBridgedCommerceSessionFlow: vi.fn(),
@@ -554,4 +560,149 @@ describe('useMarketplaceSessionConnect grant reconnect', () => {
restore();
}
});
+
+ describe('Bitkit (grant) sign-in purchase bootstrap', () => {
+ const OTHER_PUBKY = 'o'.repeat(52);
+
+ function signInWithGrant(pubky = SESSION.pubky) {
+ useAuthStore.setState({
+ currentUserPubky: pubky,
+ session: asOpaque({ grant: {}, info: { publicKey: { z32: () => pubky } } }),
+ });
+ }
+
+ afterEach(() => {
+ useAuthStore.setState({ currentUserPubky: null, session: null });
+ });
+
+ it('grant session connect uses bootstrap', async () => {
+ const restore = await enableGrantFlow();
+ try {
+ signInWithGrant();
+ vi.spyOn(MarketplaceSessionService, 'getActiveSession').mockReturnValue(null);
+ const { grantFlow } = createDeferredGrantFlow('pubkyauth://signin_grant?caps=bootstrap');
+ vi.mocked(beginMarketplaceBootstrapFlow).mockResolvedValue(grantFlow);
+ const { result } = renderHook(() => useMarketplaceSessionConnect());
+
+ expect(result.current.requestsGrantBootstrap).toBe(true);
+ act(() => result.current.start());
+ await waitFor(() => expect(result.current.status).toBe('awaiting'));
+
+ expect(result.current.authorizationUrl).toBe('pubkyauth://signin_grant?caps=bootstrap');
+ expect(result.current.requestsGrantReconnect).toBe(false);
+ expect(beginMarketplaceGrantFlow).not.toHaveBeenCalled();
+ expect(CommerceController.beginMarketplaceSessionConnect).not.toHaveBeenCalled();
+ expect(AuthController.beginBridgedCommerceSessionFlow).not.toHaveBeenCalled();
+ } finally {
+ restore();
+ }
+ });
+
+ it('challenge pubky comes from live grant session', async () => {
+ const restore = await enableGrantFlow();
+ try {
+ signInWithGrant();
+ vi.spyOn(MarketplaceSessionService, 'getActiveSession').mockReturnValue(null);
+ const { grantFlow } = createDeferredGrantFlow('pubkyauth://signin_grant?caps=bootstrap');
+ vi.mocked(beginMarketplaceBootstrapFlow).mockResolvedValue(grantFlow);
+ const { result } = renderHook(() => useMarketplaceSessionConnect());
+
+ act(() => result.current.start());
+ await waitFor(() => expect(beginMarketplaceBootstrapFlow).toHaveBeenCalledTimes(1));
+ expect(beginMarketplaceBootstrapFlow).toHaveBeenCalledWith({ pubky: SESSION.pubky });
+ } finally {
+ restore();
+ }
+ });
+
+ it('a Ring (cookie) sign-in never runs the grant bootstrap', async () => {
+ const restore = await enableGrantFlow();
+ try {
+ useAuthStore.setState({
+ currentUserPubky: SESSION.pubky,
+ session: asOpaque({ info: { publicKey: { z32: () => SESSION.pubky } } }),
+ });
+ vi.spyOn(MarketplaceSessionService, 'getActiveSession').mockReturnValue(null);
+ const { flow } = createDeferredFlow('pubkyauth:///?caps=ring');
+ vi.mocked(CommerceController.beginMarketplaceSessionConnect).mockReturnValue(flow);
+ const { result } = renderHook(() => useMarketplaceSessionConnect());
+
+ expect(result.current.requestsGrantBootstrap).toBe(false);
+ act(() => result.current.start());
+
+ expect(beginMarketplaceBootstrapFlow).not.toHaveBeenCalled();
+ expect(CommerceController.beginMarketplaceSessionConnect).toHaveBeenCalledTimes(1);
+ } finally {
+ restore();
+ }
+ });
+
+ it('browser rejects bearer for another pubky', async () => {
+ const restore = await enableGrantFlow();
+ try {
+ signInWithGrant();
+ vi.spyOn(MarketplaceSessionService, 'getActiveSession').mockReturnValue(null);
+ const establish = vi.spyOn(MarketplaceSessionService, 'establishClaimedGrantSession');
+ const { grantFlow, resolveResult } = createDeferredGrantFlow('pubkyauth://signin_grant?caps=bootstrap');
+ vi.mocked(beginMarketplaceBootstrapFlow).mockResolvedValue(grantFlow);
+ const { result } = renderHook(() => useMarketplaceSessionConnect());
+
+ act(() => result.current.start());
+ await waitFor(() => expect(result.current.status).toBe('awaiting'));
+ resolveResult({
+ status: 'connected',
+ token: 'claimed-token',
+ pubky: OTHER_PUBKY,
+ capabilities: '/pub/pubky.app/marketplace-service/v1/:rw',
+ expires_at: SESSION.expiresAt,
+ });
+
+ await waitFor(() => expect(result.current.status).toBe('mismatch'));
+ expect(establish).not.toHaveBeenCalled();
+ expect(CommerceController.writeMarketplaceSessionStore).not.toHaveBeenCalled();
+ } finally {
+ restore();
+ }
+ });
+
+ it('an ended grant sign-in shows the bootstrap copy, not the AuthToken fallback', async () => {
+ const restore = await enableGrantFlow();
+ try {
+ signInWithGrant();
+ vi.spyOn(MarketplaceSessionService, 'getActiveSession').mockReturnValue(null);
+ vi.mocked(beginMarketplaceBootstrapFlow).mockRejectedValue(new Error('shop_session_expired'));
+ const { result } = renderHook(() => useMarketplaceSessionConnect());
+
+ act(() => result.current.start());
+ await waitFor(() => expect(result.current.status).toBe('error'));
+
+ expect(result.current.errorMessage).toBe('Your Shop session ended. Sign in again.');
+ expect(CommerceController.beginMarketplaceSessionConnect).not.toHaveBeenCalled();
+ } finally {
+ restore();
+ }
+ });
+
+ it('a failed Bitkit approval shows the approval_invalid copy', async () => {
+ const restore = await enableGrantFlow();
+ try {
+ signInWithGrant();
+ vi.spyOn(MarketplaceSessionService, 'getActiveSession').mockReturnValue(null);
+ const failed = {
+ authorizationUrl: 'pubkyauth://signin_grant?caps=bootstrap',
+ awaitResult: vi.fn().mockResolvedValue({ status: 'failed' }),
+ cancel: vi.fn().mockResolvedValue(undefined),
+ };
+ vi.mocked(beginMarketplaceBootstrapFlow).mockResolvedValue(failed);
+ const { result } = renderHook(() => useMarketplaceSessionConnect());
+
+ act(() => result.current.start());
+ await waitFor(() => expect(result.current.status).toBe('error'));
+
+ expect(result.current.errorMessage).toBe('That approval could not be verified. Approve again in Bitkit.');
+ } finally {
+ restore();
+ }
+ });
+ });
});
diff --git a/src/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect.ts b/src/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect.ts
index c426db200..6ad8aaf1f 100644
--- a/src/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect.ts
+++ b/src/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect.ts
@@ -6,6 +6,7 @@ import { AuthController } from '@/controllers/auth/auth';
import { CommerceController } from '@/controllers/commerce/commerce';
import {
MARKETPLACE_FAILURE_MESSAGES,
+ marketplaceBootstrapFailureMessage,
marketplaceErrorCode,
marketplaceFailureMessage,
} from '@/libs/commerce/failure-messages';
@@ -13,6 +14,7 @@ import { Logger } from '@/libs/logger/logger';
import { getMarketplaceGrantFlowEnabled } from '@/libs/runtime-config/runtime-config';
import { copyToClipboard } from '@/libs/utils/utils';
import { AUTH_FLOW_CANCELED_ERROR_NAME } from '@/services/homeserver/error.utils';
+import { beginMarketplaceBootstrapFlow } from '@/services/marketplace/marketplace-bootstrap-client';
import { beginMarketplaceGrantFlow, type MarketplaceGrantFlow } from '@/services/marketplace/marketplace-grant-client';
import { MarketplaceSessionService } from '@/services/marketplace/marketplace-session';
import { useAuthStore } from '@/stores/auth/auth.store';
@@ -37,6 +39,13 @@ type ActiveFlow =
* `start()` first detach the current flow, so a rejection arriving from a
* detached flow is dropped silently instead of being surfaced as a failure.
*/
+/** The signed-in pubky when the Shop session is grant-backed (Bitkit sign-in), else null. */
+function grantSignInPubky(): string | null {
+ const session = useAuthStore.getState().session;
+ if (!session || session.grant === undefined) return null;
+ return session.info.publicKey.z32();
+}
+
export function useMarketplaceSessionConnect(
options: UseMarketplaceSessionConnectOptions = {},
): UseMarketplaceSessionConnectReturn {
@@ -49,6 +58,10 @@ export function useMarketplaceSessionConnect(
const [requestsGrantReconnect, setRequestsGrantReconnect] = useState(
() => getMarketplaceGrantFlowEnabled() && Boolean(MarketplaceSessionService.getActiveSession()),
);
+ const [requestsGrantBootstrap, setRequestsGrantBootstrap] = useState(
+ () =>
+ getMarketplaceGrantFlowEnabled() && grantSignInPubky() !== null && !MarketplaceSessionService.getActiveSession(),
+ );
const activeFlowRef = useRef(null);
const activeGrantFlowRef = useRef(null);
const generationRef = useRef(0);
@@ -168,14 +181,14 @@ export function useMarketplaceSessionConnect(
});
};
- // Reconnect grant cannot mint a first session: BFF createFlow requires a
- // paired cookie. A seller with no marketplace bearer must bootstrap via
- // AuthToken instead of opening a grant that 401s locally as "expired".
- if (grantFlowEnabled && MarketplaceSessionService.getActiveSession()) {
- setRequestsGrantReconnect(true);
+ const runGrantFlow = (
+ begin: () => Promise,
+ failureMessage: (code: string) => string,
+ onSessionMissing?: () => void,
+ ) => {
setAuthorizationUrl('');
setStatus('creating');
- void beginMarketplaceGrantFlow()
+ void begin()
.then(async (grantFlow) => {
if (generationRef.current !== generation) {
await grantFlow.cancel();
@@ -196,6 +209,10 @@ export function useMarketplaceSessionConnect(
if (!expectedPubky) {
throw new Error('grant_invalid_response');
}
+ if (result.pubky !== expectedPubky) {
+ setStatus('mismatch');
+ return;
+ }
const session = MarketplaceSessionService.establishClaimedGrantSession(
{
token: result.token,
@@ -213,22 +230,48 @@ export function useMarketplaceSessionConnect(
if (result.status === 'mismatch') setStatus('mismatch');
else if (result.status === 'expired') setStatus('expired');
else if (result.status === 'cancelled') setStatus('cancelled');
- else setStatus('error');
+ else {
+ setErrorMessage(failureMessage('approval_invalid'));
+ setStatus('error');
+ }
})
.catch((error: unknown) => {
if (generationRef.current !== generation) return;
activeGrantFlowRef.current = null;
setAuthorizationUrl('');
const code = error instanceof Error ? error.message : '';
- if (code === 'shop_session_missing' || code === 'shop_session_expired') {
+ if ((code === 'shop_session_missing' || code === 'shop_session_expired') && onSessionMissing) {
Logger.warn('Marketplace grant reconnect needs a session; starting AuthToken connect', { code });
- startAuthTokenConnect();
+ onSessionMissing();
return;
}
Logger.error('Marketplace grant flow failed', { error });
- setErrorMessage(marketplaceFailureMessage(code, MARKETPLACE_FAILURE_MESSAGES.sessionStart));
+ setErrorMessage(failureMessage(code));
setStatus('error');
});
+ };
+
+ // A Bitkit (grant) sign-in carries no AuthToken to redeem: its purchase
+ // session comes from the browser bootstrap, a second Bitkit approval.
+ const bootstrapPubky = grantSignInPubky();
+ if (grantFlowEnabled && bootstrapPubky && !MarketplaceSessionService.getActiveSession()) {
+ setRequestsGrantReconnect(false);
+ setRequestsGrantBootstrap(true);
+ runGrantFlow(() => beginMarketplaceBootstrapFlow({ pubky: bootstrapPubky }), marketplaceBootstrapFailureMessage);
+ return;
+ }
+
+ // Reconnect grant cannot mint a first session: BFF createFlow requires a
+ // paired cookie. A seller with no marketplace bearer must bootstrap via
+ // AuthToken instead of opening a grant that 401s locally as "expired".
+ if (grantFlowEnabled && MarketplaceSessionService.getActiveSession()) {
+ setRequestsGrantBootstrap(false);
+ setRequestsGrantReconnect(true);
+ runGrantFlow(
+ beginMarketplaceGrantFlow,
+ (code) => marketplaceFailureMessage(code, MARKETPLACE_FAILURE_MESSAGES.sessionStart),
+ startAuthTokenConnect,
+ );
return;
}
@@ -280,6 +323,7 @@ export function useMarketplaceSessionConnect(
errorMessage,
requestsFullGrant,
requestsGrantReconnect,
+ requestsGrantBootstrap,
start,
cancel,
copyAuthUrl,
diff --git a/src/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect.types.ts b/src/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect.types.ts
index d853af72e..29641929d 100644
--- a/src/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect.types.ts
+++ b/src/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect.types.ts
@@ -56,6 +56,12 @@ export interface UseMarketplaceSessionConnectReturn {
* must render this instead of re-reading the grant flag.
*/
requestsGrantReconnect: boolean;
+ /**
+ * True while `start()` is running (or about to run) the browser bootstrap:
+ * a Bitkit (grant) sign-in with no marketplace bearer yet. Its approval is a
+ * Bitkit grant, so the dialog shows Bitkit copy, never the Ring prompt.
+ */
+ requestsGrantBootstrap: boolean;
/** Begins a fresh flow, cancelling any in-flight one. */
start: () => void;
/** Cancels the in-flight flow (frees it) and returns to `idle`. */
diff --git a/src/libs/commerce/failure-messages.test.ts b/src/libs/commerce/failure-messages.test.ts
index 321d8efe2..a4e48e40d 100644
--- a/src/libs/commerce/failure-messages.test.ts
+++ b/src/libs/commerce/failure-messages.test.ts
@@ -5,6 +5,7 @@ import { ClientErrorCode, ServerErrorCode, ValidationErrorCode } from '@/libs/er
import { ErrorCategory, ErrorService } from '@/libs/error/error.types';
import {
MARKETPLACE_FAILURE_MESSAGES,
+ marketplaceBootstrapFailureMessage,
marketplaceCheckoutRefusalMessage,
marketplaceDropRefusalMessage,
marketplaceFailureMessage,
@@ -288,3 +289,41 @@ describe('marketplaceOfferFailureMessage', () => {
);
});
});
+
+describe('Bitkit purchase bootstrap reason codes', () => {
+ it.each([
+ ['origin_denied', 'This request did not come from the Shop. Reload and try again.'],
+ ['invalid_request', 'Something went wrong. Try again.'],
+ ['grant_unavailable', 'Bitkit approvals are unavailable right now. Try again later.'],
+ ['retry_later', 'Too many attempts. Wait a minute and try again.'],
+ ['challenge_not_found', 'This approval expired. Start again.'],
+ ['challenge_consumed', 'This approval was already used. Start again.'],
+ ['homeserver_proof_invalid', 'Your homeserver could not confirm this sign-in. Start again.'],
+ ['flow_expired', 'This approval expired. Start again.'],
+ ['flow_cancelled', 'Approval cancelled.'],
+ ['result_denied', 'This approval could not be completed. Start again.'],
+ [
+ 'identity_mismatch',
+ "This approval came from a different account. Approve with the account you're signed in with.",
+ ],
+ ['fresh_approval_required', 'Approve again in Bitkit.'],
+ ['flow_binding_missing', 'This approval belongs to another tab. Start again here.'],
+ ['flow_binding_denied', 'This approval belongs to another tab. Start again here.'],
+ ['flow_not_found', 'This approval expired. Start again.'],
+ ['claim_in_progress', 'Finishing your approval…'],
+ ['shop_session_expired', 'Your Shop session ended. Sign in again.'],
+ ['approval_invalid', 'That approval could not be verified. Approve again in Bitkit.'],
+ ])('bootstrap reason code %s maps to copy', (code, copy) => {
+ expect(marketplaceBootstrapFailureMessage(code)).toBe(copy);
+ });
+
+ it('keeps the reconnect copy for shop_session_expired outside the bootstrap', () => {
+ expect(marketplaceFailureMessage('shop_session_expired', MARKETPLACE_FAILURE_MESSAGES.sessionStart)).toBe(
+ MARKETPLACE_FAILURE_MESSAGES.sessionCookieExpired,
+ );
+ });
+
+ it('an unknown bootstrap code falls back to static copy, never the code', () => {
+ expect(marketplaceBootstrapFailureMessage('something_new')).toBe(MARKETPLACE_FAILURE_MESSAGES.sessionStart);
+ });
+});
diff --git a/src/libs/commerce/failure-messages.ts b/src/libs/commerce/failure-messages.ts
index 39107c6c6..d284d8d14 100644
--- a/src/libs/commerce/failure-messages.ts
+++ b/src/libs/commerce/failure-messages.ts
@@ -108,6 +108,42 @@ const CHECKOUT_REFUSAL_MESSAGES: ReadonlyMap = new Map([
],
]);
+/**
+ * Copy for the Bitkit purchase bootstrap. `shop_session_expired` here means
+ * the grant sign-in itself ended (its homeserver write was refused), not a
+ * marketplace cookie, so it must not use the reconnect copy.
+ */
+const BOOTSTRAP_APPROVAL_EXPIRED = 'This approval expired. Start again.';
+const BOOTSTRAP_OTHER_TAB = 'This approval belongs to another tab. Start again here.';
+
+export const MARKETPLACE_BOOTSTRAP_CODE_MESSAGES: ReadonlyMap = new Map([
+ ['origin_denied', 'This request did not come from the Shop. Reload and try again.'],
+ ['invalid_request', 'Something went wrong. Try again.'],
+ ['grant_unavailable', 'Bitkit approvals are unavailable right now. Try again later.'],
+ ['retry_later', 'Too many attempts. Wait a minute and try again.'],
+ ['challenge_not_found', BOOTSTRAP_APPROVAL_EXPIRED],
+ ['challenge_consumed', 'This approval was already used. Start again.'],
+ ['homeserver_proof_invalid', 'Your homeserver could not confirm this sign-in. Start again.'],
+ ['flow_expired', BOOTSTRAP_APPROVAL_EXPIRED],
+ ['flow_cancelled', 'Approval cancelled.'],
+ ['result_denied', 'This approval could not be completed. Start again.'],
+ ['identity_mismatch', "This approval came from a different account. Approve with the account you're signed in with."],
+ ['fresh_approval_required', 'Approve again in Bitkit.'],
+ ['flow_binding_missing', BOOTSTRAP_OTHER_TAB],
+ ['flow_binding_denied', BOOTSTRAP_OTHER_TAB],
+ ['flow_not_found', BOOTSTRAP_APPROVAL_EXPIRED],
+ ['claim_in_progress', 'Finishing your approval…'],
+ ['shop_session_expired', 'Your Shop session ended. Sign in again.'],
+ ['approval_invalid', 'That approval could not be verified. Approve again in Bitkit.'],
+]);
+
+export function marketplaceBootstrapFailureMessage(code: MarketplaceFailureCode): string {
+ return (
+ (code && MARKETPLACE_BOOTSTRAP_CODE_MESSAGES.get(code)) ||
+ marketplaceFailureMessage(code, MARKETPLACE_FAILURE_MESSAGES.sessionStart)
+ );
+}
+
export function marketplaceCheckoutRefusalMessage(code: MarketplaceFailureCode, message: unknown): string | null {
if (typeof code !== 'string' || typeof message !== 'string') return null;
return CHECKOUT_REFUSAL_MESSAGES.get(`${code}:${message}`) ?? null;
diff --git a/src/server/marketplace-grant/browser-bff.test.ts b/src/server/marketplace-grant/browser-bff.test.ts
new file mode 100644
index 000000000..fca26a3ab
--- /dev/null
+++ b/src/server/marketplace-grant/browser-bff.test.ts
@@ -0,0 +1,779 @@
+/** @vitest-environment node */
+import { randomUUID } from 'node:crypto';
+import { readdirSync } from 'node:fs';
+import path from 'node:path';
+import { hkdf } from '@noble/hashes/hkdf.js';
+import { sha256 } from '@noble/hashes/sha2.js';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { BffError } from './bff';
+import { type CliGrantConfig, resetMarketplaceGrantConfigForTests } from './config';
+import {
+ BrowserContextRefused,
+ browserSeedKeyForTests,
+ deriveBrowserBootstrap,
+ encodeBase64Url,
+ hashBoundCookie,
+ hashCliDeliveryId,
+ hashCliToken,
+ makeBoundCookie,
+ openBrowserFlowContext,
+ sealBrowserFlowContext,
+ sealCliFlowContext,
+ sha256Bytes,
+} from './crypto';
+
+const insertCliChallenge = vi.fn();
+const consumeCliRateLimit = vi.fn();
+const getCliChallenge = vi.fn();
+const consumeChallengeAndInsertCliFlow = vi.fn();
+const bindCliFlow = vi.fn();
+const terminalizeCliFlow = vi.fn();
+const getCliFlow = vi.fn();
+const acquireCliClaim = vi.fn();
+const completeCliClaim = vi.fn();
+const abandonCliClaim = vi.fn();
+const renewCliClaim = vi.fn();
+const assertCliGrantSchema = vi.fn();
+const fetchHomeserverProofDocument = vi.fn();
+const createBootstrapFlow = vi.fn();
+const getGrantStatus = vi.fn();
+const claimGrantResult = vi.fn();
+const ticketGrantResult = vi.fn();
+const cancelGrant = vi.fn();
+
+vi.mock('./db', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ assertCliGrantSchema: (...args: unknown[]) => assertCliGrantSchema(...args),
+ insertCliChallenge: (...args: unknown[]) => insertCliChallenge(...args),
+ consumeCliRateLimit: (...args: unknown[]) => consumeCliRateLimit(...args),
+ getCliChallenge: (...args: unknown[]) => getCliChallenge(...args),
+ consumeChallengeAndInsertCliFlow: (...args: unknown[]) => consumeChallengeAndInsertCliFlow(...args),
+ bindCliFlow: (...args: unknown[]) => bindCliFlow(...args),
+ terminalizeCliFlow: (...args: unknown[]) => terminalizeCliFlow(...args),
+ getCliFlow: (...args: unknown[]) => getCliFlow(...args),
+ acquireCliClaim: (...args: unknown[]) => acquireCliClaim(...args),
+ completeCliClaim: (...args: unknown[]) => completeCliClaim(...args),
+ abandonCliClaim: (...args: unknown[]) => abandonCliClaim(...args),
+ renewCliClaim: (...args: unknown[]) => renewCliClaim(...args),
+ };
+});
+
+vi.mock('./homeserver-proof', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ fetchHomeserverProofDocument: (...args: unknown[]) => fetchHomeserverProofDocument(...args),
+ };
+});
+
+vi.mock('./service', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ createBootstrapFlow: (...args: unknown[]) => createBootstrapFlow(...args),
+ getGrantStatus: (...args: unknown[]) => getGrantStatus(...args),
+ claimGrantResult: (...args: unknown[]) => claimGrantResult(...args),
+ ticketGrantResult: (...args: unknown[]) => ticketGrantResult(...args),
+ cancelGrant: (...args: unknown[]) => cancelGrant(...args),
+ };
+});
+
+const ENV = { ...process.env };
+const ORIGIN = 'https://shop.example';
+const pubky = 'yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy';
+const otherPubky = 'o1gg8yc7mj4ksrzr6ms3s5rs8h7bo8y7ohcq7j88wbkm7ns7tuxo';
+const challengeId = '018f4f36-7a61-7d4e-8f22-3e31ed45d2af';
+const STATE_KEY_1 = Buffer.alloc(32, 3).toString('base64');
+const STATE_KEY_2 = Buffer.alloc(32, 5).toString('base64');
+const STATE_KEY_3 = Buffer.alloc(32, 7).toString('base64');
+const ASSERTION_SEED_HEX = '11'.repeat(32);
+const REQUEST_SEED_HEX = '22'.repeat(32);
+
+function grantEnv(overrides: Record = {}): void {
+ process.env = {
+ ...ENV,
+ SHOP_BFF_GRANT_FLOW_ENABLED: 'true',
+ SHOP_ALLOWED_ORIGINS: `["${ORIGIN}"]`,
+ SHOP_PUBLIC_ORIGIN: ORIGIN,
+ MARKETPLACE_SERVICE_URL: 'https://service.example',
+ SHOP_BFF_GRANT_STATE_DATABASE_URL: 'postgres://example',
+ CRON_SECRET: 'c'.repeat(32),
+ SHOP_GRANT_ASSERTION_ISSUER: ORIGIN,
+ SHOP_GRANT_ASSERTION_KEY_ID: 'shop-bff-test-0001',
+ SHOP_GRANT_ASSERTION_KEY_EPOCH: '1',
+ SHOP_GRANT_ASSERTION_SIGNING_KEY: ASSERTION_SEED_HEX,
+ MARKETPLACE_SERVICE_REQUEST_KEY_ID: 'shop-bff-request-test-0001',
+ MARKETPLACE_SERVICE_REQUEST_KEY_EPOCH: '1',
+ MARKETPLACE_SERVICE_REQUEST_SIGNING_KEY: REQUEST_SEED_HEX,
+ SHOP_BFF_GRANT_STATE_ENCRYPTION_KEY_B64: STATE_KEY_1,
+ SHOP_BFF_GRANT_STATE_KEY_EPOCH: '1',
+ };
+ delete process.env.VERCEL;
+ delete process.env.SHOP_BFF_CLI_GRANT_ENABLED;
+ for (const [key, value] of Object.entries(overrides)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ resetMarketplaceGrantConfigForTests();
+}
+
+function rotateTo(epoch: number, key: string, previous?: { epoch: number; key: string }): void {
+ process.env.SHOP_BFF_GRANT_STATE_ENCRYPTION_KEY_B64 = key;
+ process.env.SHOP_BFF_GRANT_STATE_KEY_EPOCH = String(epoch);
+ if (previous) {
+ process.env.SHOP_BFF_GRANT_STATE_PREVIOUS_ENCRYPTION_KEY_B64 = previous.key;
+ process.env.SHOP_BFF_GRANT_STATE_PREVIOUS_KEY_EPOCH = String(previous.epoch);
+ } else {
+ delete process.env.SHOP_BFF_GRANT_STATE_PREVIOUS_ENCRYPTION_KEY_B64;
+ delete process.env.SHOP_BFF_GRANT_STATE_PREVIOUS_KEY_EPOCH;
+ }
+ resetMarketplaceGrantConfigForTests();
+}
+
+async function browserConfig(): Promise {
+ const { getBrowserBootstrapConfig } = await import('./config');
+ const config = getBrowserBootstrapConfig();
+ if (!config) throw new Error('browser bootstrap config missing');
+ return config;
+}
+
+function jsonRequest(url: string, body: unknown, headers: Record = {}): Request {
+ return new Request(url, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', origin: ORIGIN, ...headers },
+ body: JSON.stringify(body),
+ });
+}
+
+const challengeRequest = (body: unknown = { pubky }, headers: Record = {}) =>
+ jsonRequest(`${ORIGIN}/api/marketplace/bootstrap-challenges`, body, headers);
+const verifyRequest = (nonce: string) =>
+ jsonRequest(`${ORIGIN}/api/marketplace/bootstrap-challenges/${challengeId}/verify`, { nonce });
+const flowRequest = (stateId: string, headers: Record = {}) =>
+ jsonRequest(`${ORIGIN}/api/marketplace/bootstrap-flows/${stateId}/status`, {}, headers);
+
+type StoredChallenge = {
+ challenge_id: string;
+ pubky: string;
+ result_cpk: string;
+ result_delivery_id_hash: Uint8Array;
+ nonce_hash: Uint8Array;
+ consumed_at: Date | null;
+ created_at: Date;
+ expires_at: Date;
+};
+
+/** Mirrors `insertCliChallenge` into `getCliChallenge`, the way the table would. */
+function storeInsertedChallenges(): Map {
+ const rows = new Map();
+ insertCliChallenge.mockImplementation(async (_config: unknown, row: Record) => {
+ const createdAt = new Date(Math.floor(Date.now() / 1000) * 1000);
+ rows.set(String(row.challengeId), {
+ challenge_id: String(row.challengeId),
+ pubky: String(row.pubky),
+ result_cpk: String(row.resultCpk),
+ result_delivery_id_hash: row.resultDeliveryIdHash as Uint8Array,
+ nonce_hash: row.nonceHash as Uint8Array,
+ consumed_at: null,
+ created_at: createdAt,
+ expires_at: row.expiresAt as Date,
+ });
+ });
+ getCliChallenge.mockImplementation(async (_config: unknown, id: string) => rows.get(id) ?? null);
+ return rows;
+}
+
+/** A challenge row as the browser route stores it for `challengeId`. */
+function browserChallengeRow(config: CliGrantConfig, epoch = config.stateKeyEpoch, nonce = new Uint8Array(32).fill(9)) {
+ const derived = deriveBrowserBootstrap(config, epoch, challengeId);
+ const createdAt = new Date(Math.floor(Date.now() / 1000) * 1000);
+ return {
+ derived,
+ nonce,
+ row: {
+ challenge_id: challengeId,
+ pubky,
+ result_cpk: derived.resultCpk,
+ result_delivery_id_hash: hashCliDeliveryId(config, epoch, challengeId, derived.resultDeliveryId),
+ nonce_hash: sha256Bytes(nonce),
+ consumed_at: null,
+ created_at: createdAt,
+ expires_at: new Date(createdAt.getTime() + 60_000),
+ } satisfies StoredChallenge,
+ };
+}
+
+function browserFlowRow(
+ config: CliGrantConfig,
+ opts: { tokenHash?: Uint8Array; contextSealed?: Uint8Array; status?: string; epoch?: number } = {},
+) {
+ const stateId = randomUUID();
+ const bound = makeBoundCookie(stateId);
+ const epoch = opts.epoch ?? config.stateKeyEpoch;
+ const derived = deriveBrowserBootstrap(config, epoch, challengeId);
+ const row = {
+ state_id: stateId,
+ challenge_id: challengeId,
+ flow_id: randomUUID(),
+ pubky,
+ result_cpk: derived.resultCpk,
+ token_hash: opts.tokenHash ?? hashBoundCookie(config, epoch, 'flow', stateId, bound.secret),
+ context_sealed:
+ opts.contextSealed ??
+ sealBrowserFlowContext(config, stateId, pubky, {
+ kind: 'browser',
+ resultDeliveryId: encodeBase64Url(derived.resultDeliveryId),
+ resultPopSeed: encodeBase64Url(derived.resultPopSeed),
+ version: 2,
+ }),
+ result_token_sealed: null,
+ key_epoch: epoch,
+ status: opts.status ?? 'awaiting',
+ lease_owner: null,
+ lease_until: null,
+ version: '1',
+ created_at: new Date(),
+ expires_at: new Date(Date.now() + 60_000),
+ terminal_at: null,
+ };
+ return { bound, derived, row, stateId };
+}
+
+function completeServiceFlow(flowId: string) {
+ getGrantStatus.mockResolvedValue({
+ expires_at: new Date(Date.now() + 60_000).toISOString(),
+ flow_id: flowId,
+ status: 'complete',
+ terminal_code: null,
+ });
+}
+
+const claimedSession = (claimedPubky = pubky) => ({
+ capabilities: '/pub/pubky.app/marketplace-service/v1/:rw',
+ expires_at: new Date(Date.now() + 3_600_000).toISOString(),
+ pubky: claimedPubky,
+ token: 'marketplace-bearer',
+});
+
+function secretForms(bytes: Uint8Array): string[] {
+ const buffer = Buffer.from(bytes);
+ return [buffer.toString('hex'), buffer.toString('base64'), buffer.toString('base64url')];
+}
+
+describe('browser purchase bootstrap BFF', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ consumeCliRateLimit.mockResolvedValue(true);
+ assertCliGrantSchema.mockResolvedValue(undefined);
+ insertCliChallenge.mockResolvedValue(undefined);
+ bindCliFlow.mockResolvedValue(true);
+ consumeChallengeAndInsertCliFlow.mockResolvedValue(undefined);
+ completeCliClaim.mockResolvedValue(true);
+ renewCliClaim.mockResolvedValue(true);
+ abandonCliClaim.mockResolvedValue(undefined);
+ terminalizeCliFlow.mockResolvedValue(undefined);
+ createBootstrapFlow.mockResolvedValue({
+ authorization_url: 'pubkyauth://signin_grant?caps=x',
+ expires_at: new Date(Date.now() + 120_000).toISOString(),
+ flow_id: randomUUID(),
+ status: 'awaiting',
+ });
+ grantEnv();
+ });
+
+ afterEach(() => {
+ process.env = { ...ENV };
+ resetMarketplaceGrantConfigForTests();
+ vi.restoreAllMocks();
+ });
+
+ // A1
+ it('browser challenge stores derived result_cpk and delivery hash', async () => {
+ const rows = storeInsertedChallenges();
+ const { createBrowserChallenge } = await import('./browser-bff');
+ const config = await browserConfig();
+
+ const created = await createBrowserChallenge(challengeRequest());
+ const derived = deriveBrowserBootstrap(config, 1, created.challenge_id);
+ const stored = rows.get(created.challenge_id)!;
+
+ expect(stored.result_cpk).toBe(derived.resultCpk);
+ expect(Buffer.from(stored.result_delivery_id_hash)).toEqual(
+ Buffer.from(hashCliDeliveryId(config, 1, created.challenge_id, derived.resultDeliveryId)),
+ );
+ expect(created.result_cpk).toBe(derived.resultCpk);
+ expect(created.result_delivery_id).toBe(encodeBase64Url(derived.resultDeliveryId));
+ expect(created.proof_uri).toBe(
+ `pubky://${pubky}/pub/pubky.app/marketplace/v1/cli-grant-proofs/${created.challenge_id}`,
+ );
+ expect(created.proof_document).toMatchObject({
+ aud: ORIGIN,
+ challenge_id: created.challenge_id,
+ pubky,
+ result_cpk: derived.resultCpk,
+ result_delivery_id: created.result_delivery_id,
+ });
+ });
+
+ // A2
+ it('result seed never leaves bff', async () => {
+ const rows = storeInsertedChallenges();
+ const logged: unknown[] = [];
+ for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) {
+ vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
+ logged.push(args);
+ });
+ }
+ const { createBrowserChallenge, verifyBrowserChallenge, pollBrowserFlow } = await import('./browser-bff');
+ const { grantError } = await import('./http');
+ const config = await browserConfig();
+
+ const created = await createBrowserChallenge(challengeRequest());
+ expect(Object.keys(created).sort()).toEqual([
+ 'challenge_id',
+ 'expires_at',
+ 'nonce',
+ 'proof_document',
+ 'proof_uri',
+ 'result_cpk',
+ 'result_delivery_id',
+ ]);
+ const seed = deriveBrowserBootstrap(config, 1, created.challenge_id).resultPopSeed;
+ fetchHomeserverProofDocument.mockResolvedValue(created.proof_document);
+ const verifyUrl = `${ORIGIN}/api/marketplace/bootstrap-challenges/${created.challenge_id}/verify`;
+ const verified = await verifyBrowserChallenge(
+ jsonRequest(verifyUrl, { nonce: created.nonce }),
+ created.challenge_id,
+ );
+ expect(Object.keys(verified.response).sort()).toEqual(['authorization_url', 'expires_at', 'state_id', 'status']);
+
+ const flowInsert = consumeChallengeAndInsertCliFlow.mock.calls[0][2] as Record;
+ const stateId = String(flowInsert.stateId);
+ const flow = {
+ state_id: stateId,
+ challenge_id: created.challenge_id,
+ flow_id: randomUUID(),
+ pubky,
+ result_cpk: created.result_cpk,
+ token_hash: flowInsert.tokenHash,
+ context_sealed: flowInsert.contextSealed,
+ result_token_sealed: null,
+ key_epoch: 1,
+ status: 'awaiting',
+ lease_owner: null,
+ lease_until: null,
+ version: '1',
+ created_at: new Date(),
+ expires_at: new Date(Date.now() + 60_000),
+ terminal_at: null,
+ };
+ getCliFlow.mockResolvedValue(flow);
+ acquireCliClaim.mockResolvedValue(flow);
+ completeServiceFlow(flow.flow_id);
+ claimGrantResult.mockResolvedValue(claimedSession());
+ const polled = await pollBrowserFlow(flowRequest(stateId), verified.cookie, stateId);
+ expect(polled.status).toBe('connected');
+ expect(Buffer.from(claimGrantResult.mock.calls[0][3] as Uint8Array)).toEqual(Buffer.from(seed));
+
+ // A refusal body goes through the same logger path.
+ const refusal = grantError(new BffError(409, 'fresh_approval_required'));
+ const refusalBody = await refusal.json();
+
+ const outbound = JSON.stringify({
+ created,
+ proofDocument: created.proof_document,
+ verified,
+ polled,
+ refusalBody,
+ logged,
+ serviceBootstrapArgs: createBootstrapFlow.mock.calls,
+ });
+ for (const form of secretForms(seed)) expect(outbound).not.toContain(form);
+ expect(rows.size).toBe(1);
+ });
+
+ // A3
+ it('verify refuses a challenge after a state key rotation', async () => {
+ storeInsertedChallenges();
+ const { createBrowserChallenge, verifyBrowserChallenge } = await import('./browser-bff');
+ const created = await createBrowserChallenge(challengeRequest());
+
+ rotateTo(2, STATE_KEY_2, { epoch: 1, key: STATE_KEY_1 });
+ const verifyUrl = `${ORIGIN}/api/marketplace/bootstrap-challenges/${created.challenge_id}/verify`;
+ await expect(
+ verifyBrowserChallenge(jsonRequest(verifyUrl, { nonce: created.nonce }), created.challenge_id),
+ ).rejects.toEqual(new BffError(409, 'fresh_approval_required'));
+ expect(fetchHomeserverProofDocument).not.toHaveBeenCalled();
+ expect(consumeChallengeAndInsertCliFlow).not.toHaveBeenCalled();
+ expect(createBootstrapFlow).not.toHaveBeenCalled();
+ });
+
+ // A4
+ it('claim opens a context sealed under the previous epoch', async () => {
+ const epoch1 = await browserConfig();
+ const { bound, derived, row, stateId } = browserFlowRow(epoch1);
+ rotateTo(2, STATE_KEY_2, { epoch: 1, key: STATE_KEY_1 });
+ getCliFlow.mockResolvedValue(row);
+ acquireCliClaim.mockResolvedValue(row);
+ completeServiceFlow(row.flow_id);
+ claimGrantResult.mockResolvedValue(claimedSession());
+ const { pollBrowserFlow } = await import('./browser-bff');
+
+ await expect(pollBrowserFlow(flowRequest(stateId), bound.value, stateId)).resolves.toMatchObject({
+ status: 'connected',
+ pubky,
+ });
+ expect(claimGrantResult).toHaveBeenCalledWith(
+ expect.anything(),
+ row.flow_id,
+ encodeBase64Url(derived.resultDeliveryId),
+ derived.resultPopSeed,
+ expect.any(Function),
+ );
+ });
+
+ it('claim fails closed when the sealing epoch is gone', async () => {
+ const epoch1 = await browserConfig();
+ const { bound, row, stateId } = browserFlowRow(epoch1);
+ rotateTo(3, STATE_KEY_3, { epoch: 2, key: STATE_KEY_2 });
+ getCliFlow.mockResolvedValue(row);
+ acquireCliClaim.mockResolvedValue(row);
+ completeServiceFlow(row.flow_id);
+ const { pollBrowserFlow } = await import('./browser-bff');
+
+ await expect(pollBrowserFlow(flowRequest(stateId), bound.value, stateId)).rejects.toEqual(
+ new BffError(422, 'fresh_approval_required'),
+ );
+ expect(claimGrantResult).not.toHaveBeenCalled();
+ const current = await browserConfig();
+ expect(() => openBrowserFlowContext(current, stateId, pubky, 1, row.context_sealed)).toThrow(
+ new BrowserContextRefused('epoch_unavailable'),
+ );
+ });
+
+ // A5
+ it('browser seed key differs from every existing BFF key for the same epoch', async () => {
+ const config = await browserConfig();
+ const root = Uint8Array.from(Buffer.from(STATE_KEY_1, 'base64'));
+ const utf8 = new TextEncoder();
+ const epochBytes = Uint8Array.of(0, 1);
+ const info = (label: string) => Uint8Array.from([...utf8.encode(label), ...epochBytes]);
+ const stateSalt = utf8.encode('marketplace/shop-bff-state/hkdf-salt/v1');
+ const existing = {
+ sessionCookie: hkdf(sha256, root, stateSalt, info('marketplace/shop-bff-state/session-cookie-key/v1'), 32),
+ flowCookie: hkdf(sha256, root, stateSalt, info('marketplace/shop-bff-state/flow-cookie-key/v1'), 32),
+ seal: hkdf(sha256, root, stateSalt, info('marketplace/shop-bff-state/seal-key/v1'), 32),
+ cliToken: hkdf(
+ sha256,
+ root,
+ utf8.encode('shop-bff/cli-flow-token/hkdf-salt/v1'),
+ info('shop-bff/cli-flow-token/token-key/v1'),
+ 32,
+ ),
+ cliDelivery: hkdf(
+ sha256,
+ root,
+ utf8.encode('shop-bff/cli-challenge/hkdf-salt/v1'),
+ info('shop-bff/cli-challenge/delivery-key/v1'),
+ 32,
+ ),
+ };
+ const hex = (bytes: Uint8Array) => Buffer.from(bytes).toString('hex');
+ const seedKey = hex(browserSeedKeyForTests(config, 1));
+ for (const key of Object.values(existing)) expect(hex(key)).not.toBe(seedKey);
+
+ const derived = deriveBrowserBootstrap(config, 1, challengeId);
+ const raw = [hex(root), ASSERTION_SEED_HEX, REQUEST_SEED_HEX, ...Object.values(existing).map(hex)];
+ for (const output of [hex(derived.resultPopSeed), hex(derived.resultDeliveryId)]) {
+ expect(raw).not.toContain(output);
+ }
+ expect(hex(derived.resultPopSeed)).not.toBe(hex(derived.resultDeliveryId));
+ expect(deriveBrowserBootstrap(config, 1, randomUUID()).resultCpk).not.toBe(derived.resultCpk);
+
+ // Known-answer vector, computed with an independent HKDF/HMAC implementation
+ // for state key 0x03×32, epoch 1 and the fixed challenge id.
+ expect(seedKey).toBe('a72aa8ce19621199841f1658d6cff9c5eb4645845143c9bffb745793f6003817');
+ expect(hex(derived.resultPopSeed)).toBe('837f6142178ab22ae96c2bf7e30a9d124e9eeab49f2d94df0994dbfe35be1c4c');
+ expect(hex(derived.resultDeliveryId)).toBe('9f0f3139a127cc702c3275a7a644892eac828b9cf4aa112e0cf5fbf2dace6e71');
+ });
+
+ // A6
+ it('cli routes refuse a browser bootstrap flow', async () => {
+ grantEnv({ SHOP_BFF_CLI_GRANT_ENABLED: 'true' });
+ const config = await browserConfig();
+ const { bound, row, stateId } = browserFlowRow(config);
+ getCliFlow.mockResolvedValue(row);
+ const { cliFlowStatus } = await import('./cli-bff');
+
+ await expect(
+ cliFlowStatus(flowRequest(stateId, { authorization: `PubkyShopCli ${bound.value}` }), stateId),
+ ).rejects.toEqual(new BffError(401, 'cli_token_denied'));
+ expect(getGrantStatus).not.toHaveBeenCalled();
+ expect(ticketGrantResult).not.toHaveBeenCalled();
+ });
+
+ it('browser routes refuse a cli flow', async () => {
+ grantEnv({ SHOP_BFF_CLI_GRANT_ENABLED: 'true' });
+ const config = await browserConfig();
+ const stateId = randomUUID();
+ const bound = makeBoundCookie(stateId);
+ const { row } = browserFlowRow(config, { tokenHash: hashCliToken(config, 1, bound.secret) });
+ getCliFlow.mockResolvedValue({ ...row, state_id: stateId });
+ const { pollBrowserFlow } = await import('./browser-bff');
+
+ await expect(pollBrowserFlow(flowRequest(stateId), bound.value, stateId)).rejects.toEqual(
+ new BffError(403, 'flow_binding_denied'),
+ );
+ expect(getGrantStatus).not.toHaveBeenCalled();
+ expect(claimGrantResult).not.toHaveBeenCalled();
+ });
+
+ it('cli verify refuses a browser bootstrap challenge before consume', async () => {
+ grantEnv({ SHOP_BFF_CLI_GRANT_ENABLED: 'true' });
+ const config = await browserConfig();
+ const { nonce, row } = browserChallengeRow(config);
+ getCliChallenge.mockResolvedValue(row);
+ const { verifyCliChallenge } = await import('./cli-bff');
+
+ await expect(
+ verifyCliChallenge(
+ jsonRequest(`${ORIGIN}/api/cli/grant-challenges/${challengeId}/verify`, { nonce: encodeBase64Url(nonce) }),
+ challengeId,
+ ),
+ ).rejects.toEqual(new BffError(404, 'challenge_not_found'));
+ expect(fetchHomeserverProofDocument).not.toHaveBeenCalled();
+ expect(consumeChallengeAndInsertCliFlow).not.toHaveBeenCalled();
+ });
+
+ it('browser verify refuses a cli challenge before consume', async () => {
+ const config = await browserConfig();
+ const nonce = new Uint8Array(32).fill(9);
+ const cliDeliveryId = new Uint8Array(32).fill(4);
+ getCliChallenge.mockResolvedValue({
+ challenge_id: challengeId,
+ pubky,
+ result_cpk: pubky,
+ result_delivery_id_hash: hashCliDeliveryId(config, 1, challengeId, cliDeliveryId),
+ nonce_hash: sha256Bytes(nonce),
+ consumed_at: null,
+ created_at: new Date(),
+ expires_at: new Date(Date.now() + 60_000),
+ });
+ const { verifyBrowserChallenge } = await import('./browser-bff');
+
+ await expect(verifyBrowserChallenge(verifyRequest(encodeBase64Url(nonce)), challengeId)).rejects.toEqual(
+ new BffError(409, 'fresh_approval_required'),
+ );
+ expect(fetchHomeserverProofDocument).not.toHaveBeenCalled();
+ expect(consumeChallengeAndInsertCliFlow).not.toHaveBeenCalled();
+ });
+
+ // A7
+ it('browser claim refuses a cli context', async () => {
+ const config = await browserConfig();
+ const stateId = randomUUID();
+ const bound = makeBoundCookie(stateId);
+ const cliContext = sealCliFlowContext(config, stateId, pubky, {
+ resultDeliveryId: encodeBase64Url(new Uint8Array(32).fill(4)),
+ version: 1,
+ });
+ const { row } = browserFlowRow(config, {
+ tokenHash: hashBoundCookie(config, 1, 'flow', stateId, bound.secret),
+ contextSealed: cliContext,
+ });
+ const flow = { ...row, state_id: stateId };
+ getCliFlow.mockResolvedValue(flow);
+ acquireCliClaim.mockResolvedValue(flow);
+ completeServiceFlow(flow.flow_id);
+ const { pollBrowserFlow } = await import('./browser-bff');
+
+ await expect(pollBrowserFlow(flowRequest(stateId), bound.value, stateId)).rejects.toEqual(
+ new BffError(403, 'result_denied'),
+ );
+ expect(claimGrantResult).not.toHaveBeenCalled();
+ expect(abandonCliClaim).toHaveBeenCalledWith(expect.anything(), stateId, expect.any(String));
+ });
+
+ // A8
+ it('browser bootstrap does not need the cli flag', async () => {
+ storeInsertedChallenges();
+ const { getCliGrantConfig } = await import('./config');
+ const { createBrowserChallenge } = await import('./browser-bff');
+
+ expect(process.env.SHOP_BFF_CLI_GRANT_ENABLED).toBeUndefined();
+ expect(getCliGrantConfig()).toBeNull();
+ await expect(createBrowserChallenge(challengeRequest())).resolves.toMatchObject({ proof_uri: expect.any(String) });
+ });
+
+ it.each([
+ ['the grant flag is off', { SHOP_BFF_GRANT_FLOW_ENABLED: 'false' }],
+ ['the state key is missing', { SHOP_BFF_GRANT_STATE_ENCRYPTION_KEY_B64: undefined }],
+ ['the database url is missing', { SHOP_BFF_GRANT_STATE_DATABASE_URL: undefined }],
+ ])('browser bootstrap 404s without grant config (%s)', async (_label, overrides) => {
+ grantEnv(overrides);
+ const { createBrowserChallenge, verifyBrowserChallenge, pollBrowserFlow } = await import('./browser-bff');
+ const unavailable = new BffError(404, 'grant_unavailable');
+ const stateId = randomUUID();
+
+ await expect(createBrowserChallenge(challengeRequest())).rejects.toEqual(unavailable);
+ await expect(verifyBrowserChallenge(verifyRequest('x'), challengeId)).rejects.toEqual(unavailable);
+ await expect(pollBrowserFlow(flowRequest(stateId), undefined, stateId)).rejects.toEqual(unavailable);
+ expect(insertCliChallenge).not.toHaveBeenCalled();
+ });
+
+ // R3.1
+ it('bootstrap routes reject cross-origin', async () => {
+ const { createBrowserChallenge, verifyBrowserChallenge } = await import('./browser-bff');
+ const denied = new BffError(403, 'origin_denied');
+
+ await expect(
+ createBrowserChallenge(challengeRequest({ pubky }, { origin: 'https://evil.example' })),
+ ).rejects.toEqual(denied);
+ await expect(
+ verifyBrowserChallenge(
+ jsonRequest(
+ `${ORIGIN}/api/marketplace/bootstrap-challenges/${challengeId}/verify`,
+ { nonce: 'x' },
+ {
+ origin: 'https://evil.example',
+ },
+ ),
+ challengeId,
+ ),
+ ).rejects.toEqual(denied);
+ expect(insertCliChallenge).not.toHaveBeenCalled();
+ expect(getCliChallenge).not.toHaveBeenCalled();
+ });
+
+ // R3.5
+ it('replayed challenge is consumed', async () => {
+ const config = await browserConfig();
+ const { nonce, row } = browserChallengeRow(config);
+ getCliChallenge.mockResolvedValue({ ...row, consumed_at: new Date() });
+ const { verifyBrowserChallenge } = await import('./browser-bff');
+
+ await expect(verifyBrowserChallenge(verifyRequest(encodeBase64Url(nonce)), challengeId)).rejects.toEqual(
+ new BffError(409, 'challenge_consumed'),
+ );
+ expect(consumeChallengeAndInsertCliFlow).not.toHaveBeenCalled();
+ });
+
+ it('expired challenge rejected', async () => {
+ const config = await browserConfig();
+ const { nonce, row } = browserChallengeRow(config);
+ getCliChallenge.mockResolvedValue({ ...row, expires_at: new Date(Date.now() - 1_000) });
+ const { verifyBrowserChallenge } = await import('./browser-bff');
+
+ await expect(verifyBrowserChallenge(verifyRequest(encodeBase64Url(nonce)), challengeId)).rejects.toEqual(
+ new BffError(401, 'homeserver_proof_invalid'),
+ );
+ expect(fetchHomeserverProofDocument).not.toHaveBeenCalled();
+ expect(consumeChallengeAndInsertCliFlow).not.toHaveBeenCalled();
+ });
+
+ it('missing nonce rejected', async () => {
+ const config = await browserConfig();
+ const { row } = browserChallengeRow(config);
+ getCliChallenge.mockResolvedValue(row);
+ const { verifyBrowserChallenge } = await import('./browser-bff');
+
+ await expect(verifyBrowserChallenge(jsonRequest(`${ORIGIN}/verify`, {}), challengeId)).rejects.toEqual(
+ new BffError(400, 'invalid_request'),
+ );
+ await expect(
+ verifyBrowserChallenge(verifyRequest(encodeBase64Url(new Uint8Array(32).fill(1))), challengeId),
+ ).rejects.toEqual(new BffError(401, 'homeserver_proof_invalid'));
+ expect(fetchHomeserverProofDocument).not.toHaveBeenCalled();
+ expect(consumeChallengeAndInsertCliFlow).not.toHaveBeenCalled();
+ });
+
+ it('proof from another pubky rejected', async () => {
+ storeInsertedChallenges();
+ const { createBrowserChallenge, verifyBrowserChallenge } = await import('./browser-bff');
+ const created = await createBrowserChallenge(challengeRequest());
+ fetchHomeserverProofDocument.mockResolvedValue({ ...created.proof_document, pubky: otherPubky });
+ const verifyUrl = `${ORIGIN}/api/marketplace/bootstrap-challenges/${created.challenge_id}/verify`;
+
+ await expect(
+ verifyBrowserChallenge(jsonRequest(verifyUrl, { nonce: created.nonce }), created.challenge_id),
+ ).rejects.toEqual(new BffError(401, 'homeserver_proof_invalid'));
+ expect(consumeChallengeAndInsertCliFlow).not.toHaveBeenCalled();
+ expect(createBootstrapFlow).not.toHaveBeenCalled();
+ });
+
+ it('foreign homeserver proof rejected', async () => {
+ storeInsertedChallenges();
+ const { HomeserverFetchDenied } = await import('./ssrf');
+ const { createBrowserChallenge, verifyBrowserChallenge } = await import('./browser-bff');
+ const created = await createBrowserChallenge(challengeRequest());
+ fetchHomeserverProofDocument.mockRejectedValue(new HomeserverFetchDenied());
+ const verifyUrl = `${ORIGIN}/api/marketplace/bootstrap-challenges/${created.challenge_id}/verify`;
+
+ await expect(
+ verifyBrowserChallenge(jsonRequest(verifyUrl, { nonce: created.nonce }), created.challenge_id),
+ ).rejects.toEqual(new BffError(401, 'homeserver_proof_invalid'));
+ expect(consumeChallengeAndInsertCliFlow).not.toHaveBeenCalled();
+ });
+
+ it('verify binds the flow to the bootstrap cookie with a version 2 browser context', async () => {
+ storeInsertedChallenges();
+ const { createBrowserChallenge, verifyBrowserChallenge } = await import('./browser-bff');
+ const config = await browserConfig();
+ const created = await createBrowserChallenge(challengeRequest());
+ fetchHomeserverProofDocument.mockResolvedValue(created.proof_document);
+ const verifyUrl = `${ORIGIN}/api/marketplace/bootstrap-challenges/${created.challenge_id}/verify`;
+
+ const verified = await verifyBrowserChallenge(
+ jsonRequest(verifyUrl, { nonce: created.nonce }),
+ created.challenge_id,
+ );
+ const insert = consumeChallengeAndInsertCliFlow.mock.calls[0][2] as {
+ stateId: string;
+ tokenHash: Uint8Array;
+ contextSealed: Uint8Array;
+ keyEpoch: number;
+ };
+ const [, secret] = verified.cookie.split('.');
+ expect(verified.response.state_id).toBe(insert.stateId);
+ expect(Buffer.from(insert.tokenHash)).toEqual(
+ Buffer.from(hashBoundCookie(config, 1, 'flow', insert.stateId, Buffer.from(secret, 'base64url'))),
+ );
+ const context = openBrowserFlowContext(config, insert.stateId, pubky, insert.keyEpoch, insert.contextSealed);
+ expect(context).toMatchObject({ kind: 'browser', version: 2 });
+ expect(createBootstrapFlow).toHaveBeenCalledWith(
+ expect.anything(),
+ created.result_delivery_id,
+ created.result_cpk,
+ pubky,
+ );
+ expect(bindCliFlow).toHaveBeenCalledOnce();
+ expect(verified).not.toHaveProperty('cli_token');
+ });
+
+ // R3.7
+ it('claim pubky mismatch rejected', async () => {
+ const config = await browserConfig();
+ const { bound, row, stateId } = browserFlowRow(config);
+ getCliFlow.mockResolvedValue(row);
+ acquireCliClaim.mockResolvedValue(row);
+ completeServiceFlow(row.flow_id);
+ claimGrantResult.mockResolvedValue(claimedSession(otherPubky));
+ const { pollBrowserFlow } = await import('./browser-bff');
+
+ await expect(pollBrowserFlow(flowRequest(stateId), bound.value, stateId)).rejects.toEqual(
+ new BffError(409, 'identity_mismatch'),
+ );
+ expect(terminalizeCliFlow).toHaveBeenCalledWith(expect.anything(), stateId, 'mismatch');
+ expect(completeCliClaim).not.toHaveBeenCalled();
+ });
+
+ // A9
+ it('no migration added by the browser bootstrap', () => {
+ const migrations = readdirSync(path.resolve(process.cwd(), 'db/bff'))
+ .filter((name) => name.endsWith('.sql'))
+ .sort();
+ expect(migrations).toEqual(['0001_shop_grant_bff.sql', '0002_shop_grant_bff_cli.sql']);
+ });
+});
diff --git a/src/server/marketplace-grant/browser-bff.ts b/src/server/marketplace-grant/browser-bff.ts
new file mode 100644
index 000000000..9251043e0
--- /dev/null
+++ b/src/server/marketplace-grant/browser-bff.ts
@@ -0,0 +1,398 @@
+import { randomBytes, randomUUID } from 'node:crypto';
+import { z } from 'zod';
+import { assertSameOrigin, BffError, FLOW_COOKIE, parseStrictJson } from './bff';
+import { canonicalZ32, clientIp, hashesEqual, requireUuid } from './cli-bff';
+import type { CliGrantConfig } from './config';
+import { getBrowserBootstrapConfig } from './config';
+import {
+ BrowserContextRefused,
+ cookieMatches,
+ decodeBase64Url32,
+ deriveBrowserBootstrap,
+ encodeBase64Url,
+ hashBoundCookie,
+ hashCliDeliveryId,
+ makeBoundCookie,
+ openBrowserFlowContext,
+ parseBoundCookie,
+ sealBrowserFlowContext,
+ sha256Bytes,
+} from './crypto';
+import {
+ abandonCliClaim,
+ acquireCliClaim,
+ assertCliGrantSchema,
+ bindCliFlow,
+ CliChallengeConsumeConflict,
+ type CliFlowRow,
+ completeCliClaim,
+ consumeChallengeAndInsertCliFlow,
+ consumeCliRateLimit,
+ getCliChallenge,
+ getCliFlow,
+ insertCliChallenge,
+ renewCliClaim,
+ terminalizeCliFlow,
+} from './db';
+import {
+ assertProofMatches,
+ type ExpectedProof,
+ expectedProofDocument,
+ fetchHomeserverProofDocument,
+ parseProofDocument,
+ proofUri,
+} from './homeserver-proof';
+import { cancelGrant, claimGrantResult, createBootstrapFlow, getGrantStatus } from './service';
+
+/**
+ * Browser purchase bootstrap for a Bitkit (grant) sign-in: the CLI verifier's
+ * checks, run by the BFF for a same-origin browser. The result PoP seed is
+ * derived from the state key and the challenge id (never stored on the
+ * challenge row, never sent to the browser); the flow is bound to the
+ * reconnect flow's `__Host-shop-marketplace-grant` cookie, not a CLI token.
+ */
+export const BOOTSTRAP_FLOW_COOKIE = FLOW_COOKIE;
+
+const challengeBody = z.object({ pubky: z.string() }).strict();
+const verifyBody = z.object({ nonce: z.string() }).strict();
+const emptyBody = z.object({}).strict();
+const VERIFY_PER_CHALLENGE_PER_MINUTE = 5;
+
+export function requiredBrowserConfig(): CliGrantConfig {
+ let config: CliGrantConfig | null;
+ try {
+ config = getBrowserBootstrapConfig();
+ } catch {
+ // A missing or invalid state key, database URL or verifier parameter.
+ config = null;
+ }
+ if (!config) throw new BffError(404, 'grant_unavailable');
+ return config;
+}
+
+async function rateLimit(config: CliGrantConfig, key: string, limit: number): Promise {
+ if (!(await consumeCliRateLimit(config, key, limit))) {
+ throw new BffError(429, 'retry_later', 60);
+ }
+}
+
+function proofInvalid(): never {
+ throw new BffError(401, 'homeserver_proof_invalid');
+}
+
+export type BrowserChallengeResponse = {
+ challenge_id: string;
+ expires_at: string;
+ nonce: string;
+ proof_document: ExpectedProof;
+ proof_uri: string;
+ result_cpk: string;
+ result_delivery_id: string;
+};
+
+export async function createBrowserChallenge(request: Request): Promise {
+ const config = requiredBrowserConfig();
+ assertSameOrigin(request, config);
+ await assertCliGrantSchema(config);
+ const input = await parseStrictJson(request, challengeBody);
+ const pubky = canonicalZ32(input.pubky);
+ await rateLimit(
+ config,
+ `browser_challenge_ip:${clientIp(request, config.trustedProxyCount)}`,
+ config.createPerIpPerMinute,
+ );
+ await rateLimit(config, `browser_challenge_pubky:${pubky}`, config.createPerPubkyPerMinute);
+
+ const challengeId = randomUUID();
+ const derived = deriveBrowserBootstrap(config, config.stateKeyEpoch, challengeId);
+ const nonce = Uint8Array.from(randomBytes(32));
+ const expiresAt = new Date(Date.now() + config.challengeTtlSeconds * 1000);
+ const resultDeliveryId = encodeBase64Url(derived.resultDeliveryId);
+ await insertCliChallenge(config, {
+ challengeId,
+ pubky,
+ resultCpk: derived.resultCpk,
+ resultDeliveryIdHash: hashCliDeliveryId(config, config.stateKeyEpoch, challengeId, derived.resultDeliveryId),
+ nonceHash: sha256Bytes(nonce),
+ expiresAt,
+ });
+ // The proof body the grant session writes: only public, verifier-bound fields.
+ // Its window comes from the stored row (database clock), which verify checks.
+ const stored = await getCliChallenge(config, challengeId);
+ if (!stored) throw new BffError(503, 'grant_unavailable');
+ const iat = Math.ceil(stored.created_at.getTime() / 1000);
+ const exp = Math.floor(stored.expires_at.getTime() / 1000);
+ if (!(exp > iat)) throw new BffError(503, 'grant_unavailable');
+ return {
+ challenge_id: challengeId,
+ expires_at: stored.expires_at.toISOString(),
+ nonce: encodeBase64Url(nonce),
+ proof_document: expectedProofDocument({
+ aud: config.publicOrigin,
+ challengeId,
+ exp,
+ iat,
+ nonce,
+ pubky,
+ resultCpk: derived.resultCpk,
+ resultDeliveryId,
+ }),
+ proof_uri: proofUri(pubky, challengeId),
+ result_cpk: derived.resultCpk,
+ result_delivery_id: resultDeliveryId,
+ };
+}
+
+export async function verifyBrowserChallenge(
+ request: Request,
+ challengeIdParam: string,
+): Promise<{
+ cookie: string;
+ maxAge: number;
+ response: { authorization_url: string; expires_at: string; state_id: string; status: 'awaiting' };
+}> {
+ const config = requiredBrowserConfig();
+ assertSameOrigin(request, config);
+ await assertCliGrantSchema(config);
+ const challengeId = requireUuid(challengeIdParam);
+ const input = await parseStrictJson(request, verifyBody);
+ let nonce: Uint8Array;
+ try {
+ nonce = decodeBase64Url32(input.nonce);
+ } catch {
+ throw new BffError(400, 'invalid_request');
+ }
+ await rateLimit(
+ config,
+ `browser_verify_ip:${clientIp(request, config.trustedProxyCount)}`,
+ config.verifyPerIpPerMinute,
+ );
+ await rateLimit(config, `browser_verify_challenge:${challengeId}`, VERIFY_PER_CHALLENGE_PER_MINUTE);
+
+ const challenge = await getCliChallenge(config, challengeId);
+ if (!challenge) throw new BffError(404, 'challenge_not_found');
+ if (challenge.consumed_at) throw new BffError(409, 'challenge_consumed');
+
+ // Re-derive under the current epoch only. A rotation inside the challenge
+ // window (or a CLI row, whose key the CLI chose) no longer matches: refuse
+ // before consume, so no flow is created.
+ const derived = deriveBrowserBootstrap(config, config.stateKeyEpoch, challengeId);
+ if (
+ derived.resultCpk !== challenge.result_cpk ||
+ !hashesEqual(
+ hashCliDeliveryId(config, config.stateKeyEpoch, challengeId, derived.resultDeliveryId),
+ challenge.result_delivery_id_hash,
+ )
+ ) {
+ throw new BffError(409, 'fresh_approval_required');
+ }
+ if (challenge.expires_at.getTime() <= Date.now()) proofInvalid();
+ if (!hashesEqual(sha256Bytes(nonce), challenge.nonce_hash)) proofInvalid();
+
+ const resultDeliveryId = encodeBase64Url(derived.resultDeliveryId);
+ try {
+ const parsed = await fetchHomeserverProofDocument(config, challenge.pubky, challengeId);
+ const document = parseProofDocument(parsed);
+ const createdSeconds = Math.floor(challenge.created_at.getTime() / 1000);
+ const expiresSeconds = Math.floor(challenge.expires_at.getTime() / 1000);
+ if (document.iat < createdSeconds) proofInvalid();
+ if (document.exp > expiresSeconds) proofInvalid();
+ if (!(document.exp > document.iat && document.exp - document.iat <= config.challengeTtlSeconds)) {
+ proofInvalid();
+ }
+ if (document.result_delivery_id !== resultDeliveryId) proofInvalid();
+ assertProofMatches(
+ parsed,
+ expectedProofDocument({
+ aud: config.publicOrigin,
+ challengeId,
+ exp: document.exp,
+ iat: document.iat,
+ nonce,
+ pubky: challenge.pubky,
+ resultCpk: challenge.result_cpk,
+ resultDeliveryId,
+ }),
+ );
+ } catch (error) {
+ if (error instanceof BffError) throw error;
+ proofInvalid();
+ }
+
+ const stateId = randomUUID();
+ const bound = makeBoundCookie(stateId);
+ const tokenHash = hashBoundCookie(config, config.stateKeyEpoch, 'flow', stateId, bound.secret);
+ const contextSealed = sealBrowserFlowContext(config, stateId, challenge.pubky, {
+ kind: 'browser',
+ resultDeliveryId,
+ resultPopSeed: encodeBase64Url(derived.resultPopSeed),
+ version: 2,
+ });
+ const localExpiry = new Date(Date.now() + config.stateTtlSeconds * 1000);
+ try {
+ await consumeChallengeAndInsertCliFlow(config, challengeId, {
+ stateId,
+ pubky: challenge.pubky,
+ resultCpk: challenge.result_cpk,
+ tokenHash,
+ contextSealed,
+ keyEpoch: config.stateKeyEpoch,
+ expiresAt: localExpiry,
+ });
+ } catch (error) {
+ if (error instanceof CliChallengeConsumeConflict) {
+ throw new BffError(
+ error.reason === 'consumed' ? 409 : 401,
+ error.reason === 'consumed' ? 'challenge_consumed' : 'homeserver_proof_invalid',
+ );
+ }
+ throw error;
+ }
+
+ try {
+ const created = await createBootstrapFlow(config, resultDeliveryId, challenge.result_cpk, challenge.pubky);
+ const serviceExpiry = new Date(created.expires_at);
+ if (!(await bindCliFlow(config, stateId, created.flow_id, serviceExpiry))) {
+ await terminalizeCliFlow(config, stateId, 'abandoned');
+ throw new BffError(503, 'grant_unavailable');
+ }
+ return {
+ cookie: bound.value,
+ maxAge: Math.max(1, Math.floor((Math.min(localExpiry.getTime(), serviceExpiry.getTime()) - Date.now()) / 1000)),
+ response: {
+ authorization_url: created.authorization_url,
+ expires_at: created.expires_at,
+ state_id: stateId,
+ status: 'awaiting',
+ },
+ };
+ } catch (error) {
+ await terminalizeCliFlow(config, stateId, 'abandoned');
+ throw error;
+ }
+}
+
+async function authenticatedBrowserFlow(
+ request: Request,
+ flowCookie: string | undefined,
+ stateIdParam: string,
+): Promise<{ config: CliGrantConfig; flow: CliFlowRow }> {
+ const config = requiredBrowserConfig();
+ assertSameOrigin(request, config);
+ await assertCliGrantSchema(config);
+ const stateId = requireUuid(stateIdParam);
+ const parsed = parseBoundCookie(flowCookie);
+ if (!parsed || parsed.id !== stateId) throw new BffError(401, 'flow_binding_missing');
+ const flow = await getCliFlow(config, stateId);
+ if (!flow) throw new BffError(404, 'flow_not_found');
+ let expectedHash: Uint8Array;
+ try {
+ expectedHash = hashBoundCookie(config, flow.key_epoch, 'flow', stateId, parsed.secret);
+ } catch {
+ throw new BffError(422, 'fresh_approval_required');
+ }
+ if (!cookieMatches(flow.token_hash, expectedHash)) throw new BffError(403, 'flow_binding_denied');
+ return { config, flow };
+}
+
+function openContext(config: CliGrantConfig, flow: CliFlowRow) {
+ if (!flow.context_sealed) throw new BffError(403, 'result_denied');
+ try {
+ return openBrowserFlowContext(config, flow.state_id, flow.pubky, flow.key_epoch, flow.context_sealed);
+ } catch (error) {
+ if (error instanceof BrowserContextRefused && error.reason === 'epoch_unavailable') {
+ throw new BffError(422, 'fresh_approval_required');
+ }
+ throw new BffError(403, 'result_denied');
+ }
+}
+
+export type BrowserFlowPoll =
+ | { expires_at: string; state_id: string; status: 'awaiting' | 'verifying' }
+ | { state_id: string; status: 'mismatch' | 'expired' | 'cancelled' | 'failed' }
+ | { capabilities: string; expires_at: string; pubky: string; state_id: string; status: 'connected'; token: string };
+
+export async function pollBrowserFlow(
+ request: Request,
+ flowCookie: string | undefined,
+ stateIdParam: string,
+): Promise {
+ const { config, flow } = await authenticatedBrowserFlow(request, flowCookie, stateIdParam);
+ await parseStrictJson(request, emptyBody);
+ await rateLimit(config, `browser_status_flow:${flow.state_id}`, config.statusPerTokenPerMinute);
+ if (flow.expires_at.getTime() <= Date.now() && (flow.status === 'creating' || flow.status === 'awaiting')) {
+ await terminalizeCliFlow(config, flow.state_id, 'expired');
+ throw new BffError(410, 'flow_expired');
+ }
+ if (flow.status === 'expired') throw new BffError(410, 'flow_expired');
+ if (flow.status === 'cancelled') throw new BffError(410, 'flow_cancelled');
+ if (flow.status === 'mismatch') return { state_id: flow.state_id, status: 'mismatch' };
+ if (flow.status === 'claiming') throw new BffError(409, 'claim_in_progress');
+ if (flow.status !== 'awaiting' || !flow.flow_id) {
+ throw new BffError(422, 'fresh_approval_required');
+ }
+
+ const status = await getGrantStatus(config, flow.flow_id);
+ if (status.status === 'awaiting' || status.status === 'verifying') {
+ return { expires_at: status.expires_at, state_id: flow.state_id, status: status.status };
+ }
+ if (status.status !== 'complete') {
+ const localStatus = status.status === 'invalid' ? 'failed' : status.status;
+ await terminalizeCliFlow(config, flow.state_id, localStatus);
+ return {
+ state_id: flow.state_id,
+ status:
+ localStatus === 'mismatch' || localStatus === 'expired' || localStatus === 'cancelled' ? localStatus : 'failed',
+ };
+ }
+
+ const owner = randomUUID();
+ const claimedFlow = await acquireCliClaim(config, flow.state_id, owner);
+ if (!claimedFlow?.context_sealed || !claimedFlow.flow_id) {
+ if (claimedFlow) await abandonCliClaim(config, flow.state_id, owner);
+ throw new BffError(409, 'claim_in_progress');
+ }
+ try {
+ const context = openContext(config, claimedFlow);
+ const claimed = await claimGrantResult(
+ config,
+ claimedFlow.flow_id,
+ context.resultDeliveryId,
+ decodeBase64Url32(context.resultPopSeed),
+ async () => {
+ if (!(await renewCliClaim(config, flow.state_id, owner))) {
+ throw new BffError(422, 'fresh_approval_required');
+ }
+ },
+ );
+ if (claimed.pubky !== claimedFlow.pubky) {
+ await terminalizeCliFlow(config, flow.state_id, 'mismatch');
+ throw new BffError(409, 'identity_mismatch');
+ }
+ if (!(await completeCliClaim(config, flow.state_id, owner))) {
+ throw new BffError(422, 'fresh_approval_required');
+ }
+ return { ...claimed, state_id: flow.state_id, status: 'connected' };
+ } catch (error) {
+ if (!(error instanceof BffError && error.code === 'identity_mismatch')) {
+ await abandonCliClaim(config, flow.state_id, owner);
+ }
+ if (error instanceof BffError) throw error;
+ throw new BffError(422, 'fresh_approval_required');
+ }
+}
+
+export async function cancelBrowserFlow(
+ request: Request,
+ flowCookie: string | undefined,
+ stateIdParam: string,
+): Promise {
+ const { config, flow } = await authenticatedBrowserFlow(request, flowCookie, stateIdParam);
+ await parseStrictJson(request, emptyBody);
+ if (flow.status !== 'awaiting' && flow.status !== 'creating') throw new BffError(403, 'result_denied');
+ if (flow.flow_id && flow.context_sealed) {
+ const context = openContext(config, flow);
+ await cancelGrant(config, flow.flow_id, context.resultDeliveryId);
+ }
+ await terminalizeCliFlow(config, flow.state_id, 'cancelled');
+}
diff --git a/src/server/marketplace-grant/cli-bff.ts b/src/server/marketplace-grant/cli-bff.ts
index 609811f0f..3c5a807d0 100644
--- a/src/server/marketplace-grant/cli-bff.ts
+++ b/src/server/marketplace-grant/cli-bff.ts
@@ -7,6 +7,7 @@ import { getCliGrantConfig } from './config';
import {
cookieMatches,
decodeBase64Url32,
+ deriveBrowserBootstrap,
encodeBase64Url,
hashCliDeliveryId,
hashCliToken,
@@ -82,7 +83,7 @@ const proofBody = z
})
.strict();
-function canonicalZ32(value: string): string {
+export function canonicalZ32(value: string): string {
if (!Z32.test(value)) throw new BffError(400, 'invalid_request');
let key: PublicKey;
try {
@@ -94,7 +95,7 @@ function canonicalZ32(value: string): string {
return value;
}
-function requireUuid(value: string): string {
+export function requireUuid(value: string): string {
if (!UUID.test(value)) throw new BffError(400, 'invalid_request');
return value;
}
@@ -122,7 +123,7 @@ function xffHopBehindTrustedProxies(forwarded: string | null, trustedProxyCount:
return hops[index] ?? '';
}
-function clientIp(request: Request, trustedProxyCount: number): string {
+export function clientIp(request: Request, trustedProxyCount: number): string {
if (process.env.VERCEL === '1') {
return firstHop(request.headers.get('x-vercel-forwarded-for')) || platformRequestIp(request) || '0.0.0.0';
}
@@ -133,7 +134,7 @@ function tokenBucketKey(prefix: string, digest: Uint8Array): string {
return `${prefix}:${Buffer.from(digest).toString('hex')}`;
}
-function hashesEqual(actual: Uint8Array, expected: Uint8Array): boolean {
+export function hashesEqual(actual: Uint8Array, expected: Uint8Array): boolean {
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
@@ -149,6 +150,12 @@ async function rateLimit(config: CliGrantConfig, key: string, limit: number): Pr
}
}
+export function isBrowserBootstrapRow(config: CliGrantConfig, challengeId: string, resultCpk: string): boolean {
+ const epochs = [config.stateKeyEpoch];
+ if (config.previousStateKey && config.previousStateKeyEpoch !== undefined) epochs.push(config.previousStateKeyEpoch);
+ return epochs.some((epoch) => deriveBrowserBootstrap(config, epoch, challengeId).resultCpk === resultCpk);
+}
+
function proofInvalid(): never {
throw new BffError(401, 'homeserver_proof_invalid');
}
@@ -221,6 +228,11 @@ export async function verifyCliChallenge(
const challenge = await getCliChallenge(config, challengeId);
if (!challenge) throw new BffError(404, 'challenge_not_found');
if (challenge.consumed_at) throw new BffError(409, 'challenge_consumed');
+ // A browser bootstrap row carries a server-derived result key; the CLI
+ // route must not consume it (the CLI context it would write holds no seed).
+ if (isBrowserBootstrapRow(config, challengeId, challenge.result_cpk)) {
+ throw new BffError(404, 'challenge_not_found');
+ }
if (challenge.expires_at.getTime() <= Date.now()) proofInvalid();
if (!hashesEqual(sha256Bytes(nonce), challenge.nonce_hash)) proofInvalid();
diff --git a/src/server/marketplace-grant/config.ts b/src/server/marketplace-grant/config.ts
index cb76c5a66..78db1791d 100644
--- a/src/server/marketplace-grant/config.ts
+++ b/src/server/marketplace-grant/config.ts
@@ -88,6 +88,20 @@ export type CliGrantConfig = MarketplaceGrantConfig & z.infer {
+ return cliExtrasSchema.parse({
+ challengeTtlSeconds: process.env.SHOP_BFF_CLI_GRANT_CHALLENGE_TTL_SECONDS,
+ createPerIpPerMinute: process.env.SHOP_BFF_CLI_GRANT_CREATE_PER_IP_PER_MINUTE,
+ createPerPubkyPerMinute: process.env.SHOP_BFF_CLI_GRANT_CREATE_PER_PUBKY_PER_MINUTE,
+ verifyPerIpPerMinute: process.env.SHOP_BFF_CLI_GRANT_VERIFY_PER_IP_PER_MINUTE,
+ statusPerTokenPerMinute: process.env.SHOP_BFF_CLI_GRANT_STATUS_PER_TOKEN_PER_MINUTE,
+ resultPerTokenPerMinute: process.env.SHOP_BFF_CLI_GRANT_RESULT_PER_TOKEN_PER_MINUTE,
+ homeserverFetchTimeoutMs: process.env.SHOP_BFF_CLI_HOMESERVER_FETCH_TIMEOUT_MILLISECONDS,
+ trustedProxyCount: process.env.SHOP_BFF_CLI_TRUSTED_PROXY_COUNT,
+ });
+}
export function marketplaceGrantEnabled(): boolean {
return process.env.SHOP_BFF_GRANT_FLOW_ENABLED === 'true';
@@ -139,23 +153,23 @@ export function getCliGrantConfig(): CliGrantConfig | null {
cachedCli = null;
return null;
}
- cachedCli = {
- ...base,
- ...cliExtrasSchema.parse({
- challengeTtlSeconds: process.env.SHOP_BFF_CLI_GRANT_CHALLENGE_TTL_SECONDS,
- createPerIpPerMinute: process.env.SHOP_BFF_CLI_GRANT_CREATE_PER_IP_PER_MINUTE,
- createPerPubkyPerMinute: process.env.SHOP_BFF_CLI_GRANT_CREATE_PER_PUBKY_PER_MINUTE,
- verifyPerIpPerMinute: process.env.SHOP_BFF_CLI_GRANT_VERIFY_PER_IP_PER_MINUTE,
- statusPerTokenPerMinute: process.env.SHOP_BFF_CLI_GRANT_STATUS_PER_TOKEN_PER_MINUTE,
- resultPerTokenPerMinute: process.env.SHOP_BFF_CLI_GRANT_RESULT_PER_TOKEN_PER_MINUTE,
- homeserverFetchTimeoutMs: process.env.SHOP_BFF_CLI_HOMESERVER_FETCH_TIMEOUT_MILLISECONDS,
- trustedProxyCount: process.env.SHOP_BFF_CLI_TRUSTED_PROXY_COUNT,
- }),
- };
+ cachedCli = { ...base, ...cliExtrasFromEnv() };
return cachedCli;
}
+/**
+ * Browser purchase bootstrap (Bitkit sign-in): the CLI verifier's parameters,
+ * gated only by `SHOP_BFF_GRANT_FLOW_ENABLED`, never by the CLI flag.
+ */
+export function getBrowserBootstrapConfig(): CliGrantConfig | null {
+ if (cachedBrowser !== undefined) return cachedBrowser;
+ const base = getMarketplaceGrantConfig();
+ cachedBrowser = base ? { ...base, ...cliExtrasFromEnv() } : null;
+ return cachedBrowser;
+}
+
export function resetMarketplaceGrantConfigForTests(): void {
cached = undefined;
cachedCli = undefined;
+ cachedBrowser = undefined;
}
diff --git a/src/server/marketplace-grant/crypto.ts b/src/server/marketplace-grant/crypto.ts
index 423e3385b..36e3d9053 100644
--- a/src/server/marketplace-grant/crypto.ts
+++ b/src/server/marketplace-grant/crypto.ts
@@ -327,6 +327,58 @@ export type CliStateContext = {
version: 1;
};
+const BROWSER_BOOTSTRAP_SALT = utf8.encode('shop-bff/browser-bootstrap/hkdf-salt/v1');
+
+function browserSeedKey(config: MarketplaceGrantConfig, epoch: number): Uint8Array {
+ return hkdf(
+ sha256,
+ rootForEpoch(config, epoch),
+ BROWSER_BOOTSTRAP_SALT,
+ concat(utf8.encode('shop-bff/browser-bootstrap/seed-key/v1'), u16(epoch)),
+ 32,
+ );
+}
+
+export type BrowserBootstrapSecrets = {
+ resultPopSeed: Uint8Array;
+ resultCpk: string;
+ resultDeliveryId: Uint8Array;
+};
+
+/**
+ * The browser bootstrap's result PoP seed and delivery id are a pure function
+ * of the state key epoch and the challenge id, so verify can recompute them
+ * without storing a secret on the challenge row. Anyone holding the epoch's
+ * state key can recompute them; a public challenge id alone cannot.
+ */
+export function deriveBrowserBootstrap(
+ config: MarketplaceGrantConfig,
+ epoch: number,
+ challengeId: string,
+): BrowserBootstrapSecrets {
+ const key = browserSeedKey(config, epoch);
+ const id = uuidBytes(challengeId);
+ const resultPopSeed = hmac(sha256, key, concat(utf8.encode('shop-bff/browser-bootstrap/result-pop-seed/v1'), id));
+ const resultDeliveryId = hmac(
+ sha256,
+ key,
+ concat(utf8.encode('shop-bff/browser-bootstrap/result-delivery-id/v1'), id),
+ );
+ return { resultPopSeed, resultCpk: resultPublicKey(resultPopSeed), resultDeliveryId };
+}
+
+/** Test-only: the raw browser bootstrap key, for the key-separation vector. */
+export function browserSeedKeyForTests(config: MarketplaceGrantConfig, epoch: number): Uint8Array {
+ return browserSeedKey(config, epoch);
+}
+
+export type BrowserStateContext = {
+ kind: 'browser';
+ resultDeliveryId: string;
+ resultPopSeed: string;
+ version: 2;
+};
+
function cliFlowAad(stateId: string, pubky: string, epoch: number): Uint8Array {
const pubkyBytes = utf8.encode(pubky);
return concat(
@@ -356,6 +408,64 @@ export function sealCliFlowContext(
);
}
+export function sealBrowserFlowContext(
+ config: MarketplaceGrantConfig,
+ stateId: string,
+ pubky: string,
+ context: BrowserStateContext,
+): Uint8Array {
+ return seal(
+ config,
+ config.stateKeyEpoch,
+ utf8.encode(canonicalJson(context)),
+ cliFlowAad(stateId, pubky, config.stateKeyEpoch),
+ );
+}
+
+export class BrowserContextRefused extends Error {
+ constructor(readonly reason: 'epoch_unavailable' | 'not_browser') {
+ super(reason);
+ }
+}
+
+/**
+ * Opens a browser bootstrap context. An epoch whose key is gone is
+ * `epoch_unavailable` (fresh approval); anything that is not a canonical
+ * `{version: 2, kind: 'browser'}` envelope, including a CLI context, is
+ * `not_browser` (result denied).
+ */
+export function openBrowserFlowContext(
+ config: MarketplaceGrantConfig,
+ stateId: string,
+ pubky: string,
+ epoch: number,
+ sealed: Uint8Array,
+): BrowserStateContext {
+ try {
+ rootForEpoch(config, epoch);
+ } catch {
+ throw new BrowserContextRefused('epoch_unavailable');
+ }
+ let parsed: BrowserStateContext;
+ let text: string;
+ try {
+ text = new TextDecoder().decode(open(config, epoch, sealed, cliFlowAad(stateId, pubky, epoch)));
+ parsed = JSON.parse(text) as BrowserStateContext;
+ } catch {
+ throw new BrowserContextRefused('not_browser');
+ }
+ if (
+ parsed.version !== 2 ||
+ parsed.kind !== 'browser' ||
+ canonicalJson(parsed) !== text ||
+ decodeBase64Url32(parsed.resultDeliveryId).length !== 32 ||
+ decodeBase64Url32(parsed.resultPopSeed).length !== 32
+ ) {
+ throw new BrowserContextRefused('not_browser');
+ }
+ return parsed;
+}
+
export function openCliFlowContext(
config: MarketplaceGrantConfig,
stateId: string,
diff --git a/src/test/vrt/marketplace/MarketplaceSessionConnect.vrt.test.tsx b/src/test/vrt/marketplace/MarketplaceSessionConnect.vrt.test.tsx
index 019e5d9eb..bd893cebd 100644
--- a/src/test/vrt/marketplace/MarketplaceSessionConnect.vrt.test.tsx
+++ b/src/test/vrt/marketplace/MarketplaceSessionConnect.vrt.test.tsx
@@ -34,8 +34,12 @@ const view = vi.hoisted(() => ({
errorMessage: null as string | null,
isOpeningRing: false,
grantEnabled: false,
+ bootstrap: false,
}));
+const VRT_BOOTSTRAP_URL =
+ 'pubkyauth://signin_grant?caps=%2Fpub%2Fpubky.app%2Fmarketplace-service%2Fv1%2F%3Arw&relay=https%3A%2F%2Fvrt.invalid%2Finbox&secret=vrt-fixed-secret&cid=vrt.invalid&cpk=vrt-fixed-cpk';
+
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/marketplace/orders',
@@ -59,7 +63,8 @@ vi.mock('@/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect', ()
// Bridged / narrow-grant arrival: the full-grant copy (mirrors the
// CommerceController.hasFullHomeserverGrant mock above).
requestsFullGrant: true,
- requestsGrantReconnect: view.grantEnabled,
+ requestsGrantReconnect: view.grantEnabled && !view.bootstrap,
+ requestsGrantBootstrap: view.bootstrap,
start: vi.fn(),
cancel: vi.fn(),
copyAuthUrl: vi.fn(async () => {}),
@@ -91,6 +96,22 @@ describe('Marketplace session connect — visual regression', () => {
view.errorMessage = null;
view.isOpeningRing = false;
view.grantEnabled = false;
+ view.bootstrap = false;
+ });
+
+ it('renders the Bitkit purchase bootstrap approval at desktop viewport', async () => {
+ view.grantEnabled = true;
+ view.bootstrap = true;
+ view.authorizationUrl = VRT_BOOTSTRAP_URL;
+
+ const screen = await renderForVRT(
+
+
+ ,
+ { viewport: VRT_VIEWPORT_DESKTOP },
+ );
+ await openDialog(screen.getByRole('button', { name: 'Approve purchases' }));
+ await expect(screen.getByTestId(VRT_ROOT_TESTID)).toMatchScreenshot('session-connect-bitkit-bootstrap-desktop');
});
it('renders the awaiting-approval QR state at desktop viewport', async () => {
From 3ceca5cd6653a9e3d665c6d1871266ed0aff5e8d Mon Sep 17 00:00:00 2001
From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com>
Date: Wed, 23 Sep 2026 18:21:38 +0100
Subject: [PATCH 2/4] feat(marketplace): Bitkit copy on the purchase card, Lock
Server step for both signers
The session-required card names Bitkit for a Bitkit sign-in that can run
the purchase bootstrap. The Lock Server step and its dialog now say to
approve or scan with Pubky Ring or Bitkit, since the Lock Server offers a
Bitkit QR too. The bootstrap client refuses (and cancels) a marketplace
QR that asks Bitkit for anything beyond the marketplace-service
capability.
---
.../MarketplaceGetPaidSettings.tsx | 6 +--
.../MarketplaceSessionRequiredCard.test.tsx | 48 +++++++++++++++++++
.../MarketplaceSessionRequiredCard.tsx | 14 ++++--
.../marketplace-bootstrap-client.test.ts | 25 +++++++++-
.../marketplace-bootstrap-client.ts | 16 ++++++-
5 files changed, 99 insertions(+), 10 deletions(-)
create mode 100644 src/components/organisms/Marketplace/MarketplaceSessionRequiredCard.test.tsx
diff --git a/src/components/organisms/Marketplace/MarketplaceGetPaidSettings.tsx b/src/components/organisms/Marketplace/MarketplaceGetPaidSettings.tsx
index aec072edd..99ee7eec6 100644
--- a/src/components/organisms/Marketplace/MarketplaceGetPaidSettings.tsx
+++ b/src/components/organisms/Marketplace/MarketplaceGetPaidSettings.tsx
@@ -350,8 +350,8 @@ export function MarketplaceGetPaidSettings({ locksConnect, onSaved }: Marketplac
Step 1 Connect your Lock Server
- Approve the connection in Pubky Ring. The Lock Server can then lock your content for buyers — it never
- sees your identity secret.
+ Approve the connection in Pubky Ring or Bitkit. The Lock Server can then lock your content for buyers — it
+ never sees your identity secret.
{step1Connected && (
@@ -482,7 +482,7 @@ export function MarketplaceGetPaidSettings({ locksConnect, onSaved }: Marketplac
Connect Lock Server
- Scan the code with Pubky Ring. The Lock Server can then lock your content for buyers — it never sees your
+ Scan with Pubky Ring or Bitkit. The Lock Server can then lock your content for buyers — it never sees your
identity secret.
{connectUrl && (
diff --git a/src/components/organisms/Marketplace/MarketplaceSessionRequiredCard.test.tsx b/src/components/organisms/Marketplace/MarketplaceSessionRequiredCard.test.tsx
new file mode 100644
index 000000000..eb6d18864
--- /dev/null
+++ b/src/components/organisms/Marketplace/MarketplaceSessionRequiredCard.test.tsx
@@ -0,0 +1,48 @@
+import { render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { MarketplaceSessionRequiredCard } from './MarketplaceSessionRequiredCard';
+
+const view = vi.hoisted(() => ({ isGrantSession: false, grantEnabled: false }));
+
+vi.mock('@/hooks/useIsGrantSession/useIsGrantSession', () => ({
+ useIsGrantSession: () => view.isGrantSession,
+}));
+
+vi.mock('@/libs/runtime-config/runtime-config', async (importOriginal) => ({
+ ...(await importOriginal()),
+ getMarketplaceGrantFlowEnabled: () => view.grantEnabled,
+}));
+
+vi.mock('./MarketplaceSessionConnectDialog', () => ({
+ MarketplaceSessionConnectDialog: ({ triggerLabel }: { triggerLabel: string }) => {triggerLabel} ,
+}));
+
+describe('MarketplaceSessionRequiredCard', () => {
+ beforeEach(() => {
+ view.isGrantSession = false;
+ view.grantEnabled = false;
+ });
+
+ it('names Pubky Ring for a Ring sign-in', () => {
+ render( );
+
+ expect(screen.getByRole('heading', { name: 'Approve purchases in Pubky Ring' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Approve in Pubky Ring' })).toBeInTheDocument();
+ });
+
+ it('names Bitkit for a Bitkit sign-in that can bootstrap', () => {
+ view.isGrantSession = true;
+ view.grantEnabled = true;
+ render( );
+
+ expect(screen.getByRole('heading', { name: 'Approve purchases in Bitkit' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Approve in Bitkit' })).toBeInTheDocument();
+ });
+
+ it('keeps Pubky Ring copy for a Bitkit sign-in when the grant flow is off', () => {
+ view.isGrantSession = true;
+ render( );
+
+ expect(screen.getByRole('heading', { name: 'Approve purchases in Pubky Ring' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/organisms/Marketplace/MarketplaceSessionRequiredCard.tsx b/src/components/organisms/Marketplace/MarketplaceSessionRequiredCard.tsx
index 31ff99673..06df2aef9 100644
--- a/src/components/organisms/Marketplace/MarketplaceSessionRequiredCard.tsx
+++ b/src/components/organisms/Marketplace/MarketplaceSessionRequiredCard.tsx
@@ -3,14 +3,18 @@
import { KeyRound } from 'lucide-react';
import { Heading } from '@/atoms/Heading/Heading';
import { Typography } from '@/atoms/Typography/Typography';
+import { useIsGrantSession } from '@/hooks/useIsGrantSession/useIsGrantSession';
+import { getMarketplaceGrantFlowEnabled } from '@/libs/runtime-config/runtime-config';
import { MarketplaceSessionConnectDialog } from './MarketplaceSessionConnectDialog';
/**
- * Shows static Pubky Ring approval copy on durable marketplace surfaces when
- * the durable transport reports `isMarketplaceSessionRequiredError`. Sandbox
- * surfaces never see this card because they do not use the durable transport.
+ * Shows static approval copy on durable marketplace surfaces when the durable
+ * transport reports `isMarketplaceSessionRequiredError`: Bitkit for a Bitkit
+ * (grant) sign-in that can bootstrap, Pubky Ring otherwise. Sandbox surfaces
+ * never see this card because they do not use the durable transport.
*/
export function MarketplaceSessionRequiredCard({ onConnected }: { onConnected?: () => void | Promise }) {
+ const signer = useIsGrantSession() && getMarketplaceGrantFlowEnabled() ? 'Bitkit' : 'Pubky Ring';
return (
- Approve purchases in Pubky Ring
+ Approve purchases in {signer}
One approval lets you buy, bid, and make offers on this marketplace. Nothing is charged until you pay.
-
+
);
}
diff --git a/src/core/services/marketplace/marketplace-bootstrap-client.test.ts b/src/core/services/marketplace/marketplace-bootstrap-client.test.ts
index 277a89a21..1f0b20250 100644
--- a/src/core/services/marketplace/marketplace-bootstrap-client.test.ts
+++ b/src/core/services/marketplace/marketplace-bootstrap-client.test.ts
@@ -38,8 +38,11 @@ function challenge(overrides: Record
= {}) {
};
}
+const BOOTSTRAP_URL =
+ 'pubkyauth://signin_grant?caps=%2Fpub%2Fpubky.app%2Fmarketplace-service%2Fv1%2F%3Arw&relay=https%3A%2F%2Frelay.example%2Finbox&secret=s&cid=shop.example&cpk=k';
+
const verified = {
- authorization_url: 'pubkyauth://signin_grant?caps=x',
+ authorization_url: BOOTSTRAP_URL,
expires_at: new Date(Date.now() + 120_000).toISOString(),
state_id: STATE_ID,
status: 'awaiting',
@@ -144,6 +147,26 @@ describe('marketplace purchase bootstrap client', () => {
]);
});
+ it('refuses and cancels a bootstrap QR that asks Bitkit for more than purchases', async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse(challenge(), 201))
+ .mockResolvedValueOnce(
+ jsonResponse({
+ ...verified,
+ authorization_url: BOOTSTRAP_URL.replace(
+ 'caps=%2Fpub%2Fpubky.app%2Fmarketplace-service%2Fv1%2F%3Arw',
+ 'caps=%2Fpub%2Fpubky.app%2F%3Arw',
+ ),
+ }),
+ )
+ .mockResolvedValue(new Response(null, { status: 204 }));
+
+ await expect(beginMarketplaceBootstrapFlow({ pubky: PUBKY })).rejects.toThrow('result_denied');
+ expect(fetchMock.mock.calls.map((call) => call[0])).toContain(
+ `/api/marketplace/bootstrap-flows/${STATE_ID}/cancel`,
+ );
+ });
+
it('cancel posts to the bootstrap cancel route once', async () => {
fetchMock
.mockResolvedValueOnce(jsonResponse(challenge(), 201))
diff --git a/src/core/services/marketplace/marketplace-bootstrap-client.ts b/src/core/services/marketplace/marketplace-bootstrap-client.ts
index 79c846ae0..ecf21aeab 100644
--- a/src/core/services/marketplace/marketplace-bootstrap-client.ts
+++ b/src/core/services/marketplace/marketplace-bootstrap-client.ts
@@ -40,6 +40,9 @@ const pollSchema = z.object({
expires_at: z.string().optional(),
});
+/** The only capability the marketplace bootstrap grant may ask Bitkit for. */
+export const MARKETPLACE_BOOTSTRAP_CAPABILITIES = '/pub/pubky.app/marketplace-service/v1/:rw';
+
function bootstrapFailure(code: string) {
return Err.server(ServerErrorCode.SERVICE_UNAVAILABLE, code, {
service: ErrorService.Marketplace,
@@ -97,6 +100,17 @@ export async function beginMarketplaceBootstrapFlow({ pubky }: { pubky: string }
});
}
+ const cancelFlow = async () => {
+ await post(`/api/marketplace/bootstrap-flows/${verified.state_id}/cancel`, {}).catch(() => undefined);
+ };
+ // Never show Bitkit a QR that asks for more than purchases.
+ if (
+ new URL(verified.authorization_url).searchParams.getAll('caps').join(',') !== MARKETPLACE_BOOTSTRAP_CAPABILITIES
+ ) {
+ await cancelFlow();
+ throw bootstrapFailure('result_denied');
+ }
+
let cancelled = false;
return {
authorizationUrl: verified.authorization_url,
@@ -119,7 +133,7 @@ export async function beginMarketplaceBootstrapFlow({ pubky }: { pubky: string }
cancel: async () => {
if (cancelled) return;
cancelled = true;
- await post(`/api/marketplace/bootstrap-flows/${verified.state_id}/cancel`, {}).catch(() => undefined);
+ await cancelFlow();
},
};
}
From 3be1ffd84c7d173aa3ecb27482e5623ce5f418f6 Mon Sep 17 00:00:00 2001
From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com>
Date: Thu, 24 Sep 2026 08:17:37 +0100
Subject: [PATCH 3/4] feat(marketplace): caption the Bitkit purchase QR with
the client Bitkit shows
The bootstrap QR now says which client Bitkit will name and that the
request covers marketplace purchases only, read from the authorization
URL. A fixture pins the staging bootstrap URL shape (signin_grant with
caps, relay, secret, cid, cpk and only the marketplace-service
capability), and the client test checks the Shop's capability pin and
caption against it.
---
.../MarketplaceSessionConnectDialog.test.tsx | 16 ++++++++
.../MarketplaceSessionConnectDialog.tsx | 13 +++++++
.../marketplace-bootstrap-client.test.ts | 37 ++++++++++++++++++-
.../marketplace-bootstrap-client.ts | 14 +++++++
.../marketplace-bootstrap-url.staging.json | 8 ++++
5 files changed, 87 insertions(+), 1 deletion(-)
create mode 100644 src/test/fixtures/auth/marketplace-bootstrap-url.staging.json
diff --git a/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.test.tsx b/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.test.tsx
index f68100da7..5b9250dd8 100644
--- a/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.test.tsx
+++ b/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.test.tsx
@@ -108,6 +108,22 @@ describe('MarketplaceSessionConnectDialog', () => {
expect(screen.getByRole('button', { name: 'Open in Bitkit' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /open in pubky ring/i })).not.toBeInTheDocument();
expect(screen.getByText('Waiting for approval in Bitkit…')).toBeInTheDocument();
+ expect(screen.queryByTestId('bootstrap-approval-caption')).not.toBeInTheDocument();
+ });
+
+ it('bootstrap QR carries the caption naming the client Bitkit will show', () => {
+ view.status = 'awaiting';
+ view.authorizationUrl =
+ 'pubkyauth://signin_grant?caps=%2Fpub%2Fpubky.app%2Fmarketplace-service%2Fv1%2F%3Arw&relay=r&secret=s&cid=marketplace.staging.shop.pubky.app&cpk=k';
+ view.isGrantSession = true;
+ view.grantEnabled = true;
+ view.requestsGrantBootstrap = true;
+
+ render( );
+
+ expect(screen.getByTestId('bootstrap-approval-caption')).toHaveTextContent(
+ 'Bitkit shows this request from marketplace.staging.shop.pubky.app, for marketplace purchases only.',
+ );
});
it('bootstrap creating state confirms with the homeserver', () => {
diff --git a/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.tsx b/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.tsx
index 6cdf720bd..1556308f1 100644
--- a/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.tsx
+++ b/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.tsx
@@ -12,6 +12,7 @@ import { getMarketplaceGrantFlowEnabled } from '@/libs/runtime-config/runtime-co
import { GrantSessionRefusal } from '@/molecules/GrantSessionRefusal/GrantSessionRefusal';
import { QrCodeSlot } from '@/molecules/QrCodeSlot/QrCodeSlot';
import { toast } from '@/molecules/Toaster/use-toast';
+import { bootstrapApprovalCaption } from '@/services/marketplace/marketplace-bootstrap-client';
/**
* The in-app UX for establishing a marketplace transaction-service session
@@ -80,6 +81,8 @@ export function MarketplaceSessionConnectDialog({
const requestsFullGrant = session.requestsFullGrant;
const requestsGrantReconnect = session.requestsGrantReconnect;
const requestsGrantBootstrap = session.requestsGrantBootstrap;
+ const bootstrapCaption =
+ requestsGrantBootstrap && session.authorizationUrl ? bootstrapApprovalCaption(session.authorizationUrl) : null;
return (
@@ -156,6 +159,16 @@ export function MarketplaceSessionConnectDialog({
/>
+ {bootstrapCaption && (
+
+ {bootstrapCaption}
+
+ )}
+
{session.status === 'awaiting' && (