Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ that may never merge. They are not releases and are not listed here.

## Unreleased

- New command: `mapbox styles download <style-id> > 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<version>` 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`
Expand Down
67 changes: 55 additions & 12 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

<table>
<tr><th width="50%">Terminal — refuses</th><th width="50%">Redirected — raw bytes</th></tr>
<tr><td>

```
Error: Response is application/zip (988165 bytes).
Refusing to write it to the terminal — redirect
it to a file, e.g. `... > out.zip`.
```

</td><td>

```
$ mapbox styles download … > style.zip
$ file style.zip
style.zip: Zip archive data, at least v1.0 to
extract, compression method=store
```

</td></tr>
</table>

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
Expand Down
42 changes: 42 additions & 0 deletions openapi/api-styles/styles.production.v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
137 changes: 133 additions & 4 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
}
Expand All @@ -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<TokenSource>, 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.
Expand All @@ -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<TokenSource>,
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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2238,14 +2317,14 @@ mod tests {
"tilesets:write",
"tilesets:list",
"statistics:read",
"styles:download",
] {
assert!(requested.contains(&needed), "{needed} is not requested");
}

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.
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading