Skip to content

Commit e4c3fb0

Browse files
akoclaude
andcommitted
feat: lock in marketplace API surface and 403 auth handling
Third spike run confirmed the marketplace API lives at marketplace-api.mendix.com/v1/content (not appstore.home.mendix.com as earlier drafts claimed) and accepts PAT auth via the documented "Authorization: MxToken <pat>" scheme. /v1/content, /v1/content/{id}, and /v1/content/{id}/versions all return 200 with useful JSON; minSupportedMendixVersion on version objects gives us version- compatibility filtering without extra work. Updates the marketplace proposal with the validated base URL, endpoint list, response shapes, and remaining open questions (download URL and search semantics still need one more probe round). Adds a Spike Results section to the platform auth proposal summarizing the findings. Extends authTransport to treat 403 as ErrUnauthenticated in addition to 401 — Mendix portal docs state PAT rejection returns 403, and we saw 401 in practice against marketplace. Both wrap as the same typed error so callers get a single "run mxcli auth login" hint regardless of which status the backend chose. Also adds scripts/auth-spike-summary.sh, a tiny awk filter that strips the 300-line CSP headers from the spike report so the relevant status codes and body snippets are pasteable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5baa7e4 commit e4c3fb0

5 files changed

Lines changed: 141 additions & 45 deletions

File tree

docs/11-proposals/PROPOSAL_marketplace_modules.md

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,29 +15,65 @@ This creates friction in two areas:
1515

1616
## API Discovery
1717

18-
The Mendix Marketplace REST API is at:
18+
Validated against a real PAT on 2026-04-14 (see `scripts/auth-discovery-spike.sh`).
1919

20-
```
21-
https://appstore.home.mendix.com/rest/packagesapi/v2/
22-
```
20+
**Base URL:** `https://marketplace-api.mendix.com`
21+
22+
(An earlier draft of this proposal pointed at `appstore.home.mendix.com/rest/packagesapi/v2/`.
23+
That host is a different service and does not accept PAT auth at all. The correct marketplace
24+
host is `marketplace-api.mendix.com`.)
25+
26+
**Auth:** `Authorization: MxToken <pat>`. PATs are created at
27+
<https://user-settings.mendix.com/> (Developer Settings → Personal Access Tokens).
28+
Invalid/missing PAT returns 401 or 403 with a JSON error body; malformed tokens
29+
may be rejected at the gateway with 400.
30+
31+
### Validated Endpoints
32+
33+
| Endpoint | Returns | Purpose |
34+
|----------|---------|---------|
35+
| `GET /v1/content` | `{"items": [content, ...]}` | List marketplace content |
36+
| `GET /v1/content?search=<query>` | same list shape | Search (query accepted; filter behavior TBD) |
37+
| `GET /v1/content/{id}` | single content object | Module/widget detail |
38+
| `GET /v1/content/{id}/versions` | `{"items": [version, ...]}` | Available versions with compatibility metadata |
39+
40+
### Response Shapes
2341

24-
All endpoints return `401 Unauthorized` without authentication. Mendix uses header-based auth on its platform APIs:
42+
**Content object** (from `/v1/content` and `/v1/content/{id}`):
2543

26-
| Auth Pattern | Headers | Used By |
27-
|---|---|---|
28-
| API Key | `Mendix-UserName` + `Mendix-ApiKey` | Deploy API, Team Server |
29-
| PAT | `Authorization: MxToken <token>` | Platform APIs |
44+
```jsonc
45+
{
46+
"contentId": 2888,
47+
"publisher": "Mendix",
48+
"type": "Module", // or "Widget", "Theme", etc.
49+
"categories": [{"name": "Data"}],
50+
"supportCategory": "Platform", // or "Community", "Deprecated", ...
51+
"licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.html",
52+
"isPrivate": false,
53+
// ...more fields including latest version info (not yet fully mapped)
54+
}
55+
```
56+
57+
**Version object** (from `/v1/content/{id}/versions`):
3058

