mirror of https://github.com/garrytan/gstack.git
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:
parent
d051f84060
commit
7808ee380b
40
lib/auth.ts
40
lib/auth.ts
|
|
@ -32,7 +32,7 @@ export async function runDeviceAuth(team: TeamConfig): Promise<AuthTokens> {
|
||||||
reject(new Error('Auth timed out after 5 minutes. Please try again.'));
|
reject(new Error('Auth timed out after 5 minutes. Please try again.'));
|
||||||
}, AUTH_TIMEOUT_MS);
|
}, 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}`);
|
const url = new URL(req.url || '/', `http://localhost:${AUTH_CALLBACK_PORT}`);
|
||||||
|
|
||||||
// Handle the OAuth callback
|
// Handle the OAuth callback
|
||||||
|
|
@ -53,7 +53,7 @@ export async function runDeviceAuth(team: TeamConfig): Promise<AuthTokens> {
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
expires_at: Math.floor(Date.now() / 1000) + expiresIn,
|
expires_at: Math.floor(Date.now() / 1000) + expiresIn,
|
||||||
user_id: url.searchParams.get('user_id') || '',
|
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') || '',
|
email: url.searchParams.get('email') || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -63,6 +63,12 @@ export async function runDeviceAuth(team: TeamConfig): Promise<AuthTokens> {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
server.close();
|
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
|
// Save tokens
|
||||||
try {
|
try {
|
||||||
saveAuthTokens(team.supabase_url, tokens);
|
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') {
|
if (url.pathname === '/auth/token' && req.method === 'POST') {
|
||||||
let body = '';
|
let body = '';
|
||||||
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
|
||||||
req.on('end', () => {
|
req.on('end', async () => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(body);
|
const data = JSON.parse(body);
|
||||||
const tokens: AuthTokens = {
|
const tokens: AuthTokens = {
|
||||||
|
|
@ -97,6 +103,12 @@ export async function runDeviceAuth(team: TeamConfig): Promise<AuthTokens> {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
server.close();
|
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);
|
saveAuthTokens(team.supabase_url, tokens);
|
||||||
resolve(tokens);
|
resolve(tokens);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -209,3 +221,25 @@ export function isTokenExpired(tokens: AuthTokens): boolean {
|
||||||
const buffer = 300; // 5-minute buffer
|
const buffer = 300; // 5-minute buffer
|
||||||
return Math.floor(Date.now() / 1000) >= tokens.expires_at - 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ interface CacheMeta {
|
||||||
* Refresh an expired access token using the refresh token.
|
* Refresh an expired access token using the refresh token.
|
||||||
* Returns new tokens on success, null on failure.
|
* 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 {
|
try {
|
||||||
const res = await fetchWithTimeout(`${supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
|
const res = await fetchWithTimeout(`${supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -58,7 +58,7 @@ async function refreshToken(supabaseUrl: string, refreshToken: string, anonKey:
|
||||||
refresh_token: data.refresh_token as string || refreshToken,
|
refresh_token: data.refresh_token as string || refreshToken,
|
||||||
expires_at: Math.floor(Date.now() / 1000) + ((data.expires_in as number) || 3600),
|
expires_at: Math.floor(Date.now() / 1000) + ((data.expires_in as number) || 3600),
|
||||||
user_id: (data.user as any)?.id || '',
|
user_id: (data.user as any)?.id || '',
|
||||||
team_id: '',
|
team_id: existingTeamId || '', // preserve existing team_id across refresh
|
||||||
email: (data.user as any)?.email || '',
|
email: (data.user as any)?.email || '',
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -78,6 +78,7 @@ export async function getValidToken(config: SyncConfig): Promise<string | null>
|
||||||
config.team.supabase_url,
|
config.team.supabase_url,
|
||||||
config.auth.refresh_token,
|
config.auth.refresh_token,
|
||||||
config.team.supabase_anon_key,
|
config.team.supabase_anon_key,
|
||||||
|
config.auth.team_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!newTokens) return null;
|
if (!newTokens) return null;
|
||||||
|
|
@ -234,7 +235,7 @@ export async function pullTable(table: string, query?: string): Promise<Record<s
|
||||||
|
|
||||||
const url = query
|
const url = query
|
||||||
? `${restUrl(config.team.supabase_url, table)}?${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, {
|
const res = await fetchWithTimeout(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue