Skip to content

Commit 7ea707a

Browse files
zmofeiclaude
andcommitted
Add mapbox feedback create
Submits one feedback item, filed under the account that owns the token. The body goes through --data, like every other JSON write; feedback.yaml now declares its fields so --schema and generate-skills describe them. create needs user-feedback:write, which mapbox auth login now asks for. POST /oauth/register drops it until mapbox/api-accounts#2152 is deployed, so until then a login gets a 403 on create. The Feedback API's 403 says "Access token does not have <scope> scope", which missing_scope didn't recognize, so it got the generic advice instead of the scope-specific one. It's recognized now. Verified against production with a token that has the scope: create returns 200 and the item, with place_name filled in; a repeated id gets 400 "already exists"; trace_id in the body is dropped; get reads the item at once, while list took 20-40 seconds to show it. A login token gets the 403 above. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f5863c9 commit 7ea707a

8 files changed

Lines changed: 163 additions & 74 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ that may never merge. They are not releases and are not listed here.
1919

2020
## Unreleased
2121

22+
- `mapbox feedback create` submits a feedback item, filed under the
23+
account that owns the token. `mapbox auth login` now also asks for the
24+
`user-feedback:write` scope it needs, so log in again to use it.
2225
- `install.sh`/`install.ps1`: when the install finds a coding agent on the
2326
machine, it now asks, once, whether to write this CLI's own skill and
2427
install the Mapbox Agent Skills library for it, naming the agent and what
@@ -33,11 +36,6 @@ that may never merge. They are not releases and are not listed here.
3336
apps built with Mapbox — filterable, sortable, paginated. Each filter
3437
takes one value for now. Hand-authored into
3538
`custom-openapi/`, like `search`, because no upstream spec exists yet.
36-
`feedback create`, the write side, is not a command — confirmed directly
37-
against production that `user-feedback:write` is silently dropped from a
38-
`POST /oauth/register` grant, the same unregistrable shape
39-
`accounts create-token` already documents, so no `mapbox auth login`
40-
token can ever carry it.
4139
- `mapbox generate-skills`/`agent-skills install`/`agent-skills update`,
4240
when no coding agent is detected and none was named with `--agent`,
4341
`--global` or `--dir`: the failure now carries a stable

‎custom-openapi/feedback/openapi/feedback.yaml‎

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,9 @@ openapi: "3.0.0"
66
#
77
# Hand-authored down to the parameters documented at
88
# docs.mapbox.com/api/feedback. See `custom-openapi/README.md` for how a
9-
# file like this is wired in. `createFeedbackItem` is declared here but
10-
# never reaches the command surface: it needs `user-feedback:write`, which
11-
# `POST /oauth/register` silently drops from the granted scope — confirmed
12-
# directly against production (`curl -X POST
13-
# https://api.mapbox.com/oauth/register?scope=user-feedback:write ...`
14-
# returns a registration with that scope missing from the response, the
15-
# same shape `tokens:write` already documents in `src/spec.rs`'s
16-
# `UNSUPPORTED_OPERATIONS`). No `mapbox auth login` token
17-
# can ever carry it, so `feedback create` is not a command today.
9+
# file like this is wired in. `create` needs `user-feedback:write`, which
10+
# `mapbox auth login` asks for. `POST /oauth/register` drops it until
11+
# mapbox/api-accounts#2152 is deployed.
1812
info:
1913
title: "Mapbox Feedback API"
2014
description: >-
@@ -171,26 +165,66 @@ paths:
171165
description: Forbidden
172166

