From 625f4b937ae642caa72552621d348cdba5c7ee01 Mon Sep 17 00:00:00 2001 From: Mofei Zhu <13761509829@163.com> Date: Tue, 15 Sep 2026 18:30:40 +0300 Subject: [PATCH 1/6] Update README: release channel is now serving The install channel at cli.mapbox.com started serving real builds with the 0.2.1 release (manifest confirmed live), so the "not serving yet" note is stale. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 56e42b5..18791c7 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ curl -fsSL https://cli.mapbox.com/install.sh | sh irm https://cli.mapbox.com/install.ps1 | iex ``` -That channel is not serving yet. Until it is, build from source above. +This channel started serving with the 0.2.1 release. `scripts/install.sh` and `scripts/install.ps1` here are those installers' sources; `scripts/test-install.sh` and `scripts/test-install.ps1` exercise From 51e85cb3410136e3a6003e86d39206227889f539 Mon Sep 17 00:00:00 2001 From: Mofei Zhu <13761509829@163.com> Date: Tue, 15 Sep 2026 18:45:49 +0300 Subject: [PATCH 2/6] Drop the release-note sentence from the install section The version that first started serving is history, not usage guidance; the commands above are enough. --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 18791c7..fe18867 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,6 @@ curl -fsSL https://cli.mapbox.com/install.sh | sh irm https://cli.mapbox.com/install.ps1 | iex ``` -This channel started serving with the 0.2.1 release. - `scripts/install.sh` and `scripts/install.ps1` here are those installers' sources; `scripts/test-install.sh` and `scripts/test-install.ps1` exercise them end to end without touching the network. From 52cfd7773e382e6f171605ea8622f10db9359ba8 Mon Sep 17 00:00:00 2001 From: Mofei Zhu <13761509829@163.com> Date: Tue, 15 Sep 2026 18:50:32 +0300 Subject: [PATCH 3/6] Drop stale private-preview language for the Statistics API The Statistics API is generally available now, not gated behind Mapbox support enabling an account. Update the spec, docs, CLI help text, and error remedy accordingly; a 403 is no longer explained as a preview-enablement gate. --- .../statistics/openapi/statistics.yaml | 13 ++++----- docs/commands.md | 15 ++++------ src/account_usage.rs | 28 ++++++++----------- src/feature_flags.rs | 4 +-- 4 files changed, 26 insertions(+), 34 deletions(-) diff --git a/custom-openapi/statistics/openapi/statistics.yaml b/custom-openapi/statistics/openapi/statistics.yaml index a3d7e99..481c187 100644 --- a/custom-openapi/statistics/openapi/statistics.yaml +++ b/custom-openapi/statistics/openapi/statistics.yaml @@ -4,14 +4,13 @@ # generically. openapi: "3.0.0" info: - title: "Mapbox Statistics API (private preview)" + title: "Mapbox Statistics API" description: >- The one endpoint `mapbox usage` calls: usage per Mapbox product, by day, - for the whole account or for a single token. A private preview gated two - ways — the account has to be enabled for it by Mapbox support, and the - token needs the `statistics:read` scope. Written by hand rather than trimmed - from an upstream spec because openapi-specs publishes none for this API; - the parameter names are the ones the API answers to, as documented in + for the whole account or for a single token. Gated by the token's + `statistics:read` scope. Written by hand rather than trimmed from an + upstream spec because openapi-specs publishes none for this API; the + parameter names are the ones the API answers to, as documented in `src/account_usage.rs`. version: "0.0.0" servers: @@ -65,7 +64,7 @@ paths: "401": description: Unauthorized — the token does not carry the `statistics:read` scope. "403": - description: Forbidden — the account is not enabled for the private preview. + description: Forbidden — the account doesn't have access to the Statistics API. "422": description: Unprocessable — the period is malformed, backwards, or longer than 31 days. "429": diff --git a/docs/commands.md b/docs/commands.md index fa9a534..98a0d78 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -3158,13 +3158,11 @@ Removed /home/user/.local/bin/mapbox. ### `mapbox usage` Usage per Mapbox product, by day, for the account or one token. Calls the -Statistics API (`GET /statistics/v1`), a private preview gated two ways -this CLI cannot get around: the account has to be enabled for it by Mapbox -support (a 403 here means it isn't), and the token needs the -`statistics:read` scope. `mapbox auth login` requests it by default now; -log in again if your stored token predates that. Also takes -`--token-id ` for one token's usage instead of the whole account — -see `mapbox accounts list-tokens` for ids. +Statistics API (`GET /statistics/v1`), which needs the `statistics:read` +scope. `mapbox auth login` requests it by default now; log in again if +your stored token predates that. Also takes `--token-id ` for one +token's usage instead of the whole account — see `mapbox accounts +list-tokens` for ids. Gated behind a feature flag, flipped on: an official release ships this command the same as a build from source does. See `feature_flags` for what @@ -3193,8 +3191,7 @@ mapbox usage --product "Vector Tiles API" --daily #### Outputs -Run live 2026-09-08 against an account enabled for the private preview. -Numbers below are made up — the real response carries actual traffic +Run live. Numbers below are made up — the real response carries actual traffic figures, which do not belong in a page committed to the repo — but the shape, including the sort order (busiest product first), the sparkline, and every line `-o text` prints around the table, is exactly what came back. diff --git a/src/account_usage.rs b/src/account_usage.rs index d5d308b..d1e242d 100644 --- a/src/account_usage.rs +++ b/src/account_usage.rs @@ -1,15 +1,12 @@ //! `mapbox usage` — account/token usage by product and day. //! -//! Calls the Statistics API (`GET /statistics/v1`), a private preview: -//! the account needs Mapbox support to enable it (403 otherwise), and the -//! token needs the `statistics:read` scope. `mapbox auth login` requests it -//! by default now that [`crate::feature_flags::flags::ACCOUNT_USAGE`] is on -//! (see `auth::requested_scopes`); a token from before that flip won't -//! carry it until logged in again. +//! Calls the Statistics API (`GET /statistics/v1`); the token needs the +//! `statistics:read` scope. `mapbox auth login` requests it by default now +//! that [`crate::feature_flags::flags::ACCOUNT_USAGE`] is on (see +//! `auth::requested_scopes`); a token from before that flip won't carry it +//! until logged in again. //! //! Gated by [`crate::feature_flags::flags::ACCOUNT_USAGE`]; see that module. -//! The gate stays while the API itself is a preview: the switch is what lets -//! an official binary stop shipping the command if the preview is withdrawn. use std::sync::OnceLock; use std::time::Duration; @@ -79,13 +76,12 @@ fn operation() -> &'static Operation { pub fn command() -> Command { Command::new(COMMAND) - .about("Show account/token usage by product and day (Statistics API, private preview)") + .about("Show account/token usage by product and day (Statistics API)") .long_about(format!( "Show usage per Mapbox product, by day, for the account or one token.\n\n\ - Calls the Statistics API, a private preview gated two ways: the account has to \ - be enabled for it by Mapbox support first — a 403 here means it isn't — and the \ - token needs the `statistics:read` scope. `mapbox auth login` requests it by \ - default; log in again if your stored token predates that.\n\n\ + Calls the Statistics API; the token needs the `statistics:read` scope. \ + `mapbox auth login` requests it by default; log in again if your stored \ + token predates that.\n\n\ See {REPO_URL}/issues." )) .arg( @@ -659,8 +655,8 @@ fn remedy_for(status: u16) -> Remedy { if it predates that scope, or pass one from account.mapbox.com with --token.", ), 403 => Remedy::default().with_fix( - "The Statistics API is a private preview: ask Mapbox support to enable it for \ - this account before this can return anything.", + "This account doesn't have access to the Statistics API; contact Mapbox support \ + if that's unexpected.", ), 422 => Remedy::default().with_fix( "--period-start/--period-end take YYYY-MM-DD, the end can't be before the start, \ @@ -1286,7 +1282,7 @@ mod tests { } #[test] - fn a_403_carries_the_private_preview_explanation() { + fn a_403_points_at_mapbox_support() { let (server, base_url) = serve_once( "403 Forbidden", r#"{"message":"Statistics API feature is not enabled for this account"}"#, diff --git a/src/feature_flags.rs b/src/feature_flags.rs index ca61323..e4771ed 100644 --- a/src/feature_flags.rs +++ b/src/feature_flags.rs @@ -55,8 +55,8 @@ fn enabled_for(switch: bool, build_env: Option<&str>) -> bool { pub mod flags { use super::Flag; - /// Gates `account_usage::COMMAND` (`mapbox usage`), which calls a - /// private-preview API. `statistics:read` is what that call needs; + /// Gates `account_usage::COMMAND` (`mapbox usage`), which calls the + /// Statistics API. `statistics:read` is what that call needs; /// `mapbox auth login`'s default scopes don't request it. pub const ACCOUNT_USAGE: Flag = Flag { switch: true, From f1ee17545b9558c29feedfc0b1dbd75679bba062 Mon Sep 17 00:00:00 2001 From: Mofei Zhu <13761509829@163.com> Date: Tue, 15 Sep 2026 19:01:57 +0300 Subject: [PATCH 4/6] Remove the now-unused ACCOUNT_USAGE feature flag It was a no-op (switch already true everywhere) now that the Statistics API it gated is generally available and `mapbox usage` ships unconditionally. Fold statistics:read directly into DEFAULT_SCOPES and register the command unconditionally; drop the feature_flags module since nothing uses it anymore. --- docs/commands.md | 4 -- src/account_usage.rs | 11 ++--- src/auth.rs | 76 ++++------------------------- src/completion.rs | 5 +- src/feature_flags.rs | 112 ------------------------------------------- src/main.rs | 11 ++--- src/update_check.rs | 5 +- 7 files changed, 22 insertions(+), 202 deletions(-) delete mode 100644 src/feature_flags.rs diff --git a/docs/commands.md b/docs/commands.md index 98a0d78..eadeb92 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -3164,10 +3164,6 @@ your stored token predates that. Also takes `--token-id ` for one token's usage instead of the whole account — see `mapbox accounts list-tokens` for ids. -Gated behind a feature flag, flipped on: an official release ships this -command the same as a build from source does. See `feature_flags` for what -the gate is for. - #### Parameters | Parameter | Effect | diff --git a/src/account_usage.rs b/src/account_usage.rs index d1e242d..bf1d1d9 100644 --- a/src/account_usage.rs +++ b/src/account_usage.rs @@ -1,12 +1,9 @@ //! `mapbox usage` — account/token usage by product and day. //! //! Calls the Statistics API (`GET /statistics/v1`); the token needs the -//! `statistics:read` scope. `mapbox auth login` requests it by default now -//! that [`crate::feature_flags::flags::ACCOUNT_USAGE`] is on (see -//! `auth::requested_scopes`); a token from before that flip won't carry it -//! until logged in again. -//! -//! Gated by [`crate::feature_flags::flags::ACCOUNT_USAGE`]; see that module. +//! `statistics:read` scope. `mapbox auth login` requests it by default; a +//! token from before that scope was added won't carry it until logged in +//! again. use std::sync::OnceLock; use std::time::Duration; @@ -59,7 +56,7 @@ const DAILY_ARG: &str = "daily"; /// this crate, not a state a shipped binary can drift into; the pinning test /// below calls this function, so CI fails first; and nothing else — not /// `--help`, not `--schema`, not startup — calls it, so a broken spec can -/// only ever break `usage` itself, and only behind its feature flag. +/// only ever break `usage` itself. fn operation() -> &'static Operation { static PARSED: OnceLock = OnceLock::new(); PARSED.get_or_init(|| { diff --git a/src/auth.rs b/src/auth.rs index 164dfe4..6133df7 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -62,39 +62,10 @@ const VALIDATION_ENDPOINT: &str = "https://api.mapbox.com/tokens/v2"; // succeeded, so it ships alongside the other three rather than staying in // `spec::WITHHELD_OPERATIONS`. Anyone logged in before this lands needs // `mapbox auth login` again, for the same reason `scopes:list` above does. -const DEFAULT_SCOPES: &str = "styles:tiles styles:read styles:write styles:list fonts:read fonts:list fonts:write datasets:read datasets:write tokens:read scopes:list tilesets:read tilesets:write tilesets:list user-feedback:read"; - -/// [`DEFAULT_SCOPES`] plus every enabled flag's `oauth_scopes` — what -/// `mapbox auth login` actually requests. -/// -/// `statistics:read` (`ACCOUNT_USAGE`'s scope, now on) has **not** been -/// confirmed against the Accounts API's registration allowlist, unlike -/// everything in `DEFAULT_SCOPES`. If DCR drops it silently (as it does for -/// `tokens:write` and others in `spec::UNSUPPORTED_OPERATIONS`), a login -/// done with the flag on would still leave `mapbox usage` unable to -/// authenticate. Confirm this against a real `mapbox auth login` before -/// this reaches a staging or production release. -fn requested_scopes() -> String { - let gated: Vec<&'static [&'static str]> = crate::feature_flags::flags::ALL - .iter() - .filter(|flag| flag.is_enabled()) - .map(|flag| flag.oauth_scopes) - .collect(); - scopes_with(&gated) -} - -/// [`requested_scopes`], parameterised for testing — a build's own flags -/// can't be flipped from a test. -fn scopes_with(gated: &[&[&str]]) -> String { - let mut scopes = DEFAULT_SCOPES.to_string(); - for scopes_list in gated { - for scope in *scopes_list { - scopes.push(' '); - scopes.push_str(scope); - } - } - scopes -} +// `statistics:read` (needed by `mapbox usage`) registers fine — confirmed +// live against a real `mapbox auth login` — so it rides along unconditionally +// rather than through the now-removed `ACCOUNT_USAGE` flag. +const DEFAULT_SCOPES: &str = "styles:tiles styles:read styles:write styles:list fonts:read fonts:list fonts:write datasets:read datasets:write tokens:read scopes:list tilesets:read tilesets:write tilesets:list user-feedback:read statistics:read"; #[derive(serde::Serialize, serde::Deserialize, Debug)] struct ClientRegistration { @@ -1223,7 +1194,7 @@ pub fn describe_plan(action: &str, profile: Option<&str>, mode: Mode) -> Result< "would_replace_existing": stored.is_some(), "authorization_endpoint": AUTHORIZATION_ENDPOINT, "token_endpoint": TOKEN_ENDPOINT, - "scopes": requested_scopes().split(' ').map(str::to_string).collect::>(), + "scopes": DEFAULT_SCOPES.split(' ').map(str::to_string).collect::>(), }), ) } @@ -1760,10 +1731,10 @@ pub fn login(debug: bool, profile: Option<&str>, mode: Mode) -> Result<()> { let redirect_uri = format!("http://localhost:{}/callback", port); // Computed once: register_client's ceiling and the authorize scope must agree. - let scopes = requested_scopes(); + let scopes = DEFAULT_SCOPES; output::progress("Registering OAuth client with Mapbox..."); - let registration = register_client(&redirect_uri, debug, &scopes)?; + let registration = register_client(&redirect_uri, debug, scopes)?; let (code_verifier, code_challenge) = generate_pkce(); // Same generator as the verifier above, and for the same reason: `state` @@ -1776,7 +1747,7 @@ pub fn login(debug: bool, profile: Option<&str>, mode: Mode) -> Result<()> { AUTHORIZATION_ENDPOINT, percent_encode(®istration.client_id), percent_encode(&redirect_uri), - percent_encode(&scopes), + percent_encode(scopes), state, code_challenge, ); @@ -1836,35 +1807,7 @@ pub fn login(debug: bool, profile: Option<&str>, mode: Mode) -> Result<()> { #[cfg(test)] mod tests { - use super::{ - fix_for, remedy_for, scopes_with, time_until, with_auth_fix, TokenSource, DEFAULT_SCOPES, - }; - - #[test] - fn a_flags_scopes_ride_along_only_while_it_is_gated_on() { - let without = scopes_with(&[]); - let off: Vec<&str> = without.split(' ').collect(); - assert!(!off.contains(&"statistics:read"), "{off:?}"); - assert_eq!(off, DEFAULT_SCOPES.split(' ').collect::>()); - - let scopes = scopes_with(&[&["statistics:read"]]); - let on: Vec<&str> = scopes.split(' ').collect(); - assert!(on.contains(&"statistics:read"), "{on:?}"); - for scope in DEFAULT_SCOPES.split(' ') { - assert!(on.contains(&scope), "lost {scope} when a flag gated on"); - } - } - - /// Goes through `feature_flags::flags::ALL`, unlike `scopes_with` - /// above, so a flag whose scopes never reach `ALL` fails here too. - #[test] - fn requested_scopes_is_never_narrower_than_the_default_set() { - let requested = super::requested_scopes(); - let requested: Vec<&str> = requested.split(' ').collect(); - for scope in DEFAULT_SCOPES.split(' ') { - assert!(requested.contains(&scope), "lost {scope}"); - } - } + use super::{fix_for, remedy_for, time_until, with_auth_fix, TokenSource, DEFAULT_SCOPES}; /// Every scope the CLI's live operations need, that Mapbox will actually /// grant. The two the specs ask for and the platform still does not have @@ -1895,6 +1838,7 @@ mod tests { "tilesets:read", "tilesets:write", "tilesets:list", + "statistics:read", ] { assert!(requested.contains(&needed), "{needed} is not requested"); } diff --git a/src/completion.rs b/src/completion.rs index 0d7446f..f70076c 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -2,9 +2,8 @@ //! //! The script is generated by `clap_complete` from the same `Command` this //! process assembled, so it cannot describe a different CLI than the binary -//! that printed it: an operation `Operation::is_exposed` withholds, a -//! flag-gated command a release build leaves out (`feature_flags`), and a -//! service a spec sync added yesterday are all handled by not being special +//! that printed it: an operation `Operation::is_exposed` withholds and a +//! service a spec sync added yesterday are both handled by not being special //! cases at all. `a_withheld_operation_is_absent_from_every_shell` in //! `tests/completion.rs` holds the first of those. //! diff --git a/src/feature_flags.rs b/src/feature_flags.rs deleted file mode 100644 index e4771ed..0000000 --- a/src/feature_flags.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Feature flags for commands implemented ahead of a public-rollout decision. -//! -//! The gate is narrower than it reads. It withholds a command from the -//! binaries Mapbox publishes to its staging and production channels, and from -//! nothing else: `MAPBOX_CLI_BUILD_ENV` is what tells those two apart, read at -//! compile time via `option_env!`, and only Mapbox's own release pipeline sets -//! it. Anything else — `cargo build` here, a fork, a distribution packaging -//! this from source — leaves it unset, so `is_dev_build_for` answers true -//! and every flag is on whatever its `switch` says. -//! -//! So a `false` switch is a statement about what an official release ships, -//! not about what the code does. Flipping it to `true` and cutting a release -//! is the only thing that puts a flagged command in front of someone who -//! installed one. -//! -//! A [`Flag`] holds the switch and the OAuth scopes `auth login` should -//! request while it's on, so a new flag is one declaration in [`flags`]. -//! `build_app` uses `.is_enabled()` to decide whether to register a flag's -//! subcommand at all — disabled means it's absent from the `clap` tree, not -//! just hidden from `--help`. `auth::requested_scopes` folds every flag's -//! `.oauth_scopes` into what `login` asks for. - -/// One flag-gated feature: the switch, and the OAuth scopes `auth login` -/// should request only while it's active. -pub struct Flag { - /// Read through [`is_enabled`](Flag::is_enabled), which folds in the - /// dev-build override. - switch: bool, - /// Scopes `auth login` requests only while this flag is enabled. - pub oauth_scopes: &'static [&'static str], -} - -impl Flag { - pub fn is_enabled(&self) -> bool { - enabled_for(self.switch, option_env!("MAPBOX_CLI_BUILD_ENV")) - } -} - -/// True unless `build_env` names the release pipeline building for staging -/// or production. -fn is_dev_build_for(build_env: Option<&str>) -> bool { - !matches!(build_env, Some("staging") | Some("production")) -} - -fn enabled_for(switch: bool, build_env: Option<&str>) -> bool { - switch || is_dev_build_for(build_env) -} - -/// Every flag-gated feature. Flip a switch to `true`, then cut a release, -/// once that feature is ready for staging and production. -/// -/// Nothing is gated today: `ACCOUNT_USAGE` is the only entry here and its -/// switch is on. The mechanism stays for the next command that needs a -/// staged rollout. -pub mod flags { - use super::Flag; - - /// Gates `account_usage::COMMAND` (`mapbox usage`), which calls the - /// Statistics API. `statistics:read` is what that call needs; - /// `mapbox auth login`'s default scopes don't request it. - pub const ACCOUNT_USAGE: Flag = Flag { - switch: true, - oauth_scopes: &["statistics:read"], - }; - - /// Every flag, so a caller (`auth::requested_scopes`) can fold them all - /// in without naming each by hand. A flag left out of this list - /// silently contributes no scopes. - pub const ALL: &[&Flag] = &[&ACCOUNT_USAGE]; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn dev_and_an_unset_build_env_ignore_a_false_switch() { - assert!(enabled_for(false, None)); - assert!(enabled_for(false, Some("dev"))); - } - - #[test] - fn staging_and_production_hide_a_false_switch() { - assert!(!enabled_for(false, Some("staging"))); - assert!(!enabled_for(false, Some("production"))); - } - - #[test] - fn a_true_switch_is_enabled_everywhere() { - for build_env in [None, Some("dev"), Some("staging"), Some("production")] { - assert!(enabled_for(true, build_env)); - } - } - - #[test] - fn every_flag_with_oauth_scopes_is_reachable_through_all() { - assert!( - !flags::ACCOUNT_USAGE.oauth_scopes.is_empty(), - "update this test if a scope-less flag is ever added on purpose" - ); - let scopes_in_all: Vec<&str> = flags::ALL - .iter() - .flat_map(|f| f.oauth_scopes.iter().copied()) - .collect(); - for scope in flags::ACCOUNT_USAGE.oauth_scopes { - assert!( - scopes_in_all.contains(scope), - "{scope} is not reachable through ALL, so it never reaches a login" - ); - } - } -} diff --git a/src/main.rs b/src/main.rs index 89712ca..fb6f480 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,6 @@ mod completion; mod confirm; mod deprecation; mod executor; -mod feature_flags; mod generate_skills; mod http; mod link; @@ -586,17 +585,13 @@ fn build_app(specs: &[ServiceSpec]) -> Command { // Between the other two hand-written leaves, so the help lists the three // that make no request together and in the order someone meets them. // What it prints is built from `app` itself, which is why nothing here - // has to know about it: a service added by a spec sync, or a command a - // feature flag left out, is completed or not completed by having been - // registered above or not. + // has to know about it: a service added by a spec sync is completed or + // not by having been registered above or not. app = app.subcommand(completion::command()); app = app.subcommand(uninstall::command()); - // See `feature_flags`: absent from the tree, not merely hidden, when disabled. - if feature_flags::flags::ACCOUNT_USAGE.is_enabled() { - app = app.subcommand(account_usage::command()); - } + app = app.subcommand(account_usage::command()); app.subcommand(tilesets_cli::command()) } diff --git a/src/update_check.rs b/src/update_check.rs index 1a0bc69..57bf679 100644 --- a/src/update_check.rs +++ b/src/update_check.rs @@ -35,8 +35,9 @@ //! `MAPBOX_CLI_AUTH`, and this never sends a credential on a request the //! user did not type. So only a production build checks — a dev build, a //! staging build and `cargo build` make no request at all. The channel is -//! compiled in from `MAPBOX_CLI_BUILD_ENV`, the same variable -//! [`crate::feature_flags`] reads. +//! compiled in from `MAPBOX_CLI_BUILD_ENV`, read at compile time via +//! `option_env!`, the same way an official release pipeline distinguishes +//! staging from production. //! - **Someone watching.** stderr must be a terminal. A notice nobody reads //! is noise in a CI log, and the request behind it is the one a scripted //! environment has the least reason to make. This is what keeps the check From 41e827f75667c1616f4b2a2ef70ea9f98690279a Mon Sep 17 00:00:00 2001 From: Mofei Zhu <13761509829@163.com> Date: Tue, 15 Sep 2026 19:06:27 +0300 Subject: [PATCH 5/6] Bump version to 0.2.2 --- CHANGELOG.md | 11 +++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a4be7..59e4b93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,17 @@ that may never merge. They are not releases and are not listed here. ## Unreleased +## 0.2.2 - 2026-09-15 + +### Changed + +- `mapbox usage` is no longer described as a private preview: the Statistics + API it calls is generally available, so `mapbox auth login` now requests + `statistics:read` unconditionally instead of through a feature flag, and a + 403 from it is reported as an access problem rather than an unenabled + preview. Nothing about who can run the command or what it prints changed — + the flag it used to go through was already on for everyone. + ## 0.2.1 - 2026-09-15 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 8407554..ce69239 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -751,7 +751,7 @@ checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "mapbox-cli" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index c7b78d2..7c54919 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mapbox-cli" -version = "0.2.1" +version = "0.2.2" edition = "2021" description = "A command-line interface for Mapbox APIs, with commands generated at build time from OpenAPI specs." repository = "https://github.com/mapbox/cli" From ff31c71c3c227e31863762b7dc64e394b32f950e Mon Sep 17 00:00:00 2001 From: Mofei Zhu <13761509829@163.com> Date: Tue, 15 Sep 2026 19:55:41 +0300 Subject: [PATCH 6/6] Fix the 403/401 mismatch Matt flagged, and tidy DEFAULT_SCOPES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Statistics API answers 403, not 401, when a token is only missing statistics:read — confirmed against a live account. The 403 remedy now leads with "run mapbox auth login again" before falling back to Mapbox support, and the 401 remedy no longer claims a scope problem it doesn't cause. Updated the OpenAPI spec and docs/commands.md to match. Also split DEFAULT_SCOPES into a DEFAULT_SCOPES_LIST array joined into a string on first use, so the scope list reads as one per line instead of one long literal. --- CHANGELOG.md | 14 ++++-- .../statistics/openapi/statistics.yaml | 7 ++- docs/commands.md | 9 ++-- src/account_usage.rs | 50 +++++++++---------- src/auth.rs | 43 +++++++++++++--- 5 files changed, 82 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e4b93..4bcbbd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,10 +25,16 @@ that may never merge. They are not releases and are not listed here. - `mapbox usage` is no longer described as a private preview: the Statistics API it calls is generally available, so `mapbox auth login` now requests - `statistics:read` unconditionally instead of through a feature flag, and a - 403 from it is reported as an access problem rather than an unenabled - preview. Nothing about who can run the command or what it prints changed — - the flag it used to go through was already on for everyone. + `statistics:read` unconditionally instead of through a feature flag. + Nothing about who can run the command or what it prints changed — the flag + it used to go through was already on for everyone. + +- The 403 a missing `statistics:read` scope gets back now leads with "run + `mapbox auth login` again", the fix that actually applies, before falling + back to "contact Mapbox support" for the rarer case of an account with no + access at all. It used to jump straight to support, which was written for + the old private-preview gate and never distinguished the two — the API + answers both with 403, not the 401/403 split the docs previously assumed. ## 0.2.1 - 2026-09-15 diff --git a/custom-openapi/statistics/openapi/statistics.yaml b/custom-openapi/statistics/openapi/statistics.yaml index 481c187..c98bbed 100644 --- a/custom-openapi/statistics/openapi/statistics.yaml +++ b/custom-openapi/statistics/openapi/statistics.yaml @@ -62,9 +62,12 @@ paths: "200": description: Usage per product, per day, over the period. "401": - description: Unauthorized — the token does not carry the `statistics:read` scope. + description: Unauthorized — the token is missing or invalid. "403": - description: Forbidden — the account doesn't have access to the Statistics API. + description: >- + Forbidden — the token doesn't carry the `statistics:read` scope, or (less + commonly) it does and the account itself doesn't have access to the Statistics + API. "422": description: Unprocessable — the period is malformed, backwards, or longer than 31 days. "429": diff --git a/docs/commands.md b/docs/commands.md index eadeb92..cd536e2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -3253,9 +3253,12 @@ without it, a sparkline's solid glyphs sitting flush against the next row's read as cramped rather than dense, on an account with more than a couple of products. Exact per-day numbers and the per-browser/country/host breakdown are left to `-o json`; `--product` narrows the whole response, -both columns, to one product's row. A 403 answers `Statistics API feature -is not enabled for this account`; a 401 means the token's missing -`statistics:read` — a login from before the scope was added, most likely. +both columns, to one product's row. A 403 covers two different causes the +API doesn't otherwise distinguish: the token missing `statistics:read` — a +login from before the scope was added, most likely, and fixed by logging in +again — or, less commonly, an account with no access to the Statistics API +at all, which needs Mapbox support. A 401 means the token itself is missing +or invalid. `--daily` swaps every product's sparkline row for its own day-by-day listing — same total, same period, newest day first, no `DAILY TREND` diff --git a/src/account_usage.rs b/src/account_usage.rs index bf1d1d9..461c3b9 100644 --- a/src/account_usage.rs +++ b/src/account_usage.rs @@ -648,12 +648,13 @@ fn redacted_url(url: &str, query: &[(String, String)]) -> String { fn remedy_for(status: u16) -> Remedy { match status { 401 => Remedy::default().with_fix( - "Check the token has the `statistics:read` scope — run `mapbox auth login` again \ - if it predates that scope, or pass one from account.mapbox.com with --token.", + "The token is missing or invalid — run `mapbox auth login` again, or pass one \ + from account.mapbox.com with --token.", ), 403 => Remedy::default().with_fix( - "This account doesn't have access to the Statistics API; contact Mapbox support \ - if that's unexpected.", + "Check the token has the `statistics:read` scope — run `mapbox auth login` again \ + if it predates that scope. If it already has the scope, this account doesn't \ + have access to the Statistics API; contact Mapbox support.", ), 422 => Remedy::default().with_fix( "--period-start/--period-end take YYYY-MM-DD, the end can't be before the start, \ @@ -1278,11 +1279,16 @@ mod tests { } } + /// The API answers 403, not 401, when the token itself is fine but is + /// missing `statistics:read` — confirmed live against a real account. A + /// re-login fixes that case, so the fix has to lead with it rather than + /// jump straight to Mapbox support, which is only the answer once the + /// scope is already there. #[test] - fn a_403_points_at_mapbox_support() { + fn a_403_checks_the_scope_before_pointing_at_mapbox_support() { let (server, base_url) = serve_once( "403 Forbidden", - r#"{"message":"Statistics API feature is not enabled for this account"}"#, + r#"{"message":"This API requires a token with statistics:read scope."}"#, ); let matches = command().get_matches_from(["usage"]); @@ -1294,22 +1300,21 @@ mod tests { .downcast::() .expect("an HTTP failure is a CliError"); assert_eq!(cli.status, Some(403)); - assert_eq!( - cli.message, - "Statistics API feature is not enabled for this account" - ); + let fix = cli.fix.as_deref().unwrap_or_default(); + assert!(fix.contains("statistics:read"), "{fix:?}"); + assert!(fix.contains("Mapbox support"), "{fix:?}"); assert!( - cli.fix - .as_deref() - .unwrap_or_default() - .contains("Mapbox support"), - "{:?}", - cli.fix + fix.find("statistics:read").unwrap() < fix.find("Mapbox support").unwrap(), + "the self-serviceable fix should come before the support fallback: {fix:?}" ); } + /// A 401 here means the token itself is missing or invalid — not a scope + /// problem, which this endpoint answers with 403 instead. Naming the + /// scope on a 401 would send the reader chasing something a bad token + /// can't have anyway. #[test] - fn a_401_points_at_the_scope_rather_than_just_login() { + fn a_401_says_the_token_is_invalid_rather_than_naming_a_scope() { let (server, base_url) = serve_once( "401 Unauthorized", r#"{"message":"Not Authorized - Invalid Token"}"#, @@ -1323,13 +1328,8 @@ mod tests { let cli = err .downcast::() .expect("an HTTP failure is a CliError"); - assert!( - cli.fix - .as_deref() - .unwrap_or_default() - .contains("statistics:read"), - "{:?}", - cli.fix - ); + let fix = cli.fix.as_deref().unwrap_or_default(); + assert!(fix.contains("auth login"), "{fix:?}"); + assert!(!fix.contains("statistics:read"), "{fix:?}"); } } diff --git a/src/auth.rs b/src/auth.rs index 6133df7..fd577b7 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -5,6 +5,7 @@ use sha2::{Digest, Sha256}; use std::io::{IsTerminal, Read, Write}; use std::net::TcpListener; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use std::time::{Duration, Instant}; use crate::output::{self, CliError, Mode}; @@ -65,7 +66,32 @@ const VALIDATION_ENDPOINT: &str = "https://api.mapbox.com/tokens/v2"; // `statistics:read` (needed by `mapbox usage`) registers fine — confirmed // live against a real `mapbox auth login` — so it rides along unconditionally // rather than through the now-removed `ACCOUNT_USAGE` flag. -const DEFAULT_SCOPES: &str = "styles:tiles styles:read styles:write styles:list fonts:read fonts:list fonts:write datasets:read datasets:write tokens:read scopes:list tilesets:read tilesets:write tilesets:list user-feedback:read statistics:read"; +const DEFAULT_SCOPES_LIST: &[&str] = &[ + "styles:tiles", + "styles:read", + "styles:write", + "styles:list", + "fonts:read", + "fonts:list", + "fonts:write", + "datasets:read", + "datasets:write", + "tokens:read", + "scopes:list", + "tilesets:read", + "tilesets:write", + "tilesets:list", + "user-feedback:read", + "statistics:read", +]; + +/// [`DEFAULT_SCOPES_LIST`], space-joined the way the OAuth `scope` parameter +/// takes it. Computed once and cached — every caller wants the joined form, +/// and none of it changes at runtime. +fn default_scopes() -> &'static str { + static JOINED: OnceLock = OnceLock::new(); + JOINED.get_or_init(|| DEFAULT_SCOPES_LIST.join(" ")) +} #[derive(serde::Serialize, serde::Deserialize, Debug)] struct ClientRegistration { @@ -1194,7 +1220,7 @@ pub fn describe_plan(action: &str, profile: Option<&str>, mode: Mode) -> Result< "would_replace_existing": stored.is_some(), "authorization_endpoint": AUTHORIZATION_ENDPOINT, "token_endpoint": TOKEN_ENDPOINT, - "scopes": DEFAULT_SCOPES.split(' ').map(str::to_string).collect::>(), + "scopes": DEFAULT_SCOPES_LIST, }), ) } @@ -1731,7 +1757,7 @@ pub fn login(debug: bool, profile: Option<&str>, mode: Mode) -> Result<()> { let redirect_uri = format!("http://localhost:{}/callback", port); // Computed once: register_client's ceiling and the authorize scope must agree. - let scopes = DEFAULT_SCOPES; + let scopes = default_scopes(); output::progress("Registering OAuth client with Mapbox..."); let registration = register_client(&redirect_uri, debug, scopes)?; @@ -1807,7 +1833,10 @@ pub fn login(debug: bool, profile: Option<&str>, mode: Mode) -> Result<()> { #[cfg(test)] mod tests { - use super::{fix_for, remedy_for, time_until, with_auth_fix, TokenSource, DEFAULT_SCOPES}; + use super::{ + default_scopes, fix_for, remedy_for, time_until, with_auth_fix, TokenSource, + DEFAULT_SCOPES_LIST, + }; /// Every scope the CLI's live operations need, that Mapbox will actually /// grant. The two the specs ask for and the platform still does not have @@ -1818,7 +1847,7 @@ mod tests { /// became registrable (2026-09-08). #[test] fn the_requested_scopes_cover_what_the_commands_need() { - let requested: Vec<&str> = DEFAULT_SCOPES.split(' ').collect(); + let requested = DEFAULT_SCOPES_LIST; for needed in [ "styles:read", @@ -1866,7 +1895,7 @@ mod tests { /// client holding whatever prefix got through. #[test] fn the_scope_query_is_encoded_by_hand_so_it_has_to_be_exact() { - let encoded = percent_encode(DEFAULT_SCOPES); + let encoded = percent_encode(default_scopes()); assert!( !encoded.contains(' '), @@ -1874,7 +1903,7 @@ mod tests { ); assert_eq!( encoded.matches("%20").count(), - DEFAULT_SCOPES.split(' ').count() - 1, + DEFAULT_SCOPES_LIST.len() - 1, "every separator has to survive as one: {encoded}" ); assert!(encoded.contains("styles%3Atiles"), "{encoded}");