@@ -18,7 +18,7 @@ import {
1818import { createBitbucketCloudClient , createBitbucketServerClient , getReposForAuthenticatedBitbucketCloudUser , getReposForAuthenticatedBitbucketServerUser } from "../bitbucket.js" ;
1919import { Settings } from "../types.js" ;
2020import { setIntervalAsync } from "../utils.js" ;
21- import { isUnauthorized , isForbidden , isGone } from "../errors .js" ;
21+ import { PermissionSyncUpstreamError , withPermissionSyncUpstreamError } from "./permissionSyncError .js" ;
2222
2323const LOG_TAG = 'user-permission-syncer' ;
2424const logger = createLogger ( LOG_TAG ) ;
@@ -34,9 +34,9 @@ type AccountPermissionSyncJob = {
3434
3535export type PermissionCleanupReason =
3636 | 'oauth_refresh_token_rejected'
37- | 'http_unauthorized '
38- | 'http_forbidden '
39- | 'http_gone ' ;
37+ | 'upstream_credential_rejected '
38+ | 'upstream_insufficient_scope '
39+ | 'permission_endpoint_removed ' ;
4040
4141export type PermissionCleanupDecision =
4242 | {
@@ -49,9 +49,9 @@ export type PermissionCleanupDecision =
4949
5050const PERMISSION_CLEANUP_REASON_MESSAGES : Record < PermissionCleanupReason , string > = {
5151 oauth_refresh_token_rejected : 'OAuth refresh token rejection' ,
52- http_unauthorized : 'HTTP 401 Unauthorized ' ,
53- http_forbidden : 'HTTP 403 Forbidden ' ,
54- http_gone : 'HTTP 410 Gone ' ,
52+ upstream_credential_rejected : 'upstream credential rejection ' ,
53+ upstream_insufficient_scope : 'insufficient OAuth scope ' ,
54+ permission_endpoint_removed : 'permission endpoint removed ' ,
5555} ;
5656
5757export const classifyPermissionSyncFailure = ( error : unknown ) : PermissionCleanupDecision => {
@@ -64,14 +64,16 @@ export const classifyPermissionSyncFailure = (error: unknown): PermissionCleanup
6464 : { action : 'preserve_permissions' } ;
6565 }
6666
67- if ( isUnauthorized ( error ) ) {
68- return { action : 'clear_permissions' , reason : 'http_unauthorized' } ;
69- }
70- if ( isForbidden ( error ) ) {
71- return { action : 'clear_permissions' , reason : 'http_forbidden' } ;
72- }
73- if ( isGone ( error ) ) {
74- return { action : 'clear_permissions' , reason : 'http_gone' } ;
67+ if ( error instanceof PermissionSyncUpstreamError ) {
68+ if ( error . kind === 'credential_rejected' ) {
69+ return { action : 'clear_permissions' , reason : 'upstream_credential_rejected' } ;
70+ }
71+ if ( error . kind === 'insufficient_scope' ) {
72+ return { action : 'clear_permissions' , reason : 'upstream_insufficient_scope' } ;
73+ }
74+ if ( error . kind === 'permission_endpoint_removed' ) {
75+ return { action : 'clear_permissions' , reason : 'permission_endpoint_removed' } ;
76+ }
7577 }
7678
7779 return { action : 'preserve_permissions' } ;
@@ -235,12 +237,9 @@ export class AccountPermissionSyncer {
235237 try {
236238 await this . syncAccountPermissions ( account , logger ) ;
237239 } catch ( error ) {
238- // Fail-closed: when the code-host layer signals that the upstream
239- // account is permanently unauthorized (token revoked, user
240- // deprovisioned, OAuth grant dead) or that the endpoint we depend
241- // on is gone (e.g. Bitbucket Cloud's CHANGE-2770), clear the
242- // account's existing permission rows so the read-side filter stops
243- // matching through them.
240+ // Clear cached permissions only for classified permanent failures.
241+ // Ambiguous HTTP errors and transient upstream failures preserve the
242+ // last successful permission state.
244243 const cleanupDecision = classifyPermissionSyncFailure ( error ) ;
245244
246245 if ( cleanupDecision . action === 'clear_permissions' ) {
@@ -280,19 +279,34 @@ export class AccountPermissionSyncer {
280279 url : idpConfig . baseUrl ,
281280 } ) ;
282281
283- const scopes = await getGitHubOAuthScopesForAuthenticatedUser ( octokit , accessToken ) ;
282+ const scopes = await withPermissionSyncUpstreamError (
283+ 'github' ,
284+ 'inspect_token_scopes' ,
285+ ( ) => getGitHubOAuthScopesForAuthenticatedUser ( octokit , accessToken ) ,
286+ ) ;
284287
285288 // Token supports scope introspection (classic PAT or OAuth app token)
286289 if ( scopes !== null ) {
287290 if ( ! scopes . includes ( 'repo' ) ) {
288- throw new Error ( `OAuth token with scopes [${ scopes . join ( ', ' ) } ] is missing the 'repo' scope required for permission syncing. Please re-authorize with GitHub to grant the required scope.` ) ;
291+ throw new PermissionSyncUpstreamError (
292+ `OAuth token with scopes [${ scopes . join ( ', ' ) } ] is missing the 'repo' scope required for permission syncing. Please re-authorize with GitHub to grant the required scope.` ,
293+ {
294+ kind : 'insufficient_scope' ,
295+ provider : 'github' ,
296+ operation : 'inspect_token_scopes' ,
297+ } ,
298+ ) ;
289299 }
290300 }
291301
292302 // @note : we only care about the private repos since we don't need to build a mapping
293303 // for public repos.
294304 // @see : packages/web/src/prisma.ts
295- const githubRepos = await getReposForAuthenticatedUser ( /* visibility = */ 'private' , octokit ) ;
305+ const githubRepos = await withPermissionSyncUpstreamError (
306+ 'github' ,
307+ 'list_accessible_repositories' ,
308+ ( ) => getReposForAuthenticatedUser ( /* visibility = */ 'private' , octokit ) ,
309+ ) ;
296310 const gitHubRepoIds = githubRepos . map ( repo => repo . id . toString ( ) ) ;
297311
298312 const repos = await this . db . repo . findMany ( {
@@ -314,9 +328,20 @@ export class AccountPermissionSyncer {
314328 url : idpConfig . baseUrl ,
315329 } ) ;
316330
317- const scopes = await getGitLabOAuthScopesForAuthenticatedUser ( api ) ;
331+ const scopes = await withPermissionSyncUpstreamError (
332+ 'gitlab' ,
333+ 'inspect_token_scopes' ,
334+ ( ) => getGitLabOAuthScopesForAuthenticatedUser ( api ) ,
335+ ) ;
318336 if ( ! scopes . includes ( 'read_api' ) ) {
319- throw new Error ( `OAuth token with scopes [${ scopes . join ( ', ' ) } ] is missing the 'read_api' scope required for permission syncing.` ) ;
337+ throw new PermissionSyncUpstreamError (
338+ `OAuth token with scopes [${ scopes . join ( ', ' ) } ] is missing the 'read_api' scope required for permission syncing.` ,
339+ {
340+ kind : 'insufficient_scope' ,
341+ provider : 'gitlab' ,
342+ operation : 'inspect_token_scopes' ,
343+ } ,
344+ ) ;
320345 }
321346
322347 // @note : we only care about the private repos since we don't need to build a
@@ -326,7 +351,11 @@ export class AccountPermissionSyncer {
326351 //
327352 // @see : packages/web/src/prisma.ts
328353 const gitLabProjectIds = (
329- await getProjectsForAuthenticatedUser ( 'private' , api )
354+ await withPermissionSyncUpstreamError (
355+ 'gitlab' ,
356+ 'list_accessible_repositories' ,
357+ ( ) => getProjectsForAuthenticatedUser ( 'private' , api ) ,
358+ )
330359 ) . map ( project => project . id . toString ( ) ) ;
331360
332361 const repos = await this . db . repo . findMany ( {
@@ -346,7 +375,11 @@ export class AccountPermissionSyncer {
346375 // @note : we don't pass a user here since we want to use a bearer token
347376 // for authentication.
348377 const client = createBitbucketCloudClient ( /* user = */ undefined , accessToken )
349- const bitbucketRepos = await getReposForAuthenticatedBitbucketCloudUser ( client ) ;
378+ const bitbucketRepos = await withPermissionSyncUpstreamError (
379+ 'bitbucket-cloud' ,
380+ 'list_accessible_repositories' ,
381+ ( ) => getReposForAuthenticatedBitbucketCloudUser ( client ) ,
382+ ) ;
350383 const bitbucketRepoUuids = bitbucketRepos . map ( repo => repo . uuid ) ;
351384
352385 const repos = await this . db . repo . findMany ( {
@@ -364,7 +397,11 @@ export class AccountPermissionSyncer {
364397 repos . forEach ( repo => aggregatedRepoIds . add ( repo . id ) ) ;
365398 } else if ( idpConfig . provider === 'bitbucket-server' ) {
366399 const client = createBitbucketServerClient ( idpConfig . baseUrl , /* user = */ undefined , accessToken ) ;
367- const serverRepos = await getReposForAuthenticatedBitbucketServerUser ( client ) ;
400+ const serverRepos = await withPermissionSyncUpstreamError (
401+ 'bitbucket-server' ,
402+ 'list_accessible_repositories' ,
403+ ( ) => getReposForAuthenticatedBitbucketServerUser ( client ) ,
404+ ) ;
368405 const serverRepoIds = serverRepos . map ( r => r . id ) ;
369406
370407 const repos = await this . db . repo . findMany ( {
0 commit comments