173167
post:
174-
# Not `create`: this operation is never reachable (see below), so the
175-
# user-facing name doesn't matter — but the withheld-operation guard
176-
# in `generate_skills.rs` checks the bare command word against every
177-
# generated file's text, and a bare `create` collides with the
178-
# exposed, unrelated `styles create`. A multi-word operationId, the
179-
# same shape every other disabled operation already has
180-
# (`createToken`, `getFontCoverage`, …), sidesteps that.
181-
operationId: createFeedbackItem
182-
summary: Submit a new feedback item.
168+
operationId: create
169+
summary: Submit a feedback item.
183170
description: >-
184-
Not a command — see this file's own header comment for why.
171+
Submits one feedback item to Mapbox, filed under the account that owns
172+
the token. It goes to Mapbox's review queue, and there is no way to
173+
delete it afterwards. Needs a token with `user-feedback:write`.
174+
parameters:
175+
- name: "access_token"
176+
in: query
177+
required: true
178+
description: "Mapbox API Access Token"
179+
schema:
180+
type: string
181+
minLength: 1
185182
requestBody:
186183
required: true
187184
content:
188185
application/json:
189186
schema:
190187
type: object
188+
required: ["feedback", "lat", "lon", "category"]
189+
properties:
190+
id:
191+
type: string
192+
format: uuid
193+
description: >-
194+
A UUID for the item. Generated when omitted. Sending one
195+
makes a retry safe: a second request with the same id is
196+
rejected rather than filed twice.
197+
feedback:
198+
type: string
199+
description: "The text of the feedback."
200+
lat:
201+
type: number
202+
description: "Latitude of the place the feedback is about."
203+
lon:
204+
type: number
205+
description: "Longitude of the place the feedback is about."
206+
category:
207+
type: string
208+
description: >-
209+
The feedback category. Not checked against a list;
210+
`feedback list` shows the ones already in use.
211+
feature:
212+
type: object
213+
description: >-
214+
A GeoJSON Feature the feedback is about, with coordinates
215+
in GeoJSON's [longitude, latitude] order.
216+
screenshot:
217+
type: string
218+
description: >-
219+
A PNG or JPEG as a data URI (`data:image/png;base64,…`),
220+
not bare base64.
191221
responses:
192-
"201":
222+
"200":
193223
description: The created feedback item.
224+
"400":
225+
description: >-
226+
Bad Request — a missing or invalid field, or an id that already
227+
exists.
194228
"401":
195229
description: Unauthorized
196230
"403":

‎docs/commands.md‎

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Implemented commands
22

3-
Every command the CLI ships: five auth commands, 35 API operations across 11
3+
Every command the CLI ships: five auth commands, 36 API operations across 11
44
command groups, the tilesets-cli proxy, `completion` and `generate-skills`. Each is
55
shown in both of its renderings. Which one you get is decided by `--output`, whose default
66
(`auto`) reads stdout: a terminal gets the left column, a pipe or redirect
@@ -10,10 +10,11 @@ gets the right one. See
1010
Account names, style ids and tokens in the examples are replaced; everything
1111
else is as the API sent it.
1212

