Make Stuffbox linking resilient to cross-origin popup isolation and stale callbacks

Hop-State: A_06FP8WHY25K99094H3X60A8
Hop-Proposal: R_06FP8WGCVDDG61H0VZ1XQJR
Hop-Task: T_06FP8V5D2HSJG08NRN6VS3G
Hop-Attempt: AT_06FP8V5D2KATGJNV2M2SGRR
This commit is contained in:
2026-07-14 23:23:56 -07:00
committed by Hop
parent fb624c4ed1
commit b14be97965
10 changed files with 332 additions and 85 deletions
@@ -28,6 +28,7 @@ export async function GET() {
provider: stuffbox ? 'stuffbox' : user.storageProvider ? 's3' : null, provider: stuffbox ? 'stuffbox' : user.storageProvider ? 's3' : null,
stuffboxAvailable: Boolean(configuredStuffboxUrl()), stuffboxAvailable: Boolean(configuredStuffboxUrl()),
stuffboxBaseUrl: stuffbox?.baseUrl ?? null, stuffboxBaseUrl: stuffbox?.baseUrl ?? null,
stuffboxUpdatedAt: stuffbox?.updatedAt.toISOString() ?? null,
s3Provider: user.storageProvider ?? null, s3Provider: user.storageProvider ?? null,
}); });
} catch (error) { } catch (error) {
@@ -5,8 +5,8 @@ import { consumeStuffboxConnectionState } from '@/lib/stuffbox/connection-state'
import { saveStuffboxTokens } from '@/lib/stuffbox/tokens'; import { saveStuffboxTokens } from '@/lib/stuffbox/tokens';
import { renderStuffboxPopupResponse } from '@/lib/stuffbox/popup-response'; import { renderStuffboxPopupResponse } from '@/lib/stuffbox/popup-response';
function popupResponse(origin: string, success: boolean, message: string): NextResponse { function popupResponse(origin: string, success: boolean, message: string, attemptId?: string): NextResponse {
return new NextResponse(renderStuffboxPopupResponse(origin, success, message), { return new NextResponse(renderStuffboxPopupResponse(origin, success, message, attemptId), {
status: success ? 200 : 400, status: success ? 200 : 400,
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
}); });
@@ -14,16 +14,18 @@ function popupResponse(origin: string, success: boolean, message: string): NextR
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const origin = request.nextUrl.origin; const origin = request.nextUrl.origin;
let attemptId: string | undefined;
try { try {
const user = await requireAuth(); const user = await requireAuth();
const pending = await consumeStuffboxConnectionState(); const pending = await consumeStuffboxConnectionState();
attemptId = pending?.state;
const code = request.nextUrl.searchParams.get('code'); const code = request.nextUrl.searchParams.get('code');
const state = request.nextUrl.searchParams.get('state'); const state = request.nextUrl.searchParams.get('state');
const denied = request.nextUrl.searchParams.get('error'); const denied = request.nextUrl.searchParams.get('error');
if (denied) return popupResponse(origin, false, 'Stuffbox access was not approved.'); if (denied) return popupResponse(origin, false, 'Stuffbox access was not approved.', attemptId);
if (!pending || pending.userId !== user.id || !code || state !== pending.state) { if (!pending || pending.userId !== user.id || !code || state !== pending.state) {
return popupResponse(origin, false, 'The Stuffbox connection request is invalid or expired.'); return popupResponse(origin, false, 'The Stuffbox connection request is invalid or expired.', attemptId);
} }
const tokens = await exchangeAuthorizationCode(pending.baseUrl, { const tokens = await exchangeAuthorizationCode(pending.baseUrl, {
@@ -32,12 +34,12 @@ export async function GET(request: NextRequest) {
redirectUri: pending.redirectUri, redirectUri: pending.redirectUri,
}); });
await saveStuffboxTokens(user.id, pending.baseUrl, tokens); await saveStuffboxTokens(user.id, pending.baseUrl, tokens);
return popupResponse(new URL(pending.redirectUri).origin, true, 'Stuffbox connected.'); return popupResponse(new URL(pending.redirectUri).origin, true, 'Stuffbox connected.', attemptId);
} catch (error) { } catch (error) {
if (error instanceof StuffboxApiError) { if (error instanceof StuffboxApiError) {
return popupResponse(origin, false, error.message); return popupResponse(origin, false, error.message, attemptId);
} }
console.error('Stuffbox callback error:', error); console.error('Stuffbox callback error:', error);
return popupResponse(origin, false, 'Stuffbox could not be connected.'); return popupResponse(origin, false, 'Stuffbox could not be connected.', attemptId);
} }
} }
@@ -49,7 +49,11 @@ export async function POST(request: NextRequest) {
expiresAt: Math.min(Date.parse(connection.expiresAt) || Date.now() + 10 * 60_000, Date.now() + 10 * 60_000), expiresAt: Math.min(Date.parse(connection.expiresAt) || Date.now() + 10 * 60_000, Date.now() + 10 * 60_000),
}); });
return NextResponse.json({ authorizationUrl: connection.authorizationUrl }); return NextResponse.json({
authorizationUrl: connection.authorizationUrl,
connectionStartedAt: new Date().toISOString(),
connectionAttempt: pkce.state,
});
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'Authentication required') { if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
+92 -74
View File
@@ -1,8 +1,13 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Box, ChevronDown, ExternalLink } from 'lucide-react'; import { Box, ChevronDown, ExternalLink } from 'lucide-react';
import { getStorageProvider } from '@/lib/stuffbox/browser-upload'; import { hasNewStuffboxConnection } from '@/lib/stuffbox/browser-upload';
import {
monitorStuffboxConnection,
StuffboxConnectionCancelledError,
type StuffboxConnectionResult,
} from '@/lib/stuffbox/connection-monitor';
interface StorageConfigurationPromptProps { interface StorageConfigurationPromptProps {
open: boolean; open: boolean;
@@ -25,6 +30,34 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [stuffboxAvailable, setStuffboxAvailable] = useState(false); const [stuffboxAvailable, setStuffboxAvailable] = useState(false);
const [showS3, setShowS3] = useState(false); const [showS3, setShowS3] = useState(false);
const [isConnectingStuffbox, setIsConnectingStuffbox] = useState(false);
const connectionAbortRef = useRef<AbortController | null>(null);
const connectionPopupRef = useRef<Window | null>(null);
const closeConnectionPopup = () => {
try { connectionPopupRef.current?.close(); } catch { /* COOP may sever the popup handle. */ }
connectionPopupRef.current = null;
};
const cancelStuffboxConnection = () => {
connectionAbortRef.current?.abort();
connectionAbortRef.current = null;
closeConnectionPopup();
setIsConnectingStuffbox(false);
};
useEffect(() => () => {
connectionAbortRef.current?.abort();
try { connectionPopupRef.current?.close(); } catch { /* Best-effort cleanup. */ }
}, []);
useEffect(() => {
if (open) return;
connectionAbortRef.current?.abort();
connectionAbortRef.current = null;
try { connectionPopupRef.current?.close(); } catch { /* Best-effort cleanup. */ }
connectionPopupRef.current = null;
}, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -47,92 +80,72 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
const connectStuffbox = async () => { const connectStuffbox = async () => {
setError(''); setError('');
setIsSubmitting(true); setIsConnectingStuffbox(true);
const popup = window.open('', 'synapsis-stuffbox', 'popup,width=620,height=760'); const popup = window.open('', 'synapsis-stuffbox', 'popup,width=620,height=760');
if (!popup) { if (!popup) {
setError('Your browser blocked the Stuffbox window. Allow popups and try again.'); setError('Your browser blocked the Stuffbox window. Allow popups and try again.');
setIsSubmitting(false); setIsConnectingStuffbox(false);
return; return;
} }
connectionPopupRef.current = popup;
popup.document.body.textContent = 'Connecting to Stuffbox…'; popup.document.body.textContent = 'Connecting to Stuffbox…';
const controller = new AbortController();
connectionAbortRef.current = controller;
try { try {
const response = await fetch('/api/storage/stuffbox/connect', { method: 'POST' }); const response = await fetch('/api/storage/stuffbox/connect', {
method: 'POST',
signal: controller.signal,
});
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok || !data.authorizationUrl) throw new Error(data.error || 'Unable to connect Stuffbox'); if (!response.ok || !data.authorizationUrl || !data.connectionStartedAt || !data.connectionAttempt) {
const result = new Promise<void>((resolve, reject) => { throw new Error(data.error || 'Unable to connect Stuffbox');
let settled = false; }
let verifyingClosedPopup = false; const result = monitorStuffboxConnection({
let channel: BroadcastChannel | null = null; signal: controller.signal,
const timeout = window.setTimeout(() => { checkConnected: async () => hasNewStuffboxConnection(data.connectionStartedAt),
finish({ type: 'synapsis:stuffbox', success: false, message: 'Stuffbox connection timed out.' }); subscribe: (finish) => {
}, 10 * 60_000); let channel: BroadcastChannel | null = null;
const popupClosed = window.setInterval(() => { const finishCurrentAttempt = (result: StuffboxConnectionResult) => {
if (popup.closed) void verifyConnectionAfterPopupClosed(); if (result.attemptId === data.connectionAttempt) finish(result);
}, 500); };
async function verifyConnectionAfterPopupClosed() { function receive(event: MessageEvent) {
if (settled || verifyingClosedPopup) return; if (event.origin !== window.location.origin || event.data?.type !== 'synapsis:stuffbox') return;
verifyingClosedPopup = true; finishCurrentAttempt(event.data as StuffboxConnectionResult);
window.clearInterval(popupClosed);
for (let attempt = 0; attempt < 4 && !settled; attempt += 1) {
await new Promise(resolveDelay => window.setTimeout(resolveDelay, 250));
if (settled) return;
try {
if (await getStorageProvider() === 'stuffbox') {
finish({ type: 'synapsis:stuffbox', success: true, message: 'Stuffbox connected.' });
return;
}
} catch { /* Retry while the callback finishes saving the connection. */ }
} }
finish({ type: 'synapsis:stuffbox', success: false, message: 'The Stuffbox window was closed before connecting.' }); function receiveStorage(event: StorageEvent) {
} if (event.key !== 'synapsis:stuffbox:result' || !event.newValue) return;
try { finishCurrentAttempt(JSON.parse(event.newValue)); } catch { /* Ignore unrelated storage values. */ }
}
function cleanup() { window.addEventListener('message', receive);
window.clearTimeout(timeout); window.addEventListener('storage', receiveStorage);
window.clearInterval(popupClosed); if ('BroadcastChannel' in window) {
window.removeEventListener('message', receive); channel = new BroadcastChannel('synapsis:stuffbox');
window.removeEventListener('storage', receiveStorage); channel.onmessage = (event) => finishCurrentAttempt(event.data);
channel?.close(); }
} return () => {
window.removeEventListener('message', receive);
function finish(data: { type?: string; success?: boolean; message?: string }) { window.removeEventListener('storage', receiveStorage);
if (settled || data.type !== 'synapsis:stuffbox') return; channel?.close();
settled = true; };
cleanup(); },
window.focus();
if (data.success) resolve();
else reject(new Error(data.message || 'Stuffbox could not be connected.'));
}
function receive(event: MessageEvent) {
if (event.origin !== window.location.origin || event.data?.type !== 'synapsis:stuffbox') return;
finish(event.data);
}
function receiveStorage(event: StorageEvent) {
if (event.key !== 'synapsis:stuffbox:result' || !event.newValue) return;
try { finish(JSON.parse(event.newValue)); } catch { /* Ignore unrelated storage values. */ }
}
window.addEventListener('message', receive);
window.addEventListener('storage', receiveStorage);
if ('BroadcastChannel' in window) {
channel = new BroadcastChannel('synapsis:stuffbox');
channel.onmessage = (event) => finish(event.data);
}
}); });
popup.location.href = data.authorizationUrl; popup.location.href = data.authorizationUrl;
await result; await result;
if (!popup.closed) popup.close(); window.focus();
closeConnectionPopup();
await onConfigured(); await onConfigured();
} catch (connectError) { } catch (connectError) {
if (!popup.closed) popup.close(); closeConnectionPopup();
setError(connectError instanceof Error ? connectError.message : 'Unable to connect Stuffbox'); if (!controller.signal.aborted && !(connectError instanceof StuffboxConnectionCancelledError)) {
setError(connectError instanceof Error ? connectError.message : 'Unable to connect Stuffbox');
}
} finally { } finally {
setIsSubmitting(false); connectionAbortRef.current = null;
setIsConnectingStuffbox(false);
} }
}; };
@@ -177,12 +190,17 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
</div> </div>
</div> </div>
</div> </div>
<button type="button" className="btn btn-primary" onClick={connectStuffbox} disabled={isSubmitting || isLoading || !stuffboxAvailable} style={{ width: '100%', marginTop: '16px' }}> <button type="button" className="btn btn-primary" onClick={isConnectingStuffbox ? cancelStuffboxConnection : connectStuffbox} disabled={isSubmitting || isLoading || !stuffboxAvailable} style={{ width: '100%', marginTop: '16px' }}>
{isSubmitting && !showS3 ? 'Connecting…' : stuffboxAvailable ? <>Connect Stuffbox <ExternalLink size={15} /></> : isLoading ? 'Loading…' : 'Stuffbox unavailable on this node'} {isConnectingStuffbox ? 'Cancel connection' : stuffboxAvailable ? <>Connect Stuffbox <ExternalLink size={15} /></> : isLoading ? 'Loading…' : 'Stuffbox unavailable on this node'}
</button> </button>
{isConnectingStuffbox && (
<div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', lineHeight: 1.45, marginTop: '10px', textAlign: 'center' }}>
Approve access in the Stuffbox window. This page will continue automatically.
</div>
)}
</div> </div>
<button type="button" className="btn btn-ghost" onClick={() => { setShowS3((value) => !value); setError(''); }} style={{ width: '100%', marginTop: '12px', justifyContent: 'space-between' }}> <button type="button" className="btn btn-ghost" onClick={() => { setShowS3((value) => !value); setError(''); }} disabled={isConnectingStuffbox} style={{ width: '100%', marginTop: '12px', justifyContent: 'space-between' }}>
Use your own S3-compatible bucket <ChevronDown size={16} style={{ transform: showS3 ? 'rotate(180deg)' : undefined }} /> Use your own S3-compatible bucket <ChevronDown size={16} style={{ transform: showS3 ? 'rotate(180deg)' : undefined }} />
</button> </button>
@@ -214,7 +232,7 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
{error && <div style={{ color: 'var(--error)', fontSize: '13px', marginTop: '14px' }}>{error}</div>} {error && <div style={{ color: 'var(--error)', fontSize: '13px', marginTop: '14px' }}>{error}</div>}
{variant === 'modal' && ( {variant === 'modal' && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}><button type="button" className="btn btn-ghost" onClick={onCancel} disabled={isSubmitting}>Cancel</button></div> <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}><button type="button" className="btn btn-ghost" onClick={() => { cancelStuffboxConnection(); onCancel(); }} disabled={isSubmitting}>Cancel</button></div>
)} )}
</> </>
); );
@@ -222,7 +240,7 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
if (variant === 'inline') return content; if (variant === 'inline') return content;
return ( return (
<div style={{ position: 'fixed', inset: 0, zIndex: 100000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px', background: 'rgba(0, 0, 0, 0.8)' }} onClick={onCancel}> <div style={{ position: 'fixed', inset: 0, zIndex: 100000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px', background: 'rgba(0, 0, 0, 0.8)' }} onClick={() => { cancelStuffboxConnection(); onCancel(); }}>
<div className="card" style={{ width: '100%', maxWidth: '560px', maxHeight: '90vh', overflowY: 'auto', padding: '24px' }} onClick={(event) => event.stopPropagation()}> <div className="card" style={{ width: '100%', maxWidth: '560px', maxHeight: '90vh', overflowY: 'auto', padding: '24px' }} onClick={(event) => event.stopPropagation()}>
{content} {content}
</div> </div>
+25
View File
@@ -0,0 +1,25 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { hasNewStuffboxConnection } from './browser-upload';
afterEach(() => vi.unstubAllGlobals());
describe('hasNewStuffboxConnection', () => {
it('accepts a connection saved after the current attempt started', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
provider: 'stuffbox',
stuffboxUpdatedAt: '2026-07-15T06:00:01.000Z',
}), { status: 200 })));
await expect(hasNewStuffboxConnection('2026-07-15T06:00:00.000Z')).resolves.toBe(true);
});
it('does not mistake an older connection for completion of a new attempt', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
provider: 'stuffbox',
stuffboxUpdatedAt: '2026-07-15T05:59:59.000Z',
}), { status: 200 })));
await expect(hasNewStuffboxConnection('2026-07-15T06:00:00.000Z')).resolves.toBe(false);
});
});
+16
View File
@@ -26,6 +26,7 @@ interface JsonResponse extends Record<string, unknown> {
error?: string; error?: string;
code?: string; code?: string;
provider?: string | null; provider?: string | null;
stuffboxUpdatedAt?: string | null;
} }
export type StorageProvider = 'stuffbox' | 's3' | null; export type StorageProvider = 'stuffbox' | 's3' | null;
@@ -45,6 +46,21 @@ export async function getStorageProvider(): Promise<StorageProvider> {
: null; : null;
} }
export async function hasNewStuffboxConnection(startedAt: string): Promise<boolean> {
const response = await fetch('/api/storage/configuration', { cache: 'no-store' });
const configuration = await json(response);
if (!response.ok) {
throw new MediaUploadError(configuration.error || 'Unable to verify the Stuffbox connection', undefined, response.status);
}
const updatedAt = typeof configuration.stuffboxUpdatedAt === 'string'
? Date.parse(configuration.stuffboxUpdatedAt)
: Number.NaN;
return configuration.provider === 'stuffbox'
&& Number.isFinite(updatedAt)
&& updatedAt >= Date.parse(startedAt);
}
function directPut( function directPut(
uploadUrl: string, uploadUrl: string,
file: File, file: File,
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import {
monitorStuffboxConnection,
StuffboxConnectionCancelledError,
type StuffboxConnectionResult,
} from './connection-monitor';
function subscription() {
let receive: ((result: StuffboxConnectionResult) => void) | undefined;
return {
subscribe: (next: (result: StuffboxConnectionResult) => void) => {
receive = next;
return () => { receive = undefined; };
},
send: (result: StuffboxConnectionResult) => receive?.(result),
};
}
describe('monitorStuffboxConnection', () => {
it('uses persisted server state as the source of truth', async () => {
const messages = subscription();
let checks = 0;
const result = monitorStuffboxConnection({
subscribe: messages.subscribe,
checkConnected: async () => ++checks >= 3,
pollIntervalMs: 1,
timeoutMs: 100,
});
await expect(result).resolves.toBeUndefined();
expect(checks).toBeGreaterThanOrEqual(3);
});
it('verifies success messages against server state before resolving', async () => {
const messages = subscription();
let connected = false;
const result = monitorStuffboxConnection({
subscribe: messages.subscribe,
checkConnected: async () => connected,
pollIntervalMs: 20,
timeoutMs: 100,
});
messages.send({ type: 'synapsis:stuffbox', success: true });
connected = true;
messages.send({ type: 'synapsis:stuffbox', success: true });
await expect(result).resolves.toBeUndefined();
});
it('surfaces an explicit denial from Stuffbox', async () => {
const messages = subscription();
const result = monitorStuffboxConnection({
subscribe: messages.subscribe,
checkConnected: async () => false,
pollIntervalMs: 20,
timeoutMs: 100,
});
messages.send({ type: 'synapsis:stuffbox', success: false, message: 'Access denied.' });
await expect(result).rejects.toThrow('Access denied.');
});
it('can be cancelled without waiting for the timeout', async () => {
const messages = subscription();
const controller = new AbortController();
const result = monitorStuffboxConnection({
subscribe: messages.subscribe,
checkConnected: async () => false,
signal: controller.signal,
pollIntervalMs: 20,
timeoutMs: 100,
});
controller.abort();
await expect(result).rejects.toBeInstanceOf(StuffboxConnectionCancelledError);
});
});
+101
View File
@@ -0,0 +1,101 @@
export interface StuffboxConnectionResult {
type?: string;
success?: boolean;
message?: string;
attemptId?: string;
}
interface MonitorOptions {
checkConnected: () => Promise<boolean>;
subscribe: (receive: (result: StuffboxConnectionResult) => void) => () => void;
signal?: AbortSignal;
pollIntervalMs?: number;
timeoutMs?: number;
}
export class StuffboxConnectionCancelledError extends Error {
constructor() {
super('Stuffbox connection cancelled');
this.name = 'StuffboxConnectionCancelledError';
}
}
/**
* Wait for the server to confirm a Stuffbox connection.
*
* Popup window state is intentionally absent here. Cross-Origin-Opener-Policy
* can make a live cross-origin popup appear closed to its opener. Messages are
* useful accelerators, but the node's persisted connection is the authority.
*/
export function monitorStuffboxConnection({
checkConnected,
subscribe,
signal,
pollIntervalMs = 750,
timeoutMs = 10 * 60_000,
}: MonitorOptions): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false;
let checking = false;
let cleanupSubscription = () => {};
const timers: {
interval?: ReturnType<typeof setInterval>;
timeout?: ReturnType<typeof setTimeout>;
} = {};
const abort = () => finish(new StuffboxConnectionCancelledError());
signal?.addEventListener('abort', abort, { once: true });
function cleanup() {
if (timers.interval !== undefined) globalThis.clearInterval(timers.interval);
if (timers.timeout !== undefined) globalThis.clearTimeout(timers.timeout);
signal?.removeEventListener('abort', abort);
cleanupSubscription();
}
function finish(error?: Error) {
if (settled) return;
settled = true;
cleanup();
if (error) reject(error);
else resolve();
}
async function checkNow() {
if (settled || checking) return;
checking = true;
try {
if (await checkConnected()) finish();
} catch {
// Transient status failures are retried until timeout or cancellation.
} finally {
checking = false;
}
}
if (signal?.aborted) {
abort();
return;
}
cleanupSubscription = subscribe((result) => {
if (result.type !== 'synapsis:stuffbox') return;
if (result.success) {
void checkNow();
} else {
finish(new Error(result.message || 'Stuffbox access was not approved.'));
}
});
if (settled) {
cleanupSubscription();
return;
}
timers.interval = globalThis.setInterval(() => void checkNow(), pollIntervalMs);
timers.timeout = globalThis.setTimeout(() => {
finish(new Error('Stuffbox connection timed out. You can safely try again.'));
}, timeoutMs);
void checkNow();
});
}
+2 -1
View File
@@ -3,10 +3,11 @@ import { renderStuffboxPopupResponse } from './popup-response';
describe('renderStuffboxPopupResponse', () => { describe('renderStuffboxPopupResponse', () => {
it('notifies the main tab without navigating the popup into Synapsis', () => { it('notifies the main tab without navigating the popup into Synapsis', () => {
const html = renderStuffboxPopupResponse('https://synapsis.example', true, 'Stuffbox connected.'); const html = renderStuffboxPopupResponse('https://synapsis.example', true, 'Stuffbox connected.', 'attempt-123');
expect(html).toContain("new BroadcastChannel('synapsis:stuffbox')"); expect(html).toContain("new BroadcastChannel('synapsis:stuffbox')");
expect(html).toContain("localStorage.setItem('synapsis:stuffbox:result'"); expect(html).toContain("localStorage.setItem('synapsis:stuffbox:result'");
expect(html).toContain('attempt-123');
expect(html).toContain('window.setTimeout(() => window.close(), 250)'); expect(html).toContain('window.setTimeout(() => window.close(), 250)');
expect(html).not.toContain('window.location.replace'); expect(html).not.toContain('window.location.replace');
}); });
+2 -2
View File
@@ -1,6 +1,6 @@
export function renderStuffboxPopupResponse(origin: string, success: boolean, message: string): string { export function renderStuffboxPopupResponse(origin: string, success: boolean, message: string, attemptId?: string): string {
const safeJson = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c'); const safeJson = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c');
const payload = safeJson({ type: 'synapsis:stuffbox', success, message }); const payload = safeJson({ type: 'synapsis:stuffbox', success, message, attemptId });
const targetOrigin = safeJson(origin); const targetOrigin = safeJson(origin);
return `<!doctype html><html><head><meta charset="utf-8"><title>Stuffbox</title></head><body> return `<!doctype html><html><head><meta charset="utf-8"><title>Stuffbox</title></head><body>