Skip to content

feat(cli): add 'qr' command for transferring API token to mobile apps - #545

Merged
junhoyeo merged 2 commits into
junhoyeo:mainfrom
eugenn:feature/qr-login
May 24, 2026
Merged

feat(cli): add 'qr' command for transferring API token to mobile apps#545
junhoyeo merged 2 commits into
junhoyeo:mainfrom
eugenn:feature/qr-login

Conversation

@eugenn

@eugenn eugenn commented May 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new tokscale qr subcommand that renders the locally saved API token (from ~/.config/tokscale/credentials.json) as a QR code directly in the terminal. The QR payload is a JSON blob containing both the token and the username, so a companion app can authenticate and identify the user from a single scan.

Why

Tokscale is desktop-first today, but the account/leaderboard is a natural fit for a companion mobile app (on-the-go leaderboard, weekly summary push notifications, badge sharing, etc.). Mobile apps need a frictionless way to log in — typing a 50+ char tt_xxx token on a phone keyboard, or running the browser device-code flow a second time on mobile, is exactly the kind of paper-cut that kills a companion-app funnel.

Today's options are all unergonomic:

  • Browser device flow on mobile — works, but the user has to open the leaderboard, copy a code, switch apps. High enough friction that most users will give up.
  • Manual pastett_ tokens are 50+ characters; mobile keyboards make this miserable and error-prone.
  • Email / messenger / cloud notes — leaks the bearer token through extra surface area.

A QR code displayed by the already-trusted CLI is the de-facto standard for this exact problem (Discord, Signal, WhatsApp Web, GitHub CLI device flow all do variants of it):

  1. User runs tokscale qr on the machine where they already authenticated via tokscale login.
  2. Mobile app scans the QR with its built-in camera, parses the JSON payload, and is logged in.
  3. No retyping, no second browser round-trip, no token sent over email/Slack.

JSON (not bare token, not URL) was chosen for the payload so mobile apps can also display the username next to the avatar before activating the session — i.e. show a Sign in as @eugenn? confirmation step, which is a meaningful safety check when the QR may have been displayed on a screen the user no longer fully controls.

What changed

  • New tokscale qr subcommand wired in crates/tokscale-cli/src/main.rs (added to the Commands enum and routed to auth::show_qr).
  • New auth::show_qr() in crates/tokscale-cli/src/auth.rs:
    • Reads saved credentials via the existing load_credentials() helper. If the user isn't logged in, prints the same hint that whoami uses and exits cleanly (no crash, no error).
    • Builds a JSON payload {"token":"tt_xxx","username":"<name>"} and feeds it to qrcode::QrCode::new.
    • Renders the QR with unicode::Dense1x2 (two modules per terminal row → compact, square-aspect output). Colors are inverted so the QR is visible on a typical dark terminal background.
    • Prints Token: and User: plaintext below the QR for fallback / debugging.
  • Dependency: qrcode = "0.14" added under crates/tokscale-cli/Cargo.toml. Default features only; no FFI; MIT/Apache-2.0 dual-licensed.
  • README: documents the command in the existing Social section, with a note about the JSON payload shape.

No server-side, frontend, or shared-protocol changes. The mobile-app contract is just "scan, parse JSON, use token as bearer" — exactly the auth the CLI itself uses against /api/auth/token.

Usage

$ tokscale qr

  Tokscale - API Token QR Code

  Scan to get your API token:

  █████████████████████████████████████████████
  █████████████████████████████████████████████
  ████ ▄▄▄▄▄ ██▀ ▄▀██▄▀▀▀▀▀▀▄▀▄█▀▀▄█ ▄▄▄▄▄ ████
  ...
  █████████████████████████████████████████████

  Token: tt_5be17ed8c9b94...
  User:  eugenn

Decoded QR contents:

{"token":"tt_5be17ed8c9b94...","username":"eugenn"}

Validation

  • cargo build -p tokscale-cli (debug + release) — clean, no new warnings.
  • Manual: ran tokscale qr against a real credentials file. QR scans cleanly with iOS Camera and yields the expected JSON payload. The "not logged in" branch was exercised by temporarily moving credentials.json out of the way — prints the same hint as whoami, exits 0.
  • No existing code paths touched — login, logout, whoami, submit etc. are byte-for-byte unchanged.

Risk

  • New, opt-in subcommand. Users who never run tokscale qr are entirely unaffected.
  • The QR encodes the same bearer token that already lives in ~/.config/tokscale/credentials.json — no new secret material is created or exposed by this PR. The QR is only ever rendered to the user's own terminal output; there is no networking and no file write.
  • One new direct dependency (qrcode 0.14): pure Rust, no FFI, ~1.5k LOC, widely used.

Follow-ups (not in this PR)

  • Mobile app side: parse the JSON payload and call the existing /api/auth/token validator before persisting the token, mirroring login_with_token in auth.rs.
  • Optional: also accept tokscale://login?token=...&username=... once mobile apps register a URL scheme, so iOS/Android can deep-link straight from the system camera without an in-app QR reader.

Summary by cubic

