[WSAPI-3] Port Wealthsimple authentication & session management #2

Merged
chris merged 14 commits from feature/WSAPI-3 into main 2026-09-09 07:50:41 -06:00
Owner

Ticket

WSAPI-3 Port Wealthsimple authentication & session management

Summary

  • Ports budget-tracker-api's proven Wealthsimple OAuth password+TOTP login and refresh-token rotation
    into wealthsimple-api's own Mongo-backed session store (lib/config, lib/mongo, lib/session,
    lib/wealthsimple/{auth,discovery,totp,request,errors,login-cli}).
  • Read-only OAuth scope only (invest.read trade.read tax.read) — the stored credential can never
    move money. lib/session/import-cli's zod schema now exact-matches this scope, rejecting a
    hand-edited or corrupted export that claims write access.
  • No-retry-loop-on-401 guardrail: one refresh attempt on a rejection, then the session is marked dead
    and every later call halts until a human re-bootstraps via bun run ws:login.
  • bun run ws:login — the bootstrap/recovery CLI for the rare case a fresh login is genuinely needed.
  • bun run session:import <file> — new to this service, the one-time migration mechanism: imports a
    mongoexport --jsonArray file into the new store, so the cutover doesn't require a fresh login (a
    fresh login is a materially more detectable event against a live financial institution than a
    routine token refresh). Validates dates and rejects anything but the read-only scope before ever
    touching Mongo.
  • Docker/env wiring: a mongo:6 database service on a new internal backend network, .env.example
    documentation for the new variables, and root/lib/wealthsimple CLAUDE.md updates including the
    actual mongoexport migration runbook.
  • Test coverage: token refresh (success, expired-refresh-token failure, no-retry-loop-on-401,
    concurrent-refresh collapse, concurrent-refresh-failure collapse) with Wealthsimple HTTP calls
    mocked at the boundary; a real ephemeral mongodb-memory-server for every Mongo-backed module.

