fix(core): report whether the token refresh actually worked(YPE-4297) - #122
fix(core): report whether the token refresh actually worked(YPE-4297)#122Dustin-Kelley wants to merge 6 commits into
Conversation
`refreshToken` swallows failure by design — a transient failure must not
sign anyone out — and returns nothing. So a caller reading the token
afterwards cannot tell a refresh that worked from one that did not, and
`useHighlights.runWrite` was doing exactly that: await `ensureFreshToken()`,
then read `authRef.current.accessToken` and send it.
That is correct offline, where the write dies at the network layer with no
status and classifies as `transient`. It is wrong when the token endpoint
is down but the highlights API is reachable:
1. Access token is expired; the refresh 5xxs or times out. Tokens are
retained, per policy, and the token stays expired.
2. The write goes out with it anyway and comes back 401.
3. 401 classifies as `auth`, and `useHighlightPermissionFlow` reads
`auth` as a stale grant (ADR 0016).
4. A valid `highlights` grant is invalidated and consent is re-prompted.
5. The re-consent mints with the same expired token, 401s in turn, and
dead-ends as `not-permitted` — which the docs describe as an app-key
setting, so the user is told to check a console they cannot see.
Add `getAccessToken()` to the auth context. It runs the same leeway-gated,
single-flight refresh as `ensureFreshToken()`, then re-reads the refs it
left behind and says which happened: `ok` with the token, `signed-out`, or
`refresh-failed`. Non-forced on purpose — the leeway gate already refreshes
exactly when the token needs it, and joining an in-flight refresh comes
free, so concurrent callers make one HTTP call and all get the new token.
It never rejects, and makes no network call when there is no refresh token
to spend.
`runWrite` sources its token from it. `refresh-failed` reverts the paint and
settles `transient` without issuing the request, which is the line that
stops step 2 and so the whole chain; `signed-out` keeps the existing
`not-signed-in` path. The same-user identity guard is unchanged, now
evaluated against the accessor's result.
Nothing else moves. `isAuthenticated` still means "has a session", so an
offline user with a retained session still renders signed in — the
regression the obvious fix here would have introduced — and the retention
test at auth-provider.test.tsx:451 passes unmodified. `ensureFreshToken()`
stays for callers that only want the side effect.
`useHighlightPermissionFlow` is untouched: with a failed refresh now
classified `transient` upstream, its `auth` branch is correct as written.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fdcbae2 to
5b09af7
Compare
`getAccessToken` reads AuthProvider's refs, which `setAuthState` writes synchronously, while `useHighlights` compares against an `identityRef` synced from a passive effect a render later. A sign-in as a different user landing while a write awaits the accessor therefore moved the token first: the identity check passed against the departed user's captured id and the write went out under the new user's credentials — the exact outcome the guard above it exists to prevent. `AccessTokenResult`'s `ok` variant now carries the `userId` read in the same synchronous block as the token, and the write guard compares against that. The regression test drives the window directly (accessor returns a token owned by someone else while the rendered identity still lags) and fails without the change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cameronapak
left a comment
There was a problem hiding this comment.
Claude sent on behalf of Cam.
Two-axis review
Reviewed on two separate axes so neither masks the other. Standards asks whether the diff follows this repo's documented conventions. Spec asks whether it implements YPE-4297. Findings are formatted with Conventional Comments.
Nothing here is blocking. Inline comments carry the findings that anchor to changed lines. The rest are below.
Standards
praise: .changeset/core-get-access-token.md
Changeset present, and AGENTS.md:198 documents getAccessToken() alongside refreshNow / ensureFreshToken. The repo's documented release and docs convention is met.
suggestion (non-blocking): CONTEXT.md has no entry for the new concept.
CONTEXT.md already carries Highlight Write Outcome and Granted Permissions as ubiquitous-language terms. AccessTokenResult, with its signed-out / refresh-failed split, is the same kind of concept. This PR also changes what a transient Highlight Write Outcome means. AGENTS.md points at CONTEXT.md for domain language, so a term entry would keep it honest. Judgement call.
note: TOKEN_REFRESH_FAILED_MESSAGE is not an i18n violation.
.greptile/rules.md scopes localization to packages/ui/src/native/**, and CONTEXT.md:138 treats message as diagnostic. Flagging it so it does not get raised later.
Spec
praise: The spec's implementation note was already satisfied before this PR.
The ticket says: "The existing refresh lock skips rather than joins: refreshToken early-returns when isRefreshingRef.current is true... An accessor must await an in-flight refresh. It needs a shared promise ref, not a boolean."
origin/main still has isRefreshingRef. The base branch origin/highlights already carries refreshPromiseRef with join semantics, so the accessor inherits it. The new concurrency test does prove joining: two accessor calls plus bootstrap share one refreshTokens call, and all resolve on new-access rather than the expired token.
note (non-blocking): Nothing addresses the init() path, and that reads as intended.
The ticket says the state "can last indefinitely while refresh keeps failing", and auth-provider.test.tsx:393-408 still asserts isAuthenticated: true. That follows the ticket's own proposed direction: "keep isAuthenticated meaning 'has a session'". Recording it so the gap is a decision on the record, not an oversight.
isTokenValid is skipped. The ticket labels it a "Secondary optional idea", so that is fine.
question (non-blocking): The fetch path still sends the raw token.
runFetch at packages/core/src/highlights/use-highlights.ts:295 reads authRef.current.accessToken directly, so the ticket's "Make an authenticated request. Observe that it returns 401" still reproduces on GET. The spec only names runWrite, and a fetch 401 does not invalidate a grant (only write outcomes reach invalidatePermissions), so the damage the ticket describes is fixed. Was leaving runFetch alone a deliberate scope line?
Standards: 6 findings. Spec: 3 findings plus 2 notes. Nothing blocking on either axis. I verified the two strongest findings against the source rather than taking them on report.
Three changes to the same seam, all addressing review on #122. The accessor gated on the leeway window, which answers "is the token fresh enough?" when the question at that point is "did the refresh land?". A genuinely new token minted with a lifetime at or under the 60s leeway reported `refresh-failed` despite the refresh working. Gate on actual expiry instead, with a finite check so a corrupt stored expiry (NaN) fails closed rather than passing every comparison. This also dissolves the duplicated freshness rule the review flagged — the accessor no longer restates the gate at line 143. `requestPermissions` sources its pre-mint token from the accessor too. A signed-in user without the `highlights` grant reaches consent with no write in front of it, so an expired token and a failing token endpoint sent the mint anyway and dead-ended on `not-permitted`. It now resolves `transient` without minting. `signed-out` still falls through on purpose — the initiator guard owns that case and reports `user-changed`. Make `refreshToken` total. Its revocation branch awaits `clearAuthState()`, which ends in a Keychain delete that can reject; that rejection escaped through `ensureFreshToken`, `getAccessToken`, and `requestPermissions`, all three documented never to throw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`getAccessToken` is a required, non-optional member of `AuthContextValue` (auth-context.tsx:59), so a null accessor implies a null `auth`, which implies a null `accessToken`. The `rawToken !== null` arm could never run, and the comment justifying the lagging identity check rested on a "no-accessor path" that does not exist. Collapse to the two reachable outcomes and re-anchor the comment on what the check actually still buys: the branch below reuses `isSameUser` to tell a user switch from a plain failed refresh, and only the latter reports `transient`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The AccessTokenResult type reached the auth types list; the accessor it describes never reached the useYVAuth value list beside it. The Exports section is the canonical list, so the two move together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CONTEXT.md already carries Highlight Write Outcome and Granted Permissions as ubiquitous-language terms, and AGENTS.md points here for domain language. The signed-out / refresh-failed split is the same kind of concept and was missing. Add the term, plus a relationship line for what this PR changed about `transient` — an unavailable result now settles a write and a Data Exchange without a round-trip, which is what keeps a valid grant from being dropped on a 401 that only meant the network was down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thanks Cam — the two-axis split was useful here, the Spec axis caught the one finding that changed behaviour. Replies are on the individual threads; this covers the three findings that live in the review body.
|
|
@greptile review |
The bug
refreshTokenswallows failure by design — a transient failure must not sign anyone out — and returns nothing. A caller reading the token afterwards therefore cannot tell a refresh that worked from one that did not, anduseHighlights.runWritewas doing exactly that:await ensureFreshToken(), then readauthRef.current.accessTokenand send it.That is correct offline, where the write dies at the network layer with no HTTP status and classifies as
transient. It is wrong when the token endpoint is down but the highlights API is reachable (an auth-service incident, a captive portal, a 5xx):auth, anduseHighlightPermissionFlowreadsauthas a stale grant (ADR 0016).highlightsgrant is invalidated and consent is re-prompted.not-permitted— which the docs describe as an app-key setting, so the user is told to check a console they cannot see.A transient auth-server hiccup becomes a destroyed grant and a dead-end consent loop.
The fix
getAccessToken()on the auth context, exported throughuseYVAuth()and typed asAccessTokenResult:It runs the same leeway-gated, single-flight refresh as
ensureFreshToken(), then re-reads the refs that refresh left behind and says which happened. Non-forced on purpose: the leeway gate already refreshes exactly when the token is expired or inside the window, and joining an in-flight refresh comes free — so concurrent callers make one HTTP call and all receive the new token. It never rejects, and makes no network call when there is no refresh token to spend.runWritesources its token from it.refresh-failedreverts the optimistic paint and settlestransientwithout issuing the request — the line that stops step 2, and so the whole chain.signed-outkeeps the existingnot-signed-inpath. The same-user identity guard now compares against theuserIdthe accessor returns. That id is read in the same synchronous block as the token, and it has to be: the provider writes its token and identity refs together on sign-in, while theidentityRefthe guard used to read alone is synced from a passive effect a render later. A sign-in as somebody else landing while a write awaits the accessor moved the token first, so the old check passed against the departed user's captured id and sent the write under the new user's credentials. Regression test:abandons a write when the accessor returns a token owned by a different user.What deliberately did not change
isAuthenticatedstill means "has a session". An offline user with a retained session still renders as signed in. This is the regression the obvious fix (derivingisAuthenticatedfrom token validity) would have introduced, and there is an explicit test for it.auth-provider.test.tsx:451passes unmodified, as does the revoked-clears-state test.ensureFreshToken()stays on the context for callers that only want the side effect.useHighlightPermissionFlowis untouched. With a failed refresh now classifiedtransientupstream, itsauthbranch is correct as written.Tests
8 new tests. Auth provider: valid token needs no refresh; expired token refreshes and returns the new one; transient failure →
refresh-failedwith tokens and session retained; revoked →signed-outwith state cleared; no refresh token →signed-outwith no network call; concurrent callers join one refresh.useHighlights: the regression itself — expired token + refresh endpoint down + API reachable resolvestransient(notauth, notnot-signed-in),createHighlightis never called, and the paint reverts; plussigned-outmid-write mapping tonot-signed-in.Remaining test-file changes are mechanical fake updates for the new required context member.
Green: core 419/419 · ui 306 passed, 1 pre-existing skip ·
pnpm typecheck5/5 ·pnpm lintclean.Reviewer notes
transientbefore this change, because an offline write has no HTTP status. The repro that actually exercises this fix is auth-endpoint-down-while-API-up, which needs a proxy or a stubbed token host.refreshTokenscalls rather than one: with an expired stored token, bootstrap always consumes the first refresh, so the test bootstraps with a still-stale response and asserts the accessor drives the second.getAccessToken()/AccessTokenResultare additive. NoteAccessTokenResultis a required member ofAuthContextValue, so any consumer hand-rolling a mock of that type updates — which is what the three test-fake diffs here are.🤖 Generated with Claude Code
Greptile Summary
The PR adds a refresh-aware access-token accessor and uses it to prevent highlight writes and permission mints from proceeding with an expired token after a transient refresh failure.
AccessTokenResultandgetAccessToken()through the public auth context.Confidence Score: 5/5
The PR appears safe to merge.
No blocking failure remains; the previously reported token-owner race is covered by the synchronized accessor result and current-identity guard, while null-owner writes are rejected before enqueueing.
Important Files Changed
AccessTokenResultandgetAccessToken()context contract.Sequence Diagram
sequenceDiagram participant Write as Highlight write participant Auth as getAccessToken() participant Token as Token endpoint participant API as Highlights API Write->>Auth: request usable token Auth->>Token: refresh when near expiry alt refresh succeeds Token-->>Auth: new token Auth-->>Write: ok(token, userId) Write->>Write: verify captured user still owns token Write->>API: send mutation else transient refresh failure Token-->>Auth: timeout or 5xx Auth-->>Write: unavailable(refresh-failed) Write->>Write: revert optimistic paint Note over Write,API: No highlights request is issued endReviews (4): Last reviewed commit: "docs: give Access Token Result a term in..." | Re-trigger Greptile
Context used: