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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/next/purchase-session-other-tab.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
When your marketplace approval expires in one tab, Shop no longer deletes a newer approval you made in another tab, so the next reload stays connected. Signing out still removes every saved approval.
7 changes: 7 additions & 0 deletions docs/ecommerce/step-up-approval.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ The sections above describe the service token as empty-capability. That is no lo

A refused establish is never stored; the current session stays and the dialog shows why.

- **Clearing and overwriting the persisted session.** `localStorage` and the BFF session cookie are shared across tabs, so another tab may hold a newer bearer there. Every path that removes or overwrites the record touches only what it owns:
- `clearSession` (TTL margin in `getActiveSession`, revocation in Inventory automations, checkout hold expiry, a losing or failed sign-in, a step-up for another identity) removes the persisted record only when it still carries the in-memory bearer, and asks the BFF to unpair only that session's `session_id` (`DELETE /api/marketplace/session?session_id=…`, which deletes the bridge only while it pairs that session and keeps the shared cookie otherwise). With nothing in memory it removes nothing.
- A 401 (`MarketplaceTransactionService`, Inventory Studio's purchase bearer) and a session minted for another pubky clear through `clearSessionIfBearer`, only while the bearer the request carried is still the in-memory one.
- `restorePersistedSession` expires memory before reading the slot, and removes a malformed, other-account, expired or unexpected record only while the slot still holds exactly the record it read.
- `writePersistedSession` (both establish writers) does not overwrite a different bearer that expires later: another tab minted after this tab's request left.
- Sign-out and account switch (`AuthController` local-state cleanup) call `clearForSignOut`, the one path that removes a record it did not write and unpairs the cookie unscoped, because no purchase bearer may stay at rest for the user who is leaving.

## Re-approval routing for Bitkit and Pubky Ring sign-ins

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 homeserver-capability `needs_reauth` state cannot occur for it.
Expand Down
41 changes: 41 additions & 0 deletions src/app/api/marketplace/session/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/** @vitest-environment node */
import { NextRequest } from 'next/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DELETE } from './route';

const bff = vi.hoisted(() => ({ cleared: true }));

vi.mock('@/server/marketplace-grant/bff', async (importOriginal) => ({
...(await importOriginal<typeof import('@/server/marketplace-grant/bff')>()),
clearSession: async () => bff.cleared,
}));

function del(): NextRequest {
return new NextRequest('https://shop.example/api/marketplace/session?session_id=x', { method: 'DELETE' });
}

function deletedCookies(response: Response): string[] {
return response.headers
.getSetCookie()
.filter((cookie) => /Max-Age=0|Expires=Thu, 01 Jan 1970/i.test(cookie))
.map((cookie) => cookie.split('=')[0]);
}

describe('DELETE /api/marketplace/session', () => {
beforeEach(() => {
bff.cleared = true;
});

it('keeps the shared session cookie when the bridge belongs to another tab’s session', async () => {
bff.cleared = false;
const response = await DELETE(del());
expect(response.status).toBe(204);
expect(deletedCookies(response)).toEqual([]);
});

it('drops the session and flow cookies once the bridge is unpaired', async () => {
const response = await DELETE(del());
expect(response.status).toBe(204);
expect(deletedCookies(response).sort()).toEqual(['__Host-shop-bff-session', '__Host-shop-marketplace-grant']);
});
});
8 changes: 5 additions & 3 deletions src/app/api/marketplace/session/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ export async function POST(request: NextRequest) {

export async function DELETE(request: NextRequest) {
try {
await clearSession(request, request.cookies.get(SESSION_COOKIE)?.value);
const cleared = await clearSession(request, request.cookies.get(SESSION_COOKIE)?.value);
const response = new NextResponse(null, { status: 204 });
response.headers.set('cache-control', 'no-store, private');
response.cookies.delete(SESSION_COOKIE);
response.cookies.delete(FLOW_COOKIE);
if (cleared) {
response.cookies.delete(SESSION_COOKIE);
response.cookies.delete(FLOW_COOKIE);
}
return response;
} catch (error) {
return grantError(error);
Expand Down
27 changes: 20 additions & 7 deletions src/core/application/commerce/commerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1168,16 +1168,29 @@ export class CommerceApplication {
}

/**
* Drops the Marketplace Transaction Service session from memory and from
* `localStorage`. Part of the sign-out teardown: the bearer token must not
* survive the user it was minted for (this is the single cleanup point).
* The published-receipt memo backs the user-visible `published` status, so
* it is cleared here too — session teardown matches the store reset, and a
* later account re-reads its receipts instead of trusting a prior session.
* The Lock Server creator frontend session is wiped here for the same reason.
* Drops the Marketplace Transaction Service session this tab holds, with
* only its own persisted record (a newer bearer another tab persisted
* stays). The published-receipt memo backs the user-visible `published`
* status, so it is cleared here too — session teardown matches the store
* reset, and a later account re-reads its receipts instead of trusting a
* prior session. The Lock Server creator frontend session is wiped here for
* the same reason.
*/
static clearMarketplaceSession(): void {
MarketplaceSessionService.clearSession('cleared');
this.clearSessionScopedState();
}

/**
* Sign-out and account switch: also removes a purchase bearer another tab
* persisted, because none may outlive the user who is leaving.
*/
static clearMarketplaceSessionForSignOut(): void {
MarketplaceSessionService.clearForSignOut();
this.clearSessionScopedState();
}

private static clearSessionScopedState(): void {
LocksFrontendSessionStore.clear();
this.publishedReceiptUrls.clear();
this.ownReviewHomeserverMisses.clear();
Expand Down
2 changes: 1 addition & 1 deletion src/core/application/commerce/inventory-automations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ export class CommerceInventoryAutomationsApplication {
}
const identity = MarketplaceSessionService.getActiveSession();
if (identity && (identity.sessionId === id || (kind === 'purchase' && !identity.sessionId))) {
MarketplaceSessionService.clearSession('cleared');
MarketplaceSessionService.clearSessionIfBearer(identity.token, 'cleared');
}
}
}
4 changes: 2 additions & 2 deletions src/core/application/commerce/inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ describe('CommerceInventoryApplication', () => {

it('drops the purchase session, not the Studio slot, when the service rejects its bearer', async () => {
purchaseSessionOnly(capturedParity.parity_request.homeserver_verified);
const clearPurchase = vi.spyOn(MarketplaceSessionService, 'clearSession').mockImplementation(() => {});
const clearPurchase = vi.spyOn(MarketplaceSessionService, 'clearSessionIfBearer').mockImplementation(() => {});
const clearStudio = vi.spyOn(MarketplaceInventorySessionService, 'clearSession').mockImplementation(() => {});
vi.mocked(MarketplaceShopClientService.adjustInventory).mockResolvedValue({
ok: false,
Expand All @@ -341,7 +341,7 @@ describe('CommerceInventoryApplication', () => {
});

expect(result.status).toBe('grant-needed');
expect(clearPurchase).toHaveBeenCalledWith('rejected');
expect(clearPurchase).toHaveBeenCalledWith(TOKEN, 'rejected');
expect(clearStudio).not.toHaveBeenCalled();
});

Expand Down
2 changes: 1 addition & 1 deletion src/core/controllers/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1896,7 +1896,7 @@ describe('AuthController', () => {
const clearCookiesSpy = await spyOnClearCookies();
const clearAllQueryClientsSpy = await spyOnClearAllQueryClients();
const resetSpy = vi.spyOn(PubkySpecsSingleton, 'reset');
const clearMarketplaceSessionSpy = vi.spyOn(CommerceApplication, 'clearMarketplaceSession');
const clearMarketplaceSessionSpy = vi.spyOn(CommerceApplication, 'clearMarketplaceSessionForSignOut');
const resetTtlSpy = vi.spyOn(TtlCoordinator, 'resetInstance');
const resetStreamSpy = vi.spyOn(StreamCoordinator, 'resetInstance');
const resetNotifCoordSpy = vi.spyOn(NotificationCoordinator, 'resetInstance');
Expand Down
4 changes: 2 additions & 2 deletions src/core/controllers/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,8 +858,8 @@ export class AuthController {

// Reset singletons
PubkySpecsSingleton.reset();
// The marketplace transaction-service bearer token lives in memory only; drop it with the user.
CommerceController.clearMarketplaceSession();
// No marketplace bearer may stay at rest for the user who is leaving.
CommerceController.clearMarketplaceSessionForSignOut();
// Same rule for the encrypted-messaging homeserver session and its live link handles.
MessagingApplication.clearMessagingSession();
useMessagingStore.getState().clearMessagingEnabled();
Expand Down
33 changes: 32 additions & 1 deletion src/core/controllers/commerce/commerce.restore-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function otherTabPersists(capabilities: string): string {

describe('purchase-session restore never downgrades memory or the store mirror', () => {
beforeEach(() => {
MarketplaceSessionService.clearSession();
MarketplaceSessionService.clearForSignOut();
useCommerceStore.getState().reset();
});

Expand Down Expand Up @@ -99,3 +99,34 @@ describe('purchase-session restore never downgrades memory or the store mirror',
expect(MarketplaceSessionService.getActiveSession()?.token).toBe(OTHER_TAB_TOKEN);
});
});

describe('clearing the purchase session from the controller', () => {
beforeEach(() => {
MarketplaceSessionService.clearForSignOut();
useCommerceStore.getState().reset();
});

function thisTabHoldsAndOtherTabPersistsNewer(): string {
const info = MarketplaceSessionService.establishClaimedGrantSession(
{ token: WIDE_TOKEN, pubky: PUBKY, capabilities: parity, expiresAt: inOneDay() },
PUBKY,
);
CommerceController.writeMarketplaceSessionStore(info);
return otherTabPersists(parity);
}

it('a failed or losing sign-in clears only this tab’s bearer', () => {
const newer = thisTabHoldsAndOtherTabPersistsNewer();
CommerceController.clearMarketplaceSession();
expect(MarketplaceSessionService.getActiveSession()).toBeNull();
expect(window.localStorage.getItem(MARKETPLACE_SESSION_STORAGE_KEY)).toBe(newer);
});

it('sign-out leaves no purchase bearer at rest, whichever tab persisted it', () => {
thisTabHoldsAndOtherTabPersistsNewer();
CommerceController.clearMarketplaceSessionForSignOut();
expect(MarketplaceSessionService.getActiveSession()).toBeNull();
expect(useCommerceStore.getState().marketplaceSession).toBeNull();
expect(window.localStorage.getItem(MARKETPLACE_SESSION_STORAGE_KEY)).toBeNull();
});
});
13 changes: 11 additions & 2 deletions src/core/controllers/commerce/commerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,10 @@ export class CommerceController {
}

/**
* Drops the purchase bearer, the inventory bearer, and both store mirrors.
* Sign-out and failed sign-in go through here. Identity 401 uses
* Drops the purchase bearer this tab holds (only its own persisted
* record), the inventory bearer, and both store mirrors. A failed or losing
* sign-in goes through here; sign-out uses
* {@link clearMarketplaceSessionForSignOut}. Identity 401 uses
* `onMarketplaceSessionEnded` and must not reach this. Checkout TTL uses
* `clearIdentitySession` so a hold expiry cannot log the seller out of
* Inventory Studio.
Expand All @@ -278,6 +280,13 @@ export class CommerceController {
this.clearInventorySession();
}

/** Sign-out and account switch (`AuthController` local-state cleanup). */
static clearMarketplaceSessionForSignOut(): void {
CommerceApplication.clearMarketplaceSessionForSignOut();
this.clearMarketplaceSessionStore();
this.clearInventorySession();
}

/** Identity checkout bearer only. Leaves `pubky.marketplace.inventory-session.v1` in place. */
static clearIdentitySession(): void {
CommerceApplication.clearMarketplaceSession();
Expand Down
11 changes: 9 additions & 2 deletions src/core/services/marketplace/marketplace-grant-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,15 @@ export async function pairMarketplaceBffSession(session: {
if (!response.ok) throw new Error('marketplace_session_pair_failed');
}

export async function clearMarketplaceBffSession(): Promise<void> {
await fetch('/api/marketplace/session', {
/**
* Unpairs the BFF session. With `ownedSessionId` the BFF unpairs only when
* its bridge still holds that marketplace session: the cookie is shared
* across tabs, and another tab may have paired a newer one. Without it (sign-
* out) the bridge is removed whatever it holds.
*/
export async function clearMarketplaceBffSession(ownedSessionId?: string): Promise<void> {
const query = ownedSessionId === undefined ? '' : `?session_id=${encodeURIComponent(ownedSessionId)}`;
await fetch(`/api/marketplace/session${query}`, {
method: 'DELETE',
credentials: 'same-origin',
}).catch(() => undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export class MarketplaceInventorySessionService {
/** Drops the session behind a bearer the service refused. */
static clearRejectedBearer(bearer: InventoryBearer): void {
if (bearer.source === 'purchase') {
MarketplaceSessionService.clearSession('rejected');
MarketplaceSessionService.clearSessionIfBearer(bearer.token, 'rejected');
return;
}
this.clearSession('rejected');
Expand Down
Loading
Loading