diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b289f..40c6a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,23 @@ that may never merge. They are not releases and are not listed here. ## Unreleased +- New command: `mapbox styles download > style.zip` saves a + style as a ZIP with its sprite icons and custom fonts. `mapbox auth login` + now also asks for the `styles:download` scope it needs, so log in again + to use it. The account also needs access to this API, which Mapbox grants + on request; without it the command fails with a 403 saying so. + +- A command that returns a file (`styles download`, `static get-image`, a + tile or glyph range) now confirms it on stderr at a terminal: + `Wrote application/zip (988165 bytes).` stdout is unchanged, and + `--quiet`/`-q` hides it. + +- A 403 that names a missing scope now says which token lacks it: a login + is told to run `mapbox auth login` again (also in `next_actions`), and a + token from `--token` or `MAPBOX_ACCESS_TOKEN` to add the scope to that + token. A 403 for an API the account has not been given access to says to + contact Mapbox. Other 403s are unchanged. + - A run at a terminal now opens with a `mapbox · v` banner on stderr. stdout is unchanged, and nothing is printed when stderr is not a terminal or for `mapbox completion`. `--quiet`/`-q` or `MAPBOX_QUIET=1` diff --git a/docs/commands.md b/docs/commands.md index e2b6390..c0fd1c1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -478,18 +478,13 @@ Three kinds of reason sit behind those decisions, and they are worth telling apart: - **No token can carry the scope.** `POST /oauth/register` refuses - `fonts:metadata`, `tokens:write` and `styles:download`, so a login can - never obtain them. `src/spec.rs`'s `UNSUPPORTED_OPERATIONS` records - which operations need one, and an entry comes off that list once the - scope becomes registrable — nothing here can force that. - `fonts:list` and `fonts:write` used to be on that list — - both became registrable on 2026-09-08, which is what shipped - `fonts list`, `fonts upload` and `fonts delete`. `styles - download-style-zip`'s real blocker turned out to be one level deeper: - production answers it 403 "This is a prerelease API. Please contact - support," regardless of scope — access is granted per-account by Mapbox - support, not by OAuth, so adding `styles:download` to the allowlist would - not unblock it by itself. + `fonts:metadata` and `tokens:write`, so a login can never obtain them. + `src/spec.rs`'s `UNSUPPORTED_OPERATIONS` records which operations need + one, and an entry comes off that list once the scope becomes + registrable — nothing here can force that. `fonts:list` and + `fonts:write` became registrable on 2026-09-08, which shipped + `fonts list`, `fonts upload` and `fonts delete`; `styles:download` + followed on 2026-09-30 and shipped `styles download`. - **Withheld deliberately.** `styles set-style-protected` unlocks a style for deletion — a live token holding `styles:protect` (which *is* registrable) can call it successfully, this CLI just declines to offer a @@ -2221,6 +2216,54 @@ Error: Style not found (HTTP 404) Unlike `styles draft delete`, which cannot tell a real id from a typo, this one does. +### `mapbox styles download` + +The style as a ZIP: `style.json`, every sprite icon as an SVG under +`sprite_images/`, the custom fonts it uses under `fonts/`, and a license +file. Only the style's owner can download it. + +The account also needs access to this API, which Mapbox grants on request. +Without it the answer is a 403 whose `fix` says to contact Mapbox at +help@mapbox.com; a new token or login does not change it. A login from +before this command shipped lacks the `styles:download` scope, and that +403 asks for `mapbox auth login` instead. + +#### Examples + +```sh +mapbox styles download cmums8rlh000301s96498hnju --username user > style.zip +``` + +#### Outputs + + + + +
Terminal — refusesRedirected — raw bytes
+ +``` +Error: Response is application/zip (988165 bytes). +Refusing to write it to the terminal — redirect +it to a file, e.g. `... > out.zip`. +``` + + + +``` +$ mapbox styles download … > style.zip +$ file style.zip +style.zip: Zip archive data, at least v1.0 to +extract, compression method=store +``` + +
+ +A login without the scope: + +```json +{"code":"http_403","docs":["https://docs.mapbox.com/api/maps/styles/","https://docs.mapbox.com/api/accounts/tokens/"],"fix":"Your login lacks the `styles:download` scope; it predates this CLI asking for it. Run `mapbox auth login` again.","message":"This API requires a token with styles:download scope.","next_actions":["mapbox auth login"],"status":403} +``` + ### `mapbox styles draft get` The draft version of a style. Every style carries a published version and a diff --git a/openapi/api-styles/styles.production.v1.yaml b/openapi/api-styles/styles.production.v1.yaml index 7b534c4..4f21cf3 100644 --- a/openapi/api-styles/styles.production.v1.yaml +++ b/openapi/api-styles/styles.production.v1.yaml @@ -439,6 +439,48 @@ paths: - styles - draft - delete + /styles/v1/{username}/{style_id}.zip: + get: + operationId: downloadStyleZip + x-mapbox-docs-examples: + - example-id: request-style-bundle-published + title: Request the style bundle for a published style + - example-id: request-style-bundle-draft + title: Request the style bundle for a draft style + summary: Download a style as a ZIP bundle + description: | + Retrieves a ZIP file containing the style JSON, sprite images, referenced + custom fonts, and a license file. The response is cached for several minutes. + Access to this endpoint is available by request. + tags: + - Styles + parameters: + - $ref: '#/components/parameters/username' + - $ref: '#/components/parameters/style_id' + responses: + '200': + description: ZIP archive of the style. + content: + application/zip: + schema: + type: string + format: binary + examples: + request-style-bundle-published: + summary: Published style ZIP bundle + externalValue: https://api.mapbox.com/styles/v1/examples/cjikt35x83t1z2rnxpdmjs7y7.zip?access_token=YOUR_MAPBOX_ACCESS_TOKEN + request-style-bundle-draft: + summary: Draft style ZIP bundle + externalValue: https://api.mapbox.com/styles/v1/examples/cjikt35x83t1z2rnxpdmjs7y7/draft.zip?access_token=YOUR_MAPBOX_ACCESS_TOKEN + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + x-mapbox-cli-command: + - styles + - download /styles/v1/{username}/{style_id}/sprite: get: operationId: getSpriteJson diff --git a/src/auth.rs b/src/auth.rs index b064cf1..94b2058 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -66,6 +66,10 @@ 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. +// `styles:download` (`styles download`) became registrable on 2026-09-30. +// Holding it is not enough on its own: the account also needs access Mapbox +// grants on request, and `remedy::for_http` tells that 403 apart from a +// missing scope. const DEFAULT_SCOPES_LIST: &[&str] = &[ "styles:tiles", "styles:read", @@ -83,6 +87,7 @@ const DEFAULT_SCOPES_LIST: &[&str] = &[ "tilesets:list", "user-feedback:read", "statistics:read", + "styles:download", ]; /// [`DEFAULT_SCOPES_LIST`], space-joined the way the OAuth `scope` parameter @@ -846,6 +851,9 @@ fn time_until(expires_at: u64, now: u64) -> String { /// request. A stale token in the environment outranks a stored login, so /// logging in again will not fix it; a token typed with `--token` outranks /// both, so neither `--use-login` nor a fresh login touches it. +/// +/// A 403 that names a missing scope gets the same treatment, for the same +/// reason: a new login only adds the scope to a token that came from a login. pub fn with_auth_fix( err: anyhow::Error, matches: &clap::ArgMatches, @@ -856,6 +864,16 @@ pub fn with_auth_fix( Ok(cli) if cli.status == Some(401) => cli .with_remedy(credential_remedy(matches, use_login, profile)) .into(), + Ok(cli) if cli.status == Some(403) => { + match remedy::missing_scope(&cli.message).map(str::to_owned) { + Some(scope) => { + let (source, stored) = token_source(matches, use_login, profile); + cli.with_remedy(scope_remedy_for(source, CLAP_TOKEN_ENV, stored, &scope)) + .into() + } + None => cli.into(), + } + } Ok(cli) => cli.into(), Err(other) => other, } @@ -870,6 +888,17 @@ pub fn with_auth_fix( /// wording is in [`remedy_for`], which takes the facts, so every branch is /// reachable from a test without an environment or a credential store. fn credential_remedy(matches: &clap::ArgMatches, use_login: bool, profile: Option<&str>) -> Remedy { + let (source, stored) = token_source(matches, use_login, profile); + remedy_for(source, CLAP_TOKEN_ENV, stored) +} + +/// Where the token that was just sent came from, and whether a login is +/// stored behind it. +fn token_source( + matches: &clap::ArgMatches, + use_login: bool, + profile: Option<&str>, +) -> (Option, bool) { // The same three candidates in the same order the service arm applies // and `whoami` reports — through `resolve_source`, so there is one copy // of that order and not a third. @@ -888,7 +917,57 @@ fn credential_remedy(matches: &clap::ArgMatches, use_login: bool, profile: Optio ) .map(|(source, _)| source); - remedy_for(source, CLAP_TOKEN_ENV, stored.is_some()) + (source, stored.is_some()) +} + +/// The advice for a token that lacks a scope, by where it came from. +/// +/// A login gets every scope this CLI asks for, so one from before a scope +/// was added is fixed by logging in again. A token from `--token` or the +/// environment outranks the login, so the scope has to be added to that +/// token instead; `whoami` is the action there, as it is for a 401. +fn scope_remedy_for( + source: Option, + environment: &str, + stored: bool, + scope: &str, +) -> Remedy { + let (fix, action) = match source { + Some(TokenSource::Flag) => ( + format!( + "The token passed with `--token` lacks the `{scope}` scope. Add the scope \ + to that token, or drop the flag to use another." + ), + "mapbox auth whoami", + ), + Some(TokenSource::Environment) if stored => ( + format!( + "The token from {environment} lacks the `{scope}` scope, and it outranks \ + your login. Add the scope to that token, or run the same command with \ + `--use-login`." + ), + "mapbox auth whoami", + ), + Some(TokenSource::Environment) => ( + format!( + "The token from {environment} lacks the `{scope}` scope. Add the scope to \ + that token, or unset {environment} and run `mapbox auth login`." + ), + "mapbox auth whoami", + ), + Some(TokenSource::Login) | None => ( + format!( + "Your login lacks the `{scope}` scope; it predates this CLI asking for \ + it. Run `mapbox auth login` again." + ), + "mapbox auth login", + ), + }; + + Remedy::default() + .with_fix(&fix) + .with_action(Some(action.to_string())) + .with_doc(Some(remedy::TOKENS_DOC)) } /// The advice for each place a token can come from. @@ -2204,8 +2283,8 @@ pub fn login(debug: bool, profile: Option<&str>, mode: Mode) -> Result<()> { #[cfg(test)] mod tests { use super::{ - default_scopes, fix_for, remedy_for, time_until, with_auth_fix, TokenSource, - DEFAULT_SCOPES_LIST, + default_scopes, fix_for, remedy_for, scope_remedy_for, time_until, with_auth_fix, + TokenSource, DEFAULT_SCOPES_LIST, }; /// Every scope the CLI's live operations need, that Mapbox will actually @@ -2238,6 +2317,7 @@ mod tests { "tilesets:write", "tilesets:list", "statistics:read", + "styles:download", ] { assert!(requested.contains(&needed), "{needed} is not requested"); } @@ -2245,7 +2325,6 @@ mod tests { for unavailable in [ "tokens:write", "fonts:metadata", - "styles:download", // Registrable, and deliberately not asked for: the only command // that needed it unlocks a style for deletion, and is withheld. // A scope no command uses is a capability handed out for free. @@ -2340,6 +2419,56 @@ mod tests { ); } + /// A new login only changes the token when the token came from a login. + /// Suggesting one for a `--token` or environment token sends a script + /// round a loop that ends in the same 403. + #[test] + fn a_missing_scope_suggests_a_login_only_for_a_login_token() { + let scope = "styles:download"; + let action = |source, stored| scope_remedy_for(source, ENV, stored, scope).next_actions; + + assert_eq!( + action(Some(TokenSource::Login), true), + ["mapbox auth login"] + ); + assert_eq!(action(None, false), ["mapbox auth login"]); + assert_eq!( + action(Some(TokenSource::Flag), true), + ["mapbox auth whoami"] + ); + assert_eq!( + action(Some(TokenSource::Environment), true), + ["mapbox auth whoami"] + ); + assert_eq!( + action(Some(TokenSource::Environment), false), + ["mapbox auth whoami"] + ); + } + + #[test] + fn a_missing_scope_names_the_scope_and_the_token_it_is_missing_from() { + let scope = "styles:download"; + for (source, stored, mentions) in [ + (Some(TokenSource::Login), true, "mapbox auth login"), + (Some(TokenSource::Flag), true, "--token"), + (Some(TokenSource::Environment), true, ENV), + (Some(TokenSource::Environment), false, ENV), + ] { + let fix = scope_remedy_for(source, ENV, stored, scope) + .fix + .expect("a missing scope has an explanation"); + assert!(fix.contains("`styles:download`"), "{fix}"); + assert!(fix.contains(mentions), "{fix}"); + } + + let typed = scope_remedy_for(Some(TokenSource::Flag), ENV, true, scope) + .fix + .unwrap(); + assert!(!typed.contains("auth login"), "{typed}"); + assert!(!typed.contains("--use-login"), "{typed}"); + } + /// `next_actions` is for a command that runs, so it is `whoami` wherever /// the question is "which of these tokens won" and the login wherever a /// login is what is missing or refused. diff --git a/src/executor.rs b/src/executor.rs index 54bb2b8..6aa51fa 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::io::IsTerminal; use std::time::Duration; use anyhow::{anyhow, Result}; @@ -317,7 +318,13 @@ fn dispatch( .expect("a failure always takes the text path"); return Err(CliError::http(status.as_u16(), text) .with_request_id(headers.request_id) - .with_remedy(remedy::for_http(status.as_u16(), op, matches, username)) + .with_remedy(remedy::for_http( + status.as_u16(), + op, + matches, + username, + text, + )) .into()); } @@ -378,7 +385,13 @@ fn dispatch( // `--output json` on a tile endpoint is far more likely to be a // global flag riding along than a deliberate request to mangle // the image. - None => write_binary(&body, &content_type)?, + None => { + write_binary(&body, &content_type)?; + let quiet = matches.get_flag(output::banner::ARG); + if binary_notice_enabled(std::io::stderr().is_terminal(), quiet) { + output::progress(&binary_notice(body.len(), &content_type)); + } + } } Ok(()) @@ -1377,11 +1390,27 @@ fn suggested_extension(content_type: &str) -> &'static str { } } +/// A redirected download otherwise ends in silence, which reads as nothing +/// having happened. Shown under the banner's rules: only to someone watching +/// stderr, and not under `--quiet`. +fn binary_notice_enabled(stderr_is_terminal: bool, quiet: bool) -> bool { + stderr_is_terminal && !quiet +} + +fn binary_notice(bytes: usize, content_type: &str) -> String { + let kind = if content_type.is_empty() { + "binary" + } else { + content_type + }; + format!("Wrote {kind} ({bytes} bytes).") +} + /// Writes raw bytes to stdout, refusing to do so when that's a terminal — /// same as `curl`, and for the same reason: a few hundred KB of PNG would /// otherwise scramble the shell. fn write_binary(body: &[u8], content_type: &str) -> Result<()> { - use std::io::{IsTerminal, Write}; + use std::io::Write; let mut stdout = std::io::stdout(); if stdout.is_terminal() { @@ -1412,14 +1441,30 @@ fn write_binary(body: &[u8], content_type: &str) -> Result<()> { #[cfg(test)] mod tests { use super::{ - describe_body, empty_success_line, extra_query_from_env, file_name_of, - is_binary_content_type, part_media_type, path_segment, payload_of, query_pairs, - redacted_url, request_id, resolve_body_source, resolve_data, shell_value, - substitute_path_param, with_page_context, BodySource, NextPage, ResponseHeaders, - ACCESS_TOKEN, EXTRA_QUERY_ENV, REQUEST_ID_HEADERS, + binary_notice, binary_notice_enabled, describe_body, empty_success_line, + extra_query_from_env, file_name_of, is_binary_content_type, part_media_type, path_segment, + payload_of, query_pairs, redacted_url, request_id, resolve_body_source, resolve_data, + shell_value, substitute_path_param, with_page_context, BodySource, NextPage, + ResponseHeaders, ACCESS_TOKEN, EXTRA_QUERY_ENV, REQUEST_ID_HEADERS, }; use std::borrow::Cow; + #[test] + fn a_binary_notice_is_shown_only_at_a_terminal_and_not_when_quiet() { + assert!(binary_notice_enabled(true, false)); + assert!(!binary_notice_enabled(false, false)); + assert!(!binary_notice_enabled(true, true)); + } + + #[test] + fn a_binary_notice_names_the_type_and_size() { + assert_eq!( + binary_notice(988165, "application/zip"), + "Wrote application/zip (988165 bytes)." + ); + assert_eq!(binary_notice(3, ""), "Wrote binary (3 bytes)."); + } + use crate::http::Payload; use crate::output::CliError; use crate::spec::{Parameter, RequestBody}; diff --git a/src/main.rs b/src/main.rs index e9d5802..de67a96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -465,7 +465,7 @@ fn build_app(specs: &[ServiceSpec]) -> Command { // error on every command. .value_parser(FalseyValueParser::new()) .global(true) - .help("Don't print the name-and-version banner to stderr"), + .help("Don't print the name-and-version banner, or the note after a download, to stderr"), ) .arg( Arg::new(http::TIMEOUT_ARG) @@ -2208,7 +2208,7 @@ mod tests { let supplied = an_invocation_of(&specs, svc, op); for username in [Some("someone"), None] { for status in [400, 401, 403, 404, 409, 422, 429, 500, 503] { - let remedy = remedy::for_http(status, op, &supplied, username); + let remedy = remedy::for_http(status, op, &supplied, username, ""); for action in &remedy.next_actions { assert!( would_run(&specs, action), diff --git a/src/remedy.rs b/src/remedy.rs index b1d1b56..8b2dd84 100644 --- a/src/remedy.rs +++ b/src/remedy.rs @@ -130,11 +130,13 @@ pub fn docs_for_service(service: &str) -> Option<&'static str> { /// /// `matches` and `username` are what the failed invocation actually supplied: /// a suggestion has to carry them, or it is a command line that will not run. +/// `body` is the API's answer, read only where the status alone is ambiguous. pub fn for_http( status: u16, op: &Operation, matches: &ArgMatches, username: Option<&str>, + body: &str, ) -> Remedy { let remedy = Remedy::default().with_doc(docs_for_service(&op.service)); @@ -152,14 +154,7 @@ pub fn for_http( where each one lands in the request.", ) .with_action(Some(schema_command(op))), - 403 => remedy - .with_fix( - "The token was accepted but is not allowed to do this: either it lacks \ - the scope the operation needs, or it belongs to an account that does \ - not own what the request names.", - ) - .with_action(Some("mapbox auth whoami".to_string())) - .with_doc(Some(TOKENS_DOC)), + 403 => for_forbidden(remedy, body), // `whoami` is here on every 404, not only on an account-scoped path: // the Mapbox APIs answer a request the token is not allowed to make // with 404 rather than 403 — `accounts list-tokens` with a `pk` @@ -197,6 +192,52 @@ pub fn for_http( } } +/// What the shared auth middleware answers when the account lacks the +/// feature flag an endpoint is gated on — the same text on every API that +/// uses it, which is what makes it safe to key on. It says "prerelease" +/// whatever the flag is for, so the advice built on it must not. +const ACCOUNT_FLAG_MISSING: &str = "This is a prerelease API."; + +/// A 403 has three causes, and they need three different answers. Two of +/// them the API names in its message, so read it rather than guess: the +/// wrong advice for an account flag is "log in again", which cannot help. +fn for_forbidden(remedy: Remedy, body: &str) -> Remedy { + if body.contains(ACCOUNT_FLAG_MISSING) { + return remedy.with_fix( + "Your account does not have access to this API. Mapbox grants it per \ + account, so a new token or a new login will not help: contact Mapbox \ + at help@mapbox.com to request access.", + ); + } + // Whether a new login helps depends on where the token came from, which + // only `auth` knows. It fills the fix in through `with_auth_fix`, as it + // does for a 401. + if missing_scope(body).is_some() { + return remedy.with_doc(Some(TOKENS_DOC)); + } + remedy + .with_fix( + "The token was accepted but is not allowed to do this: either it lacks \ + the scope the operation needs, or it belongs to an account that does \ + not own what the request names.", + ) + .with_action(Some("mapbox auth whoami".to_string())) + .with_doc(Some(TOKENS_DOC)) +} + +/// The scope a 403 names: "This API requires a token with styles:download +/// scope." Checked against the shape of a scope, since it is echoed into +/// advice the user may paste. +pub fn missing_scope(body: &str) -> Option<&str> { + let (_, rest) = body.split_once("requires a token with ")?; + let (scope, _) = rest.split_once(" scope")?; + let well_formed = scope.contains(':') + && scope + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, ':' | '-')); + well_formed.then_some(scope) +} + /// The remedy for a request that never reached the API. /// /// Shared with `auth --verify`, which fails the same way through the same @@ -399,6 +440,7 @@ paths: operation(&svc, "get-style"), ¬hing(), Some("someone"), + "", ); assert_eq!( @@ -424,12 +466,13 @@ paths: operation(&svc, "get-style"), ¬hing(), Some("someone"), + "", ); assert!(for_someone.next_actions[0].ends_with("--username someone")); // Nothing resolved an account, so there is nothing to name — and // guessing one would be worse than leaving the flag off. - let anonymous = for_http(404, operation(&svc, "get-style"), ¬hing(), None); + let anonymous = for_http(404, operation(&svc, "get-style"), ¬hing(), None, ""); assert_eq!(anonymous.next_actions[0], "mapbox styles list-styles"); } @@ -447,6 +490,7 @@ paths: operation(&svc, "get-sprite-image"), &supplied, Some("someone"), + "", ); assert_eq!( @@ -467,6 +511,7 @@ paths: operation(&svc, "get-sprite-image"), ¬hing(), Some("someone"), + "", ); assert_eq!( @@ -508,6 +553,7 @@ paths: operation(&svc, "get-thing"), ¬hing(), Some("someone"), + "", ); assert_eq!(remedy.next_actions[0], "mapbox styles list-things"); } @@ -522,6 +568,7 @@ paths: operation(&svc, "list-styles"), ¬hing(), Some("someone"), + "", ); assert_eq!(remedy.next_actions, ["mapbox auth whoami"]); } @@ -532,7 +579,7 @@ paths: fn a_rejected_request_offers_the_schema() { let svc = styles(); for status in [400, 422] { - let remedy = for_http(status, operation(&svc, "get-style"), ¬hing(), None); + let remedy = for_http(status, operation(&svc, "get-style"), ¬hing(), None, ""); assert_eq!( remedy.next_actions, ["mapbox styles get-style --schema"], @@ -547,7 +594,7 @@ paths: #[test] fn a_401_leaves_the_fix_to_auth_but_still_carries_the_page() { let svc = styles(); - let remedy = for_http(401, operation(&svc, "get-style"), ¬hing(), None); + let remedy = for_http(401, operation(&svc, "get-style"), ¬hing(), None, ""); assert!(remedy.fix.is_none(), "auth writes this one"); assert!(remedy.next_actions.is_empty()); @@ -559,7 +606,7 @@ paths: #[test] fn a_403_asks_about_the_scope_and_the_account() { let svc = styles(); - let remedy = for_http(403, operation(&svc, "get-style"), ¬hing(), None); + let remedy = for_http(403, operation(&svc, "get-style"), ¬hing(), None, ""); let fix = remedy.fix.expect("a 403 has an explanation"); assert!(fix.contains("scope"), "{fix}"); @@ -567,12 +614,52 @@ paths: assert_eq!(remedy.next_actions, ["mapbox auth whoami"]); } + /// The account-flag 403 is not a scope problem, and "log in again" is + /// the one piece of advice guaranteed not to help with it. + #[test] + fn a_403_for_a_missing_account_flag_asks_for_access() { + let svc = styles(); + let body = r#"{"message":"This is a prerelease API. Please contact support at help@mapbox.com to request access."}"#; + let remedy = for_http(403, operation(&svc, "get-style"), ¬hing(), None, body); + + let fix = remedy.fix.expect("a 403 has an explanation"); + assert!(fix.contains("help@mapbox.com"), "{fix}"); + assert!(!fix.contains("auth login"), "{fix}"); + assert!(remedy.next_actions.is_empty(), "{:?}", remedy.next_actions); + } + + /// A 403 that names a scope leaves the advice to `auth`, which knows + /// whether a new login would change the token that was sent. + #[test] + fn a_403_that_names_a_scope_leaves_the_fix_to_auth() { + let svc = styles(); + let body = r#"{"message":"This API requires a token with styles:download scope."}"#; + let remedy = for_http(403, operation(&svc, "get-style"), ¬hing(), None, body); + + assert_eq!(remedy.fix, None); + assert!(remedy.next_actions.is_empty(), "{:?}", remedy.next_actions); + assert!(remedy.docs.iter().any(|d| d == TOKENS_DOC)); + } + + /// The scope is echoed into advice, so anything that does not look like + /// one falls back to the generic answer instead. + #[test] + fn a_scope_is_only_read_when_it_looks_like_one() { + assert_eq!( + missing_scope("requires a token with fonts:metadata scope"), + Some("fonts:metadata") + ); + assert_eq!(missing_scope("requires a token with `rm -rf` scope"), None); + assert_eq!(missing_scope("requires a token with admin scope"), None); + assert_eq!(missing_scope("Forbidden"), None); + } + /// A status nobody has written advice for still gets the page. Inventing /// a fix for it would cost more than saying nothing. #[test] fn a_status_with_no_advice_still_gets_the_page() { let svc = styles(); - let remedy = for_http(402, operation(&svc, "get-style"), ¬hing(), None); + let remedy = for_http(402, operation(&svc, "get-style"), ¬hing(), None, ""); assert!(remedy.fix.is_none()); assert!(remedy.next_actions.is_empty()); @@ -596,7 +683,7 @@ paths: fn a_server_failure_says_so_and_names_the_status_page() { let svc = styles(); for status in [500, 502, 503] { - let remedy = for_http(status, operation(&svc, "get-style"), ¬hing(), None); + let remedy = for_http(status, operation(&svc, "get-style"), ¬hing(), None, ""); let fix = remedy.fix.expect("an explanation"); assert!(fix.contains("Retry"), "HTTP {status}: {fix}"); assert!(remedy.docs.contains(&STATUS_PAGE.to_string())); @@ -622,11 +709,17 @@ paths: ) .expect("fixture parses"); - let remedy = for_http(403, operation(&accounts, "list-tokens"), ¬hing(), None); + let remedy = for_http( + 403, + operation(&accounts, "list-tokens"), + ¬hing(), + None, + "", + ); assert_eq!(remedy.docs, [TOKENS_DOC]); // And the ordinary case still carries both. - let remedy = for_http(403, operation(&svc, "get-style"), ¬hing(), None); + let remedy = for_http(403, operation(&svc, "get-style"), ¬hing(), None, ""); assert_eq!( remedy.docs, ["https://docs.mapbox.com/api/maps/styles/", TOKENS_DOC] diff --git a/src/schema.rs b/src/schema.rs index 31a3e8a..de31d92 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1004,7 +1004,6 @@ mod tests { for withheld in [ "mapbox styles set-style-protected", "mapbox styles admin-get-style", - "mapbox styles download-style-zip", "mapbox accounts create-token", "mapbox tilesets get-legacy-tile", ] { diff --git a/src/spec.rs b/src/spec.rs index 8e4a413..a0dad8b 100644 --- a/src/spec.rs +++ b/src/spec.rs @@ -477,6 +477,12 @@ pub struct DetailOperation { /// finished propagating yet. Moved back here rather than left in /// `WITHHELD_OPERATIONS`, since the 404 was a propagation delay, not a real /// problem with the operation. +/// +/// `styles:download` came off on 2026-09-30, once it became registrable, +/// which ships `downloadStyleZip` as `styles download`. The scope was never +/// its only gate: the endpoint also wants the account to have access, which +/// Mapbox grants per account and no token can carry. `remedy::for_http` +/// turns that 403 into a request for access rather than a scope problem. const UNSUPPORTED_OPERATIONS: &[(&str, &str, &str)] = &[ // Confirmed live 2026-09-08: `fonts:metadata` is a real scope name, not // a typo in the docs — the endpoint literally answers 403 "This API @@ -491,19 +497,6 @@ const UNSUPPORTED_OPERATIONS: &[(&str, &str, &str)] = &[ ("accounts", "createToken", "tokens:write"), ("accounts", "updateToken", "tokens:write"), ("accounts", "deleteToken", "tokens:write"), - // Found by testing it, not by reading the spec: the styles spec - // documents no scope, but an early probe got 403 "requires a token - // with styles:download scope". `POST /oauth/register` then drops - // `styles:download` from the granted set, so no login can get it. - // - // Deeper problem too (verified 2026-09-08 with a real token, no scope - // involved): the endpoint now answers 403 "This is a prerelease API. - // Please contact support at help@mapbox.com to request access." So - // access is gated per-account, not by OAuth scope at all — making - // `styles:download` registrable wouldn't unblock this command by - // itself, which is why it wasn't registered alongside the other two - // fonts scopes. - ("styles", "downloadStyleZip", "styles:download"), ]; fn unsupported_scope_for(service_name: &str, operation_id: &str) -> Option<&'static str> { diff --git a/tests/fixtures/api_command_surface.txt b/tests/fixtures/api_command_surface.txt index 9dd4675..6865b32 100644 --- a/tests/fixtures/api_command_surface.txt +++ b/tests/fixtures/api_command_surface.txt @@ -20,6 +20,7 @@ mapbox static get-image | aliases: (none) mapbox static get-tile | aliases: (none) mapbox styles create | aliases: (none) mapbox styles delete | aliases: (none) +mapbox styles download | aliases: (none) mapbox styles draft delete | aliases: (none) mapbox styles draft get | aliases: (none) mapbox styles draft update | aliases: (none)