Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
46c73e8
Add "My Words" collaborative editor (v1, standalone)
claude Jun 25, 2026
1199711
Implement applyEdit for Word (My Words)
claude Jun 25, 2026
de5bdec
Add paragraph-numbered view + paragraph-targeted insert (My Words)
claude Jun 25, 2026
b7517be
bump model
kcarnold Jun 25, 2026
4d87dfb
Add visibility for tool calling failures
kcarnold Jun 25, 2026
a5339ac
update to ai-sdk v7
kcarnold Jun 25, 2026
2b98149
My Words: zod tool schemas, change-reporting results, tutor framing
claude Jun 25, 2026
bcf8776
My Words: make the corpus legible to the model
claude Jun 25, 2026
1445b9b
My Words: make `view` the single source for the scratchpad
claude Jun 25, 2026
e43d1b3
My Words: don't accumulate tool dumps in the context window
claude Jun 25, 2026
ce27e40
attempts to fix an intermittent empty-response problem
kcarnold Jun 25, 2026
4cacccf
Merge branch 'feat/remove-auth0-frontend' into claude/confident-ride-…
kcarnold Jun 25, 2026
465c9d7
Add My Words interaction design note
claude Jun 26, 2026
1c09a7f
Prototype My Words interaction models with a playback harness
claude Jun 26, 2026
2a4272f
Fix live-path issues in My Words interaction loop
claude Jun 26, 2026
94ebac4
Improve Propose awareness; exclude tracked deletions from AI reads
claude Jun 26, 2026
89106f8
Move My Words chat to the bottom; lighten captions; fold messages int…
claude Jun 26, 2026
f5ca85c
Scope str_replace to a paragraph; dump nearby paragraphs on failure
claude Jun 26, 2026
fddd240
Persist the My Words scratchpad across reloads
claude Jun 26, 2026
adae0bf
docs: research note on voice-native My Words conversation
claude Jul 2, 2026
fd798d5
Merge remote-tracking branch 'origin/claude/mywords-interaction-desig…
claude Jul 3, 2026
a7d50e9
spike: voice conversation over the document via OpenAI Realtime (WebRTC)
claude Jul 3, 2026
b1cc1e0
fix(voice-spike): use GA Realtime session.update schema
claude Jul 3, 2026
f68c5f7
fix(voice-spike): surface the SDP exchange error body
claude Jul 3, 2026
93faa12
Update model
kcarnold Jul 3, 2026
d263d85
Make the call look more like the example
kcarnold Jul 3, 2026
d9e981a
fix(voice-spike): audio playback + one active response at a time
claude Jul 3, 2026
cb13122
fix(voice-spike): prime audio playback in the gesture window + status…
claude Jul 3, 2026
f29142b
Bump model on frontend
kcarnold Jul 3, 2026
cb275a9
LiveKit spike
kcarnold Jul 6, 2026
47fe610
dedupe logs by segment, but keep feeding interims into the corpus (to…
kcarnold Jul 6, 2026
1ff2947
bump model version
kcarnold Jul 6, 2026
ae2fa00
Merge remote-tracking branch 'origin/main' into claude/voice-native-c…
kcarnold Jul 7, 2026
99f3144
feat(my-words): lower every edit op to a canonical paragraph-range sp…
kcarnold Jul 7, 2026
4df21ad
fix(editor): apply edits node-incrementally instead of rewriting the …
kcarnold Jul 7, 2026
90d8ba9
feat(my-words): reveal-then-apply with a veto window; per-host splice…
kcarnold Jul 7, 2026
d40e0e4
feat(voice): scratchpad as a second document, with undo and veto in t…
kcarnold Jul 7, 2026
8abea63
docs: voice-scratchpad design note; fix stale jsdom claim in frontend…
kcarnold Jul 7, 2026
6c02471
Merge remote-tracking branch 'origin/main' into claude/voice-native-c…
kcarnold Jul 17, 2026
877e175
Drop LiveKit service; simplify back to plain realtime API.
kcarnold Jul 25, 2026
753cf99
Merge remote-tracking branch 'origin/main' into claude/voice-native-c…
kcarnold Jul 25, 2026
dfb6bcd
Merge origin/main into voice-native-conversation-research
kcarnold Jul 27, 2026
ae41d44
Move the My Words scratchpad onto document settings
kcarnold Jul 27, 2026
9fc2ff6
Require a session to mint a Realtime token; flag My Words off
kcarnold Jul 27, 2026
e761c2d
fix docs
kcarnold Jul 27, 2026
4fa5db5
update lockfile
kcarnold Jul 27, 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
91 changes: 90 additions & 1 deletion backend/src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,14 @@ const app = createApp();
* updateUser/deleteUser record their calls. Cast to Auth — we only touch the
* surface the app handlers use.
*/
function makeAuthApp(user: { id: string; loggingConsent?: string } | null) {
function makeAuthApp(
user: {
id: string;
loggingConsent?: string;
isAnonymous?: boolean;
email?: string;
} | null,
) {
const calls: { updateUser: unknown[]; deleteUser: unknown[] } = {
updateUser: [],
deleteUser: [],
Expand Down Expand Up @@ -274,6 +281,88 @@ describe('POST /api/openai/chat/completions', () => {
});
});

describe('POST /api/openai/realtime/session', () => {
/** The mint call never reaches OpenAI when the route refuses first. */
function stubUpstream() {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ value: 'ek_test' }), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}

it('401s without a session when auth is on, without minting', async () => {
vi.stubEnv('OPENAI_API_KEY', 'sk-main');
const fetchMock = stubUpstream();
const { app: authApp } = makeAuthApp(null);

const res = await authApp.request('/api/openai/realtime/session', {
method: 'POST',
});

expect(res.status).toBe(401);
// The point of the gate: no ephemeral credential was minted against our key.
expect(fetchMock).not.toHaveBeenCalled();
});

it('mints against the main key for a signed-in user', async () => {
vi.stubEnv('OPENAI_API_KEY', 'sk-main');
const fetchMock = stubUpstream();
// Allowlisted address — the route enforces the same beta gate as the proxy.
const { app: authApp } = makeAuthApp({
id: 'user-1',
email: 'writer@calvin.edu',
});

const res = await authApp.request('/api/openai/realtime/session', {
method: 'POST',
});

expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toContain('realtime/client_secrets');
expect((init.headers as Record<string, string>).Authorization).toBe(
'Bearer sk-main',
);
});

it('403s a signed-in user outside the beta allowlist', async () => {
vi.stubEnv('OPENAI_API_KEY', 'sk-main');
const fetchMock = stubUpstream();
const { app: authApp } = makeAuthApp({
id: 'user-2',
email: 'someone@example.com',
});

const res = await authApp.request('/api/openai/realtime/session', {
method: 'POST',
});

expect(res.status).toBe(403);
expect(fetchMock).not.toHaveBeenCalled();
});

it('spends the capped demo key for an anonymous session', async () => {
vi.stubEnv('OPENAI_API_KEY', 'sk-main');
vi.stubEnv('OPENAI_DEMO_API_KEY', 'sk-demo');
const fetchMock = stubUpstream();
const { app: authApp } = makeAuthApp({ id: 'anon-1', isAnonymous: true });

const res = await authApp.request('/api/openai/realtime/session', {
method: 'POST',
});

expect(res.status).toBe(200);
const [, init] = fetchMock.mock.calls[0]!;
expect((init.headers as Record<string, string>).Authorization).toBe(
'Bearer sk-demo',
);
});
});

