Skip to content

fix(api): answer expired and lost-race token actions alike on revoke and RI exchange - #1172

Merged
IAmJSD merged 4 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-587
Oct 26, 2026
Merged

IAmJSD merged 4 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-587

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Decision this PR proposes (owner call)

The issue asks for one mapping for every emailed-token action and leaves 409 vs 410 for an expired token to the owner. This PR picks 410, the code approve and cancel already answer since #584 (and the one the frontend's approval-details.ts already reads as "the approval link has expired"). That way only revoke and RI exchange change, not approve and cancel. If you'd rather have 409, it's a few constants and test expectations.

Condition Approve / cancel (unchanged) Revoke (before → after) RI exchange approve / reject (before → after)
Invalid or missing token 403 403 403
Token matches but expired 410 409 → 410 no check, then 500 on Postgres → 410
Wrong token on an expired link 403 409 → 403 403
Wrong status / lost race 409 409 500 on Postgres → 409

Root cause

  • Revoke. validateRevokeToken had its own copy of the checks. It answered an expired link with 409, and it read the expiry before matching the token, so a wrong token on an expired link also got the 409 "expired" answer.
  • RI exchange. validateExchangeApproval and rejectRIExchange never read expires_at. Expiry and status were enforced only by the TransitionRIExchangeStatus CAS. When the CAS matched no row, diagnoseTransitionFailure returned a plain fmt.Errorf, which the handler wrapped as a 500. The handler's transitioned == nil → 409 branch only runs against mocks, because the Postgres store never returns (nil, nil). So on a real database, both an expired exchange and one a concurrent approve or reject had already moved answered 500.

Fix

  • internal/api/handler_purchases.go: validateRevokeToken now calls purchase.ValidateApprovalToken, the same check approve and cancel use. It answers 403 invalid revocation token, or 410 when the token matches but has expired, then checks the 24-hour revocation window (still 403). This runs after authorizeApprovalAction and its fix(api): answer a hidden execution like a missing one on approve, cancel and revoke #935 concealment, as before. The order of the denial checks is unchanged.
  • internal/config: new sentinels ErrRIExchangeExpired and ErrRIExchangeNotInExpectedStatus, which diagnoseTransitionFailure now wraps. The message text is unchanged.
  • internal/api/handler_ri_exchange.go:
    • checkExchangeToken gives approve and reject the same order: no stored token or a wrong token is 403, and a matching token on an exchange past expires_at is 410. This check runs before any write, and on approve before the 4-eyes check.
    • riExchangeTransitionError maps a lost CAS to 410 (expired in between) or 409 (another writer won). It covers the token approve, the session approve and the reject transition. Any other store error stays a 500.
  • internal/api/openapi.yaml: adds 410 to revoke, RI approve and RI reject, and rewords their 409s.

Concurrency

The only DB-side change is wrapping errors. No SQL changed. The race that matters is several approves and rejects on one pending exchange. Postgres serializes them on the row lock. The losers' UPDATE ... WHERE status = 'pending' matches nothing, and the diagnose SELECT sees the winner's committed status. A new integration test runs that race on real Postgres: 5 rounds of 8 concurrent callers, approve and reject mixed. In every round exactly one wins, every loser gets ErrRIExchangeNotInExpectedStatus (409), none gets the expiry sentinel, and the row ends in the winner's status. The handler tests cover the other window: an exchange that expires between the token check and the CAS gets 410.

Verification

Everything below is unit, mock and fixture evidence, plus a store-level integration test on a local Postgres testcontainer. No cloud APIs, purchases or deploys. I didn't drive the RI exchange handler against real Postgres end to end. The handler-to-status mapping is covered with the mock store returning the same wrapped sentinels the real store now returns, and the store side is covered by the integration test.

Tests ran with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default AWS profile is invalid.