31-
The exact auth scheme for the marketplace API needs validation with real credentials.
59+
```jsonc
60+
{
61+
"name": "Database Connector",
62+
"versionId": "f7c2bddf-05a3-4db0-8185-e7adf6c6d4af", // uuid
63+
"versionNumber": "7.0.2",
64+
"minSupportedMendixVersion": "10.24.11", // enables version-compat filtering
65+
"publicationDate": "2025-12-12T08:08:53.880Z"
66+
// release notes, download URL(s) TBD — need to inspect full response
67+
}
68+
```
3269

33-
### Discovered Endpoints (to validate)
70+
### Open Endpoint Questions
3471

35-
| Endpoint | Purpose |
36-
|----------|---------|
37-
| `GET /rest/packagesapi/v2/packages/{id}` | Module metadata (name, versions, AppStoreGuid) |
38-
| `GET /rest/packagesapi/v2/packages/{id}/versions` | List available versions |
39-
| `GET /rest/packagesapi/v2/packages/{id}/versions/{ver}/download` | Download .mpk |
40-
| `GET /rest/packagesapi/v2/packages?search=keyword` | Search marketplace |
72+
- **Download URL**: The `.mpk` download path is not yet identified. Candidates to probe next:
73+
`/v1/content/{id}/versions/{versionId}/download`, a `downloadUrl` field inside the version
74+
object, or a separate binary host referenced by the version response.
75+
- **Search semantics**: `?search=database` accepted without error but truncated output made
76+
it unclear whether the result set was actually filtered vs. returned unchanged.
4177

4278
### Known Component IDs
4379

