Skip to content
Closed
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
32 changes: 31 additions & 1 deletion backend/src/__tests__/handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -19,6 +20,7 @@ beforeEach(async () => {

afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
closeDb();
process.env = { ...env };
});
Expand Down Expand Up @@ -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)', () => {
Expand Down
82 changes: 81 additions & 1 deletion backend/src/__tests__/openaiProxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
attributeRequest,
createSseUsageScanner,
parseUsage,
PLATFORM_AUTH_ERROR_HEADER,
} from '../openaiProxy.js';
import {
ANONYMOUS_USER_ID,
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -558,19 +625,32 @@ 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),
},
);

expect(res.status).toBe(200);
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({
Expand Down
27 changes: 23 additions & 4 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
}),
);
Expand All @@ -110,7 +115,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono {
app.post(
'/api/openai/responses',
openaiProxy('responses', {
resolveUser,
resolveIdentity: resolveProxyIdentity,
authEnabled: !!auth,
}),
);
Expand Down Expand Up @@ -221,6 +226,20 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono {
};
}

async function resolveProxyIdentity(c: Context): Promise<ProxyIdentity> {
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.
Expand Down
37 changes: 32 additions & 5 deletions backend/src/openaiProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProxyUser | null>;
resolveIdentity: (c: Context) => Promise<ProxyIdentity>;
/**
* Whether auth is configured at all. When it isn't (local dev with
* BETTER_AUTH_ENABLED unset), unauthenticated requests are served rather than
Expand Down Expand Up @@ -223,15 +230,23 @@ function requestedModel(parsed: Record<string, unknown> | null): string {

export function openaiProxy(endpoint: OpenAIEndpoint, options: ProxyOptions) {
return async (c: Context): Promise<Response> => {
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.
Expand Down Expand Up @@ -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 },
);
}
12 changes: 11 additions & 1 deletion frontend/src/api/__tests__/googleDocsEditorAPI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/api/__tests__/wordEditorAPI.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
7 changes: 6 additions & 1 deletion frontend/src/api/googleDocsEditorAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ declare global {
setDocumentProperty?: (key: string, value: string) => Promise<void>;
getDocumentProperty?: (key: string) => Promise<string | null>;
getDocumentId: () => Promise<string>;
getDocumentName: () => Promise<string>;
getAllTabs: () => Promise<
{ id: string; title: string; text: string }[]
>;
Expand Down Expand Up @@ -269,10 +270,14 @@ export const googleDocsEditorAPI: EditorAPI = {
* Gets the current document context (before cursor, selection, after cursor).
*/
async getDocContext(): Promise<DocContext> {
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 || '',
Expand Down
Loading
Loading