go build ./... && go vet ./... && go vet -tags integration ./internal/config/ ./internal/api/      # ok
go test -count=1 ./internal/api/ ./internal/config/ ./internal/server/ ./internal/mocks/ ./internal/purchase/   # all ok
go test -short -race -count=1 ./internal/api/ ./internal/config/                             # ok
go test -tags integration -count=1 ./internal/config/ -run TestPostgresStoreDB_TransitionRIExchangeStatus   # ok (testcontainers Postgres)
golangci-lint v2.10.1 run --allow-parallel-runners --build-tags integration ./internal/api/... ./internal/config/...   # 0 issues
gocyclo -over 10 -ignore "_test\.go" <changed non-test files>                                 # silent
bash scripts/gosec-hook.sh <changed non-test files>                                           # exit 0
git secrets --scan (fresh git init of HEAD, isolated HOME, scripts/setup-git-secrets.sh)       # clean
bash scripts/test-git-secrets-allowlist.sh                                                    # 42 PASS, 0 FAIL

Fail-before / pass-after. I restored main's handler_ri_exchange.go, handler_purchases.go and store_postgres.go and kept the new tests. The new sentinel declarations stayed so the tests compile; they change no behavior.

  • TestHandleRequest_ApproveRIExchange_StatusCodes: 6 new subtests FAIL. They are: expired → 410, 410 before the 4-eyes check, expired mid-flight → 410, and lost race → 409, for both token and session. On main these came back 200 or 500.
  • TestHandleRequest_RejectRIExchange_StatusCodes: 3 new subtests FAIL (expired, expired mid-flight, lost race).
  • TestHandleRequest_TokenActions_TokenAndStatusErrorsAre4xx: revoke_expired_token (409) and revoke_wrong_token_on_an_expired_link (409) FAIL.
  • TestHandler_revokePurchase_ExpiredToken FAILS (409).
  • TestPGXMock_TransitionRIExchangeStatus_{WrongStatus,Expired} FAIL (no sentinel in the chain).
  • Integration: both TestPostgresStoreDB_TransitionRIExchangeStatus_* tests FAIL ("Target error should be in err chain").
  • After restoring the fix, all of them PASS.

Fixture change. TestHandler_revokePurchase_ExpiredToken stored the raw token where the hash belongs. It only passed because expiry was checked before the match. It now stores config.HashApprovalToken(token) and expects 410.

Behavior changes reviewers should weigh

  • An expired email revoke link answers 410 instead of 409. The server-rendered revoke form doesn't branch on the status code, and no frontend code calls this route, so the UI needs no change.
  • POST /api/ri-exchange/approve/{id} from the dashboard (session mode, frontend/src/api/riexchange.ts) now gets 409 or 410 instead of 500 when an exchange was already handled or has expired. The frontend shows the error message generically and has no 500-specific handling. @cristim: no frontend change was made. If the RI exchange page should treat 410 differently (for example, refresh the row), that's a UI follow-up.
  • The fix(api): answer a hidden execution like a missing one on approve, cancel and revoke #935 concealment is untouched. On revoke, authorizeApprovalAction and concealExecutionDenial still run before any token check, so only an authorized contact email ever sees 403, 410 or 409. The signed-in oracle test passes unchanged.

Out of scope (noted, not fixed)

  • GetRIExchangeRecord on Postgres returns an error, not (nil, nil), for a missing id. So RI approve and reject answer a missing exchange with 500 in production, while openapi.yaml and the mock-based tests say 404.
  • Delayed (purchase-delay) approve: if the token expires between approveViaToken's validation and the token-pinned CAS, the result is ErrApprovalTokenChanged, which answers 403, not 410. The store can't tell rotated from expired there without a wider store change.
  • The approve and cancel 403 descriptions in openapi.yaml still list RBAC and contact-email denials that fix(api): answer a hidden execution like a missing one on approve, cancel and revoke #935 now answers as 404.

Closes #587

Maintainer: please mirror the issue's triage labels on this PR: triaged, priority/p3, severity/low, urgency/eventually, impact/few, effort/s, type/bug.

🤖 Generated with Claude Code

…and RI exchange

Approve and cancel already answer an emailed-token action with 403 for an
invalid token, 410 for an expired one and 409 for a wrong status. Revoke
answered an expired link with 409, and read the expiry before matching the
token. The RI exchange approve and reject links never checked expires_at:
the transition CAS refused an expired or already-moved record with a plain
error, so both came back as 500.

- validateRevokeToken reuses purchase.ValidateApprovalToken: token matched
  first, then 410 for an expired link, then the revocation window (403).
