Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
157 commits
Select commit Hold shift + click to select a range
652addf
Add UIST prototype (Vite + React)
MaryChen68 Jun 22, 2026
664db7b
Build guarded user-owned words prototype
nhyiramante1 Jun 22, 2026
0e91f61
Refine owned-words prototype guidance and layout
nhyiramante1 Jun 22, 2026
c4a430b
Improve coach modes and placement prep
nhyiramante1 Jun 23, 2026
9e0fca1
Add placement no-clobber guard and evaluation log
nhyiramante1 Jun 23, 2026
e902c26
Fix right-pane scroll layout and add placement UX polish
nhyiramante1 Jun 23, 2026
8b68618
Add Phase B placement flow and fix paragraph-anchor insertion
nhyiramante1 Jun 23, 2026
c5040bb
feat(uist): seamless paragraph insert + local newline inference; remo…
nhyiramante1 Jun 23, 2026
d9e7ee5
Add reflective mind-map prototype (prototype-mindmap)
nhyiramante1 Jun 30, 2026
89eb178
Phase 1 direct map commands
nhyiramante1 Jun 30, 2026
4b80bdd
Phase 2 structure commands
nhyiramante1 Jun 30, 2026
799b2ee
mindmap: direct structure commands, connection UX, and map-aware mirr…
nhyiramante1 Jun 30, 2026
1aec76e
mindmap: tighten map UI and harden persisted card sizes
nhyiramante1 Jun 30, 2026
fbf434a
mindmap: acknowledge direct map commands
nhyiramante1 Jun 30, 2026
78670f8
mindmap: harden draft and large-turn gates
nhyiramante1 Jun 30, 2026
a148d90
mindmap: resolve card refs in connection commands
nhyiramante1 Jun 30, 2026
2ddaaf5
mindmap: prioritize commands and preserve tentative mirrors
nhyiramante1 Jun 30, 2026
e2c6037
mindmap: move calibration vocab into config
nhyiramante1 Jun 30, 2026
a0288db
mindmap: refine command routing and follow-through
nhyiramante1 Jul 1, 2026
4af466e
mindmap: fix confirm follow-through continuation
nhyiramante1 Jul 1, 2026
9760715
mindmap: bump model to gpt-5.4
nhyiramante1 Jul 1, 2026
ad04fbe
mindmap: queue missing connection labels
nhyiramante1 Jul 1, 2026
8de99c5
mindmap: exclude command wording from mirrors
nhyiramante1 Jul 1, 2026
79c0778
mindmap: harden LLM JSON contract against invalid output
nhyiramante1 Jul 1, 2026
ba964e9
mindmap: hand control back after complete commands
nhyiramante1 Jul 1, 2026
931b9bf
mindmap: dagre auto-clean layout and non-overlapping card placement
nhyiramante1 Jul 1, 2026
06873e0
mindmap: highlight referenced cards and persist draft anchor
nhyiramante1 Jul 1, 2026
93c6078
uist: capture older untracked UI work
nhyiramante1 Jul 1, 2026
a8556d4
mindmap: disengage repeated organize prompts
nhyiramante1 Jul 1, 2026
8c80705
mindmap: de-duplicate organize-focus prompt note
nhyiramante1 Jul 1, 2026
ba02178
mindmap: add voice input and tighten sparse-map capture
nhyiramante1 Jul 1, 2026
efb5523
mindmap: handle empty backend JSON responses
nhyiramante1 Jul 1, 2026
c0b652a
mindmap: make capture failures more legible
nhyiramante1 Jul 1, 2026
5719f94
mindmap: fence carry-forward card wording
nhyiramante1 Jul 1, 2026
958bdb7
mindmap: clarify ambiguous #ref commands
nhyiramante1 Jul 1, 2026
f29f654
mindmap: break sparse capture loops
nhyiramante1 Jul 1, 2026
8291454
mindmap: keep new cards in view
nhyiramante1 Jul 1, 2026
ee9775f
mindmap: confirm compact relationship wording
nhyiramante1 Jul 1, 2026
3d64f10
mindmap: keep child card placement threaded
nhyiramante1 Jul 1, 2026
00acd6d
mindmap: execute explicit ref nesting commands
nhyiramante1 Jul 1, 2026
a27dbb1
mindmap: harden command confirmations and clear controls
nhyiramante1 Jul 1, 2026
16ecc22
mindmap: close command hardening gaps
nhyiramante1 Jul 2, 2026
dde327c
mindmap: harden command and reset gates
nhyiramante1 Jul 2, 2026
26393bb
mindmap: recognize explicit map commands and harden the command path
nhyiramante1 Jul 2, 2026
1482449
mindmap: fix command declines and stale questions
nhyiramante1 Jul 2, 2026
c376006
mindmap: block negated exact-text card commands
MaryChen68 Jul 2, 2026
c516ce7
mindmap: add remote-coder handoff doc
MaryChen68 Jul 2, 2026
8cbe94d
mindmap: anchor coverage questions instead of stale settle wording
MaryChen68 Jul 2, 2026
14358a8
mindmap: fix anaphoric cards and coverage state
nhyiramante1 Jul 2, 2026
ae2d733
mindmap: bug sweep for coverage state, commands, and focus-help
MaryChen68 Jul 2, 2026
84e4971
mindmap: keep near-match commands pending through rejection and corre…
nhyiramante1 Jul 2, 2026
c44d130
mindmap: add user-facing Coach Trace panel
MaryChen68 Jul 2, 2026
58d5e03
mindmap: add Coach Trace derive layer (trace.ts)
nhyiramante1 Jul 2, 2026
8d7d1ea
mindmap: integrate real trace layer into Coach Trace panel
MaryChen68 Jul 2, 2026
6de41a8
mindmap: move Coach Trace to a compact map-header status chip
MaryChen68 Jul 2, 2026
30672fc
mindmap: enlarge coach trace header status
nhyiramante1 Jul 2, 2026
a8d1be2
mindmap: add mirror pressure and draft salience bridges
nhyiramante1 Jul 2, 2026
df71340
mindmap: fix salience bridge parser edge cases
nhyiramante1 Jul 2, 2026
aa12957
mindmap: harden bridge and mirror repair states
nhyiramante1 Jul 2, 2026
e6d0df0
mindmap: add read-only Under the Hood inspector and loosen mirror con…
nhyiramante1 Jul 3, 2026
4bb5674
Polish mindmap draft docking and UI controls
nhyiramante1 Jul 3, 2026
7334998
mindmap: add direction-aware cleanup and dock drag
nhyiramante1 Jul 3, 2026
8203db6
mindmap: remove retired Coach Trace and dead UI surface
nhyiramante1 Jul 3, 2026
d454739
mindmap: improve map-aware coach transitions
nhyiramante1 Jul 3, 2026
8ec5a0b
mindmap: show live underhood causal path
nhyiramante1 Jul 3, 2026
fc750c0
mindmap: handle legacy underhood event stages
nhyiramante1 Jul 3, 2026
a207501
mindmap: next-move controls, layout refresh, and coach-routing fixes
nhyiramante1 Jul 4, 2026
2c8c373
mindmap: close reviewer routing fixes
nhyiramante1 Jul 4, 2026
20c20b6
mindmap: reviewer-round-2 fixes and toolbar relocation
nhyiramante1 Jul 4, 2026
467cd68
mindmap: harden coach controls and layout
nhyiramante1 Jul 4, 2026
74b71b8
Improve prototype mindmap voice memory and map controls
nhyiramante1 Jul 5, 2026
950e900
mindmap: add graceful weird-input lane (meta-aside + affect) with cap…
nhyiramante1 Jul 6, 2026
40141ba
Fix nested connection endpoint handling
nhyiramante1 Jul 6, 2026
bd349aa
additional commit for nested endpoint handling
nhyiramante1 Jul 6, 2026
feea59c
implement card and draft selection as input and for dragging
nhyiramante1 Jul 7, 2026
76a0be4
added text formatting for the draft tab
nhyiramante1 Jul 7, 2026
028235a
fix formatting in draft box
nhyiramante1 Jul 7, 2026
93d97e9
fix AI misleading response to user asking for a parent card to be nes…
nhyiramante1 Jul 8, 2026
bef48b0
rework coach steering: selection targets, draft-selection mirror, hon…
nhyiramante1 Jul 10, 2026
a10c191
fix docked draft pill clipped by header scrollbar
nhyiramante1 Jul 10, 2026
9a61c96
rename Under the Hood panel to Control Room and sharpen next-move labels
nhyiramante1 Jul 10, 2026
237f71b
refactor plan
nhyiramante1 Jul 15, 2026
80a1029
feat(mindmap): typed proposal pipeline, provider tools, and provenanc…
nhyiramante1 Jul 17, 2026
44ccc3d
updates to the refactor plan
nhyiramante1 Jul 20, 2026
b11207b
fix(mindmap): remove tentative evidence classifier
nhyiramante1 Jul 20, 2026
121a942
test(mindmap): close stage 1.5 context gate
nhyiramante1 Jul 20, 2026
be0900f
open thread deletion + polish changes described in docs
nhyiramante1 Jul 20, 2026
d7f1ac1
fix(mindmap): surface held ideas and repair recovery
nhyiramante1 Jul 20, 2026
0d42c5e
feat(mindmap): add controlled working-memory recall
nhyiramante1 Jul 20, 2026
bbd37cd
fix(mindmap): make draft anchors passive
nhyiramante1 Jul 20, 2026
3ca2692
fix(mindmap): support cross-block passive anchors
nhyiramante1 Jul 20, 2026
d3e5647
feat(mindmap): enforce faithful mirrors and add eval runner
nhyiramante1 Jul 20, 2026
73347ac
feat(mindmap): checkpoint draft grounding foundation
nhyiramante1 Jul 21, 2026
8df4fca
feat(mindmap): enforce draft chat anchors and trace suggestion adoption
nhyiramante1 Jul 21, 2026
7324ae6
feat(mindmap): surface persistent suggestion provenance
nhyiramante1 Jul 21, 2026
66171cd
fix(mindmap): prevent retroactive suggestion adoption
nhyiramante1 Jul 21, 2026
3b7b621
Implement bounded reflection recovery and eval bakeoff
nhyiramante1 Jul 21, 2026
c5215f9
Tune assistance prompts and question framing evals
nhyiramante1 Jul 21, 2026
bb06b39
Close reflection visible-text grounding gap
nhyiramante1 Jul 21, 2026
72d1582
Add source-backed conversational recaps
nhyiramante1 Jul 21, 2026
4b9bb27
Trace L1 cross-turn synthesis provenance
nhyiramante1 Jul 21, 2026
d6fc312
Honor explicit card-reference nesting intent
nhyiramante1 Jul 21, 2026
9ec90e6
docs(mindmap): align current design and eval guidance
nhyiramante1 Jul 21, 2026
f5ef31c
docs(mindmap): track agent handoff, pass plans, and docs read-order i…
nhyiramante1 Jul 23, 2026
ff2b165
docs(mindmap): update branch references after rename to mindmap-main
nhyiramante1 Jul 23, 2026
b667ea1
docs(mindmap): record multilingual checkpoint-1 review in agent handoff
nhyiramante1 Jul 23, 2026
7448891
Add mindmap UI localization foundation
nhyiramante1 Jul 23, 2026
e7c7807
docs(mindmap): record multilingual checkpoint-2 review; queue checkpo…
nhyiramante1 Jul 23, 2026
47e2999
Add original-language coach context
nhyiramante1 Jul 23, 2026
44183c1
docs(mindmap): record multilingual checkpoint-3 review; queue checkpo…
nhyiramante1 Jul 23, 2026
aba632d
docs(mindmap): ratify checkpoint-4 product decisions (4a/4b split)
nhyiramante1 Jul 23, 2026
028e430
Add multilingual grounding profiles
nhyiramante1 Jul 23, 2026
6f4d820
Add explicit AI translation responses
nhyiramante1 Jul 23, 2026
48de46b
docs(mindmap): scope Control Room in the 4b translated view
nhyiramante1 Jul 23, 2026
b2fd0f8
docs(mindmap): accept 3+4a as committed; add Checkpoint 6 (digest + d…
nhyiramante1 Jul 23, 2026
91f3af4
docs(mindmap): record 4b browser-QA blockers; file 2 separate bugs; r…
nhyiramante1 Jul 24, 2026
fa5043c
feat(mindmap): add read-only reader translation view
nhyiramante1 Jul 24, 2026
1cc79ba
docs(mindmap): accept reader translation remediation
nhyiramante1 Jul 24, 2026
ca0461a
merge: land mindmap reader translation view
nhyiramante1 Jul 24, 2026
901470b
docs(mindmap): correct Tier-2 collision map — reflection arc already …
nhyiramante1 Jul 24, 2026
c9a2624
docs(mindmap): add self-contained 6c turn-progress narration spec
nhyiramante1 Jul 24, 2026
7b6d0c5
fix(mindmap): raise reader display-cache cap to 1 MiB
nhyiramante1 Jul 24, 2026
07c89f4
feat(mindmap): finalize 6c turn-progress narration + relabel decayed …
nhyiramante1 Jul 24, 2026
aaaadcb
build(mindmap): port generate-i18n script (6a)
nhyiramante1 Jul 24, 2026
8840bf1
docs(mindmap): record 6c/6a/relabel/cache-cap landed; park backend re…
nhyiramante1 Jul 24, 2026
4fcc425
fix(mindmap): keep InfluenceBadge an echo signal, not an authorship c…
nhyiramante1 Jul 24, 2026
b37b1df
refactor(mindmap): extract ControlRoom.tsx from App.tsx (item 7, slic…
nhyiramante1 Jul 24, 2026
afce738
fix(mindmap): make L0 grounded recap synthesize, not parrot (item 6c …
nhyiramante1 Jul 24, 2026
84b4613
docs(mindmap): record 6c/6a live-verified, L0 recap fix + level decis…
nhyiramante1 Jul 24, 2026
e0115e1
merge: fold ControlRoom.tsx extraction into mindmap-main (item 7, sli…
nhyiramante1 Jul 24, 2026
ef686e1
Merge remote-tracking branch 'origin/main' into feat/mindmap-tool-int…
nhyiramante1 Jul 27, 2026
6c92887
rename: prototype-uist -> prototype-word-bank
nhyiramante1 Jul 27, 2026
6780e13
Integrate mindmap with tool launcher auth
nhyiramante1 Jul 27, 2026
7326d5b
Fix mindmap launcher review findings
nhyiramante1 Jul 27, 2026
06f6536
refactor(mindmap): extract session-persistence.ts, add root error bou…
nhyiramante1 Jul 27, 2026
7f12dd0
harden(mindmap): fail closed on launch gate, backend URL, and standal…
nhyiramante1 Jul 27, 2026
b0deddd
fix(mindmap): roll back the user bubble when a turn fails
nhyiramante1 Jul 27, 2026
4c24f37
polish(mindmap): tighten the launch choice screen
nhyiramante1 Jul 27, 2026
c070683
harden mindmap launcher integration before rollout
nhyiramante1 Jul 27, 2026
d73c52f
Merge remote-tracking branch 'origin/main' into mindmap-main
nhyiramante1 Jul 27, 2026
c8000a8
Merge branch 'mindmap-main' into feat/mindmap-tool-integration
nhyiramante1 Jul 27, 2026
96db491
fix(mindmap): use secure session identifiers
nhyiramante1 Jul 27, 2026
a3e2e53
Merge origin/main into mindmap-main
nhyiramante1 Jul 28, 2026
ab36c9c
fix(mindmap): use secure session identifiers
nhyiramante1 Jul 27, 2026
23c9eaf
fix(mindmap): sanitize restored draft HTML
nhyiramante1 Jul 28, 2026
f8de585
Merge refreshed mindmap-main into tool integration
nhyiramante1 Jul 28, 2026
b8ef12a
Merge base security fixes into tool integration
nhyiramante1 Jul 28, 2026
95c4779
security(mindmap): document inert HTML sanitization
nhyiramante1 Jul 28, 2026
4ec5339
Merge CodeQL sanitizer documentation into tool integration
nhyiramante1 Jul 28, 2026
9f1640b
Merge pull request #570 from AIToolsLab/feat/mindmap-tool-integration
nhyiramante1 Jul 28, 2026
af24e93
test(frontend): satisfy async mock lint
nhyiramante1 Jul 28, 2026
478bead
Merge remote-tracking branch 'origin/main' into mindmap-main
nhyiramante1 Jul 29, 2026
fd54c85
Address review on registry test and popup reservation
nhyiramante1 Jul 29, 2026
62db2b9
chore: untrack prototype-word-bank build output
kcarnold Jul 29, 2026
801fe21
Merge remote-tracking branch 'origin/mindmap-main' into mindmap-main
nhyiramante1 Jul 29, 2026
a32e1dc
Regenerate Playwright visual snapshots
github-actions[bot] Jul 29, 2026
9c3e110
chore: refresh checks after visual baseline
nhyiramante1 Jul 29, 2026
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
97 changes: 97 additions & 0 deletions .github/workflows/mindmap-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: Mindmap launcher tests

on:
pull_request:
paths:
- "prototype-mindmap/**"
- "frontend/src/api/handoff.ts"
- "frontend/src/api/wordEditorAPI.ts"
- "frontend/src/pages/tools/**"
- "frontend/src/vite-env.d.ts"
- "backend/src/app.ts"
- "backend/src/openaiProxy.ts"
- "backend/src/toolGrants.ts"
- "backend/src/__tests__/handoff.test.ts"
- "backend/src/__tests__/openaiProxy.test.ts"
- "scripts/get_env.py"
- "scripts/test_get_env.py"
- ".github/workflows/mindmap-tests.yml"
push:
branches: [main, mindmap-main]
paths:
- "prototype-mindmap/**"
- "frontend/src/api/handoff.ts"
- "frontend/src/api/wordEditorAPI.ts"
- "frontend/src/pages/tools/**"
- "backend/src/app.ts"
- "backend/src/openaiProxy.ts"
- "backend/src/toolGrants.ts"
- "scripts/get_env.py"
- ".github/workflows/mindmap-tests.yml"

permissions:
contents: read

jobs:
integration-units:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
cache-dependency-path: |
backend/package-lock.json
frontend/package-lock.json
- name: Backend dependencies
working-directory: backend
run: npm ci
- name: Backend typecheck and tests
working-directory: backend
run: npm run typecheck && npm test
- name: Frontend dependencies
working-directory: frontend
run: npm ci
- name: Frontend typecheck and tests
working-directory: frontend
run: npm run typecheck && npm test
- name: get_env formatting and idempotency tests
run: python -m unittest scripts/test_get_env.py

mindmap:
runs-on: ubuntu-latest
defaults:
run:
working-directory: prototype-mindmap
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
cache-dependency-path: prototype-mindmap/package-lock.json
- run: npm ci
- name: TypeScript
run: npx tsc --noEmit
- name: Vitest
run: npm test
- name: Eval typecheck
run: npm run eval:typecheck
- name: Production build
env:
VITE_BACKEND_URL: https://ci.invalid/api
VITE_REQUIRE_LAUNCH: "true"
run: npm run build
- name: Install Chromium
run: npx playwright install --with-deps chromium
- name: Playwright mocked smoke
run: npm run test:e2e
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v6
with:
name: mindmap-playwright-report
path: prototype-mindmap/playwright-report
if-no-files-found: ignore
retention-days: 7
826 changes: 826 additions & 0 deletions AGENT-HANDOFF.md

Large diffs are not rendered by default.

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 },
);
}
Loading
Loading