describe('log-viewer secret gate', () => {
it('rejects logs_poll with a wrong secret and accepts the right one', async () => {
vi.stubEnv("LOG_SECRET", 'super-secret');
Expand Down
71 changes: 70 additions & 1 deletion backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { deviceClientIds, gitCommit, logSecret } from './config.js';
import { eraseLoggedData } from './erasure.js';
import { appendLog, pollLogs, zipLogs } from './logging.js';
import { openaiProxy } from './openaiProxy.js';
import { attributeRequest, openaiProxy } from './openaiProxy.js';
import { captureException, posthogMiddleware } from './posthog.js';
import { costUsd } from './pricing.js';
import {
Expand All @@ -27,6 +27,17 @@ import {
import { summarizeUsage } from './usage.js';
import { isUserAllowed } from './userAllowlist.js';

// Mints short-lived ephemeral credentials so a browser can open a WebRTC
// Realtime session without ever seeing the server API key. This is the only
// server involvement in the voice tab: audio and tool calls run browser →
// OpenAI directly (frontend/src/pages/my-words/voice/, and
// docs/my-words-voice-native-research.md).
const OPENAI_REALTIME_SESSION_URL =
'https://api.openai.com/v1/realtime/client_secrets';
// The realtime-capable model the ephemeral session is bound to. Override via env
// as OpenAI ships new ids (gpt-realtime, gpt-realtime-2, …).
const REALTIME_MODEL = process.env.OPENAI_REALTIME_MODEL || 'gpt-realtime-2';

/** Extract the raw bearer token from the Authorization header, or null. */
function bearerToken(c: Context): string | null {
const header = c.req.header('Authorization') ?? '';
Expand Down Expand Up @@ -104,6 +115,64 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono {
}),
);

// Mint an ephemeral Realtime session token for the browser voice spike. The
// server key stays here; the browser receives only the short-lived secret it
// uses to open the WebRTC session directly with OpenAI.
app.post('/api/openai/realtime/session', async (c) => {
// A realtime session spends a model key just like a proxied generation, so
// it answers to the same attribution: a real session spends the main key, a
// demo session the capped one, and sessionless traffic is refused wherever
// auth is on rather than quietly billing the main key. Without this the
// route was open — anyone who could reach it could mint a token against
// OPENAI_API_KEY.
//
// Metering is still missing, and can't be done here: the audio runs
// browser → OpenAI over WebRTC, so no tokens pass through this server to
// count. Recording it means reading `usage` off the client's `response.done`
// events and reporting them back. Until then a voice session is billable but
// absent from `llm_usage`, which is why the page stays behind a flag.
const user = await resolveUser(c);
// Same beta allowlist the proxy enforces — otherwise voice is a way around
// it that also happens to spend a model key.
if (user && !user.isAllowed) return c.json({ detail: 'Forbidden' }, 403);
const attribution = attributeRequest(user, !!auth);
if (!attribution) {
return c.json({ detail: 'Sign in to use voice.' }, 401);
}
const key = attribution.apiKey;
if (!key) {
return c.json({ detail: 'OPENAI_API_KEY not set' }, 500);
}
let upstream: Response;
try {
upstream = await fetch(OPENAI_REALTIME_SESSION_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
session: { type: 'realtime', model: REALTIME_MODEL },
}),
});
} catch (e) {
console.error(
'[realtime-session] upstream fetch failed:',
(e as Error).message,
);
throw e; // -> onError -> 500 JSON
}

const text = await upstream.text();
if (!upstream.ok) {
console.warn(`[realtime-session] ${upstream.status} ${text.slice(0, 200)}`);
}
return new Response(text, {
status: upstream.status,
headers: { 'Content-Type': 'application/json' },
});
});

// Resolve the authenticated user from the request's session, or null. Returns
// null when auth is disabled (dev/tests without BETTER_AUTH_ENABLED) so the
// caller can 401 — identity-keyed logging requires a session. `isAnonymous`
Expand Down
Loading
Loading