- checkExchangeToken gives RI approve and reject the same 403/410 order.
- TransitionRIExchangeStatus wraps ErrRIExchangeExpired and
  ErrRIExchangeNotInExpectedStatus; riExchangeTransitionError maps them to
  410 and 409 on the approve (token and session) and reject paths.
- openapi.yaml documents the 410s and the 409 race.

Closes LeanerCloud#587

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

VERDICT: APPROVE fea837d

PR #1172 (Closes #587). One commit, 370 changed lines (cap 400: OK). Rebase check: git merge-tree against current origin/main (25 commits ahead of the base) is clean; the merged tree builds and the targeted api/config tests pass on it. Issue #587 asks for one invalid/expired/wrong-status mapping across the emailed-token actions. The PR applies approve/cancel's mapping (403/410/409) to revoke and RI exchange. Picking 410 over 409 is the owner call the issue reserves, and the PR body says so.

Findings

  • minor internal/api/handler_ri_exchange.go:2659-2668 - riExchangeTransitionError swaps the store's wrapped error (which says "expected status X but current status is Y" or names the id) for a bare ClientError, and handleRequestError (handler.go:865-882) never logs ClientErrors. Before this PR, a lost race or an expiry on Postgres was at least logged as API error: ... with the current status. Now a concurrent approve/reject loser and a mid-flight expiry leave no server log line. The cases still differ by status (409 vs 410), but only for whoever sees the response. Suggest one logging.Infof("ri-exchange %s transition lost: %v", ...) in the 409/410 branches, as concealExecutionDenial does at handler_purchases.go:865. Non-blocking, since purchase tokenActionError does not log its 409/410 either.
  • nit internal/api/handler_ri_exchange.go:2625-2626 - the 409 messages still say "already processed, expired, or ...", but expiry is now 410. The openapi 409 text was updated; the response text was not.
  • nit test gap - validateRevokeToken (handler_purchases.go:1885-1898) now matches the token before it checks the revocation window, so a wrong token no longer gets the window-closed 403 text. No test pins that order: moving checkRevocationWindow ahead of the match survives the suite (probe M5 below).

Oracle / semantics check

  • For a caller without the token, expired, live and already-handled records give the same status and body: RI 403 "invalid approval|rejection token" (checkExchangeToken :2640-2649, match before expiry at :2647); revoke 403 "invalid revocation token" after authorizeApprovalAction + concealExecutionDenial (handler_purchases.go:1771-1777). Timing is the same: one record fetch, one constant-time digest compare, and the expiry is read only after a match. The PR removes two oracles: revoke used to answer an expired link with 409 and a closed window with its own 403 before the token match. Existing pre-auth differences (404 for an unknown exchange id, "does not support approval" for a record with no token) are unchanged.
  • Race/idempotency: the CAS SQL is unchanged (store_postgres.go:3398-3400). Only diagnoseTransitionFailure's errors gain sentinels (:3435-3438), and no caller parses the old strings. The session path keeps its 409 status pre-check (fetchAndAuthorizeRIExchange). A completed exchange past expires_at answers 410 to the token holder, which matches purchase approve (ValidateApprovalToken checks expiry before status).

Local results

  • go test -run 'RIExchange|Revoke|revoke|TokenActions|TokenAndStatus' ./internal/api/: 551 passed. go test -run TransitionRIExchange ./internal/config/: 5 passed. go vet (with and without -tags integration) api+config: ok.
  • Integration (Docker, testcontainers Postgres): TestPostgresStoreDB_TransitionRIExchangeStatus_{ExpiredIsErrRIExchangeExpired,ConcurrentLosersGetNotInExpectedStatus} PASS.
  • Merged-tree spec: redocly 1.25.0 lint 0 errors, warnings the same as main.