Known limitations (inherited from budget-tracker-api's source)

Both identified during pre-PR review and deliberately left as-is — fixing either would mean diverging
from this ticket's "port faithfully" scope, and neither is a regression introduced by this port:

  1. lib/session's markDead() and rotateTokens() are a blind singleton updateOne({}, ...) with no
    compare-and-swap against the specific refresh token that failed or rotated. A human running
    ws:login concurrently with an in-flight stale refresh could theoretically have the stale call
    clobber the fresh login. Identical to budget-tracker-api's live source.
  2. Config.assertWealthsimpleConfig() only checks that WS_TOTP_SECRET is non-empty, not that it's
    valid base32 — a malformed seed isn't caught until after the real password POST. Also identical to
    budget-tracker-api's source.

Scope trims vs. budget-tracker-api (approved in planning)

  • No curl-impersonate transport. WS_IMPERSONATE is unset in the live deployment this service's
    session was migrated from — the live session already runs on plain fetch. wsFetch here calls
    fetch directly with no swappable transport seam; porting the curl-impersonate Chrome binary
    machinery is a separate hardening concern with zero behavioral parity gap today.
  • No push-alert on session death. This service has no notification infrastructure yet.
    killSession logs at error level and marks the document dead — the whole halt contract this
    ticket owns. Surfacing a dead session to a human some other way is a later "WSAPI session health"
    ticket.

Manual step (not part of this PR)

The live session migration — the actual mongoexport/session:import runbook, now documented in root
CLAUDE.md's "Running locally" section — is a manual operational step run separately against the real
budget-tracker-api and wealthsimple-api Mongo instances. No code in this PR performs it, and no real
network call to Wealthsimple happens anywhere in this PR's automated test suite.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn

## Ticket [WSAPI-3](http://192.168.2.100:7123/home/browse/WSAPI-3/) Port Wealthsimple authentication & session management ## Summary - Ports budget-tracker-api's proven Wealthsimple OAuth password+TOTP login and refresh-token rotation into wealthsimple-api's own Mongo-backed session store (`lib/config`, `lib/mongo`, `lib/session`, `lib/wealthsimple/{auth,discovery,totp,request,errors,login-cli}`). - Read-only OAuth scope only (`invest.read trade.read tax.read`) — the stored credential can never move money. `lib/session/import-cli`'s zod schema now exact-matches this scope, rejecting a hand-edited or corrupted export that claims write access. - No-retry-loop-on-401 guardrail: one refresh attempt on a rejection, then the session is marked dead and every later call halts until a human re-bootstraps via `bun run ws:login`. - `bun run ws:login` — the bootstrap/recovery CLI for the rare case a fresh login is genuinely needed. - `bun run session:import <file>` — new to this service, the one-time migration mechanism: imports a `mongoexport --jsonArray` file into the new store, so the cutover doesn't require a fresh login (a fresh login is a materially more detectable event against a live financial institution than a routine token refresh). Validates dates and rejects anything but the read-only scope before ever touching Mongo. - Docker/env wiring: a `mongo:6` `database` service on a new internal `backend` network, `.env.example` documentation for the new variables, and root/`lib/wealthsimple` `CLAUDE.md` updates including the actual `mongoexport` migration runbook. - Test coverage: token refresh (success, expired-refresh-token failure, no-retry-loop-on-401, concurrent-refresh collapse, concurrent-refresh-failure collapse) with Wealthsimple HTTP calls mocked at the boundary; a real ephemeral `mongodb-memory-server` for every Mongo-backed module. ## Known limitations (inherited from budget-tracker-api's source) Both identified during pre-PR review and deliberately left as-is — fixing either would mean diverging from this ticket's "port faithfully" scope, and neither is a regression introduced by this port: 1. `lib/session`'s `markDead()` and `rotateTokens()` are a blind singleton `updateOne({}, ...)` with no compare-and-swap against the specific refresh token that failed or rotated. A human running `ws:login` concurrently with an in-flight stale refresh could theoretically have the stale call clobber the fresh login. Identical to budget-tracker-api's live source. 2. `Config.assertWealthsimpleConfig()` only checks that `WS_TOTP_SECRET` is non-empty, not that it's valid base32 — a malformed seed isn't caught until after the real password POST. Also identical to budget-tracker-api's source. ## Scope trims vs. budget-tracker-api (approved in planning) - **No curl-impersonate transport.** `WS_IMPERSONATE` is unset in the live deployment this service's session was migrated from — the live session already runs on plain `fetch`. `wsFetch` here calls `fetch` directly with no swappable transport seam; porting the curl-impersonate Chrome binary machinery is a separate hardening concern with zero behavioral parity gap today. - **No push-alert on session death.** This service has no notification infrastructure yet. `killSession` logs at `error` level and marks the document dead — the whole halt contract this ticket owns. Surfacing a dead session to a human some other way is a later "WSAPI session health" ticket. ## Manual step (not part of this PR) The live session migration — the actual `mongoexport`/`session:import` runbook, now documented in root `CLAUDE.md`'s "Running locally" section — is a manual operational step run separately against the real budget-tracker-api and wealthsimple-api Mongo instances. No code in this PR performs it, and no real network call to Wealthsimple happens anywhere in this PR's automated test suite. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Static config surface for the Mongo connection string/db name and the
Wealthsimple bootstrap credentials, trimmed to what this service needs so far.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Memoized MongoClient plus a typed collection<T> accessor and closeDatabase
teardown, backing the Wealthsimple session store landing in the next step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Ported from budget-tracker-api's data/wealthsimple-session, plus a new
restore() that upserts every field verbatim (including dead/updatedAt) for
the live-session migration import landing later in this ticket.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Generates the 6-digit TOTP code Wealthsimple's 2FA expects from the enrolled
base32 seed. Explicit SHA1/6-digit/30s parameters, RFC 6238 test vectors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Ported from budget-tracker-api's request module, minus the curl-impersonate
transport seam (WS_IMPERSONATE is unset in the live deployment this service
inherits its session from, so wsFetch calls fetch directly). Owns the cookie
jar, per-call-kind header map, and x-ws-session-id derivation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Ported from budget-tracker-api, trimmed to the subset auth/discovery use
(SessionDeadReason/StoredSessionReason, SessionDeadError, LoginFailedError,
OtpRequiredError, DiscoveryError). Sync/client-only error types dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Scrapes the wssdi device id and production clientId out of the Wealthsimple
login page and JS bundle when a session has no cached credentials to reuse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Matches budget-tracker-api's ownership split (the type lives beside the
SessionDeadReason it is a subset of) now that errors.ts exists, rather than
duplicating the union locally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Ported from budget-tracker-api: loginAndPersist (two-attempt OTP retry,
read-only scope), getAuthenticatedSession, refreshAuthenticatedSession (401
compare-and-swap plus the 5-minute no-retry-loop floor), single-flight
refreshOnce, and getSessionHaltReason. killSession logs and marks the session
dead rather than pushing an alert -- no notification infra exists yet.

