Skip to content

refactor(auth): AuthOutcome contract — auth functions own persistence#209

Draft
bird-m wants to merge 1 commit intofollowup/token-persistence-bundledfrom
followup/auth-contract-redesign
Draft

refactor(auth): AuthOutcome contract — auth functions own persistence#209
bird-m wants to merge 1 commit intofollowup/token-persistence-bundledfrom
followup/auth-contract-redesign

Conversation

@bird-m
Copy link
Copy Markdown
Collaborator

@bird-m bird-m commented Apr 22, 2026

Stacked on #207.

Problem this solves

On #207, BugBot caught a high-severity bug where I updated three of four caller sites to call `storeToken` after auth but missed the fourth (`runDirectSignupIfRequested`), silently discarding tokens in agent/CI/classic modes. That class of bug — "forgot to do the right thing at one of N call sites" — is only structurally preventable if persistence is inside the function's contract.

This PR makes forgetting impossible.

Design

New discriminated-union `AuthOutcome` in `oauth.ts`:

```ts
type AuthOutcome =
| { status: 'complete'; user: StoredUser; userInfo: AmplitudeUserInfo;
tokens: StoredOAuthToken; zone: AmplitudeZone }
| { status: 'pending-recovery'; tokens: StoredOAuthToken; zone: AmplitudeZone };

type SignupOutcome = AuthOutcome | { status: 'skipped' };
```

Both `performAmplitudeAuth` and `performSignupOrAuth`:

  • Fetch userInfo internally.
  • Persist to `~/.ampli.json` before returning (complete record on success, pending sentinel on userInfo-fetch failure for crash recovery).
  • Return an `AuthOutcome` that callers pattern-match on.

`performAmplitudeAuth` also absorbs EU-on-US region detection (`detectRegionFromToken`), which setup-utils used to do inline.

Caller simplifications

All four sites collapse substantially (net -77 lines):

  • `bin.ts` TUI authTask — no separate `fetchAmplitudeUser`, no `storeToken`, no `signupUserInfo` threading. Chain signup → OAuth → retry-with-forceFresh purely via status pattern-matching.
  • `bin.ts` /login — outcome pattern match replaces ~30 lines of orchestration.
  • `setup-utils.ts` askForWizardLogin — the `if (userInfo)` wrapper goes away entirely; pending-recovery becomes a clean fallback to the manual API-key prompt.
  • `bin.ts` runDirectSignupIfRequested — the storeToken call added in the previous PR goes away; function handles its own persistence.

Deleted

  • `AmplitudeAuthResult` interface → `AuthOutcome`
  • `PerformSignupOrAuthResult` type
  • `performOAuthFlow` legacy shim + `OAuthConfig` type (unused externally)

What this closes out

What's preserved

  • Crash-recovery via pending sentinel on userInfo-fetch failure (so `--signup` users never get forced back to a browser redirect).
  • Atomic-write semantics via existing `storeToken` → `atomicWriteJSON`.
  • All existing telemetry (`agentic signup attempted` with status/retry-count/env-with-apikey).

Verification

  • 1128 unit tests pass
  • Lint clean (prettier + eslint)
  • TypeScript build clean
  • `grep -rn "3600 \* 1000" bin.ts src/` returns no non-test matches
  • Manual: TUI signup flow
  • Manual: /login command
  • Manual: classic-mode auth (`--yes` / non-TUI)
  • Manual: simulate userInfo fetch failure during signup, verify next-run recovery

…ontract

Collapses the "caller remembers to call storeToken after auth" sequencing
brittleness into a typed contract where persistence is guaranteed by the
function's return type. Forgetting to persist is now structurally impossible.

## Design

New discriminated-union `AuthOutcome` in `oauth.ts`:

  type AuthOutcome =
    | { status: 'complete'; user: StoredUser; userInfo: AmplitudeUserInfo;
        tokens: StoredOAuthToken; zone: AmplitudeZone }
    | { status: 'pending-recovery'; tokens: StoredOAuthToken; zone: AmplitudeZone };

  type SignupOutcome = AuthOutcome | { status: 'skipped' };