Attacked (mutation probes, each restored)

  • M1 checkExchangeToken checks expiry before the token match -> KILLED (403 wrong-token-on-expired subtests, approve+reject).
  • M2 drop the expiry pre-check -> KILLED (410 token approve; 410 before 4-eyes).
  • M3 swap the 410/409 in riExchangeTransitionError -> KILLED (mid-flight expiry and lost-race subtests, token+session).
  • M4 revoke expired back to 409 -> KILLED (TestHandler_revokePurchase_ExpiredToken, revoke_expired_token).
  • M5 revoke window checked before the token match -> SURVIVED (nit above).
  • M6 store drops the NotInExpectedStatus sentinel -> KILLED (TestPGXMock_TransitionRIExchangeStatus_WrongStatus).

@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

Independent review 2 (claude-fleet, cold): APPROVE fea837d

Findings (none blocking):

  • [non-blocking] internal/config/store_postgres.go:3435 and internal/api/handler_ri_exchange.go:2648 - When the CAS loses, the expiry check wins over the status check. A second approve or reject on an exchange that was already approved or completed and has since passed expires_at gets 410 "exchange has expired" rather than 409 "already processed". This matches purchase approve/cancel (ValidateApprovalToken also checks expiry before status), so it is consistent with the issue's mapping. The 409 text "already processed, expired, ..." still names expiry, although expiry is now 410.
  • [non-blocking] The three items the PR body lists as out of scope are real and pre-existing, and are worth follow-ups:
    • GetRIExchangeRecord on a missing id answers 500, not 404.
    • The delayed-approve expiry race answers 403.
    • The approve/cancel 403 docs are stale.
  • [non-blocking, unrelated] internal/email/templates.go:316-317 and 374-375 put the RI approve/reject links in plain <a href> (GET), but those routes are POST-only (router.go:322-323).

Against #587:

  • Revoke now uses purchase.ValidateApprovalToken (handler_purchases.go:1885), the same check approve/cancel use: 403 for an invalid token, 410 for an expired one, 409 for the wrong status.
  • RI approve and reject share checkExchangeToken, which matches the token before checking expiry.
  • riExchangeTransitionError maps a lost CAS on Postgres to 409/410 instead of 500.
  • The openapi spec is updated for all three routes.

Attacked:

  • Order:
    • Revoke still runs authorizeApprovalAction and concealExecutionDenial before the token check (handler_purchases.go:1771-1777).
    • A wrong token on an expired link, or outside the revocation window, now gets the generic 403 instead of a distinguishable 409 or window message.
    • On RI approve, the 410 comes before the 4-eyes check, and nothing is written.
  • Blast radius:
    • TransitionRIExchangeStatus has 3 callers, all updated. The new sentinels are api-only, and the mock store signature is unchanged.
    • The wrapping keeps the old message text.
    • The frontend does not branch on these codes: approval-details.ts already handles 410, and riexchange.ts:149 shows errors generically.
  • Concurrency: the store SQL is unchanged. The integration race test (8 callers x 5 rounds on real Postgres) passes. A Go-clock vs DB-clock skew can only move a request between 410-before-write and 410-at-CAS, never into a write.
  • Mutation probes on a scratch copy, which diff -r showed identical afterwards. All 6 were caught:
    1. expiry checked before the token match;
    2. the expiry pre-check dropped;
    3. 410 and 409 swapped;
    4. revoke answering an expired token with 409;
    5. revoke skipping the window check;
    6. %v instead of %w for the wrong-status sentinel.

Verified locally, on a git archive export of HEAD with no cloud credentials:

  • go build ./..., and go vet on ./internal/api/ and ./internal/config/, including with -tags integration: ok. gofmt -l: clean.
  • go test -count=1 ./internal/api/ ./internal/config/ ./internal/purchase/, under the heavy lock: ok.
  • go test -tags integration -count=1 ./internal/config/ -run TestPostgresStoreDB_TransitionRIExchangeStatus: PASS on testcontainers Postgres.
  • Fail-before, on the base export with the new tests and the sentinel declarations added: the revoke expiry tests, 6 RI approve subtests, 3 RI reject subtests and the pgxmock WrongStatus/Expired tests all FAIL, matching the PR's claims.

Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE fea837d1864a53bffddf93b56a616967aa4b0332

Blocking:

  • none

