Report a refused Google grant before a missing calendar on push
CI / ci (push) Successful in 4m28s
CI / ci (pull_request) Successful in 5m3s

A grant refused while no calendar was chosen (for example during calendar
listing) made a later push return no_calendar instead of
google_reconnect_required, sending the household to choose a calendar when
only connecting again helps. grantedConnection now decides the precedence
for every Calendar call that uses the grant (listing, choosing, pushing):
not connected, then a refused grant, then anything else such as no
calendar chosen. It holds the existing cleared-token check, moved out of
accessToken.

Fixes F16-T4-01 for Maestro task b8d8da5a-0450-4571-963e-b3dfead24283.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 00:13:12 -04:00
co-authored by Claude Opus 5.5
parent 1c8092800a
commit ee67d520d3
5 changed files with 107 additions and 12 deletions
+23 -8
View File
@@ -160,7 +160,7 @@ func forgetEvents(ctx context.Context, q *db.Queries, now string) error {
// calendars lists the writable calendars, each once, with the stored refresh
// token. The caller holds s.account.
func (s *Service) calendars(ctx context.Context) ([]Calendar, error) {
row, err := s.connection(ctx)
row, err := s.grantedConnection(ctx)
if err != nil {
return nil, err
}
@@ -207,14 +207,29 @@ func (s *Service) connection(ctx context.Context) (db.IntegrationsGoogle, error)
return row, nil
}
// accessToken trades row's refresh token for an access token, retrying a
// transient failure as send does. A grant Google reports as invalid_grant,
// or one already recorded as refused (no refresh token stored), is
// ErrReconnect.
func (s *Service) accessToken(ctx context.Context, row db.IntegrationsGoogle) (string, error) {
if row.RefreshTokenCt == nil {
return "", ErrReconnect
// grantedConnection reads the connected account for a Calendar call that
// uses the stored grant: listing, choosing and pushing. It decides the
// precedence of their preconditions: nothing connected is ErrNotConnected,
// then a grant already recorded as refused (no refresh token stored) is
// ErrReconnect, before anything else a call checks, such as whether a
// calendar is chosen. Only connecting again restores a refused grant, so
// it is reported whatever the calendar choice, and the refused token is
// never offered to Google.
func (s *Service) grantedConnection(ctx context.Context) (db.IntegrationsGoogle, error) {
row, err := s.connection(ctx)
if err != nil {
return db.IntegrationsGoogle{}, err
}
if row.RefreshTokenCt == nil {
return db.IntegrationsGoogle{}, ErrReconnect
}
return row, nil
}
// accessToken trades row's refresh token, from grantedConnection, for an
// access token, retrying a transient failure as send does. A grant Google
// reports as invalid_grant is ErrReconnect.
func (s *Service) accessToken(ctx context.Context, row db.IntegrationsGoogle) (string, error) {
refresh, err := s.box.Decrypt(row.RefreshTokenCt)
if err != nil {
return "", fmt.Errorf("gcal: open refresh token: %w", err)