Retire the v2 API cache, bound candidate offers, deflake link leak tests
CI / ci (push) Successful in 2m46s
CI / ci (pull_request) Successful in 2m51s

- Bump the service worker API cache to v3 so activation deletes the v2
  cache, whose recipe-candidate responses used the full page URL as
  candidate_id; test that activation deletes v1 and v2 and keeps v3.
- Give candidate offers a 30-minute lifetime, drop expired offers when
  new ones are stored, and clear a dinner's offers on every hand
  grounding (candidate, link or manual) and on archive. A candidate
  search still running when its dinner is cleared stores nothing, so a
  stale candidate_id gets the same 400 as an unknown one.
- Make the link leak assertions in TestDinnerResponsesHideTokenInLinkPath
  and TestCreateRegularNeverNamesFromLink check phrases a ULID cannot
  contain, so a dinner id holding "KEY" no longer fails them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 22:35:30 -04:00
co-authored by Claude Opus 5.5
parent db6f39cdb9
commit 515919088a
9 changed files with 301 additions and 36 deletions
+12 -7
View File
@@ -145,11 +145,14 @@ type Service struct {
// offerKey keys the candidate ids Candidates offers, so they are
// opaque and stable for this process.
offerKey []byte
// offersMu guards offers: by dinner id, the page URL of each candidate
// id last offered by Candidates, so a candidate id is resolved here
// and its URL never leaves the server.
offersMu sync.Mutex
offers map[string]map[string]string
// offersMu guards offers and offerSearches. offers holds, by dinner
// id, the candidates last offered by Candidates until they expire, so
// a candidate id is resolved here and its URL never leaves the server.
// offerSearches holds the Candidates searches running, by dinner id,
// so clearing a dinner's offers keeps them from storing theirs.
offersMu sync.Mutex
offers map[string]offerSet
offerSearches map[string]map[*offerSearch]struct{}
}
// NewService returns a service over st that grounds dinners with grounder
@@ -166,7 +169,7 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
case logger == nil:
return nil, errors.New("dinners: nil logger")
}
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]map[string]string)}
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
rand.Read(s.offerKey) // never fails: it crashes the program instead
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
if err != nil {
@@ -395,7 +398,8 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
// Archive sets dinner id's status to archived, recording updatedBy. The row
// is kept, so its name still counts for Side quest dedupe. A
// KindGroundDinner job running for it is stopped.
// KindGroundDinner job running for it is stopped, and its candidates no
// longer ground it.
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
@@ -412,6 +416,7 @@ func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, er
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
}
s.stopGroundings(row.ID, math.MaxInt64)
s.clearOffers(row.ID)
return fromRow(row), nil
}