@@ -783,6 +783,19 @@ const missingOAuthScopesFromProviderState = (value: unknown): readonly string[]
783783 : [ ] ;
784784} ;
785785
786+ /** Epoch ms of the definitive refresh rejection recorded on `provider_state`,
787+ * or null. Set when the AS rejects the grant itself (RFC 6749 invalid_grant —
788+ * retrying cannot change the verdict); cleared by the reconnect mint, which
789+ * rewrites `provider_state` wholesale. While set, refresh attempts are
790+ * skipped: the pre-fix behavior re-sent a known-dead grant to the AS every
791+ * proactive cycle, forever, and surfaced nothing to the user. */
792+ const oauthReauthRequiredAtFromProviderState = ( value : unknown ) : number | null => {
793+ const decoded = decodeJsonColumn ( value ) ;
794+ if ( decoded == null || typeof decoded !== "object" || Array . isArray ( decoded ) ) return null ;
795+ const at = ( decoded as Record < string , unknown > ) . oauthReauthRequiredAt ;
796+ return typeof at === "number" ? at : null ;
797+ } ;
798+
786799const rowToConnection = ( row : ConnectionRow ) : Connection => {
787800 const owner = row . owner as Owner ;
788801 const integration = IntegrationSlug . make ( row . integration ) ;
@@ -1744,6 +1757,44 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
17441757 * upstream 401 on a token we believed was still valid (`reactive`). */
17451758 type RefreshTrigger = "proactive" | "reactive" ;
17461759
1760+ /** Record the AS's invalid_grant verdict on the row so later refreshes
1761+ * skip the doomed token request, and stamp `last_health` expired so the
1762+ * accounts list shows the dead connection at a glance instead of only
1763+ * after a manual probe. Merges into `provider_state` (preserving
1764+ * `missingOAuthScopes`); the reconnect mint rewrites the column wholesale,
1765+ * which is what re-arms refresh. Best-effort: a bookkeeping write failure
1766+ * must not mask the refresh failure being reported. */
1767+ const markRefreshGrantDead = (
1768+ row : ConnectionRow ,
1769+ detail : string ,
1770+ ) : Effect . Effect < void , never > => {
1771+ const existingState = decodeJsonColumn ( row . provider_state ) ;
1772+ const mergedState =
1773+ existingState != null && typeof existingState === "object" && ! Array . isArray ( existingState )
1774+ ? ( existingState as Record < string , unknown > )
1775+ : { } ;
1776+ const health : HealthCheckResult = {
1777+ status : "expired" ,
1778+ checkedAt : Date . now ( ) ,
1779+ detail,
1780+ } ;
1781+ return core
1782+ . updateMany ( "connection" , {
1783+ where : ( b : AnyCb ) =>
1784+ b . and (
1785+ byOwner ( row . owner as Owner ) ( b ) ,
1786+ b ( "integration" , "=" , String ( row . integration ) ) ,
1787+ b ( "name" , "=" , String ( row . name ) ) ,
1788+ ) ,
1789+ set : {
1790+ provider_state : { ...mergedState , oauthReauthRequiredAt : Date . now ( ) } ,
1791+ last_health : health ,
1792+ updated_at : new Date ( ) ,
1793+ } ,
1794+ } )
1795+ . pipe ( Effect . ignore ) ;
1796+ } ;
1797+
17471798 // Perform the actual refresh-token grant and persist the rotated material.
17481799 const performTokenRefresh = (
17491800 row : ConnectionRow ,
@@ -1761,6 +1812,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
17611812 reauthRequired : true ,
17621813 } ) ;
17631814
1815+ // A recorded invalid_grant is the AS's standing verdict on this grant:
1816+ // re-sending it cannot succeed, so don't. Fail as reauth-required
1817+ // without a token request — the reconnect mint rewrites
1818+ // `provider_state` and thereby re-arms refresh. Without this gate a
1819+ // dead connection re-sent its dead grant on every proactive cycle,
1820+ // indefinitely (owner.com's Datadog connections: 100+ identical
1821+ // rejections over two days, surfacing nothing).
1822+ if ( oauthReauthRequiredAtFromProviderState ( row . provider_state ) !== null ) {
1823+ yield * Effect . annotateCurrentSpan ( { "executor.oauth.refresh.skipped_known_dead" : true } ) ;
1824+ return yield * reauth (
1825+ "The authorization server rejected this connection's refresh token (invalid_grant). Reconnect to continue." ,
1826+ ) ;
1827+ }
1828+
17641829 // Load the backing app by the owner STORED on the connection (a Personal
17651830 // connection may be backed by a shared Workspace app) — no derivation.
17661831 const clientOwner = ( row . oauth_client_owner ?? row . owner ) as Owner ;
@@ -1866,6 +1931,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
18661931 cause,
18671932 } ) ;
18681933 } ) ,
1934+ // Persist the definitive verdict so the NEXT refresh skips
1935+ // the doomed grant (see the known-dead gate above) and the
1936+ // connection shows `expired` without waiting for a probe.
1937+ Effect . tapError ( ( error ) =>
1938+ Predicate . isTagged ( error , "CredentialResolutionError" ) &&
1939+ error . reauthRequired === true
1940+ ? // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field
1941+ markRefreshGrantDead ( row , error . message )
1942+ : Effect . void ,
1943+ ) ,
18691944 ) ;
18701945 } ) ;
18711946
@@ -1932,6 +2007,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
19322007 ) ,
19332008 Effect . withSpan ( "executor.oauth.refresh" , {
19342009 attributes : {
2010+ // Tenant + subject make refresh outcomes answerable PER CUSTOMER
2011+ // ("is org X's Datadog refresh healthy?") — without them the only
2012+ // grouping dimensions were integration-wide. Opaque ids, never
2013+ // emails or org names.
2014+ "executor.tenant" : tenant ,
2015+ ...( subject != null ? { "executor.subject" : subject } : { } ) ,
19352016 "executor.integration" : String ( row . integration ) ,
19362017 "executor.connection" : String ( row . name ) ,
19372018 // Which path drove this refresh: the expiry check ahead of a call,
@@ -3277,6 +3358,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
32773358 } ) . pipe (
32783359 Effect . withSpan ( "executor.connection.health.check" , {
32793360 attributes : {
3361+ "executor.tenant" : tenant ,
3362+ ...( subject != null ? { "executor.subject" : subject } : { } ) ,
32803363 "executor.integration" : String ( ref . integration ) ,
32813364 "executor.connection" : String ( ref . name ) ,
32823365 } ,
@@ -3332,7 +3415,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
33323415 return result ;
33333416 } ) . pipe (
33343417 Effect . withSpan ( "executor.connection.validate" , {
3335- attributes : { "executor.integration" : String ( input . integration ) } ,
3418+ attributes : {
3419+ "executor.tenant" : tenant ,
3420+ ...( subject != null ? { "executor.subject" : subject } : { } ) ,
3421+ "executor.integration" : String ( input . integration ) ,
3422+ } ,
33363423 } ) ,
33373424 ) ;
33383425
0 commit comments