diff --git a/backend/src/__tests__/handoff.test.ts b/backend/src/__tests__/handoff.test.ts index 3d7b4841..b9c4a0ff 100644 --- a/backend/src/__tests__/handoff.test.ts +++ b/backend/src/__tests__/handoff.test.ts @@ -4,7 +4,8 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createApp } from '../app.js'; import type { Auth } from '../auth.js'; -import { closeDb } from '../db.js'; +import { closeDb, db } from '../db.js'; +import { PLATFORM_AUTH_ERROR_HEADER } from '../openaiProxy.js'; const env = { ...process.env }; @@ -19,6 +20,7 @@ beforeEach(async () => { afterEach(() => { vi.unstubAllEnvs(); + vi.unstubAllGlobals(); closeDb(); process.env = { ...env }; }); @@ -175,6 +177,34 @@ describe('tool token end-to-end', () => { const res = await post(createApp(), '/api/log', { event: 'after_revoke' }, token); expect(res.status).toBe(401); }); + + it.each(['revoked', 'expired'] as const)( + 'fails a %s tool token closed at the proxy even with demo access', + async (state) => { + vi.stubEnv('OPENAI_DEMO_API_KEY', 'demo-key'); + const token = await tokenFor(['openai:chat']); + if (state === 'revoked') { + await post(createApp(), '/api/handoff/revoke', {}, token); + } else { + db() + .prepare( + 'UPDATE tool_grant SET token_expires_at = ? WHERE access_token = ?', + ) + .run(Date.now() - 1, token); + } + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const res = await post( + createApp(), + '/api/openai/chat/completions', + { model: 'gpt-4o' }, + token, + ); + expect(res.status).toBe(401); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBe('platform-auth'); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); }); describe('X-Client-Id header attribution (device-flow tools)', () => { diff --git a/backend/src/__tests__/openaiProxy.test.ts b/backend/src/__tests__/openaiProxy.test.ts index e2d79664..6f93f344 100644 --- a/backend/src/__tests__/openaiProxy.test.ts +++ b/backend/src/__tests__/openaiProxy.test.ts @@ -9,6 +9,7 @@ import { attributeRequest, createSseUsageScanner, parseUsage, + PLATFORM_AUTH_ERROR_HEADER, } from '../openaiProxy.js'; import { ANONYMOUS_USER_ID, @@ -252,6 +253,33 @@ describe('POST /api/openai/chat/completions', () => { ); expect(res.status).toBe(401); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBe('platform-auth'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects a presented invalid tool token even when demo access is configured', async () => { + vi.stubEnv('OPENAI_DEMO_API_KEY', 'demo-key'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const res = await appWithUser(null).request( + '/api/openai/chat/completions', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer wtk_invalid-or-revoked', + }, + body: JSON.stringify({ model: 'gpt-4o' }), + }, + ); + + expect(res.status).toBe(401); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBe('platform-auth'); + expect(await res.json()).toEqual({ + error: { code: 'platform_auth' }, + detail: 'Tool access token is invalid or expired.', + }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -281,9 +309,48 @@ describe('POST /api/openai/chat/completions', () => { ); expect(res.status).toBe(403); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBe('platform-auth'); expect(fetchMock).not.toHaveBeenCalled(); }); + it('does not mark an upstream OpenAI authentication error as platform auth', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ error: { message: 'provider key' } }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + const res = await appWithUser(real('usr-upstream')).request( + '/api/openai/chat/completions', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-4o' }), + }, + ); + expect(res.status).toBe(401); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBeNull(); + }); + + it('exposes the platform-auth marker header through CORS', async () => { + const res = await appWithUser(null).request( + '/api/openai/chat/completions', + { + method: 'OPTIONS', + headers: { + Origin: 'https://mindmap.example', + 'Access-Control-Request-Method': 'POST', + }, + }, + ); + expect(res.headers.get('Access-Control-Expose-Headers')).toContain( + PLATFORM_AUTH_ERROR_HEADER, + ); + }); + it('serves a sessionless request on the demo key and meters it to `demo`', async () => { // Demo mode sends a token that isn't a session, so it lands here — the path // that would otherwise 401 once the proxy started requiring auth. @@ -558,12 +625,20 @@ describe('POST /api/openai/responses', () => { ); vi.stubGlobal('fetch', fetchMock); + const requestBody = { + model: 'gpt-4o', + input: 'hi', + tools: [{ type: 'web_search_preview' }], + temperature: 0.4, + max_output_tokens: 321, + metadata: { surface: 'mindmap' }, + }; const res = await appWithUser(real('usr-r')).request( '/api/openai/responses', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: 'gpt-4o', input: 'hi' }), + body: JSON.stringify(requestBody), }, ); @@ -571,6 +646,11 @@ describe('POST /api/openai/responses', () => { expect(fetchMock.mock.calls[0]?.[0]).toBe( 'https://api.openai.com/v1/responses', ); + expect( + JSON.parse( + String((fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.body), + ), + ).toEqual(requestBody); expect(await res.json()).toEqual(body); const rows = await waitForUsage(); expect(rows[0]).toMatchObject({ diff --git a/backend/src/app.ts b/backend/src/app.ts index b83cdf70..ba89c4d6 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -11,7 +11,12 @@ import { import { deviceClientIds, gitCommit, logSecret } from './config.js'; import { eraseLoggedData } from './erasure.js'; import { appendLog, pollLogs, zipLogs } from './logging.js'; -import { attributeRequest, openaiProxy } from './openaiProxy.js'; +import { + attributeRequest, + openaiProxy, + PLATFORM_AUTH_ERROR_HEADER, + type ProxyIdentity, +} from './openaiProxy.js'; import { captureException, posthogMiddleware } from './posthog.js'; import { costUsd } from './pricing.js'; import { @@ -74,7 +79,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { const app = new Hono(); // CORS stays fully permissive for now to preserve existing behaviour. - app.use('*', cors()); + app.use('*', cors({ exposeHeaders: [PLATFORM_AUTH_ERROR_HEADER] })); app.use('*', posthogMiddleware); if (auth) { @@ -101,7 +106,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { openaiProxy('chat/completions', { // The proxy only reads { id, isAnonymous }; resolveUser also returns // loggingConsent, which is structurally compatible and simply ignored here. - resolveUser, + resolveIdentity: resolveProxyIdentity, authEnabled: !!auth, }), ); @@ -110,7 +115,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { app.post( '/api/openai/responses', openaiProxy('responses', { - resolveUser, + resolveIdentity: resolveProxyIdentity, authEnabled: !!auth, }), ); @@ -221,6 +226,20 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { }; } + async function resolveProxyIdentity(c: Context): Promise { + const bearer = bearerToken(c); + if (bearer?.startsWith('wtk_')) { + const tool = resolveToolToken(bearer); + return tool + ? { kind: 'authenticated', user: tool.user } + : { kind: 'rejected_tool_credential' }; + } + const user = await resolveUser(c); + return user + ? { kind: 'authenticated', user } + : { kind: 'sessionless' }; + } + // Client event logging. Requires an authenticated session: the log is keyed by // the Better Auth user id (not a client-supplied name), and content fields are // stripped to the user's consent level before they ever hit disk. diff --git a/backend/src/openaiProxy.ts b/backend/src/openaiProxy.ts index b60bb285..d2d4ec85 100644 --- a/backend/src/openaiProxy.ts +++ b/backend/src/openaiProxy.ts @@ -39,9 +39,16 @@ export type ProxyUser = Pick< 'id' | 'isAnonymous' | 'isAllowed' | 'clientId' >; +export type ProxyIdentity = + | { kind: 'authenticated'; user: ProxyUser } + | { kind: 'sessionless' } + | { kind: 'rejected_tool_credential' }; + +export const PLATFORM_AUTH_ERROR_HEADER = 'X-Writing-Tools-Error'; +export const PLATFORM_AUTH_ERROR_VALUE = 'platform-auth'; + export interface ProxyOptions { - /** Authenticated user for this request, or null if there's no session. */ - resolveUser: (c: Context) => Promise; + resolveIdentity: (c: Context) => Promise; /** * Whether auth is configured at all. When it isn't (local dev with * BETTER_AUTH_ENABLED unset), unauthenticated requests are served rather than @@ -223,15 +230,23 @@ function requestedModel(parsed: Record | null): string { export function openaiProxy(endpoint: OpenAIEndpoint, options: ProxyOptions) { return async (c: Context): Promise => { - const user = await options.resolveUser(c); + const identity = await options.resolveIdentity(c); + if (identity.kind === 'rejected_tool_credential') { + return platformAuthError(c, 401, 'Tool access token is invalid or expired.'); + } + const user = identity.kind === 'authenticated' ? identity.user : null; // Enforce the beta allowlist server-side. The client also shows a "not allowed" // screen, but that's UX — this is the gate a disallowed user can't bypass by // calling the proxy directly. isAllowed already folds in anonymous/demo users // (always allowed) and the per-user alwaysAllow grant, so this one check covers // every authenticated case; sessionless traffic is left to attributeRequest. - if (user && !user.isAllowed) return c.json({ detail: 'Forbidden' }, 403); + if (user && !user.isAllowed) { + return platformAuthError(c, 403, 'This account is not permitted.'); + } const attribution = attributeRequest(user, options.authEnabled); - if (!attribution) return c.json({ detail: 'Unauthorized' }, 401); + if (!attribution) { + return platformAuthError(c, 401, 'Authentication is required.'); + } // Which client the tokens are provenance-tagged to (a tool's client_id, or // null for the first-party add-in). Independent of who pays: a demo user's // spend goes to the capped key but the row still names their tool. @@ -369,3 +384,15 @@ export function openaiProxy(endpoint: OpenAIEndpoint, options: ProxyOptions) { }); }; } + +function platformAuthError( + c: Context, + status: 401 | 403, + detail: string, +): Response { + return c.json( + { error: { code: 'platform_auth' }, detail }, + status, + { [PLATFORM_AUTH_ERROR_HEADER]: PLATFORM_AUTH_ERROR_VALUE }, + ); +} diff --git a/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts b/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts index b4f51a52..91c53c6a 100644 --- a/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts +++ b/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts @@ -66,7 +66,10 @@ beforeEach(() => { getDocContextMock = vi.fn().mockResolvedValue(CONTEXT_A); fakeWindow = Object.assign(makeEmitter(), { - GoogleAppsScript: { getDocContext: getDocContextMock }, + GoogleAppsScript: { + getDocContext: getDocContextMock, + getDocumentName: vi.fn().mockResolvedValue('Research notes'), + }, }); fakeDocument = Object.assign(makeEmitter(), { hasFocus: () => focused, @@ -88,6 +91,13 @@ afterEach(() => { }); describe('googleDocsEditorAPI selection polling', () => { + it('includes the Google document title in an explicit context fetch', async () => { + await expect(googleDocsEditorAPI.getDocContext()).resolves.toEqual({ + ...CONTEXT_A, + documentLabel: 'Research notes', + }); + }); + it('pulls immediately and notifies handlers when the selection changes', async () => { getDocContextMock .mockResolvedValueOnce(CONTEXT_A) // initial pull diff --git a/frontend/src/api/__tests__/wordEditorAPI.test.ts b/frontend/src/api/__tests__/wordEditorAPI.test.ts new file mode 100644 index 00000000..8fdc6522 --- /dev/null +++ b/frontend/src/api/__tests__/wordEditorAPI.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { wordDocumentLabel } from '../wordEditorAPI'; + +describe('wordDocumentLabel', () => { + it('uses the decoded filename from the Office document URL', () => { + expect( + wordDocumentLabel('https://example.sharepoint.com/docs/My%20Essay.docx'), + ).toBe('My Essay.docx'); + }); + + it('falls back for unsaved documents', () => { + expect(wordDocumentLabel('')).toBe('Word document'); + expect(wordDocumentLabel(undefined)).toBe('Word document'); + }); + + it.each([ + ['C:\\Users\\writer\\OneDrive\\Essay.docx', 'Essay.docx'], + ['C:/Users/writer/OneDrive/Essay%20Draft.docx', 'Essay Draft.docx'], + ['\\\\server\\private\\team\\Shared.docx', 'Shared.docx'], + ['https://example.test/private/folder/Final%20Draft.docx', 'Final Draft.docx'], + ['https://example.test/private/folder/Bad%ZZ.docx', 'Bad%ZZ.docx'], + ])('never exposes directory components from %s', (input, expected) => { + expect(wordDocumentLabel(input)).toBe(expected); + }); +}); diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts index 6961e4b0..ec8d8452 100644 --- a/frontend/src/api/googleDocsEditorAPI.ts +++ b/frontend/src/api/googleDocsEditorAPI.ts @@ -43,6 +43,7 @@ declare global { setDocumentProperty?: (key: string, value: string) => Promise; getDocumentProperty?: (key: string) => Promise; getDocumentId: () => Promise; + getDocumentName: () => Promise; getAllTabs: () => Promise< { id: string; title: string; text: string }[] >; @@ -269,10 +270,14 @@ export const googleDocsEditorAPI: EditorAPI = { * Gets the current document context (before cursor, selection, after cursor). */ async getDocContext(): Promise { - const context = await window.GoogleAppsScript.getDocContext(); + const [context, documentName] = await Promise.all([ + window.GoogleAppsScript.getDocContext(), + window.GoogleAppsScript.getDocumentName().catch(() => ''), + ]); // Normalize line endings (Google Docs uses \n) return { + documentLabel: documentName.trim() || 'Google Docs document', beforeCursor: context.beforeCursor || '', selectedText: context.selectedText || '', afterCursor: context.afterCursor || '', diff --git a/frontend/src/api/handoff.ts b/frontend/src/api/handoff.ts index 398a6b01..eff4fdb4 100644 --- a/frontend/src/api/handoff.ts +++ b/frontend/src/api/handoff.ts @@ -84,3 +84,52 @@ export function openInBrowser(url: string): void { } window.open(url, '_blank', 'noopener,noreferrer'); } + +export type BrowserLaunchReservation = + | { kind: 'office' } + | { kind: 'window'; popup: Window }; + +function officeBrowserOpener(): ((url: string) => void) | undefined { + const officeUi = ( + globalThis as { + Office?: { + context?: { ui?: { openBrowserWindow?: (u: string) => void } }; + }; + } + ).Office?.context?.ui; + return officeUi?.openBrowserWindow?.bind(officeUi); +} + +/** + * Reserve a browser popup before any asynchronous work or document access. + * Office's system-browser API does not use popup blocking, so it remains deferred + * until the final URL is ready. + */ +export function reserveBrowserLaunch(): BrowserLaunchReservation | null { + if (officeBrowserOpener()) return { kind: 'office' }; + const popup = window.open('about:blank', '_blank'); + if (!popup) return null; + try { + popup.opener = null; + } catch { + // Some browser wrappers expose a read-only opener. + } + return { kind: 'window', popup }; +} + +export function completeBrowserLaunch( + reservation: BrowserLaunchReservation, + url: string, +): void { + if (reservation.kind === 'office') { + officeBrowserOpener()?.(url); + return; + } + reservation.popup.location.replace(url); +} + +export function cancelBrowserLaunch(reservation: BrowserLaunchReservation): void { + if (reservation.kind === 'window' && !reservation.popup.closed) { + reservation.popup.close(); + } +} diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts index ef27941c..240b91e7 100644 --- a/frontend/src/api/wordEditorAPI.ts +++ b/frontend/src/api/wordEditorAPI.ts @@ -1,3 +1,23 @@ +export function wordDocumentLabel(url: string | undefined): string { + if (!url) return 'Word document'; + let value = url; + if (/^https?:\/\//i.test(url)) { + try { + value = new URL(url).pathname; + } catch { + // Treat malformed HTTP(S) values as opaque paths below. + } + } + const segments = value.split(/[\\/]/).filter(Boolean); + const filename = segments[segments.length - 1]; + if (!filename) return 'Word document'; + try { + return decodeURIComponent(filename); + } catch { + return filename; + } +} + /** * Whether the host supports `getReviewedText` (WordApi 1.4). We use it to read * the document as if tracked changes were accepted, so the AI never sees deleted @@ -33,6 +53,7 @@ export const wordEditorAPI: EditorAPI = { Word.run(async (context: Word.RequestContext) => { const body: Word.Body = context.document.body; const docContext: DocContext = { + documentLabel: wordDocumentLabel(Office.context.document.url), beforeCursor: '', selectedText: '', afterCursor: '', diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx index 20cea5be..982ed57f 100644 --- a/frontend/src/editor/index.tsx +++ b/frontend/src/editor/index.tsx @@ -60,7 +60,12 @@ export function EditorScreen({ const editorAPI: EditorAPI = useMemo( () => ({ getDocContext: async (): Promise => { - return Promise.resolve(docContextRef.current); + return Promise.resolve({ + ...docContextRef.current, + documentLabel: + docContextRef.current.documentLabel ?? + 'Standalone editor draft', + }); }, addSelectionChangeHandler: (handler: () => void) => { selectionChangeHandlers.current.push(handler); diff --git a/frontend/src/pages/tools/__tests__/tools-config.test.ts b/frontend/src/pages/tools/__tests__/tools-config.test.ts new file mode 100644 index 00000000..15f7c3ad --- /dev/null +++ b/frontend/src/pages/tools/__tests__/tools-config.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + launchFirstPartyTool, + MINDMAP_TOOL, + resolveMindmapToolUrl, + type FirstPartyTool, +} from '../index'; + +const mindmap: FirstPartyTool = { + id: 'mindmap', + name: 'Mindmap', + description: 'Test mindmap', + url: 'https://mindmap.example/', + scopes: ['openai:chat', 'doc:read'], +}; + +describe('mindmap tool registration', () => { + it('uses local and production URL defaults with an environment override', () => { + expect(resolveMindmapToolUrl(undefined, true)).toBe( + 'http://localhost:5181/', + ); + expect(resolveMindmapToolUrl(undefined, false)).toBe( + 'https://mindmap.thoughtful-ai.com/', + ); + expect(resolveMindmapToolUrl('https://preview.example/', true)).toBe( + 'https://preview.example/', + ); + }); + + it('requests AI and document-read access without the retired logging scope', () => { + expect(MINDMAP_TOOL.scopes).toEqual(['openai:chat', 'doc:read']); + expect(MINDMAP_TOOL.scopes).not.toContain('log:write'); + }); + + it('does not access the document or backend when the popup is blocked', async () => { + const getAccessToken = vi.fn(); + const getDocContext = vi.fn(); + const createGrant = vi.fn(); + await expect( + launchFirstPartyTool(mindmap, { + getAccessToken, + getDocContext, + createGrant, + reserveLaunch: () => null, + completeLaunch: vi.fn(), + cancelLaunch: vi.fn(), + }), + ).rejects.toThrow('blocked'); + expect(getAccessToken).not.toHaveBeenCalled(); + expect(getDocContext).not.toHaveBeenCalled(); + expect(createGrant).not.toHaveBeenCalled(); + }); + + it('reserves before reading a document and completes a granted launch', async () => { + const calls: string[] = []; + const result = await launchFirstPartyTool(mindmap, { + getAccessToken: () => { + calls.push('token'); + return Promise.resolve('token'); + }, + getDocContext: () => { + calls.push('doc'); + return Promise.resolve({ + beforeCursor: 'draft', + selectedText: '', + afterCursor: '', + }); + }, + createGrant: () => { + calls.push('grant'); + return Promise.resolve({ grantId: 'grant', expiresIn: 120 }); + }, + reserveLaunch: () => { + calls.push('reserve'); + return { kind: 'office' }; + }, + completeLaunch: (_reservation, url) => calls.push(`open:${url}`), + cancelLaunch: vi.fn(), + }); + expect(calls.slice(0, 4)).toEqual(['reserve', 'token', 'doc', 'grant']); + expect(calls[4]).toContain('#wt_grant=grant'); + expect(result).toEqual({ sharedDoc: true }); + }); +}); diff --git a/frontend/src/pages/tools/index.tsx b/frontend/src/pages/tools/index.tsx index 9b3d7b9d..732a4e49 100644 --- a/frontend/src/pages/tools/index.tsx +++ b/frontend/src/pages/tools/index.tsx @@ -12,8 +12,12 @@ import { useContext, useState } from 'react'; import { Button } from 'reshaped'; import { + cancelBrowserLaunch, + completeBrowserLaunch, createHandoff, openInBrowser, + reserveBrowserLaunch, + type BrowserLaunchReservation, type ToolScope, withGrantFragment, } from '@/api/handoff'; @@ -23,7 +27,7 @@ import { useAppAuth } from '@/contexts/appAuthContext'; import { useLog } from '@/hooks/useLog'; import classes from './styles.module.css'; -interface FirstPartyTool { +export interface FirstPartyTool { /** Tool client_id — must also be listed in the backend device allowlist. */ id: string; name: string; @@ -38,26 +42,71 @@ interface FirstPartyTool { * backend's BETTER_AUTH_DEVICE_CLIENT_IDS allowlist, and the URL points at wherever * the tool is hosted. A manifest-driven registry replaces this list in a later phase. */ -const FIRST_PARTY_TOOLS: FirstPartyTool[] = [ - { - // Throwaway tool for exercising the handoff flow end-to-end in dev. - // Served from sandbox/test-tool/ (see that dir's README). Remove before merge. - id: 'test-tool', - name: 'Platform API test tool', - description: - 'Dev-only. Exchanges the handoff grant and lets you fire each Platform API v0 call (LLM proxy, log, doc re-fetch, revoke).', - url: 'http://localhost:4000/', - scopes: ['openai:chat', 'log:write', 'doc:read'], - }, - { - id: 'mindmap', - name: 'Mindmap', - description: - 'Explore your draft as a client-side mindmap. Opens in your browser with a read-only snapshot of your current document.', - url: 'https://mindmap.thoughtful-ai.com/', - scopes: ['openai:chat', 'log:write', 'doc:read'], - }, -]; +export function resolveMindmapToolUrl( + explicit: string | undefined, + isDevelopment: boolean, +): string { + return ( + explicit || + (isDevelopment + ? 'http://localhost:5181/' + : 'https://mindmap.thoughtful-ai.com/') + ); +} + +export const MINDMAP_TOOL_URL = resolveMindmapToolUrl( + import.meta.env.VITE_MINDMAP_TOOL_URL, + import.meta.env.DEV, +); + +export const MINDMAP_TOOL: FirstPartyTool = { + id: 'mindmap', + name: 'Mindmap', + description: + 'Explore your draft as a client-side mindmap. Opens in your browser with a read-only snapshot of your current document.', + url: MINDMAP_TOOL_URL, + scopes: ['openai:chat', 'doc:read'], +}; + +export const FIRST_PARTY_TOOLS: FirstPartyTool[] = [MINDMAP_TOOL]; + +interface LaunchToolDependencies { + getAccessToken(): Promise; + getDocContext(): Promise; + createGrant: typeof createHandoff; + reserveLaunch(): BrowserLaunchReservation | null; + completeLaunch(reservation: BrowserLaunchReservation, url: string): void; + cancelLaunch(reservation: BrowserLaunchReservation): void; +} + +export async function launchFirstPartyTool( + tool: FirstPartyTool, + dependencies: LaunchToolDependencies, +): Promise<{ sharedDoc: boolean }> { + const reservation = dependencies.reserveLaunch(); + if (!reservation) { + throw new Error('Your browser blocked the new window. Allow popups and try again.'); + } + try { + const token = await dependencies.getAccessToken(); + const doc = tool.scopes.includes('doc:read') + ? await dependencies.getDocContext() + : undefined; + const { grantId } = await dependencies.createGrant(token, { + toolClientId: tool.id, + scopes: tool.scopes, + doc, + }); + dependencies.completeLaunch( + reservation, + withGrantFragment(tool.url, grantId), + ); + return { sharedDoc: doc !== undefined }; + } catch (error) { + dependencies.cancelLaunch(reservation); + throw error; + } +} const SCOPE_LABELS: Record = { 'openai:chat': 'AI access', @@ -88,21 +137,17 @@ export default function Tools() { setError(null); setBusyToolId(tool.id); try { - const token = await getAccessToken(); - // Only read the document when the tool asked for it — the snapshot leaves - // our trust boundary, so we don't gather it speculatively. - const doc = tool.scopes.includes('doc:read') - ? await editorAPI.getDocContext() - : undefined; - const { grantId } = await createHandoff(token, { - toolClientId: tool.id, - scopes: tool.scopes, - doc, + const result = await launchFirstPartyTool(tool, { + getAccessToken, + getDocContext: () => editorAPI.getDocContext(), + createGrant: createHandoff, + reserveLaunch: reserveBrowserLaunch, + completeLaunch: completeBrowserLaunch, + cancelLaunch: cancelBrowserLaunch, }); - openInBrowser(withGrantFragment(tool.url, grantId)); void toolsLog.toolLaunched(log, { tool: tool.id, - sharedDoc: doc !== undefined, + sharedDoc: result.sharedDoc, scopes: tool.scopes, }); } catch (e) { diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index 012fd296..40b2ddeb 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -127,6 +127,8 @@ interface ContextSection { interface DocContext { contextData?: ContextSection[]; + /** Human-readable source label carried with launcher document snapshots. */ + documentLabel?: string; beforeCursor: string; selectedText: string; afterCursor: string; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 11f02fe2..d2d6b188 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1 +1,5 @@ /// + +interface ImportMetaEnv { + readonly VITE_MINDMAP_TOOL_URL?: string; +}