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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 13 additions & 0 deletions src/app/api/marketplace/bootstrap-challenges/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
17 changes: 17 additions & 0 deletions src/app/api/marketplace/bootstrap-flows/[stateId]/cancel/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
14 changes: 14 additions & 0 deletions src/app/api/marketplace/bootstrap-flows/[stateId]/status/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
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 (
<div
role="status"
data-testid="grant-session-refusal"
className="rounded-xl border border-border bg-muted/40 p-4 text-sm text-muted-foreground"
>
<Typography as="p" className="text-sm text-muted-foreground">
{'Bitkit sign-in does not cover this step yet. Sign in with Pubky Ring to continue.'}
{message}
</Typography>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,8 @@ export function MarketplaceGetPaidSettings({ locksConnect, onSaved }: Marketplac
<span className="text-brand">Step 1</span> Connect your Lock Server
</Typography>
<Typography as="p" className="text-sm text-muted-foreground">
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.
</Typography>
{step1Connected && (
<Typography as="p" className="mt-2 flex items-center gap-2 text-sm text-brand">
Expand Down Expand Up @@ -482,7 +482,7 @@ export function MarketplaceGetPaidSettings({ locksConnect, onSaved }: Marketplac
<DialogTitle>Connect Lock Server</DialogTitle>
</DialogHeader>
<Typography as="p" className="text-sm text-muted-foreground">
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.
</Typography>
{connectUrl && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MarketplaceInventoryGrantDialog autoOpen />);

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(<MarketplaceInventoryGrantDialog autoOpen />);

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(<MarketplaceMessagingEnablePanel reconnect={false} />);

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();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -68,10 +68,10 @@ export function MarketplaceInventoryGrantDialog({
<DialogTitle>Approve inventory access</DialogTitle>
</DialogHeader>
<Typography as="p" className="text-sm text-muted-foreground">
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.
</Typography>
{isGrantSession ? (
<GrantSessionRefusal />
<GrantSessionRefusal message={GRANT_SESSION_REFUSAL_COPY.inventory} />
) : grant.status === 'error' ? (
<div role="alert" className="rounded-xl border border-destructive/40 p-4 text-sm">
{grant.errorMessage}
Expand Down Expand Up @@ -108,7 +108,7 @@ export function MarketplaceInventoryGrantDialog({
disabled={!grant.authorizationUrl || grant.isOpeningSigner}
>
<Smartphone className="mr-2 size-4" />
Open in Bitkit / Ring
Open in Pubky Ring
</Button>
<Button
variant="ghost"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useIsGrantSession } from '@/hooks/useIsGrantSession/useIsGrantSession';
import { useMarketplaceMessagingEnable } from '@/hooks/useMarketplaceMessagingEnable/useMarketplaceMessagingEnable';
import { MESSAGING_COPY } from '@/libs/commerce/messaging-copy';
import { Logger } from '@/libs/logger/logger';
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';

Expand Down Expand Up @@ -72,7 +72,7 @@ export function MarketplaceMessagingEnablePanel({
</Typography>

{isGrantSession ? (
<GrantSessionRefusal />
<GrantSessionRefusal message={GRANT_SESSION_REFUSAL_COPY.messaging} />
) : enable.status === 'error' ? (
<div className="grid gap-3">
<div role="alert" className="rounded-xl border border-destructive/40 p-4 text-sm">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('@/libs/runtime-config/runtime-config')>()),
getMarketplaceGrantFlowEnabled: () => view.grantEnabled,
}));
vi.mock('@/hooks/useIsGrantSession/useIsGrantSession', () => ({
useIsGrantSession: () => view.isGrantSession,
}));
Expand All @@ -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 () => {}),
Expand Down Expand Up @@ -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;
Expand All @@ -78,6 +87,56 @@ 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(<MarketplaceSessionConnectDialog autoOpen />);

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();
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(<MarketplaceSessionConnectDialog />);

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', () => {
view.status = 'creating';
view.isGrantSession = true;
view.grantEnabled = true;
view.requestsGrantBootstrap = true;

render(<MarketplaceSessionConnectDialog />);

expect(screen.getByText('Confirming with your homeserver…')).toBeInTheDocument();
});

it('a cookie session still starts the classic approval when opened', () => {
view.status = 'awaiting';
render(<MarketplaceSessionConnectDialog autoOpen />);
Expand Down
Loading
Loading