Both `performAmplitudeAuth` and `performSignupOrAuth`:
- Fetch userInfo internally (performAmplitudeAuth grows a `fetchAmplitudeUser`
  call; performSignupOrAuth already had one via `fetchUserWithProvisioningRetry`).
- Persist the record to `~/.ampli.json` before returning — complete record
  on success, pending sentinel on userInfo-fetch failure for crash recovery.
- Return an AuthOutcome that callers pattern-match on.

`performAmplitudeAuth` also absorbs the EU-on-US region detection that
setup-utils used to do inline, via `detectRegionFromToken`.

## Why

BugBot caught a bug on the previous refactor (#207) where
`runDirectSignupIfRequested` silently discarded tokens because I updated
three of four caller sites to call storeToken but missed the fourth.
That class of bug is only preventable by a contract that makes forgetting
impossible — which this is.

The previous refactor also traded a persistent low-grade wrongness
(hardcoded 1h expiresAt) for a dormant high-grade failure mode (forgetting
to persist → total loss). This contract closes that trade: persistence is
inside the function, so there's no way for a caller to forget.

## Caller simplifications

All four caller sites collapse substantially:
- `bin.ts` TUI authTask: ~60 lines → ~35 lines; no separate fetchAmplitudeUser,
  no storeToken, no signupUserInfo threading.
- `bin.ts` /login: ~55 lines → ~30 lines.
- `setup-utils.ts` askForWizardLogin: the `if (userInfo)` wrapper goes away
  entirely; graceful fallback becomes a simple pattern match on status.
- `bin.ts` runDirectSignupIfRequested: the just-fixed storeToken call goes
  away — the function now handles its own persistence.

## Deleted

- `AmplitudeAuthResult` interface (replaced by AuthOutcome)
- `PerformSignupOrAuthResult` type
- `performOAuthFlow` legacy shim + `OAuthConfig` type (unused)

## Tests

- login-flow.test.ts: rewritten to mock `performAmplitudeAuth` returning
  AuthOutcome shapes, dropping the per-piece mocks for fetchAmplitudeUser,
  detectRegionFromToken, and storeToken (those are now internal to
  performAmplitudeAuth and not observable from setup-utils tests).
- signup-or-auth.test.ts: status transitions updated (null → skipped,
  tokens-on-top → complete outcome shape).
- cli.test.ts: default auth mocks return AuthOutcome; caller-side
  storeToken assertions removed.

1128 tests pass, lint clean, TypeScript build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

🧙 Wizard CI

Run the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands:

Test all apps:

  • /wizard-ci all

Test all apps in a directory:

  • /wizard-ci django
  • /wizard-ci fastapi
  • /wizard-ci flask
  • /wizard-ci javascript-node
  • /wizard-ci javascript-web
  • /wizard-ci next-js
  • /wizard-ci python
  • /wizard-ci react-router
  • /wizard-ci vue

Test an individual app:

  • /wizard-ci django/django3-saas
  • /wizard-ci fastapi/fastapi3-ai-saas
  • /wizard-ci flask/flask3-social-media
Show more apps
  • /wizard-ci javascript-node/express-todo
  • /wizard-ci javascript-node/fastify-blog
  • /wizard-ci javascript-node/hono-links
  • /wizard-ci javascript-node/koa-notes
  • /wizard-ci javascript-node/native-http-contacts
  • /wizard-ci javascript-web/saas-dashboard
  • /wizard-ci next-js/15-app-router-saas
  • /wizard-ci next-js/15-app-router-todo
  • /wizard-ci next-js/15-pages-router-saas
  • /wizard-ci next-js/15-pages-router-todo
  • /wizard-ci python/meeting-summarizer
  • /wizard-ci react-router/react-router-v7-project
  • /wizard-ci react-router/rrv7-starter
  • /wizard-ci react-router/saas-template
  • /wizard-ci react-router/shopper
  • /wizard-ci vue/movies

Results will be posted here when complete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant