* Re-authenticate after 403: rotate secret via hello, then verify with a * heartbeat. Returns true if auth is restored, false otherwise. * * Recovery sequence (issue EvoMap/evolver#529): * attempt 1 -> hello with current Bearer + rotate_secret=true * (works when the s
()
| 932 | * manual-reset hint to the user instead of churning forever. |
| 933 | */ |
| 934 | async reAuthenticate() { |
| 935 | if (this._reauthInProgress) return false; |
| 936 | if (this._reauthBackoffUntil > Date.now()) { |
| 937 | const waitSec = Math.ceil((this._reauthBackoffUntil - Date.now()) / 1000); |
| 938 | this.logger.warn(`[lifecycle] re-auth suppressed: backoff active for ${waitSec}s`); |
| 939 | return false; |
| 940 | } |
| 941 | this._reauthInProgress = true; |
| 942 | let manualResetRequired = false; |
| 943 | let hubUnreachable = false; |
| 944 | let droppedDivergedSecret = false; |
| 945 | try { |
| 946 | for (let attempt = 1; attempt <= MAX_REAUTH_ATTEMPTS; attempt++) { |
| 947 | this.logger.warn(`[lifecycle] re-auth attempt ${attempt}/${MAX_REAUTH_ATTEMPTS}: rotating secret via hello...`); |
| 948 | const helloResult = await this.hello({ rotateSecret: true }); |
| 949 | if (!helloResult.ok) { |
| 950 | this.logger.error(`[lifecycle] re-auth hello failed: ${helloResult.error}`); |
| 951 | if (helloResult.error === 'hello_rate_limited' || helloResult.error === 'hello_rate_limit_active') break; |
| 952 | // Hub link is down (WAF HTML, network error, timeout) -- NOT an auth |
| 953 | // failure. Bail without arming the re-auth backoff: otherwise a |
| 954 | // transient outage that arrives mid-rotate would burn both attempts |
| 955 | // (attempt 2's hello short-circuits on the hub-unreachable window) |
| 956 | // and suppress genuine auth recovery for up to REAUTH_BACKOFF_MAX_MS, |
| 957 | // even though a JSON 401/403 retry would succeed once the hub is |
| 958 | // reachable again. The hub-unreachable window already gates re-entry. |
| 959 | if (helloResult.error === 'hub_unreachable' || helloResult.error === 'hub_unreachable_backoff') { |
| 960 | hubUnreachable = true; |
| 961 | break; |
| 962 | } |
| 963 | if (isSecretDivergenceError(helloResult.error)) { |
| 964 | // The hub has explicitly rejected our current secret as diverged |
| 965 | // from its record. Drop store/env-derived auth once, then spend |
| 966 | // the second attempt on an unauthenticated rotate hello instead |
| 967 | // of backing off while still presenting the same stale Bearer. |
| 968 | if (attempt < MAX_REAUTH_ATTEMPTS && !droppedDivergedSecret) { |
| 969 | this._dropLocalSecret(SECRET_DIVERGENCE_ERROR); |
| 970 | droppedDivergedSecret = true; |
| 971 | continue; |
| 972 | } |
| 973 | break; |
| 974 | } |
| 975 | if (typeof helloResult.error === 'string' && helloResult.error.startsWith('node_id_already_claimed')) { |
| 976 | // Hub does not believe we own this nodeId. Our locally cached |
| 977 | // secret(s) are useless. Drop them so attempt 2 retries WITHOUT |
| 978 | // a Bearer (lenient hello path). If even unauthenticated rotate |
| 979 | // is rejected, only a manual reset can recover. |
| 980 | if (attempt < MAX_REAUTH_ATTEMPTS) { |
| 981 | this._dropLocalSecret('node_id_already_claimed'); |
| 982 | continue; |
| 983 | } |
| 984 | manualResetRequired = true; |
| 985 | break; |
| 986 | } |
| 987 | continue; |
| 988 | } |
| 989 | const newSecret = helloResult.response?.payload?.node_secret; |
| 990 | if (!newSecret) { |
| 991 | this.logger.error('[lifecycle] re-auth: hub did not return a new secret (rotate may not have taken effect)'); |
no test coverage detected