13-
**31 of the 35 were run against the live API and show what came back:** 26
13+
**32 of the 36 were run against the live API and show what came back:** 26
1414
on 2026-09-01, `fonts list`, `fonts upload` and `fonts delete` on
1515
2026-09-08, once `fonts:list`/`fonts:write` became registrable, and
16-
`feedback list` and `feedback get` on 2026-09-24.
16+
`feedback list` and `feedback get` on 2026-09-24, and `feedback create` on
17+
2026-10-02.
1718
The write operations were exercised as round trips on throwaway objects —
1819
a style created, updated, drafted and deleted; icons uploaded to a sprite
1920
and taken out again; a font uploaded and deleted — leaving the account as
@@ -78,7 +79,8 @@ nests, and is typed `mapbox styles draft get`.
7879
[accounts.list-scopes](#mapbox-accounts-list-scopes)
7980

8081
**[Feedback](#feedback)** — [feedback.list](#mapbox-feedback-list) ·
81-
[feedback.get](#mapbox-feedback-get)
82+
[feedback.get](#mapbox-feedback-get) ·
83+
[feedback.create](#mapbox-feedback-create)
8284

8385
**[Fonts](#fonts)** — [fonts.list](#mapbox-fonts-list) ·
8486
[fonts.upload](#mapbox-fonts-upload) · [fonts.delete](#mapbox-fonts-delete)
@@ -488,7 +490,8 @@ telling apart:
488490
registrable — nothing here can force that. `fonts:list` and
489491
`fonts:write` became registrable on 2026-09-08, which shipped
490492
`fonts list`, `fonts upload` and `fonts delete`; `styles:download`
491-
followed on 2026-09-30 and shipped `styles download`.
493+
followed on 2026-09-30 and shipped `styles download`, and
494+
`user-feedback:write` shipped `feedback create`.
492495
- **Withheld deliberately.** `styles set-style-protected` unlocks a style
493496
for deletion — a live token holding `styles:protect` (which *is*
494497
registrable) can call it successfully, this CLI just declines to offer a
@@ -898,11 +901,9 @@ hand down to the parameters documented at docs.mapbox.com/api/feedback —
898901
see `custom-openapi/README.md` for why this command group doesn't come from
899902
the vendored specs the way most others do.
900903

901-
**`feedback create`, the write side of this API, is not a command.** It
902-
needs a `user-feedback:write` scope that `POST /oauth/register` silently
903-
drops from the granted set — confirmed directly against production, the
904-
same shape `accounts create-token` already documents. No
905-
`mapbox auth login` token can ever carry it.
904+
`list` and `get` need `user-feedback:read` and `create` needs
905+
`user-feedback:write`; `mapbox auth login` asks for both. A login from
906+
before `create` shipped lacks the write scope, so log in again to use it.
906907

907908
### `mapbox feedback list`
908909

@@ -1031,6 +1032,72 @@ time, not wrapped in `items`:
10311032

10321033
Same trimming as `list` above.
10331034

1035+
### `mapbox feedback create`
1036+
1037+
Submits one feedback item, filed under the account that owns the token.
1038+
It goes to Mapbox's review queue, and the API has no way to delete it, so
1039+
check the body with `--dry-run` first.
1040+
1041+
#### Parameters
1042+
1043+
The body is JSON, through `--data` (a string, `@<path>` or `@-`).
1044+
1045+
| Field | Effect |
1046+
| --- | --- |
1047+
| `feedback` (required) | The text of the feedback. |
1048+
| `lat`, `lon` (required) | The place the feedback is about. |
1049+
| `category` (required) | Any string. It isn't checked against a list; `feedback list` shows the ones already in use. |
1050+
| `id` | A UUID. Generated when omitted. Send your own to make a retry safe: a second request with the same id gets a 400 `Feedback with id … already exists.` rather than a duplicate. |
1051+
| `feature` | A GeoJSON Feature the feedback is about, coordinates in `[lon, lat]` order. |
1052+
| `screenshot` | A PNG or JPEG as a data URI (`data:image/png;base64,…`), not bare base64. |
1053+
1054+
`trace_id` can't be set here: the API drops it, and an item created this
1055+
way has `trace_id: null`. The new item is readable with `get` at once; it
1056+
can take a minute or so to show up in `list`.
1057+
1058+
#### Examples
1059+
1060+
```sh
1061+
mapbox feedback create --dry-run --data @feedback.json
1062+
mapbox feedback create --data '{"id":"77bf502a-026a-460b-a3d1-56a604465a7d","feedback":"Test from mapbox-cli development (feedback create). Please ignore.","lat":60.1699,"lon":24.9384,"category":"general"}'
1063+
```
1064+
1065+
#### Outputs
1066+
1067+
Captured live on 2026-10-02. `place_name` is filled in by the API from
1068+
`lat`/`lon`:
1069+
1070+
<table>
1071+
<tr><th width="50%">Terminal — <code>-o text</code></th><th width="50%">Agent — <code>-o json</code></th></tr>
1072+
<tr><td>
1073+
1074+
```text
1075+
category general
1076+
created_at 2026-10-02T11:59:35.969Z
1077+
feedback Test from mapbox-cli development (feedback create). Please ignore.
1078+
has_screenshot no
1079+
id 77bf502a-026a-460b-a3d1-56a604465a7d
1080+
location.lat 60.1699
1081+
location.lon 24.9384
1082+
location.place_name Mannerheimintie 20b, 00100 Helsinki, Finland
1083+
received_at 2026-10-02T11:59:35.969Z
1084+
status received
1085+
updated_at 2026-10-02T11:59:35.969Z
1086+
```
1087+
1088+
</td><td>
1089+
1090+
```json
1091+
{"category":"general","created_at":"2026-10-02T11:59:35.969Z","feedback":"Test from mapbox-cli development (feedback create). Please ignore.","has_screenshot":false,"id":"77bf502a-026a-460b-a3d1-56a604465a7d","location":{"lat":60.1699,"lon":24.9384,"place_name":"Mannerheimintie 20b, 00100 Helsinki, Finland"},"received_at":"2026-10-02T11:59:35.969Z","status":"received","updated_at":"2026-10-02T11:59:35.969Z"}
1092+
```
1093+
1094+
</td></tr>
1095+
</table>
1096+
1097+
A token without `user-feedback:write` gets a 403 naming the scope. For a
1098+
login, the fix is `mapbox auth login` again; for a token passed with
1099+
`--token` or `MAPBOX_ACCESS_TOKEN`, it is adding the scope to that token.
1100+
10341101
---
10351102
## Fonts
10361103

‎src/auth.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ const VALIDATION_ENDPOINT: &str = "https://api.mapbox.com/tokens/v2";
7070
// Holding it is not enough on its own: the account also needs access Mapbox
7171
// grants on request, and `remedy::for_http` tells that 403 apart from a
7272
// missing scope.
73+
// `user-feedback:write` (`feedback create`) is registrable once
74+
// mapbox/api-accounts#2152 is deployed. Until then registration silently
75+
// drops it, so asking for it changes nothing.
7376
const DEFAULT_SCOPES_LIST: &[&str] = &[
7477
"styles:tiles",
7578
"styles:read",
@@ -88,6 +91,7 @@ const DEFAULT_SCOPES_LIST: &[&str] = &[
8891
"user-feedback:read",
8992
"statistics:read",
9093
"styles:download",
94+
"user-feedback:write",
9195
];
9296

9397
/// [`DEFAULT_SCOPES_LIST`], space-joined the way the OAuth `scope` parameter
@@ -2318,6 +2322,7 @@ mod tests {
23182322
"tilesets:list",
23192323
"statistics:read",
23202324
"styles:download",
2325+
"user-feedback:write",
23212326
] {
23222327
assert!(requested.contains(&needed), "{needed} is not requested");
23232328
}

‎src/main.rs‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1729,15 +1729,12 @@ mod tests {
17291729
.filter(|op| op.disabled_scope.is_some())
17301730
.map(|op| op.command())
17311731
.collect();
1732-
// Every current UNSUPPORTED_OPERATIONS entry sourced from
1733-
// `MAPBOX_SPEC_ENTRIES` is also `disabled` (or `tbd`, which strips
1734-
// the same way) in the maintainer-only decision record, so those
1735-
// are absent from the bundled specs rather than merely filtered
1736-
// here. A custom spec has no such record to strip it at the
1737-
// source — `feedback.yaml`'s `createFeedback` is UNSUPPORTED_OPERATIONS'
1738-
// first entry that actually reaches this list non-empty — so the
1739-
// loop below is doing real work for it, not just standing guard
1740-
// over an empty case.
1732+
// Every current UNSUPPORTED_OPERATIONS entry is also `disabled` (or
1733+
// `tbd`, which strips the same way) in the maintainer-only decision
1734+
// record, so this is legitimately empty now rather than a broken guard — the
1735+
// operations are absent from the bundled specs, not merely filtered
1736+
// here. Nothing left to assert on `disabled` itself; the loop below
1737+
// still holds for whatever, if anything, shows up.
17411738

17421739
let app = build_app(&specs);
17431740
for (path, _) in leaf_commands(&app, &[]) {

‎src/remedy.rs‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,14 @@ fn for_forbidden(remedy: Remedy, body: &str) -> Remedy {
226226
.with_doc(Some(TOKENS_DOC))
227227
}
228228

229-
/// The scope a 403 names: "This API requires a token with styles:download
230-
/// scope." Checked against the shape of a scope, since it is echoed into
231-
/// advice the user may paste.
229+
/// The scope a 403 names. The shared auth middleware says "This API
230+
/// requires a token with styles:download scope."; the Feedback API says
231+
/// "Access token does not have user-feedback:write scope". Checked against
232+
/// the shape of a scope, since it is echoed into advice the user may paste.
232233
pub fn missing_scope(body: &str) -> Option<&str> {
233-
let (_, rest) = body.split_once("requires a token with ")?;
234+
let (_, rest) = body
235+
.split_once("requires a token with ")
236+
.or_else(|| body.split_once("does not have "))?;
234237
let (scope, _) = rest.split_once(" scope")?;
235238
let well_formed = scope.contains(':')
236239
&& scope
@@ -653,6 +656,10 @@ paths:
653656
assert_eq!(missing_scope("requires a token with `rm -rf` scope"), None);
654657
assert_eq!(missing_scope("requires a token with admin scope"), None);
655658
assert_eq!(missing_scope("Forbidden"), None);
659+
assert_eq!(
660+
missing_scope("Access token does not have user-feedback:write scope"),
661+
Some("user-feedback:write")
662+
);
656663
}
657664

658665
/// A status nobody has written advice for still gets the page. Inventing

‎src/spec.rs‎

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -497,13 +497,6 @@ const UNSUPPORTED_OPERATIONS: &[(&str, &str, &str)] = &[
497497
("accounts", "createToken", "tokens:write"),
498498
("accounts", "updateToken", "tokens:write"),
499499
("accounts", "deleteToken", "tokens:write"),
500-
// Confirmed 2026-09-24 with a direct POST /oauth/register against
501-
// production requesting `user-feedback:write` alongside two scopes
502-
// already known registrable — the response's granted `scope` carried
503-
// the other two and silently dropped this one, the same shape
504-
// `tokens:write` above already documents. `user-feedback:read`
505-
// (list/get) is unaffected and already in `DEFAULT_SCOPES_LIST`.
506-
("feedback", "createFeedbackItem", "user-feedback:write"),
507500
];
508501

509502
fn unsupported_scope_for(service_name: &str, operation_id: &str) -> Option<&'static str> {
@@ -1960,37 +1953,24 @@ paths:
19601953
assert!(hidden.is_empty());
19611954
}
19621955

1963-
/// `createFeedbackItem` needs `user-feedback:write`, confirmed
1964-
/// unregistrable via a direct `POST /oauth/register` against
1965-
/// production — see `UNSUPPORTED_OPERATIONS`'s own comment for that.
1966-
/// `list` and `get` need only `user-feedback:read`, already in
1967-
/// `DEFAULT_SCOPES_LIST`, so they must stay reachable.
1956+
/// All three are commands: `list` and `get` need `user-feedback:read`
1957+
/// and `create` needs `user-feedback:write`, both of which a login asks
1958+
/// for.
19681959
#[test]
1969-
fn feedback_create_is_unreachable_but_list_and_get_are_not() {
1960+
fn every_feedback_operation_is_exposed() {
19701961
let spec = parse_spec(
19711962
"feedback",
19721963
include_str!("../custom-openapi/feedback/openapi/feedback.yaml"),
19731964
)
19741965
.expect("feedback.yaml parses");
19751966

1976-
let create = spec
1977-
.operations
1978-
.iter()
1979-
.find(|op| op.command_path == ["create-feedback-item"])
1980-
.expect("the create-feedback-item operation exists in the spec");
1981-
assert!(
1982-
create.disabled_scope.is_some(),
1983-
"createFeedbackItem must be disabled — user-feedback:write isn't registrable"
1984-
);
1985-
assert!(!create.is_exposed());
1986-
1987-
for path in [["list"], ["get"]] {
1967+
for path in [["list"], ["get"], ["create"]] {
19881968
let op = spec
19891969
.operations
19901970
.iter()
19911971
.find(|op| op.command_path == path)
19921972
.unwrap_or_else(|| panic!("the {path:?} operation exists in the spec"));
1993-
assert!(op.is_exposed(), "{path:?} needs only user-feedback:read");
1973+
assert!(op.is_exposed(), "{path:?} must be a command");
19941974
}
19951975
}
19961976
}

‎tests/fixtures/api_command_surface.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mapbox accounts list-scopes | aliases: (none)
22
mapbox accounts list-tokens | aliases: (none)
33
mapbox accounts retrieve-token | aliases: (none)
4+
mapbox feedback create | aliases: (none)
45
mapbox feedback get | aliases: (none)
56
mapbox feedback list | aliases: (none)
67
mapbox fonts delete | aliases: (none)

0 commit comments

Comments
 (0)