* MUL-2744: feat(auth): auto-renew daemon PAT in-place within 7-day window
Daemons currently hold a 90-day PAT and have no renewal path: once the
token's expires_at passes, every request 401s and the user has to find
the silent failure in the daemon log and re-run `multica login`.
This adds an in-place renewal:
- New `POST /api/tokens/current/renew` (Auth-protected, mul_ only). The
server checks remaining lifetime: ≥ 7 days is a no-op; < 7 days bumps
expires_at to now + 90 days via a guarded UPDATE that makes concurrent
renews idempotent (the WHERE expires_at < $2 clause means only one
writer wins; the loser sees pgx.ErrNoRows and reports the already-
extended value). No raw token rotation — the same secret stays in
every CLI/daemon process sharing the config.
- Daemon-side `tokenRenewalLoop`: fires once on startup (covers
machine-was-off cases) and then every 3 days. With a 7-day server
threshold this gives at least two renewal attempts before the window
closes, so a single network blip can't push the token out.
- 401 fallback: when the renew call comes back 401 (token already
revoked/expired), the daemon logs a user-actionable WARN telling the
operator to run `multica login` — instead of the current silent
failure mode. Loop keeps running so the warning repeats until fixed.
PAT cache (auth.AuthCacheTTL = 10m) doesn't need invalidation: the next
miss after the UPDATE re-reads the row and re-caches with the bumped
TTL automatically.
Co-authored-by: multica-agent <github@multica.ai>
* MUL-2744: fix(auth): renew PAT before first sync; CAS against renewal threshold
Addresses the two issues Elon raised on #3360.
Must-fix: if the PAT is already revoked/expired when the daemon starts,
syncWorkspacesFromAPI 401s and Run returns before the background
tokenRenewalLoop ever fires its initial renewal. The operator only sees
a generic auth failure in the workspace-sync log with no hint that
'multica login' is the fix. Now the startup path runs an inline
tryRenewToken first, surfacing the existing 401 WARN before anything
else gets a chance to fail. Pulled the renew + first-sync pair into
preflightAuth so the ordering invariant is enforced at one site and
tests can exercise the failure modes without spinning up the full Run
setup. Removed the redundant initial tryRenewToken from
tokenRenewalLoop — startup now owns the first call.
Nit: the previous WHERE clause on ExtendPersonalAccessTokenExpiry
(expires_at < $2) did not actually make concurrent renews idempotent
the way the comment claimed. Two callers race-computing
$2 = now + 90d produce strictly-different values, and the second
writer's $2 always exceeds the row the first writer just wrote, so the
UPDATE re-matches and bumps again. Switched to a CAS against the
renewal threshold (expires_at <= $renew_threshold_at, i.e. now + 7d):
once writer A pushes expires_at past the threshold, writer B's UPDATE
matches zero rows and the loser falls back to reporting the
already-extended value as a no-op.
Tests:
- TestPreflightAuth_RenewsBeforeWorkspaceSyncOnExpiredToken locks in
the call ordering — renew endpoint is hit before workspaces, and the
re-login WARN appears even though both endpoints 401.
- TestPreflightAuth_SyncProceedsWhenRenewIsNoOp covers steady-state
startup: a renew=false no-op must still progress to workspace sync.
- TestPreflightAuth_TransientRenewFailureDoesNotBlockStartup covers a
500 from the renew endpoint — startup must continue, no WARN.
- TestRenewPAT_ParallelRenewExtendsExactlyOnce fires N=8 concurrent
renews at one row and asserts exactly one returns renewed=true with
the others reporting the same already-extended expires_at, plus the
DB carries only that single bumped value.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>