docs/11-proposals/PROPOSAL_platform_auth.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,33 @@ Each of these APIs uses a different authentication header scheme, and today `mxc
1616

1717
This proposal specifies a shared `internal/auth` package and `mxcli auth` command set that every platform-API consumer can use.
1818

19+
## Spike Results (2026-04-14)
20+
21+
Validated against real credentials. See `scripts/auth-discovery-spike.sh`.
22+
23+
- **Marketplace lives at `marketplace-api.mendix.com`, not `appstore.home.mendix.com`.**
24+
The older host is a different service that does not accept PAT auth at all.
25+
- **PAT works as documented**`Authorization: MxToken <pat>` (capital M, capital T)
26+
against `marketplace-api.mendix.com/v1/content` returns 200 with a well-formed JSON
27+
list. Catalog host also accepts the same header; paths TBD.
28+
- **401 and 403** are both "credential rejected" — our `authTransport` wraps either
29+
as `ErrUnauthenticated`. A malformed token may return 400 at the gateway (nginx)
30+
with an HTML body; we do not treat 400 as an auth failure since it is a client
31+
error, not an auth rejection.
32+
- **No API key needed for marketplace.** The earlier assumption that marketplace
33+
required the Deploy-API-style `Mendix-UserName` + `Mendix-ApiKey` scheme was wrong.
34+
PAT is sufficient for both Content API and marketplace.
35+
36+
`internal/auth/scheme.go` host map now lists:
37+
38+
```go
39+
"marketplace-api.mendix.com": SchemePAT,
40+
"catalog.mendix.com": SchemePAT,
41+
```
42+
43+
API key (`SchemeAPIKey`) is still reserved for the Deploy API follow-up work;
44+
marketplace no longer needs it.
45+
1946
## Mendix Authentication Schemes
2047

2148
Based on current Mendix documentation (2026-04):

internal/auth/client.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
5757
if err != nil {
5858
return nil, err
5959
}
60-
if resp.StatusCode == http.StatusUnauthorized {
61-
// Don't consume the body — the caller may want to inspect it.
62-
// Wrap as a typed error alongside the response so callers can
63-
// either check err or inspect resp directly.
60+
// Mendix platform APIs return 401 when no valid credential is presented,
61+
// and 403 when the PAT is invalid/expired (per the portal PAT docs).
62+
// Both mean "credential rejected" for our purposes.
63+
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
6464
return resp, &ErrUnauthenticated{Profile: t.cred.Profile}
6565
}
6666
return resp, nil

internal/auth/client_test.go

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -66,31 +66,36 @@ func TestAuthTransport_UnknownHost(t *testing.T) {
6666
}
6767
}
6868

69-
func TestAuthTransport_401WrapsAsUnauthenticated(t *testing.T) {
70-
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71-
w.WriteHeader(http.StatusUnauthorized)
72-
}))
73-
defer ts.Close()
74-
75-
target, _ := url.Parse(ts.URL)
76-
cred := &Credential{Profile: "default", Scheme: SchemePAT, Token: "bad"}
77-
client := &http.Client{
78-
Transport: &authTransport{
79-
cred: cred,
80-
inner: &rewriteTransport{target: target, inner: http.DefaultTransport},
81-
},
82-
}
69+
func TestAuthTransport_UnauthorizedStatusesWrap(t *testing.T) {
70+
// Mendix returns 401 when no credential is valid and 403 for
71+
// invalid/expired PATs (per portal docs). Both wrap as
72+
// ErrUnauthenticated.
73+
for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} {
74+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75+
w.WriteHeader(status)
76+
}))
77+
78+
target, _ := url.Parse(ts.URL)
79+
cred := &Credential{Profile: "default", Scheme: SchemePAT, Token: "bad"}
80+
client := &http.Client{
81+
Transport: &authTransport{
82+
cred: cred,
83+
inner: &rewriteTransport{target: target, inner: http.DefaultTransport},
84+
},
85+
}
8386

84-
resp, err := client.Get("https://marketplace-api.mendix.com/foo")
85-
if resp != nil {
86-
resp.Body.Close()
87-
}
88-
var unauth *ErrUnauthenticated
89-
if !errors.As(err, &unauth) {
90-
t.Fatalf("expected ErrUnauthenticated, got %v", err)
91-
}
92-
if unauth.Profile != "default" {
93-
t.Errorf("expected profile=default in error, got %q", unauth.Profile)
87+
resp, err := client.Get("https://marketplace-api.mendix.com/foo")
88+
if resp != nil {
89+
resp.Body.Close()
90+
}
91+
var unauth *ErrUnauthenticated
92+
if !errors.As(err, &unauth) {
93+
t.Errorf("status %d: expected ErrUnauthenticated, got %v", status, err)
94+
}
95+
if unauth != nil && unauth.Profile != "default" {
96+
t.Errorf("status %d: expected profile=default, got %q", status, unauth.Profile)
97+
}
98+
ts.Close()
9499
}
95100
}
96101

scripts/auth-spike-summary.sh

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env bash
2+
# Extracts a compact summary from /tmp/auth-spike-report.md — just the
3+
# status codes and the first 20 lines of each JSON body, skipping the
4+
# noisy Mendix CSP/security headers. Safe to paste.
5+
6+
set -u
7+
REPORT=/tmp/auth-spike-report.md
8+
9+
if [[ ! -f "$REPORT" ]]; then
10+
echo "error: $REPORT not found — run scripts/auth-discovery-spike.sh first" >&2
11+
exit 1
12+
fi
13+
14+
awk '
15+
/^### / { print ""; print $0; next }
16+
/^HTTP [0-9]+/ { print $0; in_body=0; body_lines=0; next }
17+
/^--- response body/ { in_body=1; body_lines=0; next }
18+
/^--- response headers/ { in_body=0; next }
19+
/^```$/ { in_body=0; next }
20+
in_body && body_lines < 20 {
21+
if (length($0) > 200) {
22+
print substr($0, 1, 200) " ...[truncated]"
23+
} else {
24+
print $0
25+
}
26+
body_lines++
27+
}
28+
' "$REPORT"

0 commit comments

Comments
 (0)