Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0d53eca
fix(auth): sign out and drop the keys of a grant session that cannot …
BitcoinErrorLog Sep 24, 2026
2b3a7d0
fix(auth): drop a pending #s= export after a grant session restore
BitcoinErrorLog Sep 24, 2026
7d8e29a
fix(auth): keep the authorization URL out of the QR slot markup
BitcoinErrorLog Sep 24, 2026
65f6708
fix(marketplace): end a bootstrap flow whose claim lease lapsed
BitcoinErrorLog Sep 24, 2026
15af2e8
fix(marketplace): terminalize a cancelled bootstrap flow before openi…
BitcoinErrorLog Sep 24, 2026
6a60b15
fix(marketplace): key the challenge pubky bucket by client IP
BitcoinErrorLog Sep 24, 2026
a383356
fix(auth): hold every grant session to the full Shop grant and drop i…
BitcoinErrorLog Sep 24, 2026
a5a2109
fix(auth): confirm a #s= session hand-off before it signs the tab in
BitcoinErrorLog Sep 24, 2026
ed0e90c
docs(changelog): note the Bitkit sign-in and hand-off fixes
BitcoinErrorLog Sep 24, 2026
255157e
style(auth): format the grant backlog tests and sort the restore imports
BitcoinErrorLog Sep 24, 2026
2bc65d1
fix(auth): keep a failed grant-key cleanup pending until the store is…
BitcoinErrorLog Sep 24, 2026
9390b58
fix(marketplace): drop the per-pubky challenge bucket
BitcoinErrorLog Sep 24, 2026
0645f3c
fix(marketplace): key rate limits by the platform-written client hop
BitcoinErrorLog Sep 24, 2026
a2a2e07
fix(auth): refuse a step-up for a grant session at the controller
BitcoinErrorLog Sep 24, 2026
b656309
test(auth): give the side-by-side Bitkit fixture the full Shop grant
BitcoinErrorLog Sep 24, 2026
32fdbc3
Merge branch 'release/shop-v0.6.8' into cursor/bitkit-grant-backlog-240a
BitcoinErrorLog Sep 24, 2026
1b77ca8
Merge branch 'release/shop-v0.6.8' into cursor/bitkit-grant-backlog-240a
BitcoinErrorLog Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/next/48.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A link that opens the Shop with a pubky.app session hand-off now asks "Continue as this account?" and names the pubky before it signs the tab in. Bitkit sign-in no longer leaves an approved session behind when the browser cannot save it, and it refuses an approval narrower than the Shop permissions.
3 changes: 2 additions & 1 deletion docs/adr/0029-vibe-session-consumer.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Both are read as literal `process.env.NEXT_PUBLIC_*` so Next inlines them. They

1. If a persisted `sessionExport` exists, restore it with the existing retry loop (`HomeserverService.restoreSession` + homeserver environment check).
2. If there is **no** persist, or persist fails with a **definitive auth** error (`AppError` auth category except wrong-environment, or `isPubkyExpiredError`), and consumer mode is on: obtain an export via **fragment → bridge**, then run the same `restoreSession` path.
3. Fragment `#s=` is consumed on the first client pass (`instrumentation-client.ts` via `consumeFragmentSessionExport`) and taken once by restore (`takeFragmentSessionExport`). The hash is stripped with `history.replaceState` even when consumer mode is off.
3. Fragment `#s=` is consumed on the first client pass (`instrumentation-client.ts` via `consumeFragmentSessionExport`) and taken once by restore (`takeFragmentSessionExport`). The hash is stripped with `history.replaceState` even when consumer mode is off. A fragment export that restores is **not applied until the user confirms it**: the Controller's `confirmSessionHandoff` opens `DialogSessionHandoff`, which names the pubky the link would sign the tab in as. Only Continue applies it. Not me, Escape, or a logout declines it; a declined hand-off also suppresses the bridge leg for the tab, so the refused identity is not applied silently by another route. Application declines every hand-off when no confirmer is passed.
4. Bridge: hidden iframe to `${bridgeOrigin}/session-bridge`, `sandbox="allow-scripts allow-same-origin"`. After `load`, post `{ type: 'pubky-session-request', v: 1 }` to `bridgeOrigin`. Accept a reply only if `event.origin === bridgeOrigin && event.source === iframe.contentWindow && data.v === 1`. Load timeout 15 s; reply timeout 3 s from load; one request per load; `AbortSignal`; cleanup of listener / iframe / timers on every path; late messages ignored.