Non-blocking:

  • Merge state: this head now CONFLICTS with main in internal/api/handler_purchases.go (and its test). sec(purchase): pin the approval token in the token approve/cancel CAS; compare revoke token first #1242 (2033cb8) already moved validateRevokeToken to match the token first, but kept 409 for an expired link. The resolution is simple (keep this PR's purchase.ValidateApprovalToken switch), but the merge commit is a new head and needs its own review.
  • internal/api/handler_ri_exchange.go: riExchangeApproveConflictMsg/riExchangeRejectConflictMsg still say "already processed, expired, or ..." on the 409, although expiry is now a 410. That is wording only.
  • Edge: an exchange that already completed and then passed expires_at answers 410 ("expired"), not 409, on a token re-approve or reject, because both checkExchangeToken and diagnoseTransitionFailure check expiry before status. This matches the purchase links (ValidateApprovalToken also checks expiry before status), so it is acceptable, but the openapi 409 text ("no longer pending") doesn't mention it.
  • The body says the diagnoseTransitionFailure "message text is unchanged". The expired message now has : <id> appended. The error is internal only and mapped to 410, so this is harmless.
  • Owner decision (flagged in the body): 410 for an expired token on every emailed-token action. I agree with it, since approve and cancel and the frontend already use 410.

Attacked:

  • Token oracle: the 410 is only reachable after ApprovalTokenMatches, on revoke, RI approve and RI reject, so expiry is not disclosed to a non-holder. On revoke, authorizeApprovalAction/fix(api): answer a hidden execution like a missing one on approve, cancel and revoke #935 concealment still runs first. RI token approve checks the token and expiry before the 4-eyes check and before any write. riExchangeTransitionError maps only the two new sentinels, and every other store error stays a 500. The only SQL change is error wrapping. The CAS UPDATE ... WHERE status=pending is unchanged.
  • Fail-before: I restored the merge-base handler_purchases.go, handler_ri_exchange.go and store_postgres.go and kept the new tests. TestHandleRequest_{Approve,Reject}RIExchange_StatusCodes, TestHandleRequest_TokenActions_TokenAndStatusErrorsAre4xx, TestHandler_revokePurchase_ExpiredToken and TestPGXMock_TransitionRIExchangeStatus_{WrongStatus,Expired} failed, and so did both real-Postgres TestPostgresStoreDB_TransitionRIExchangeStatus_* tests (including the 8-caller race). All passed after restoring.
  • Ran go build ./..., go vet ./..., go vet -tags integration ./..., go test -race -short -count=1 on internal/{api,config} (ok), golangci-lint with and without the integration tag (0 issues), gocyclo -over 10 (silent), and targeted go test -race -tags integration -run 'RIExchange|ExchangeToken|Revoke|revoke' on internal/api plus both new TestPostgresStoreDB_TransitionRIExchangeStatus_* tests under -race (ok on real Postgres). Full-package integration runs of internal/config and internal/api did not finish within 30-45 min on this host (load avg 13, about 20 other agents' Postgres containers). 313 config tests passed before the timeout. The only failures were testcontainers start-up errors ("container status removing", "container did not come up") in unrelated tests, and none of them involved the changed code.

Body-stale:

From earlier reviews (confirmed, non-blocking):

  • riExchangeTransitionError turns the store's descriptive error into a bare ClientError, and handleRequestError doesn't log ClientErrors. So a lost race or a mid-flight expiry on RI approve/reject no longer leaves a server log line that names the current status. One logging.Infof in the 409/410 branches would restore that.
  • No test pins that validateRevokeToken matches the token before it checks the revocation window. Moving checkRevocationWindow ahead of the match survives the suite.

Reviewer: rB24. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

IAmJSD and others added 2 commits October 26, 2026 04:30
Conflict in validateRevokeToken: LeanerCloud#1242 (on main) moved the token match
ahead of the expiry and window checks but kept 409 for an expired link;
this branch routes revoke through purchase.ValidateApprovalToken for the
403/410 mapping. Kept this branch's switch, which already matches the
token (constant time, against the stored digest) before the expiry and
the window, and merged the two doc comments. LeanerCloud#1242's test for that order
(TestHandleRequest_RevokeViaEmail_WrongTokenOnLapsedRowIsInvalid) now
expects 410 for the right token on an expired link, matching approve and
cancel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e 409 text

Review follow-ups on LeanerCloud#1172. riExchangeTransitionError turns the store's
error, which names the exchange and its current status, into a bare
ClientError, and handleRequestError never logs ClientErrors, so a lost
approve/reject race or a mid-flight expiry left no server log line. Log
the store error at info level in the 409 and 410 branches.

Expiry is answered with 410 now, so the 409 conflict messages no longer
list "expired" as a cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Merged current main (c8bb869) into the branch, in two merge commits: c1919cc, then c85ef50 for #1426. I also added one commit with review follow-ups. New head: c85ef50dedaa62da22b2da12e100aaa96d81afe8.

Conflict: validateRevokeToken (internal/api/handler_purchases.go) and its test. On main, #1242 moved the token match ahead of the expiry and window checks, but kept 409 for an expired link. This branch routes revoke through purchase.ValidateApprovalToken for the 403/410 mapping. Resolution:

cristim's review nits (new commit ff88e48):

  • No log line on a lost transition: riExchangeTransitionError now logs the store error with logging.Infof in the 410 and 409 branches. That error names the exchange and its current status.
  • 409 text still said "expired": riExchangeApproveConflictMsg is now "exchange already processed or was canceled by a newer analysis run". riExchangeRejectConflictMsg is now "exchange already processed or was canceled". The status tests that check the exact text are updated.
  • No test pinned the revoke check order (probe M5): sec(purchase): pin the approval token in the token approve/cancel CAS; compare revoke token first #1242's test, now on this branch, covers it. I moved checkRevocationWindow ahead of the token match and the "wrong token past the revocation window" case failed, so M5 is now killed. I didn't add a duplicate test.

Checks on the merged tree, with no cloud credentials:

  • go build ./..., go vet ./..., go vet -tags integration ./... and the e2e vet all pass.
  • golangci-lint v2.10.1 (with and without the integration tag) and gocyclo -over 10 are clean on internal/{api,config}.
  • go test -race -short passes on internal/{api,config}.
  • The full go test -race -tags integration run passes on internal/api and internal/config (testcontainers Postgres).
  • The secrets scan prints "clean; planted key flagged", and the allowlist test passes.

IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
Conflicts in internal/api/handler_purchases.go and its test come only
from LeanerCloud#1172's commit, which this branch is stacked on; this branch does
not touch those files. Took LeanerCloud#1172's resolution of the same merge
(c1919cc): its 403/410 validateRevokeToken switch, which keeps LeanerCloud#1242's
token-first order, and LeanerCloud#1242's order test expecting 410 for an expired
link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
Bring in LeanerCloud#1172's new head (main merge plus its review follow-ups) so the
two stacked PRs agree. The one conflict is the RI approve/reject status
table: kept this branch's LeanerCloud#601 rows (missing record answers 403 like a
wrong token in token mode) with LeanerCloud#1172's 409 text, which no longer lists
"expired".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE c85ef50dedaa62da22b2da12e100aaa96d81afe8