Adds a tokscale qr command to show your saved API token and username as a QR code for fast mobile sign-in. Secured with a confirmation prompt and safer JSON encoding, and the token is only inside the QR.

  • New Features

    • New CLI subcommand tokscale qr with --yes to skip the prompt for scripts.
    • Reads saved credentials; if not logged in, prints the same hint as whoami and exits.
    • QR payload is JSON {"token":"tt_xxx","username":"..."} encoded via serde_json to safely handle quotes and backslashes.
    • Renders with Unicode Dense1x2 and inverted colors; shows User below the QR only.
    • Requires on-screen confirmation; non-TTY stdin aborts unless --yes is passed.
  • Dependencies

    • Add qrcode 0.14 to tokscale-cli (pure Rust).

Written for commit 87e47b2. Summary will update on new commits. Review in cubic

@vercel

vercel Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
tokscale Ignored Ignored Preview May 24, 2026 4:12pm

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 5 files

@eugenn

eugenn commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

@junhoyeo @IvGolovach Could you check this PR? It would simplify login for mobile apps.

eugenn and others added 2 commits May 25, 2026 01:10
…dact plaintext

The hand-rolled `format!(r#"{{"token":"{}","username":"{}"}}"#, ...)` payload
broke or injected fields whenever a username or token contained `"` or `\`.
Build the JSON via `serde_json::to_string` so embedded quotes/backslashes can't
escape the structure.

The previous flow also printed the bearer token to the terminal twice — once in
the QR and once in plaintext below it — with no confirmation. Anyone able to
see the screen (screen share, recorded demo, office camera) could replay the
token. The token is now only inside the QR (no plaintext echo), and rendering
is gated on a `[y/N]` prompt unless `--yes` is passed. Non-TTY stdin without
`--yes` aborts instead of dumping the QR into a log stream.

Constraint: QR command was already a published surface; --yes flag preserves scripted use
Rejected: drop --yes entirely | breaks every existing automation that pipes from this command
Confidence: high
Scope-risk: narrow
Directive: do NOT re-add a plaintext token echo below the QR — the QR is the *only* place the token should appear on screen
Not-tested: interactive confirmation flow (would need a pty harness); covered manually
@junhoyeo
junhoyeo force-pushed the feature/qr-login branch from f4a15ae to 87e47b2 Compare May 24, 2026 16:12
@junhoyeo
junhoyeo merged commit 1a4a911 into junhoyeo:main May 24, 2026
3 of 4 checks passed
junhoyeo added a commit that referenced this pull request May 25, 2026
…rift (#593)

Schema:
- Add 0010_submit_count_safety.sql to ALTER TABLE submissions ADD COLUMN
  IF NOT EXISTS submit_count. The column is referenced by schema.ts and
  written on every /api/submit call, but no earlier .sql migration adds
  it; prod has it because somebody once ran drizzle-kit push directly,
  so a fresh drizzle-kit migrate on CI/staging/dev currently breaks
  /api/submit. Verified locally against postgres:16.

API:
- GET /api/users/[username]/devices: aggregated per-device totals
  (tokens, cost, input/output, active days) for a user's submission
  devices, sourced from submitted_devices LEFT JOIN daily_breakdown.
- GET /api/users/[username]/devices/[deviceId]: per-device detail with
  day-by-day contributions. Cross-user device ids silently 404.
- PATCH /api/settings/devices/[deviceId]: session-authenticated rename
  (or clear, via name: null). Validates length and rejects control
  characters; ownership enforced in the same WHERE.

Helpers:
- src/lib/devices/shared.ts: deviceDisplayLabel() and toIsoString()
  shared across the three routes so the public label format cannot drift.

Local dev:
- scripts/seed-dev.ts: idempotent synthetic seed (3 users × 2 devices
  × 14 days + 1 group). Refuses any non-localhost DATABASE_URL.

Ported from #389 onto main's submitted_devices model (#517) rather than
that PR's submissions.source_id schema rewrite. #389's CLI source-id
lockfile and profile Devices tab UI are deliberately not in this PR;
they want separate decisions (CLI lock vs main's existing device.id
payload; UI as a styled-components review pass).

Constraint: do not regress main's submitted_devices schema or rehash the migrated multi-machine model
Rejected: include #389's index-cleanup migration (drop idx_submissions_user_id et al.) | needs a fresh pg_stat_user_indexes audit on current prod before dropping anything in a single transaction
Rejected: bundle #389's CLI source-id lockfile | main already sends device.id via #517/#545; the lockfile is a behavior change, not a port
Confidence: high
Scope-risk: narrow
Directive: /api/users/[username]/devices is public — match it to /api/users/[username]'s visibility model when extending; the rename endpoint is the only auth-gated piece
Not-tested: interaction with the existing rank cache (rankTotal in embed routes is unaffected)
@junhoyeo

Copy link
Copy Markdown
Owner

@eugenn this has been merged to v3.0.0: https://github.com/junhoyeo/tokscale/releases/tag/v3.0.0 thanks for the contribution!

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.

2 participants