Explicit ticket coverage: refresh success rotates both tokens, an expired
refresh token or malformed response marks the session dead via
SessionDeadError, a transport throw is treated identically, calling
refreshAuthenticatedSession twice inside the floor issues zero additional
requests, and concurrent refreshes collapse into one outbound call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
lib/wealthsimple/login-cli ported from budget-tracker-api: fails fast on
missing WS_* config, calls loginAndPersist(), logs success with a masked
token, closes Mongo in finally. scripts/ws-login.ts is the thin bin entry
point wired as `bun run ws:login`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
lib/session/import-cli reads a mongoexport --jsonArray file holding one
Wealthsimple session document, validates it against a zod schema tolerant of
mongoexport's Extended JSON dates, and upserts it verbatim via Session.restore
-- this is the mechanism for migrating the live budget-tracker-api session
without a fresh password+TOTP login, since the two stacks' Mongo instances
sit on mutually unreachable Docker networks. scripts/import-session.ts is the
bin entry point wired as `bun run session:import <file>`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
docker-compose.yml gets a mongo:6 database service on a new internal backend
network; api depends_on it and joins backend alongside the existing
api-shared. .env.example documents MONGO_URI/MONGO_DB and the WS_* bootstrap
credentials. Root CLAUDE.md drops the WSAPI-2 scaffold-only note, extends the
architecture tree and Stack table, and documents the new env-var groups and
the bson-override testing gotcha. New lib/wealthsimple/CLAUDE.md covers the
session-singleton pattern, the no-retry-loop-on-401 guardrail, that
loginAndPersist must stay reachable from exactly the bootstrap CLI, and the
two scope trims vs. budget-tracker-api (no curl-impersonate, no push alert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
Confirmed fixes:
- index.ts now closes the Mongo client on shutdown (closeDatabase was never
  wired into onShutdown).
- lib/session/import-cli's exportedDateSchema rejects an Invalid Date
  (garbage date string or malformed $numberLong) rather than silently
  importing it as epoch-0.
- Extracted mask() into a neutral lib/mask module so lib/session/import-cli
  no longer transitively imports lib/wealthsimple/auth (and loginAndPersist)
  through lib/wealthsimple/login-cli -- fixes a layering inversion against
  the "loginAndPersist reachable from exactly the bootstrap CLI" invariant.
- Renamed the import-cli's logger module from 'wealthsimple-auth' to
  'wealthsimple-session-import' in both lib/session/import-cli/index.ts and
  scripts/import-session.ts.
- exportedSessionSchema.scope now rejects anything but the exact read-only
  scope, closing the one unchecked door into a read-only-only system.
- .env.example's WS_* placeholders are now blank instead of 'change_me',
  matching the file's own "leave unset" guidance and no longer breaking
  lib/config/.test.ts when copied to .env verbatim.
- Added root-anchored .gitignore patterns for a dropped mongoexport file,
  and wrote the actual migration runbook into CLAUDE.md's "Running locally"
  section (previously only gestured at session:import).
- lib/session/index.ts no longer shadows the imported collection() function
  with a same-named local in every method.

Added test coverage: concurrent markDead() collapses to one transition;
concurrent refresh failure rejects every caller from one outbound call and
marks dead exactly once; the transport-throw refresh test now asserts the
call count; lib/config's "missing variable" test asserts the other two
variable names are absent from the message; import-cli tests for the new
invalid-date and non-read-only-scope rejections.

Two pre-existing limitations inherited verbatim from budget-tracker-api's
source (not introduced by this port, intentionally left as-is per the
ticket's "port faithfully" scope): lib/session's markDead()/rotateTokens()
have no compare-and-swap against a specific token, and
Config.assertWealthsimpleConfig() checks only non-emptiness, not base32
validity, for WS_TOTP_SECRET.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
[WSAPI-3] CLAUDE.md: add the missing lib/mask/ architecture-tree entry.
All checks were successful
server / check (pull_request) Successful in 20s
c4b8ca69ab
The pre-PR remediation commit (c77c555) added lib/mask/ but never listed it
alongside the other top-level lib/ modules in the architecture tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSc34oDVEBXLz9TJWt26Nn
chris merged commit 6e7e78f4df into main 2026-09-09 07:50:41 -06:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
chris/wealthsimple-api!2
No description provided.