| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { initClerk, getClerkToken, getCurrentClerkUser, openSignIn, subscribeClerk } from '@/services/clerk'; |
| import { |
| classifyGrantDenial, |
| describeGrantDelay, |
| retryableGrantDelayMs, |
| routeGrantContextDenial, |
| shouldWaitInline, |
| type GrantMintPhase, |
| } from '@/services/mcp-grant-denial'; |
|
|
| |
| |
| |
| |
| try { |
| const savedTheme = localStorage.getItem('worldmonitor-theme'); |
| if (savedTheme === 'light') document.documentElement.dataset.theme = 'light'; |
| } catch { |
| |
| } |
|
|
| const API_BASE = ''; |
|
|
| interface ContextResponse { |
| client_name: string; |
| redirect_host: string; |
| } |
|
|
| interface MintResponse { |
| redirect: string; |
| } |
|
|
| interface ApiError { |
| error: string; |
| error_description?: string; |
| } |
|
|
| function $(id: string): HTMLElement { |
| const el = document.getElementById(id); |
| if (!el) throw new Error(`Element #${id} not found`); |
| return el; |
| } |
|
|
| function setText(id: string, text: string): void { $(id).textContent = text; } |
|
|
| function show(id: string): void { $(id).hidden = false; } |
| function hide(id: string): void { $(id).hidden = true; } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function showErrorView(message: string, title: string): void { |
| resetMintPhase(); |
| hide('loading'); |
| hide('consent'); |
| hide('retryContextBtn'); |
| setText('errorTitle', title); |
| setText('errorBody', message); |
| show('errorView'); |
| } |
|
|
| function showRetryableContextView(message: string): void { |
| resetMintPhase(); |
| hide('loading'); |
| hide('consent'); |
| setText('errorTitle', 'Authorization temporarily unavailable'); |
| setText('errorBody', message); |
| show('retryContextBtn'); |
| show('errorView'); |
| } |
|
|
| function showContextLoading(): void { |
| hide('errorView'); |
| hide('consent'); |
| setText('loadingBody', 'Loading authorization request…'); |
| show('loading'); |
| } |
|
|
| function getNonceFromQuery(): string | null { |
| const p = new URLSearchParams(window.location.search); |
| const n = p.get('nonce'); |
| return typeof n === 'string' && n.length > 0 ? n : null; |
| } |
|
|
| async function authedFetch(path: string, init: RequestInit = {}): Promise<Response> { |
| const token = await getClerkToken(); |
| const headers = new Headers(init.headers); |
| if (token) headers.set('Authorization', `Bearer ${token}`); |
| return fetch(`${API_BASE}${path}`, { ...init, headers }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| let mintPhase: GrantMintPhase = 'idle'; |
| let mintRetryTimeout: number | null = null; |
|
|
| function resetMintPhase(): void { |
| if (mintRetryTimeout !== null) { |
| window.clearTimeout(mintRetryTimeout); |
| mintRetryTimeout = null; |
| } |
| mintPhase = 'idle'; |
| } |
|
|
| const CONTEXT_AUTO_RETRY_BUDGET = 1; |
| let contextLoadGeneration = 0; |
|
|
| async function loadContext(nonce: string): Promise<void> { |
| const generation = ++contextLoadGeneration; |
| await loadContextAttempt(nonce, CONTEXT_AUTO_RETRY_BUDGET, generation); |
| } |
|
|
| async function loadContextAttempt( |
| nonce: string, |
| retriesRemaining: number, |
| generation: number, |
| ): Promise<void> { |
| let resp: Response; |
| try { |
| resp = await authedFetch(`/api/internal/mcp-grant-context?nonce=${encodeURIComponent(nonce)}`); |
| } catch { |
| |
| |
| |
| |
| |
| if (generation !== contextLoadGeneration) return; |
| if (mintPhase !== 'idle') return; |
| showRetryableContextView( |
| 'Could not reach the authorization service. Check your connection and try again.', |
| ); |
| return; |
| } |
|
|
| if (generation !== contextLoadGeneration) return; |
|
|
| if (!resp.ok) { |
| let body: ApiError | null = null; |
| try { body = (await resp.json()) as ApiError; } catch { } |
| const verdict = classifyGrantDenial(resp.status, body?.error); |
| const action = routeGrantContextDenial(verdict.action, mintPhase, retriesRemaining); |
| switch (action) { |
| case 'sign_in': |
| |
| openSignIn(); |
| return; |
| case 'preserve_consent': |
| return; |
| case 'retry': { |
| const waitMs = retryableGrantDelayMs(resp.headers.get('Retry-After')); |
| |
| |
| |
| if (!shouldWaitInline(waitMs)) { |
| showRetryableContextView( |
| `${verdict.message} Try again in ${describeGrantDelay(waitMs)}.`, |
| ); |
| return; |
| } |
| setText('loadingBody', 'Authorization service is temporarily unavailable. Retrying…'); |
| await new Promise<void>((resolve) => { |
| window.setTimeout(resolve, waitMs); |
| }); |
| if (generation !== contextLoadGeneration) return; |
| await loadContextAttempt(nonce, retriesRemaining - 1, generation); |
| return; |
| } |
| case 'show_retry': |
| showRetryableContextView(verdict.message); |
| return; |
| case 'terminal': |
| showErrorView(verdict.message, verdict.title); |
| return; |
| } |
| } |
|
|
| let ctx: ContextResponse; |
| try { |
| ctx = (await resp.json()) as ContextResponse; |
| } catch { |
| if (generation !== contextLoadGeneration) return; |
| showErrorView('The authorization service returned an unexpected response.', 'Unexpected response'); |
| return; |
| } |
|
|
| |
| |
| if (generation !== contextLoadGeneration) return; |
|
|
| setText('clientName', ctx.client_name); |
| setText('clientHost', ctx.redirect_host); |
| const u = getCurrentClerkUser(); |
| setText('userEmail', u?.email ?? 'your account'); |
| hide('loading'); |
| show('consent'); |
| } |
|
|
| async function onAuthorizeClick(nonce: string): Promise<void> { |
| const btn = $('authorizeBtn') as HTMLButtonElement; |
| const errEl = $('mintError'); |
| const reenable = (): void => { |
| resetMintPhase(); |
| btn.disabled = false; |
| btn.textContent = 'Authorize'; |
| }; |
| resetMintPhase(); |
| btn.disabled = true; |
| btn.textContent = 'Authorizing…'; |
| hide('mintError'); |
|
|
| let resp: Response; |
| mintPhase = 'in_flight'; |
| try { |
| resp = await authedFetch('/api/internal/mcp-grant-mint', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ nonce }), |
| }); |
| } catch { |
| reenable(); |
| errEl.textContent = 'Network error. Please try again.'; |
| show('mintError'); |
| return; |
| } |
|
|
| if (!resp.ok) { |
| let body: ApiError | null = null; |
| try { body = (await resp.json()) as ApiError; } catch { } |
| const verdict = classifyGrantDenial(resp.status, body?.error); |
| if (verdict.action === 'sign_in') { |
| openSignIn(); |
| reenable(); |
| return; |
| } |
| if (verdict.action === 'retryable') { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const waitMs = retryableGrantDelayMs(resp.headers.get('Retry-After')); |
| |
| |
| |
| |
| if (!shouldWaitInline(waitMs)) { |
| errEl.textContent = `${verdict.message} Try again in ${describeGrantDelay(waitMs)}.`; |
| show('mintError'); |
| reenable(); |
| return; |
| } |
| errEl.textContent = verdict.message; |
| show('mintError'); |
| btn.textContent = 'Retry shortly…'; |
| mintPhase = 'retry_cooldown'; |
| mintRetryTimeout = window.setTimeout(reenable, waitMs); |
| return; |
| } |
| resetMintPhase(); |
| showErrorView(verdict.message, verdict.title); |
| return; |
| } |
|
|
| let mint: MintResponse; |
| try { |
| mint = (await resp.json()) as MintResponse; |
| } catch { |
| reenable(); |
| errEl.textContent = 'Unexpected response from the authorization service.'; |
| show('mintError'); |
| return; |
| } |
|
|
| |
| |
| |
| |
| let target: URL; |
| try { |
| target = new URL(mint.redirect); |
| } catch { |
| showErrorView('The authorization service returned an invalid redirect.', 'Invalid redirect'); |
| return; |
| } |
| if (target.origin !== 'https://api.worldmonitor.app') { |
| showErrorView('The authorization service returned an unexpected redirect host.', 'Unexpected redirect host'); |
| return; |
| } |
|
|
| window.location.assign(target.toString()); |
| } |
|
|
| async function bootstrap(): Promise<void> { |
| const nonce = getNonceFromQuery(); |
| if (!nonce) { |
| showErrorView('Missing authorization parameter. Start over from your MCP client.', 'Missing authorization parameter'); |
| return; |
| } |
|
|
| try { |
| await initClerk(); |
| } catch { |
| showErrorView('Sign-in is unavailable. Please try again later.', 'Sign-in unavailable'); |
| return; |
| } |
|
|
| const reactToAuth = async (): Promise<void> => { |
| if (!getCurrentClerkUser()) { |
| |
| openSignIn(); |
| return; |
| } |
| await loadContext(nonce); |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| $('retryContextBtn').addEventListener('click', () => { |
| showContextLoading(); |
| void loadContext(nonce); |
| }); |
| $('authorizeBtn').addEventListener('click', () => { void onAuthorizeClick(nonce); }); |
|
|
| subscribeClerk(() => { void reactToAuth(); }); |
| await reactToAuth(); |
| } |
|
|
| void bootstrap(); |
|
|