fix: resolve team_id during auth and preserve across token refresh

P1 from Codex review: interactive auth saved team_id: '' making all
subsequent sync operations fail. Now resolves team_id from team_members
table immediately after OAuth callback.

Also fixes token refresh in sync.ts to preserve the existing team_id
instead of resetting it to empty, and removes order=created_at.desc
from pullTable() default query since sync_heartbeats and team_members
tables don't have that column (P2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-19 01:28:15 -07:00
parent d051f84060
commit 7808ee380b
2 changed files with 41 additions and 6 deletions
+37 -3
View File
@@ -32,7 +32,7 @@ export async function runDeviceAuth(team: TeamConfig): Promise<AuthTokens> {
reject(new Error('Auth timed out after 5 minutes. Please try again.'));
}, AUTH_TIMEOUT_MS);
const server = http.createServer((req, res) => {
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://localhost:${AUTH_CALLBACK_PORT}`);
// Handle the OAuth callback
@@ -53,7 +53,7 @@ export async function runDeviceAuth(team: TeamConfig): Promise<AuthTokens> {
refresh_token: refreshToken,
expires_at: Math.floor(Date.now() / 1000) + expiresIn,
user_id: url.searchParams.get('user_id') || '',
team_id: '', // filled in by sync.ts after first API call
team_id: '',
email: url.searchParams.get('email') || '',
};
@@ -63,6 +63,12 @@ export async function runDeviceAuth(team: TeamConfig): Promise<AuthTokens> {
clearTimeout(timeout);
server.close();
// Resolve team_id from team_members table before saving
try {
const teamId = await resolveTeamId(team, tokens.access_token, tokens.user_id);
if (teamId) tokens.team_id = teamId;
} catch { /* non-fatal — team_id can be resolved later */ }
// Save tokens
try {
saveAuthTokens(team.supabase_url, tokens);
@@ -79,7 +85,7 @@ export async function runDeviceAuth(team: TeamConfig): Promise<AuthTokens> {
if (url.pathname === '/auth/token' && req.method === 'POST') {
let body = '';
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
req.on('end', () => {
req.on('end', async () => {
try {
const data = JSON.parse(body);
const tokens: AuthTokens = {
@@ -97,6 +103,12 @@ export async function runDeviceAuth(team: TeamConfig): Promise<AuthTokens> {
clearTimeout(timeout);
server.close();
// Resolve team_id before saving
try {
const teamId = await resolveTeamId(team, tokens.access_token, tokens.user_id);
if (teamId) tokens.team_id = teamId;
} catch { /* non-fatal */ }
saveAuthTokens(team.supabase_url, tokens);
resolve(tokens);
} catch (err: any) {
@@ -209,3 +221,25 @@ export function isTokenExpired(tokens: AuthTokens): boolean {
const buffer = 300; // 5-minute buffer
return Math.floor(Date.now() / 1000) >= tokens.expires_at - buffer;
}
/**
* Look up the user's team_id from team_members table after auth.
* Returns the first team_id found, or null if the lookup fails.
*/
async function resolveTeamId(team: TeamConfig, accessToken: string, userId: string): Promise<string | null> {
if (!userId) return null;
try {
const url = `${team.supabase_url}/rest/v1/team_members?user_id=eq.${userId}&select=team_id&limit=1`;
const res = await fetch(url, {
headers: {
'apikey': team.supabase_anon_key,
'Authorization': `Bearer ${accessToken}`,
},
});
if (!res.ok) return null;
const rows = await res.json() as Array<{ team_id: string }>;
return rows.length > 0 ? rows[0].team_id : null;
} catch {
return null;
}
}
+4 -3
View File
@@ -39,7 +39,7 @@ interface CacheMeta {
* Refresh an expired access token using the refresh token.
* Returns new tokens on success, null on failure.
*/
async function refreshToken(supabaseUrl: string, refreshToken: string, anonKey: string): Promise<AuthTokens | null> {
async function refreshToken(supabaseUrl: string, refreshToken: string, anonKey: string, existingTeamId?: string): Promise<AuthTokens | null> {
try {
const res = await fetchWithTimeout(`${supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
method: 'POST',
@@ -58,7 +58,7 @@ async function refreshToken(supabaseUrl: string, refreshToken: string, anonKey:
refresh_token: data.refresh_token as string || refreshToken,
expires_at: Math.floor(Date.now() / 1000) + ((data.expires_in as number) || 3600),
user_id: (data.user as any)?.id || '',
team_id: '',
team_id: existingTeamId || '', // preserve existing team_id across refresh
email: (data.user as any)?.email || '',
};
} catch {
@@ -78,6 +78,7 @@ export async function getValidToken(config: SyncConfig): Promise<string | null>
config.team.supabase_url,
config.auth.refresh_token,
config.team.supabase_anon_key,
config.auth.team_id,
);
if (!newTokens) return null;
@@ -234,7 +235,7 @@ export async function pullTable(table: string, query?: string): Promise<Record<s
const url = query
? `${restUrl(config.team.supabase_url, table)}?${query}`
: `${restUrl(config.team.supabase_url, table)}?team_id=eq.${config.auth.team_id}&order=created_at.desc&limit=500`;
: `${restUrl(config.team.supabase_url, table)}?team_id=eq.${config.auth.team_id}&limit=500`;
const res = await fetchWithTimeout(url, {
method: 'GET',