### Contract
Expand Down Expand Up @@ -75,6 +75,7 @@ RouteGuard and auth-store rehydrate both call `shouldAttemptSessionRestore` (`sr
- The homeserver **HttpOnly cookie** binds identity. The consumer never reads or copies that cookie.
- Accept `postMessage` only from `bridgeOrigin` and the iframe `contentWindow`. Never `'*'`.
- Strip `#s=` before any auth-dependent routing or network.
- A `#s=` hand-off needs the user's confirmation of the named pubky. Any page can link to the Shop with `#s=<export>` for a session whose cookie this browser holds, including one a third party may have planted through a cross-site sign-in, and nothing binds the link to this device: the board opens the Shop without a Shop-issued state or nonce to echo. Binding the hand-off to a same-device nonce needs the board to carry that nonce; until it does, the prompt is the control.
- The iframe sandbox allows scripts and same-origin so the bridge page keeps the pubky-app origin; it cannot navigate the parent.

## Consequences
Expand Down
8 changes: 8 additions & 0 deletions docs/ecommerce/step-up-approval.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ Approvals per payment method and feature under Option C:
- Manual, blocks launch: test what Pubky Ring displays for an empty-capabilities pubkyauth request (`caps=''`); if it does not visibly distinguish empty from wide, file a Ring issue before launch (the QR/phish-swap row's "low-value empty-caps prompt" reasoning depends on this).
- marketplace-service: `create_session` accepts an empty-capabilities token; posting identical bytes twice returns 401 on the second call (integration-level replay test — current `auth.rs:269–374` tests cover verification, not the single-use INSERT path).

## Grant (Bitkit) sessions need no step-up

A Bitkit sign-in (`pubkyauth://signin_grant`) requests exactly `CAPABILITIES`, and the Shop refuses anything else: `AuthApplication.assertFullGrantSession` signs out and rejects an approved grant session whose `info.capabilities` do not match `capabilitiesMatchFullGrant`, and a stored grant session that restores narrower is signed out and its record removed. Every live grant session therefore already holds `/priv/pubky.app/:rw`, so `canCurrentSessionWrite(PRIVATE_APP_DATA_PATH)` is true and the capability-based `needs_reauth` state cannot occur for it.

The other `needs_reauth` trigger is a 401/403 on the private document. For a grant session with the full grant, that refusal means the grant itself is no longer honored (revoked in Bitkit, or expired). A step-up approval widens scope; it cannot repair a refused grant. `CommerceApplication.isPrivateAccessDenied` therefore does not report `needs_reauth` for a grant session: watchlist sync reports `error` (the outbox job stays pending) and receipt publication reports `unavailable`, both retried on the next load.

`MarketplaceReauthDialog` renders only in the `needs_reauth` state, so it never opens for a grant session. A delegated-grant step-up QR (contract row R3.9a) is not built: no state reaches it. `AuthController.getStepUpAuthUrl` still refuses a grant session before any Ring flow starts, so a future grant path that skips the full-grant checks gets an error in the dialog, not a Ring step-up that would replace the grant session with a cookie session while its grant record stays stored.

## Verification that differed from the brief

1. **`signinWithAuthToken` does not exist.** `pubky.d.ts` has no AuthToken→Session API (only `AuthFlow.awaitApproval` line 188, `awaitToken` line 198, `Pubky.restoreSession` line 831, `Signer.signin` line 1294). Option A's "same bytes sign in to the homeserver" requires re-implementing protocol internals, not an SDK call.
Expand Down
3 changes: 3 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Metadata } from '@/molecules/Metadata/Metadata';
import { StructuredData } from '@/molecules/StructuredData/StructuredData';
import { Toaster } from '@/molecules/Toaster/Toaster';
import { CoordinatorsManager } from '@/organisms/CoordinatorsManager/CoordinatorsManager';
import { DialogSessionHandoff } from '@/organisms/DialogSessionHandoff/DialogSessionHandoff';
import { DialogSignIn } from '@/organisms/DialogSignIn/DialogSignIn';
import { Header } from '@/organisms/Header/Header';
import { DatabaseProvider } from '@/providers/DatabaseProvider/DatabaseProvider';
Expand Down Expand Up @@ -56,6 +57,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<Toaster />
<DialogSignIn />
</RouteGuardProvider>
{/* Outside RouteGuardProvider: it waits on the restore this dialog answers. */}
<DialogSessionHandoff />
</DatabaseProvider>
</ErrorBoundaryProvider>
</GlobalErrorHandlerProvider>
Expand Down
29 changes: 29 additions & 0 deletions src/components/molecules/QrCodeSlot/QrCodeSlot.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { QrCodeSlot } from './QrCodeSlot';

vi.mock('next/image', () => ({
__esModule: true,
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
}));

const RELAY_SECRET = 'c2VjcmV0LWNoYW5uZWwta2V5LWZvci10aGlzLWZsb3c';
const AUTH_URL = `pubkyauth://signin_grant?caps=%2Fpub%2Fpubky.app%2F%3Arw&relay=https%3A%2F%2Frelay.example%2Finbox&secret=${RELAY_SECRET}&cid=shop.pubky.app`;

describe('QrCodeSlot with the real QR renderer', () => {
it('keeps the relay secret out of the DOM markup', () => {
const { container } = render(
<QrCodeSlot
isLoading={false}
isExpired={false}
url={AUTH_URL}
generatingLabel="Generating..."
clickToReloadLabel="Click to reload"
/>,
);

expect(container.querySelector('svg')).not.toBeNull();
expect(container.innerHTML).not.toContain(RELAY_SECRET);
expect(container.innerHTML).not.toContain('pubkyauth://');
});
});
6 changes: 5 additions & 1 deletion src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ describe('QrCodeSlot', () => {
const qr = screen.getByTestId('qrcode-svg');
expect(qr).toHaveAttribute('data-value', 'auth-url');
expect(qr).toHaveAttribute('width', '176');
expect(screen.getByTestId('qr-auth-url')).toHaveAttribute('data-auth-url', 'auth-url');
const slotAttributeValues = Array.from(
screen.getByTestId('qr-auth-url').attributes,
(attribute) => attribute.value,
);
expect(slotAttributeValues).not.toContain('auth-url');

const ringLogo = screen.getByAltText('Pubky Ring');
expect(ringLogo).toHaveAttribute('src', '/images/ring-logo.svg');
Expand Down
3 changes: 0 additions & 3 deletions src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ exports[`QrCodeSlot - Snapshots > matches snapshot for a non-default size 1`] =
<div>
<span
class="contents"
data-auth-url="auth-url"
data-testid="qr-auth-url"
>
<svg
Expand All @@ -30,7 +29,6 @@ exports[`QrCodeSlot - Snapshots > matches snapshot for active QR with hover effe
<div>
<span
class="contents"
data-auth-url="auth-url"
data-testid="qr-auth-url"
>
<svg
Expand All @@ -56,7 +54,6 @@ exports[`QrCodeSlot - Snapshots > matches snapshot for active QR without hover e
<div>
<span
class="contents"
data-auth-url="auth-url"
data-testid="qr-auth-url"
>
<svg
Expand Down
4 changes: 3 additions & 1 deletion src/components/molecules/QrCodeSlot/QrCodeSlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ export function QrCodeSlot({
return blurredQr;
}

// The URL carries the relay channel secret: it is drawn into the QR only,
// never written to a DOM attribute where a CSS attribute selector can read it.
return (
<span data-testid="qr-auth-url" data-auth-url={url} className="contents">
<span data-testid="qr-auth-url" className="contents">
<QRCodeSVG value={url} size={size} className={cn(activeQrHasHoverEffect && HOVER_OPACITY)} />
{showRingLogo && (
<Image
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Pubky } from '@/models/models.types';
import { useSessionHandoffStore } from '@/stores/sessionHandoff/sessionHandoff.store';
import { DialogSessionHandoff } from './DialogSessionHandoff';

const answerSessionHandoff = vi.hoisted(() => vi.fn());
vi.mock('@/controllers/auth/auth', () => ({
AuthController: { answerSessionHandoff },
}));

const PUBKY = 'o1gg8yc7mj4ksrzr6ms3s5rs8h7bo8y7ohcq7j88wbkm7ns7tuxo' as Pubky;

describe('DialogSessionHandoff', () => {
afterEach(() => {
act(() => useSessionHandoffStore.getState().setPendingPubky(null));
answerSessionHandoff.mockClear();
});

it('renders nothing while no hand-off waits', () => {
render(<DialogSessionHandoff />);

expect(screen.queryByTestId('session-handoff-dialog')).not.toBeInTheDocument();
});

it('names the account the link would sign in as', () => {
act(() => useSessionHandoffStore.getState().setPendingPubky(PUBKY));
render(<DialogSessionHandoff />);

expect(screen.getByRole('heading', { name: 'Continue as this account?' })).toBeInTheDocument();
expect(screen.getByTestId('session-handoff-pubky')).toHaveTextContent('pubkyo1gg8yc7...7ns7tuxo');
});

it('Continue accepts the hand-off', async () => {
act(() => useSessionHandoffStore.getState().setPendingPubky(PUBKY));
render(<DialogSessionHandoff />);

await userEvent.setup().click(screen.getByTestId('session-handoff-accept'));

expect(answerSessionHandoff).toHaveBeenCalledExactlyOnceWith(true);
});

it('Not me declines the hand-off', async () => {
act(() => useSessionHandoffStore.getState().setPendingPubky(PUBKY));
render(<DialogSessionHandoff />);

await userEvent.setup().click(screen.getByTestId('session-handoff-decline'));

expect(answerSessionHandoff).toHaveBeenCalledExactlyOnceWith(false);
});

it('dismissing the dialog declines the hand-off', async () => {
act(() => useSessionHandoffStore.getState().setPendingPubky(PUBKY));
render(<DialogSessionHandoff />);

await userEvent.setup().keyboard('{Escape}');

expect(answerSessionHandoff).toHaveBeenCalledExactlyOnceWith(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use client';

import { Button } from '@/atoms/Button/Button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/atoms/Dialog/Dialog';
import { Typography } from '@/atoms/Typography/Typography';
import { useSessionHandoff } from '@/hooks/useSessionHandoff/useSessionHandoff';
import { formatPublicKey, withPubkyPrefix } from '@/libs/utils/utils';

/**
* Asks before a `#s=` link signs this tab in. Closing the dialog any way other
* than Continue declines the hand-off.
*/
export function DialogSessionHandoff() {
const { pendingPubky, accept, decline } = useSessionHandoff();
if (!pendingPubky) return null;

return (
<Dialog
open
onOpenChange={(open) => {
if (!open) decline();
}}
>
<DialogContent className="sm:max-w-md" data-testid="session-handoff-dialog">
<DialogHeader className="pr-6">
<DialogTitle>Continue as this account?</DialogTitle>
<DialogDescription>
The link you opened signs this browser in to the Shop. Continue only if you opened it from your own Pubky
account.
</DialogDescription>
</DialogHeader>
<Typography
as="p"
className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all"
data-testid="session-handoff-pubky"
title={withPubkyPrefix(pendingPubky)}
>
{formatPublicKey({ key: pendingPubky, length: 16, includePrefix: true })}
</Typography>
<DialogFooter className="gap-2">
<Button variant="secondary" onClick={decline} data-testid="session-handoff-decline">
Not me
</Button>
<Button onClick={accept} data-testid="session-handoff-accept">
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,19 @@ vi.mock('@/hooks/useStepUpReauth/useStepUpReauth', () => ({
useStepUpReauth: () => reauth,
}));

const grant = vi.hoisted(() => ({ isGrantSession: false }));
vi.mock('@/hooks/useIsGrantSession/useIsGrantSession', () => ({
useIsGrantSession: () => grant.isGrantSession,
}));

describe('MarketplaceReauthDialog', () => {
beforeEach(() => {
grant.isGrantSession = false;
vi.mocked(reauth.start).mockClear();
});

it('grant session sees refusal not classic qr (step-up)', async () => {
grant.isGrantSession = true;
it('opening starts a fresh step-up flow and shows its QR', async () => {
render(<MarketplaceReauthDialog triggerLabel="Sign in again" />);

await userEvent.setup().click(screen.getByRole('button', { name: 'Sign in again' }));

expect(screen.getByTestId('grant-session-refusal')).toBeInTheDocument();
expect(screen.queryByLabelText('Copy authorization link')).not.toBeInTheDocument();
expect(reauth.start).not.toHaveBeenCalled();
expect(reauth.start).toHaveBeenCalledOnce();
expect(screen.getByLabelText('Copy authorization link')).toBeInTheDocument();
expect(screen.queryByTestId('grant-session-refusal')).not.toBeInTheDocument();
});

it('asks for a sign-in in product language and does not print capability paths', async () => {
Expand Down
11 changes: 3 additions & 8 deletions src/components/organisms/Marketplace/MarketplaceReauthDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@ import { Copy, KeyRound, Loader2, RefreshCw, Smartphone } from 'lucide-react';
import { Button } from '@/atoms/Button/Button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/atoms/Dialog/Dialog';
import { Typography } from '@/atoms/Typography/Typography';
import { useIsGrantSession } from '@/hooks/useIsGrantSession/useIsGrantSession';
import { useStepUpReauth } from '@/hooks/useStepUpReauth/useStepUpReauth';
import { Logger } from '@/libs/logger/logger';
import { GrantSessionRefusal } from '@/molecules/GrantSessionRefusal/GrantSessionRefusal';
import { QrCodeSlot } from '@/molecules/QrCodeSlot/QrCodeSlot';
import { toast } from '@/molecules/Toaster/use-toast';

Expand Down Expand Up @@ -47,14 +45,13 @@ export function MarketplaceReauthDialog({
// Referencing `reauth.start`/`reauth.cancel` directly keeps the effect
// dependency-stable: both are useCallback-memoized in the hook.
const { start, cancel } = reauth;
const isGrantSession = useIsGrantSession();
useEffect(() => {
if (open) {
if (!isGrantSession) start();
start();
return;
}
cancel();
}, [open, start, cancel, isGrantSession]);
}, [open, start, cancel]);

const copyUrl = async () => {
try {
Expand Down Expand Up @@ -83,9 +80,7 @@ export function MarketplaceReauthDialog({
Sign in again for this device.
</Typography>

{isGrantSession ? (
<GrantSessionRefusal />
) : reauth.status === 'error' ? (
{reauth.status === 'error' ? (
<div className="grid gap-3">
<div role="alert" className="rounded-xl border border-destructive/40 p-4 text-sm">
{reauth.errorMessage}
Expand Down
1 change: 0 additions & 1 deletion src/components/organisms/Scan/Scan.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ exports[`Scan Components - Snapshots > ScanContent - Snapshots > matches snapsho
>
<span
class="contents"
data-auth-url="mock-auth-url"
data-testid="qr-auth-url"
>
<img
Expand Down
1 change: 0 additions & 1 deletion src/components/organisms/SignIn/SignIn.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ exports[`SignInContent - Snapshots > matches snapshot for the QR sign-in layout
>
<span
class="contents"
data-auth-url="mock-auth-url"
data-testid="qr-auth-url"
>
<img
Expand Down
Loading
Loading