Blocking:

  • none

Non-blocking:

  • internal/config/store_postgres.go diagnoseTransitionFailure and internal/api/handler_ri_exchange.go checkExchangeToken both check expiry before status. So a token holder who re-clicks approve or reject on an exchange that already completed and has since passed expires_at gets 410 "exchange has expired" instead of 409 "already processed". That fits "expired means expired", and only the token holder ever sees it. Noting it as a product nuance (an earlier review raised it too).
  • Owner decision this PR makes: every emailed-token action answers 410 for an expired-but-matching token, with no 409 for that case. That matches what approve and cancel (since fix(api): answer 4xx for bad approval tokens and a missing revoke execution #584) and the frontend's approval-details.ts already do. I agree with it.

Attacked:

  • Token oracle: on revoke, RI approve and RI reject, the 410 is only reachable after ApprovalTokenMatches succeeds, so a non-holder always gets 403. On revoke, authorizeApprovalAction and the fix(api): answer a hidden execution like a missing one on approve, cancel and revoke #935 concealment still run first. On RI token approve, the token check runs before requireDifferentRIExchangeApprover, so the 4-eyes check cannot leak anything to a wrong-token caller. riExchangeTransitionError only maps the two new wrapped sentinels. A not-found or DB error from the transition stays a 500. ValidateApprovalToken returns only the two sentinels, so the err != nil fallback in validateRevokeToken cannot be reached today.
  • Concurrency: no SQL changed, only error wrapping. The new integration test (8 concurrent callers x 5 rounds on real Postgres) shows exactly one winner each round, with every loser getting ErrRIExchangeNotInExpectedStatus.
  • Ran in a scratch worktree at c85ef50: go build ./...; go vet with and without -tags integration on internal/{api,config}; go test -race -short -count=1 on internal/{api,config,mocks} (ok); go test -race -tags integration -count=1 -run 'RIExchange|Revoke|TokenActions' on internal/{config,api} (ok); golangci-lint (0 issues). git merge-tree against origin/main 8368330: clean.
  • Fail-before: I reverse-applied the non-test diff of handler_ri_exchange.go, handler_purchases.go and store_postgres.go (keeping the sentinels). Then TestHandler_revokePurchase_ExpiredToken, TokenActions.../revoke_expired_token, 7 ApproveRIExchange_StatusCodes subtests, 4 RejectRIExchange_StatusCodes subtests and both TestPostgresStoreDB_TransitionRIExchangeStatus_* integration tests fail. Restored afterwards.

Body-stale:

Reviewer: rC5. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE c85ef50dedaa62da22b2da12e100aaa96d81afe8

Blocking:

  • none

Non-blocking:

  • Owner call still open: the issue leaves 409 vs 410 for an expired token to the owner. The PR picks 410, which matches approve and cancel since fix(api): answer 4xx for bad approval tokens and a missing revoke execution #584 and needs no frontend change. I agree with that choice, but it's a product decision the acting owner should confirm.
  • internal/config/store_postgres.go diagnoseTransitionFailure: the expiry test ignores the current status. So an exchange a concurrent caller already moved to processing or canceled, which then passes expires_at, answers 410, not 409. Token callers hit the same 410 earlier in checkExchangeToken. That's consistent, and harmless, but the openapi 409 text ("including when a concurrent approve or reject changed it first") only holds while the record is unexpired.
  • Out of scope, but worth a follow-up issue as the body says: on Postgres, GetRIExchangeRecord returns an error for a missing id, so RI approve and reject answer 500 where openapi says 404.

Attacked:

  • Read the full non-test diff against the merge-base, plus purchase.ValidateApprovalToken, the TransitionRIExchangeStatus CAS SQL and its diagnose query. The token is still matched before the expiry is read on all three families, so a token guesser learns nothing new. The fix(api): answer a hidden execution like a missing one on approve, cancel and revoke #935 concealment still runs before validateRevokeToken. On approve, the 4-eyes check now runs after the expiry check, which only the token holder can reach. Unknown store errors still answer 500. No SQL changed.
  • Ran go build ./..., go vet with and without -tags integration on internal/api and internal/config, go test -race -short -count=1 ./internal/api/ ./internal/config/ (ok), and go test -race -tags integration -count=1 ./internal/config/ -run TestPostgresStoreDB_TransitionRIExchangeStatus (both tests ran on testcontainers Postgres and passed). The branch also merges cleanly into current main (git merge-tree).
  • Fail-before: restored the merge-base's handler_ri_exchange.go, handler_purchases.go and store_postgres.go, keeping the new tests and sentinels. These failed: 7 approve subtests and 4 reject subtests in the RI exchange StatusCodes tests, revoke_expired_token, TestHandler_revokePurchase_ExpiredToken, RevokeViaEmail_WrongTokenOnLapsedRowIsInvalid/right_token_on_an_expired_link, both pgxmock tests and both integration tests. With the fix restored, all pass.

Body-stale:

  • The table row "Wrong token on an expired link: revoke 409 → 403" and the root-cause line "it read the expiry before matching the token" are no longer true. Main (sec(purchase): token approve/cancel race on rotation, and revoke reveals expiry to a wrong token #1190) already matches the token first and answers 403. In my fail-before run, revoke_wrong_token_on_an_expired_link passes on the pre-fix code, so it isn't a fail-before case as the Verification section says.
  • "Fixture change: TestHandler_revokePurchase_ExpiredToken stored the raw token..." The hash fixture is already on main. This PR only changes that test's expected code, 409 to 410.

Reviewer: rC6. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at c85ef50dedaa62da22b2da12e100aaa96d81afe8 merged onto main (f3498548), together with #1172, #1175, #1191, #1257, #1259 in that order (combined tree 2be2745a, Linux, Go 1.26.10): go build ./..., go vet with and without -tags integration, the full go test -race -short -count=1 ./..., and go test -race -tags integration -count=1 on every changed package, against Docker Postgres. All pass. CI Success and pre-commit are green on this head, with 2 approving independent review(s) on it. Nothing was run against any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 26, 2026 07:45
@IAmJSD
IAmJSD merged commit 89e1140 into LeanerCloud:main Oct 26, 2026
41 checks passed
@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on c85ef50d (two required: money, auth or security path), green CI, and the combined-tree verification above. Squash-merged at exactly c85ef50dedaa62da22b2da12e100aaa96d81afe8.

IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
LeanerCloud#1172, which this branch carries, merged as squash 89e1140. For both
conflicted files (internal/api/handler_ri_exchange.go and
ri_exchange_approve_reject_status_test.go), main's content is
byte-identical to LeanerCloud#1172's head c85ef50 and hasn't changed since, so the
conflicts are only this branch's own additions on top: kept this branch's
side. The diff against main is now only LeanerCloud#1175's own change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit that referenced this pull request Oct 26, 2026
…and reject (#1175)

* fix(api): answer expired and lost-race token actions alike on revoke and RI exchange

Approve and cancel already answer an emailed-token action with 403 for an
invalid token, 410 for an expired one and 409 for a wrong status. Revoke
answered an expired link with 409, and read the expiry before matching the
token. The RI exchange approve and reject links never checked expires_at:
the transition CAS refused an expired or already-moved record with a plain
error, so both came back as 500.

- validateRevokeToken reuses purchase.ValidateApprovalToken: token matched
  first, then 410 for an expired link, then the revocation window (403).
- checkExchangeToken gives RI approve and reject the same 403/410 order.
- TransitionRIExchangeStatus wraps ErrRIExchangeExpired and
  ErrRIExchangeNotInExpectedStatus; riExchangeTransitionError maps them to
  410 and 409 on the approve (token and session) and reject paths.
- openapi.yaml documents the 410s and the 409 race.

Closes #587

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(api): answer a missing RI exchange like a wrong token on approve and reject

The RI exchange approve and reject email links are open to callers with
no session, since the token alone authorizes them. They answered a wrong
token on an existing exchange with 403 but a missing exchange id with 404
(with the in-memory mocks) or 500 (on Postgres, whose GetRIExchangeRecord
returned a bare error for no row), so anyone could probe which exchange
ids exist.

A 401 before the lookup, the purchase routes' fix (#435), does not fit:
it would break the token-only email flow. Instead token mode now gives
one answer to every caller who does not hold the token:

- checkExchangeToken treats a missing record and a record without a
  token like a wrong one: 403 "invalid approval token" / "invalid
  rejection token". The #587 order is unchanged: the token match comes
  first, then 410 for an expired exchange, then the transition's 409/410.
- PostgresStore.GetRIExchangeRecord wraps ErrNotFound for no row.
  getRIExchangeRecord maps it (or a nil record) to "missing", so the
  session approve path answers a missing exchange with the documented
  404 instead of 500. Other store errors stay 500.
- openapi.yaml: 404 is session mode only on approve and is gone from
  reject; the 403s say an unknown id answers like a wrong token.

Closes #601

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(api): log lost RI exchange transitions and drop "expired" from the 409 text

Review follow-ups on #1172. riExchangeTransitionError turns the store's
error, which names the exchange and its current status, into a bare
ClientError, and handleRequestError never logs ClientErrors, so a lost
approve/reject race or a mid-flight expiry left no server log line. Log
the store error at info level in the 409 and 410 branches.

Expiry is answered with 410 now, so the 409 conflict messages no longer
list "expired" as a cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/bug Defect urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(api): expired-token status codes differ across approve, cancel, revoke and RI-exchange token actions

2 participants