Skip to content

Commit 8bbb787

Browse files
ralyodioclaude
andauthored
Fix qc throttling and 'session expired': Bearer auth for events, no refresh storm (0.5.1, qc 0.4.2) (#284)
- authenticateRequest's token client called setSession() with an empty refresh token, which auth-js rejects, so every Bearer request through it (qc's /api/events) was a 401. Use a client carrying the Authorization header and getUser(token). - qc reconnected /api/events on 401 straight after a refresh with no backoff, rotating refresh tokens in a tight loop (GoTrue logged several per 60ms) until the session died. Now one refresh per rejection, then stop. Parallel 401s share one refresh; a stale-token 401 just retries. - qc waits out 429s (Retry-After, which the middleware now sends). - The TUI no longer loads 40 chats on start to join their live rooms: the events stream joins all of the user's conversations on connect. - Closing one SSE connection no longer drops the user from every room while another of their connections is open. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 795a7b0 commit 8bbb787

12 files changed

Lines changed: 152 additions & 21 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qryptchat-web",
33
"private": true,
4-
"version": "0.5.0",
4+
"version": "0.5.1",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",

‎packages/qryptchat/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/qryptchat",
3-
"version": "0.4.1",
3+
"version": "0.4.2",
44
"description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
55
"type": "module",
66
"bin": {

‎src/app/api/events/route.js‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ export async function GET(request, { params } = {}) {
4040
const userId = userData.id;
4141
console.log(`📡 [SSE] Internal user ID: ${userId}`);
4242

43+
// Join every live conversation up front, so news from any chat arrives
44+
// without the client first loading each one's messages.
45+
const { data: memberships } = await supabase
46+
.from('conversation_participants')
47+
.select('conversation_id')
48+
.eq('user_id', userId)
49+
.is('left_at', null);
50+
4351
// Create a readable stream for SSE
4452
const stream = new ReadableStream({
4553
start(controller) {
@@ -63,6 +71,7 @@ export async function GET(request, { params } = {}) {
6371

6472
// Add connection to SSE manager with internal user ID
6573
sseManager.addConnection(responseWriter, userId);
74+
for (const m of memberships || []) sseManager.joinRoom(userId, m.conversation_id);
6675

6776
// Send initial connection success message
6877
const welcomeMessage = sseManager.formatSSEMessage('CONNECTED', {

‎src/cli/api.js‎

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,34 @@ export class QcClient {
5959
return this.session.access_token;
6060
}
6161

62-
async request(path, { method = 'GET', body, signal, retry = true } = {}) {
62+
/**
63+
* A 401 means the access token is stale, unless another request already
64+
* swapped it out while this one was in flight: then just retry with the new
65+
* one. Refresh tokens rotate, so refreshing twice for one expiry burns a token.
66+
*/
67+
async reauth(usedToken) {
68+
if (this.session.access_token === usedToken) await this.refresh();
69+
}
70+
71+
async request(path, { method = 'GET', body, signal, retry = true, throttled = 0 } = {}) {
72+
const used = await this.token();
6373
const res = await this.fetch(`${this.base}${path}`, {
6474
method,
6575
signal,
6676
headers: {
67-
Authorization: `Bearer ${await this.token()}`,
77+
Authorization: `Bearer ${used}`,
6878
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
6979
},
7080
body: body !== undefined ? JSON.stringify(body) : undefined,
7181
});
7282
if (res.status === 401 && retry) {
73-
await this.refresh();
74-
return this.request(path, { method, body, signal, retry: false });
83+
await this.reauth(used);
84+
return this.request(path, { method, body, signal, retry: false, throttled });
85+
}
86+
// Rate limited: wait as told (or back off) and try again a few times.
87+
if (res.status === 429 && throttled < 3) {
88+
await sleep(retryAfterMs(res, throttled), signal);
89+
return this.request(path, { method, body, signal, retry, throttled: throttled + 1 });
7590
}
7691
const data = await res.json().catch(() => ({}));
7792
if (!res.ok) throw new QcError(data.error_description || data.error || `HTTP ${res.status}`, res.status);
@@ -162,20 +177,31 @@ export class QcClient {
162177
*/
163178
async events(onEvent, { signal, onStatus = () => {} } = {}) {
164179
let delay = 1000;
180+
let reauthed = false;
165181
while (!signal?.aborted) {
166182
onStatus('connecting');
167183
try {
184+
const used = await this.token();
168185
const res = await this.fetch(`${this.base}/api/events`, {
169-
headers: { Authorization: `Bearer ${await this.token()}`, Accept: 'text/event-stream' },
186+
headers: { Authorization: `Bearer ${used}`, Accept: 'text/event-stream' },
170187
signal,
171188
});
172189
if (res.status === 401) {
173-
await this.refresh();
190+
// One refresh per rejection. If a fresh token is refused too, stop:
191+
// looping here would rotate refresh tokens until the session dies.
192+
if (reauthed) throw new QcError('The server refused this session. Run qc login.', 401);
193+
reauthed = true;
194+
await this.reauth(used);
195+
continue;
196+
}
197+
if (res.status === 429) {
198+
await sleep(retryAfterMs(res, 2), signal);
174199
continue;
175200
}
176201
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
177202
onStatus('live');
178203
delay = 1000;
204+
reauthed = false;
179205
await readSse(res.body, onEvent);
180206
} catch (err) {
181207
if (signal?.aborted) break;
@@ -217,6 +243,12 @@ export async function readSse(body, onEvent) {
217243
}
218244
}
219245

246+
/** How long a 429 says to wait: Retry-After seconds, else 2s, 4s, 8s ... */
247+
export function retryAfterMs(res, attempt = 0) {
248+
const s = Number(res.headers?.get?.('retry-after'));
249+
return Number.isFinite(s) && s > 0 ? Math.min(s, 60) * 1000 : 2000 * 2 ** attempt;
250+
}
251+
220252
function sleep(ms, signal) {
221253
return new Promise((resolve) => {
222254
const t = setTimeout(resolve, ms);

‎src/cli/tui.js‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -278,12 +278,9 @@ export async function runTui(client, { initialChat } = {}) {
278278
} else {
279279
state.selected = Math.max(0, state.conversations.findIndex((c) => c.id === before));
280280
}
281+
// The events stream joins every chat's live room itself, so unread
282+
// counts arrive without loading each chat (which tripped the rate limit).
281283
redraw();
282-
// Loading a conversation's messages joins its live room on the server,
283-
// so warm every room: that is what makes unread counts arrive.
284-
for (const c of state.conversations.slice(0, 40)) {
285-
if (c.id !== state.activeId && !state.messages[c.id]) await loadMessages(c.id);
286-
}
287284
} catch (err) {
288285
state.loading = false;
289286
note(err.status === 401 ? 'Session ended: run qc login' : `Could not load chats: ${err.message}`);

‎src/lib/api/middleware/auth.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export async function authenticateRequest(request) {
2020
? await createSupabaseServerClientWithToken(token)
2121
: await createSupabaseServerClient();
2222

23-
const { data: { user }, error } = await supabase.auth.getUser();
23+
// With a Bearer token the client holds no session, so name the JWT to verify.
24+
const { data: { user }, error } = token ? await supabase.auth.getUser(token) : await supabase.auth.getUser();
2425

2526
if (error || !user) {
2627
return { success: false, error: 'Unauthorized' };

‎src/lib/api/middleware/auth.test.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ describe('authenticateRequest bearer token parsing', () => {
5252
expect(auth.user).toEqual({ id: 'user-1' });
5353
expect(mocks.createSupabaseServerClientWithToken).toHaveBeenCalledWith('access-token-123');
5454
expect(mocks.createSupabaseServerClient).not.toHaveBeenCalled();
55+
// The token client holds no session, so the JWT must be named explicitly.
56+
expect(mocks.tokenClient.auth.getUser).toHaveBeenCalledWith('access-token-123');
5557
});
5658

5759
it('falls back to cookies when the authorization header has no token', async () => {

‎src/lib/api/sse-manager.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ class SSEManager {
5555

5656
this.connectionUsers.delete(response);
5757

58-
// Remove from all rooms
58+
// Leave the rooms only with the user's last connection: a closing qc
59+
// session must not silence the same user's open browser tab.
60+
if (this.userConnections.has(userId)) return;
5961
for (const [conversationId, users] of this.conversationRooms.entries()) {
6062
users.delete(userId);
6163
if (users.size === 0) {

‎src/lib/api/sse-manager.test.js‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { sseManager } from './sse-manager.js';
3+
4+
describe('sseManager rooms', () => {
5+
it('keeps a user in their rooms while another of their connections is open', () => {
6+
vi.spyOn(console, 'log').mockImplementation(() => {});
7+
const browser = { write: vi.fn() };
8+
const terminal = { write: vi.fn() };
9+
sseManager.addConnection(browser, 'u-rooms');
10+
sseManager.addConnection(terminal, 'u-rooms');
11+
sseManager.joinRoom('u-rooms', 'conv-rooms');
12+
13+
// qc reconnecting must not silence the open browser tab.
14+
sseManager.removeConnection(terminal);
15+
sseManager.broadcastToRoom('conv-rooms', 'NEW_MESSAGE', { id: 1 });
16+
expect(browser.write).toHaveBeenCalled();
17+
18+
sseManager.removeConnection(browser);
19+
expect(sseManager.conversationRooms.has('conv-rooms')).toBe(false);
20+
});
21+
});

‎src/lib/supabase.js‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createBrowserClient, createServerClient } from '@supabase/ssr';
2+
import { createClient } from '@supabase/supabase-js';
23

34
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
45
const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
@@ -35,12 +36,19 @@ export async function createSupabaseServerClient() {
3536
});
3637
}
3738

39+
/**
40+
* A server client that acts as the bearer of `token` (qc, MCP, passkey and
41+
* CoinPay sessions, which carry no cookie). The token rides on every request,
42+
* so RLS sees that user. setSession() is not an option: auth-js refuses a
43+
* session without a refresh token, which silently turned every Bearer request
44+
* into a 401.
45+
*/
3846
export async function createSupabaseServerClientWithToken(token) {
39-
const client = await createSupabaseServerClient();
40-
if (token) {
41-
await client.auth.setSession({ access_token: token, refresh_token: '' });
42-
}
43-
return client;
47+
if (!token) return createSupabaseServerClient();
48+
return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
49+
global: { headers: { Authorization: `Bearer ${token}` } },
50+
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
51+
});
4452
}
4553

4654
export class SupabaseHelpers {

0 commit comments

Comments
 (0)