@@ -61,6 +61,8 @@ type PendingDevice = {
6161 deviceId : string ;
6262 publicKey ?: string ;
6363 displayName ?: string ;
64+ clientId ?: string ;
65+ clientMode ?: string ;
6466 role ?: string ;
6567 roles ?: string [ ] ;
6668 scopes ?: string [ ] ;
@@ -86,6 +88,11 @@ type DevicePairingList = {
8688 paired ?: PairedDevice [ ] ;
8789} ;
8890
91+ type ApprovePairingGatewayContext = {
92+ originalRequest : PendingDevice | null ;
93+ scopes ?: OperatorScope [ ] ;
94+ } ;
95+
8996const FALLBACK_NOTICE = "Direct scope access failed; using local fallback." ;
9097const DEFAULT_DEVICES_TIMEOUT_MS = 10_000 ;
9198const FALLBACK_STATE_MISMATCH_MESSAGE =
@@ -225,7 +232,7 @@ async function approvePairingWithFallback(
225232 opts : DevicesRpcOpts ,
226233 requestId : string ,
227234) : Promise < Record < string , unknown > | null > {
228- const scopes = await resolveApprovePairingGatewayScopes ( opts , requestId ) ;
235+ const { scopes, originalRequest } = await resolveApprovePairingGatewayContext ( opts , requestId ) ;
229236 try {
230237 return await callGatewayCli (
231238 "device.pair.approve" ,
@@ -248,6 +255,53 @@ async function approvePairingWithFallback(
248255 }
249256 const gatewayRequestId = normalizeOptionalString ( fallback . details . requestId ) ;
250257 if ( gatewayRequestId && gatewayRequestId !== requestId ) {
258+ const local = await listDevicePairing ( ) ;
259+ const localList = {
260+ pending : local . pending as PendingDevice [ ] ,
261+ paired : local . paired . map ( ( device ) => redactLocalPairedDevice ( device ) ) ,
262+ } ;
263+ const replacement = findSameDeviceReplacementRequest ( {
264+ originalRequest,
265+ originalRequestId : requestId ,
266+ gatewayRequestId,
267+ pending : localList . pending ,
268+ paired : localList . paired ,
269+ } ) ;
270+ if ( replacement ) {
271+ const approved = await approveDevicePairing ( replacement . requestId , {
272+ callerScopes : [ "operator.admin" ] ,
273+ } ) ;
274+ if ( ! approved ) {
275+ return null ;
276+ }
277+ if ( approved . status === "forbidden" ) {
278+ throw new Error ( formatDevicePairingForbiddenMessage ( approved ) , { cause : error } ) ;
279+ }
280+ if ( opts . json !== true ) {
281+ defaultRuntime . log (
282+ theme . warn (
283+ `Pending request ${ sanitizeForLog ( requestId ) } was replaced by same-device repair ${ sanitizeForLog ( replacement . requestId ) } ; approving latest compatible request.` ,
284+ ) ,
285+ ) ;
286+ defaultRuntime . log ( theme . warn ( FALLBACK_NOTICE ) ) ;
287+ }
288+ return {
289+ requestId : replacement . requestId ,
290+ resolved : {
291+ kind : "same-device-replacement" ,
292+ requestedRequestId : requestId ,
293+ approvedRequestId : replacement . requestId ,
294+ } ,
295+ device : redactLocalPairedDevice ( approved . device ) ,
296+ } ;
297+ }
298+ const hasOriginalPending = Boolean ( findPendingRequestById ( localList . pending , requestId ) ) ;
299+ const hasGatewayPending = Boolean (
300+ findPendingRequestById ( localList . pending , gatewayRequestId ) ,
301+ ) ;
302+ if ( ! hasOriginalPending && ! hasGatewayPending ) {
303+ return null ;
304+ }
251305 throw buildFallbackStateMismatchError ( fallback . details ) ;
252306 }
253307 const approved = await approveDevicePairing ( requestId , {
@@ -303,6 +357,122 @@ function normalizeOperatorScopes(scopes: string[] | undefined): string[] {
303357 ) ;
304358}
305359
360+ function findPendingRequestById (
361+ pending : PendingDevice [ ] | undefined ,
362+ requestId : string | null | undefined ,
363+ ) : PendingDevice | null {
364+ const normalizedRequestId = normalizeOptionalString ( requestId ) ;
365+ if ( ! normalizedRequestId ) {
366+ return null ;
367+ }
368+ return (
369+ pending ?. find (
370+ ( request ) => normalizeOptionalString ( request . requestId ) === normalizedRequestId ,
371+ ) ?? null
372+ ) ;
373+ }
374+
375+ function hasExactRoleMatch ( original : PendingDevice , replacement : PendingDevice ) : boolean {
376+ const originalRoles = normalizeDeviceRoles ( original ) ;
377+ const replacementRoles = normalizeDeviceRoles ( replacement ) ;
378+ if ( originalRoles . length !== replacementRoles . length ) {
379+ return false ;
380+ }
381+ const replacementRoleSet = new Set ( replacementRoles ) ;
382+ return originalRoles . every ( ( role ) => replacementRoleSet . has ( role ) ) ;
383+ }
384+
385+ function hasCompatibleClientMetadata ( original : PendingDevice , replacement : PendingDevice ) : boolean {
386+ const originalClientId = normalizeOptionalString ( original . clientId ) ;
387+ const replacementClientId = normalizeOptionalString ( replacement . clientId ) ;
388+ if ( originalClientId && replacementClientId && originalClientId !== replacementClientId ) {
389+ return false ;
390+ }
391+ const originalClientMode = normalizeOptionalString ( original . clientMode ) ;
392+ const replacementClientMode = normalizeOptionalString ( replacement . clientMode ) ;
393+ return ! (
394+ originalClientMode &&
395+ replacementClientMode &&
396+ originalClientMode !== replacementClientMode
397+ ) ;
398+ }
399+
400+ function resolveOriginalReplacementScopes (
401+ original : PendingDevice ,
402+ paired : PairedDevice | undefined ,
403+ ) : string [ ] {
404+ const requestedScopes = normalizeDeviceAuthScopes ( original . scopes ) ;
405+ const inferredOperatorScopes = resolvePendingOperatorApprovalScopes ( original , paired ) ;
406+ return [ ...new Set ( [ ...requestedScopes , ...inferredOperatorScopes ] ) ] ;
407+ }
408+
409+ function replacementScopesCoverOriginal (
410+ original : PendingDevice ,
411+ replacement : PendingDevice ,
412+ paired : PairedDevice | undefined ,
413+ ) : boolean {
414+ const originalScopes = resolveOriginalReplacementScopes ( original , paired ) ;
415+ const replacementScopes = normalizeDeviceAuthScopes ( replacement . scopes ) ;
416+ const replacementScopeSet = new Set ( replacementScopes ) ;
417+ if ( ! originalScopes . every ( ( scope ) => replacementScopeSet . has ( scope ) ) ) {
418+ return false ;
419+ }
420+ // Same-device repair reconnects can supersede a stale request with a combined
421+ // request that appends the pairing scope required for the repaired session to
422+ // reconnect and complete approval.
423+ return replacementScopes . every (
424+ ( scope ) => originalScopes . includes ( scope ) || scope === PAIRING_SCOPE ,
425+ ) ;
426+ }
427+
428+ function findSameDeviceReplacementRequest ( params : {
429+ originalRequest : PendingDevice | null ;
430+ originalRequestId : string ;
431+ gatewayRequestId : string ;
432+ pending : PendingDevice [ ] | undefined ;
433+ paired : PairedDevice [ ] | undefined ;
434+ } ) : PendingDevice | null {
435+ const originalRequestId = normalizeOptionalString ( params . originalRequestId ) ;
436+ if ( ! params . originalRequest || ! originalRequestId ) {
437+ // Without the pre-approve snapshot we cannot prove that the gateway's newer
438+ // request is the same-device repair contract the operator intended to approve.
439+ return null ;
440+ }
441+ if ( normalizeOptionalString ( params . originalRequest . requestId ) !== originalRequestId ) {
442+ return null ;
443+ }
444+ const replacement = findPendingRequestById ( params . pending , params . gatewayRequestId ) ;
445+ if ( ! replacement ) {
446+ return null ;
447+ }
448+ const originalDeviceId = normalizeOptionalString ( params . originalRequest . deviceId ) ;
449+ const replacementDeviceId = normalizeOptionalString ( replacement . deviceId ) ;
450+ if ( ! originalDeviceId || originalDeviceId !== replacementDeviceId ) {
451+ return null ;
452+ }
453+ const originalPublicKey = normalizeOptionalString ( params . originalRequest . publicKey ) ;
454+ const replacementPublicKey = normalizeOptionalString ( replacement . publicKey ) ;
455+ if ( ! originalPublicKey || ! replacementPublicKey || originalPublicKey !== replacementPublicKey ) {
456+ return null ;
457+ }
458+ if ( ! hasExactRoleMatch ( params . originalRequest , replacement ) ) {
459+ return null ;
460+ }
461+ if ( ! hasCompatibleClientMetadata ( params . originalRequest , replacement ) ) {
462+ return null ;
463+ }
464+ const pairedByDeviceId = indexPairedDevices ( params . paired ) ;
465+ const originalPaired = lookupPairedDevice ( pairedByDeviceId , params . originalRequest ) ;
466+ const replacementPaired = lookupPairedDevice ( pairedByDeviceId , replacement ) ;
467+ if ( ! replacementScopesCoverOriginal ( params . originalRequest , replacement , originalPaired ) ) {
468+ return null ;
469+ }
470+ if ( replacement . isRepair !== true && ( ! originalPaired || ! replacementPaired ) ) {
471+ return null ;
472+ }
473+ return replacement ;
474+ }
475+
306476function resolvePairedOperatorScopes ( paired : PairedDevice | undefined ) : string [ ] {
307477 const operatorToken = paired ?. tokens ?. find ( ( token ) => {
308478 const role = normalizeOptionalString ( token . role ) ;
@@ -347,22 +517,25 @@ function resolveApprovePairingScopesForRequest(
347517 return [ ...out ] ;
348518}
349519
350- async function resolveApprovePairingGatewayScopes (
520+ async function resolveApprovePairingGatewayContext (
351521 opts : DevicesRpcOpts ,
352522 requestId : string ,
353- ) : Promise < OperatorScope [ ] | undefined > {
523+ ) : Promise < ApprovePairingGatewayContext > {
354524 try {
355525 const list = await listPairingWithFallback ( opts ) ;
356- const request = list . pending ?. find ( ( pending ) => pending . requestId === requestId ) ;
526+ const request = findPendingRequestById ( list . pending , requestId ) ;
357527 if ( ! request ) {
358- return undefined ;
528+ return { originalRequest : null , scopes : undefined } ;
359529 }
360- return resolveApprovePairingScopesForRequest (
361- request ,
362- lookupPairedDevice ( indexPairedDevices ( list . paired ) , request ) ,
363- ) ;
530+ return {
531+ originalRequest : request ,
532+ scopes : resolveApprovePairingScopesForRequest (
533+ request ,
534+ lookupPairedDevice ( indexPairedDevices ( list . paired ) , request ) ,
535+ ) ,
536+ } ;
364537 } catch {
365- return undefined ;
538+ return { originalRequest : null , scopes : undefined } ;
366539 }
367540}
368541
@@ -753,9 +926,14 @@ export async function runDevicesApproveCommand(
753926 defaultRuntime . writeJson ( result ) ;
754927 return ;
755928 }
929+ const resultRequestId = ( result as { requestId ?: unknown } ) ?. requestId ;
930+ const approvedRequestId =
931+ typeof resultRequestId === "string" && resultRequestId . trim ( ) . length > 0
932+ ? resultRequestId
933+ : resolvedRequestId ;
756934 const deviceId = ( result as { device ?: { deviceId ?: string } } ) ?. device ?. deviceId ;
757935 defaultRuntime . log (
758- `${ theme . success ( "Approved" ) } ${ theme . command ( deviceId ?? "ok" ) } ${ theme . muted ( `(${ resolvedRequestId } )` ) } ` ,
936+ `${ theme . success ( "Approved" ) } ${ theme . command ( deviceId ?? "ok" ) } ${ theme . muted ( `(${ approvedRequestId } )` ) } ` ,
759937 ) ;
760938}
761939
0 commit comments