feat(api): report login methods and add GET /capabilities

This commit is contained in:
Deluan 2026-09-26 01:57:43 -04:00
commit 28d023449d
17 changed files with 458 additions and 79 deletions

View file

@ -3,7 +3,7 @@
"info": {
"title": "Navidrome API",
"version": "1.0.0",
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need an access token declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted\nfrom it, stops working within one access-token lifetime at most.\n",
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /capabilities` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n\nOperations that need an access token declare `security: [{bearerAuth: []}]` and the scope they need in\n`x-scope` (OpenAPI 3.0 does not allow scopes on bearer schemes). A revoked grant, and every token minted\nfrom it, stops working within one access-token lifetime at most.\n",
"license": {
"name": "GPL-3.0",
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
@ -35,7 +35,7 @@
],
"security": [],
"summary": "Describe the server",
"description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n",
"description": "Returns the public server description. No authentication required.\nCapability modules are listed by `GET /capabilities`.\n",
"responses": {
"200": {
"description": "Server description.",
@ -53,6 +53,41 @@
}
}
},
"/capabilities": {
"get": {
"operationId": "getCapabilities",
"x-module": "core",
"x-stability-level": "alpha",
"tags": [
"server"
],
"summary": "List implemented capability modules",
"description": "The capability modules this server implements. Any valid access token may read it, whatever its scopes.",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Implemented modules.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Capabilities"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/openapi.json": {
"get": {
"operationId": "getOpenAPISpecJSON",
@ -512,17 +547,23 @@
"description": "True until the first admin user has been created."
},
"loginMethods": {
"type": "array",
"description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.",
"items": {
"type": "string",
"enum": [
"password"
]
}
"$ref": "#/components/schemas/LoginMethods"
}
}
},
"LoginMethods": {
"type": "object",
"description": "Login methods this server accepts, keyed by method. A missing key means the method is not offered.\nKeys are optional on purpose: discovery is read by clients of any version against servers of any\nversion, so new methods are added as new optional keys. Clients ignore keys they do not know.\n",
"properties": {
"password": {
"$ref": "#/components/schemas/PasswordLoginMethod"
}
}
},
"PasswordLoginMethod": {
"type": "object",
"description": "Username and password login (`POST /auth/login`). No settings yet."
},
"Problem": {
"type": "object",
"description": "RFC 9457 problem details, returned for every 4xx and 5xx response.",
@ -598,6 +639,44 @@
}
}
},
"Capabilities": {
"type": "object",
"description": "Capability modules this server implements, keyed by module. Keys are optional; a missing key means the\nmodule is not implemented. New modules are added as new optional keys. These are server facts, not what\nthe calling token may use.\n",
"properties": {
"core": {
"$ref": "#/components/schemas/CoreCapability"
},
"password": {
"$ref": "#/components/schemas/PasswordCapability"
}
}
},
"CoreCapability": {
"type": "object",
"description": "The mandatory core module.",
"required": [
"version"
],
"properties": {
"version": {
"type": "integer",
"description": "Module version. Bumped only on semantic change."
}
}
},
"PasswordCapability": {
"type": "object",
"description": "The password login module (login, first-admin setup, password change).",
"required": [
"version"
],
"properties": {
"version": {
"type": "integer",
"description": "Module version. Bumped only on semantic change."
}
}
},
"TokenRequest": {
"type": "object",
"description": "Optional narrowing of a new access token.",
@ -924,6 +1003,21 @@
}
}
},
"Unauthorized": {
"description": "Missing, invalid, or expired credentials.",
"headers": {
"WWW-Authenticate": {
"$ref": "#/components/headers/WWWAuthenticate"
}
},
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"NotModified": {
"description": "Not modified.",
"headers": {
@ -942,21 +1036,6 @@
}
}
},
"Unauthorized": {
"description": "Missing, invalid, or expired credentials.",
"headers": {
"WWW-Authenticate": {
"$ref": "#/components/headers/WWWAuthenticate"
}
},
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"PayloadTooLarge": {
"description": "The request body is too large (`payload_too_large`).",
"content": {
@ -1047,14 +1126,14 @@
}
},
"headers": {
"ETag": {
"description": "Entity tag for `If-None-Match` revalidation.",
"WWWAuthenticate": {
"description": "RFC 6750 Bearer challenge, for example `Bearer error=\"insufficient_scope\", scope=\"read\"`.",
"schema": {
"type": "string"
}
},
"WWWAuthenticate": {
"description": "RFC 6750 Bearer challenge, for example `Bearer error=\"insufficient_scope\", scope=\"read\"`.",
"ETag": {
"description": "Entity tag for `If-None-Match` revalidation.",
"schema": {
"type": "string"
}

View file

@ -4,7 +4,7 @@ info:
version: 1.0.0
description: |
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
capability modules through `GET /server` and never sniff versions.
capability modules through `GET /capabilities` and never sniff versions.
Enums are open: new values may be added to any enum within v1. Clients must
accept values they do not recognise instead of failing.
@ -40,8 +40,7 @@ paths:
summary: Describe the server
description: |
Returns the public server description. No authentication required.
Authenticated requests will additionally receive the implemented capability modules
once authentication is available.
Capability modules are listed by `GET /capabilities`.
responses:
'200':
description: Server description.
@ -51,6 +50,23 @@ paths:
$ref: '#/components/schemas/ServerInfo'
'500':
$ref: '#/components/responses/InternalError'
/capabilities:
get:
operationId: getCapabilities
x-module: core
x-stability-level: alpha
tags: [server]
summary: List implemented capability modules
description: The capability modules this server implements. Any valid access token may read it, whatever its scopes.
security: [{bearerAuth: []}]
responses:
'200':
description: Implemented modules.
content:
application/json:
schema: {$ref: '#/components/schemas/Capabilities'}
'401': {$ref: '#/components/responses/Unauthorized'}
'500': {$ref: '#/components/responses/InternalError'}
/openapi.json:
get:
operationId: getOpenAPISpecJSON
@ -342,12 +358,19 @@ components:
type: boolean
description: True until the first admin user has been created.
loginMethods:
type: array
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
items:
type: string
enum:
- password
$ref: '#/components/schemas/LoginMethods'
LoginMethods:
type: object
description: |
Login methods this server accepts, keyed by method. A missing key means the method is not offered.
Keys are optional on purpose: discovery is read by clients of any version against servers of any
version, so new methods are added as new optional keys. Clients ignore keys they do not know.
properties:
password:
$ref: '#/components/schemas/PasswordLoginMethod'
PasswordLoginMethod:
type: object
description: Username and password login (`POST /auth/login`). No settings yet.
Problem:
type: object
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
@ -409,6 +432,35 @@ components:
message:
type: string
description: Why the value was rejected.
Capabilities:
type: object
description: |
Capability modules this server implements, keyed by module. Keys are optional; a missing key means the
module is not implemented. New modules are added as new optional keys. These are server facts, not what
the calling token may use.
properties:
core:
$ref: '#/components/schemas/CoreCapability'
password:
$ref: '#/components/schemas/PasswordCapability'
CoreCapability:
type: object
description: The mandatory core module.
required:
- version
properties:
version:
type: integer
description: Module version. Bumped only on semantic change.
PasswordCapability:
type: object
description: The password login module (login, first-admin setup, password change).
required:
- version
properties:
version:
type: integer
description: Module version. Bumped only on semantic change.
TokenRequest:
type: object
description: Optional narrowing of a new access token.
@ -654,6 +706,15 @@ components:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
Unauthorized:
description: Missing, invalid, or expired credentials.
headers:
WWW-Authenticate:
$ref: '#/components/headers/WWWAuthenticate'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
NotModified:
description: Not modified.
headers:
@ -665,15 +726,6 @@ components:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
Unauthorized:
description: Missing, invalid, or expired credentials.
headers:
WWW-Authenticate:
$ref: '#/components/headers/WWWAuthenticate'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
PayloadTooLarge:
description: "The request body is too large (`payload_too_large`)."
content:
@ -733,11 +785,11 @@ components:
maximum: 2000
default: 100
headers:
ETag:
description: Entity tag for `If-None-Match` revalidation.
schema:
type: string
WWWAuthenticate:
description: 'RFC 6750 Bearer challenge, for example `Bearer error="insufficient_scope", scope="read"`.'
schema:
type: string
ETag:
description: Entity tag for `If-None-Match` revalidation.
schema:
type: string

View file

@ -0,0 +1,10 @@
type: object
description: |
Capability modules this server implements, keyed by module. Keys are optional; a missing key means the
module is not implemented. New modules are added as new optional keys. These are server facts, not what
the calling token may use.
properties:
core:
$ref: ./CoreCapability.yaml
password:
$ref: ./PasswordCapability.yaml

View file

@ -0,0 +1,5 @@
type: object
description: The mandatory core module.
required: [version]
properties:
version: {type: integer, description: Module version. Bumped only on semantic change.}

View file

@ -0,0 +1,8 @@
type: object
description: |
Login methods this server accepts, keyed by method. A missing key means the method is not offered.
Keys are optional on purpose: discovery is read by clients of any version against servers of any
version, so new methods are added as new optional keys. Clients ignore keys they do not know.
properties:
password:
$ref: ./PasswordLoginMethod.yaml

View file

@ -0,0 +1,5 @@
type: object
description: The password login module (login, first-admin setup, password change).
required: [version]
properties:
version: {type: integer, description: Module version. Bumped only on semantic change.}

View file

@ -0,0 +1,2 @@
type: object
description: Username and password login (`POST /auth/login`). No settings yet.

View file

@ -15,8 +15,4 @@ properties:
type: boolean
description: True until the first admin user has been created.
loginMethods:
type: array
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
items:
type: string
enum: [password]
$ref: ./LoginMethods.yaml

View file

@ -4,7 +4,7 @@ info:
version: 1.0.0
description: |
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
capability modules through `GET /server` and never sniff versions.
capability modules through `GET /capabilities` and never sniff versions.
Enums are open: new values may be added to any enum within v1. Clients must
accept values they do not recognise instead of failing.
@ -32,6 +32,8 @@ tags:
paths:
/server:
$ref: ./paths/server.yaml
/capabilities:
$ref: ./paths/capabilities.yaml
/openapi.json:
$ref: ./paths/openapi.yaml#/json
/openapi.yaml:

View file

@ -0,0 +1,16 @@
get:
operationId: getCapabilities
x-module: core
x-stability-level: alpha
tags: [server]
summary: List implemented capability modules
description: The capability modules this server implements. Any valid access token may read it, whatever its scopes.
security: [{bearerAuth: []}]
responses:
'200':
description: Implemented modules.
content:
application/json:
schema: {$ref: ../components/schemas/Capabilities.yaml}
'401': {$ref: ../components/responses/Unauthorized.yaml}
'500': {$ref: ../components/responses/InternalError.yaml}

View file

@ -7,8 +7,7 @@ get:
summary: Describe the server
description: |
Returns the public server description. No authentication required.
Authenticated requests will additionally receive the implemented capability modules
once authentication is available.
Capability modules are listed by `GET /capabilities`.
responses:
'200':
description: Server description.

View file

@ -104,21 +104,6 @@ func (e Scope) Valid() bool {
}
}
// Defines values for ServerInfoLoginMethods.
const (
ServerInfoLoginMethodsPassword ServerInfoLoginMethods = "password"
)
// Valid indicates whether the value is a known member of the ServerInfoLoginMethods enum.
func (e ServerInfoLoginMethods) Valid() bool {
switch e {
case ServerInfoLoginMethodsPassword:
return true
default:
return false
}
}
// AccessToken A short-lived access token. Opaque; clients must not decode it.
type AccessToken struct {
// AccessToken The token. Send it as `Authorization: Bearer <token>`.
@ -155,6 +140,23 @@ type AuthUser struct {
UserName string `json:"userName"`
}
// Capabilities Capability modules this server implements, keyed by module. Keys are optional; a missing key means the
// module is not implemented. New modules are added as new optional keys. These are server facts, not what
// the calling token may use.
type Capabilities struct {
// Core The mandatory core module.
Core *CoreCapability `json:"core,omitempty"`
// Password The password login module (login, first-admin setup, password change).
Password *PasswordCapability `json:"password,omitempty"`
}
// CoreCapability The mandatory core module.
type CoreCapability struct {
// Version Module version. Bumped only on semantic change.
Version int `json:"version"`
}
// CredentialsRequest Username, password and client description for a login or first-admin setup.
type CredentialsRequest struct {
// Client Name of the client app.
@ -236,12 +238,26 @@ type GrantList struct {
Total int `json:"total"`
}
// LoginMethods Login methods this server accepts, keyed by method. A missing key means the method is not offered.
// Keys are optional on purpose: discovery is read by clients of any version against servers of any
// version, so new methods are added as new optional keys. Clients ignore keys they do not know.
type LoginMethods struct {
// Password Username and password login (`POST /auth/login`). No settings yet.
Password *PasswordLoginMethod `json:"password,omitempty"`
}
// LogoutResponse Result of a logout.
type LogoutResponse struct {
// LogoutUrl Where to send the browser to finish logging out of an external provider. Null when there is nothing more to do.
LogoutUrl *string `json:"logoutUrl"`
}
// PasswordCapability The password login module (login, first-admin setup, password change).
type PasswordCapability struct {
// Version Module version. Bumped only on semantic change.
Version int `json:"version"`
}
// PasswordChangeRequest Change the caller's own password.
type PasswordChangeRequest struct {
// CurrentPassword The current password.
@ -254,6 +270,9 @@ type PasswordChangeRequest struct {
RevokeOtherGrants *bool `json:"revokeOtherGrants,omitempty"`
}
// PasswordLoginMethod Username and password login (`POST /auth/login`). No settings yet.
type PasswordLoginMethod = map[string]interface{}
// Problem RFC 9457 problem details, returned for every 4xx and 5xx response.
type Problem struct {
// Code Machine-readable error code, and the value clients switch on. New codes may be added.
@ -292,8 +311,10 @@ type ScopeRequest = string
// ServerInfo Public server description. Everything an add-server screen needs before login.
type ServerInfo struct {
// LoginMethods Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
LoginMethods []ServerInfoLoginMethods `json:"loginMethods"`
// LoginMethods Login methods this server accepts, keyed by method. A missing key means the method is not offered.
// Keys are optional on purpose: discovery is read by clients of any version against servers of any
// version, so new methods are added as new optional keys. Clients ignore keys they do not know.
LoginMethods LoginMethods `json:"loginMethods"`
// Name Human-readable server product name.
Name string `json:"name"`
@ -308,9 +329,6 @@ type ServerInfo struct {
SpecVersion string `json:"specVersion"`
}
// ServerInfoLoginMethods defines model for ServerInfo.LoginMethods.
type ServerInfoLoginMethods string
// TokenRequest Optional narrowing of a new access token.
type TokenRequest struct {
// Scopes Subset of the grant's scopes. Omit for all of them; an empty list asks for none.
@ -400,6 +418,9 @@ type ServerInterface interface {
// CreateAccessToken Mint an access token
// (POST /auth/token)
CreateAccessToken(w http.ResponseWriter, r *http.Request)
// GetCapabilities List implemented capability modules
// (GET /capabilities)
GetCapabilities(w http.ResponseWriter, r *http.Request)
// GetServerInfo Describe the server
// (GET /server)
GetServerInfo(w http.ResponseWriter, r *http.Request)
@ -451,6 +472,12 @@ func (_ Unimplemented) CreateAccessToken(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNotImplemented)
}
// GetCapabilities List implemented capability modules
// (GET /capabilities)
func (_ Unimplemented) GetCapabilities(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// GetServerInfo Describe the server
// (GET /server)
func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) {
@ -608,6 +635,20 @@ func (siw *ServerInterfaceWrapper) CreateAccessToken(w http.ResponseWriter, r *h
handler.ServeHTTP(w, r)
}
// GetCapabilities operation middleware
func (siw *ServerInterfaceWrapper) GetCapabilities(w http.ResponseWriter, r *http.Request) {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetCapabilities(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetServerInfo operation middleware
func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.Request) {
@ -756,6 +797,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/auth/token", wrapper.CreateAccessToken)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/capabilities", wrapper.GetCapabilities)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/server", wrapper.GetServerInfo)
})
@ -1508,6 +1552,62 @@ func (response CreateAccessToken500ApplicationProblemPlusJSONResponse) VisitCrea
return err
}
type GetCapabilitiesRequestObject struct {
}
type GetCapabilitiesResponseObject interface {
VisitGetCapabilitiesResponse(w http.ResponseWriter) error
}
type GetCapabilities200JSONResponse Capabilities
func (response GetCapabilities200JSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(response); err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
_, err := buf.WriteTo(w)
return err
}
type GetCapabilities401ApplicationProblemPlusJSONResponse struct {
UnauthorizedApplicationProblemPlusJSONResponse
}
func (response GetCapabilities401ApplicationProblemPlusJSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(response.Body); err != nil {
return err
}
w.Header().Set("Content-Type", "application/problem+json")
if response.Headers.WWWAuthenticate != nil {
w.Header().Set("WWW-Authenticate", fmt.Sprint(*response.Headers.WWWAuthenticate))
}
w.WriteHeader(401)
_, err := buf.WriteTo(w)
return err
}
type GetCapabilities500ApplicationProblemPlusJSONResponse struct {
InternalErrorApplicationProblemPlusJSONResponse
}
func (response GetCapabilities500ApplicationProblemPlusJSONResponse) VisitGetCapabilitiesResponse(w http.ResponseWriter) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(response); err != nil {
return err
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(500)
_, err := buf.WriteTo(w)
return err
}
type GetServerInfoRequestObject struct {
}
@ -1568,6 +1668,9 @@ type StrictServerInterface interface {
// CreateAccessToken Mint an access token
// (POST /auth/token)
CreateAccessToken(ctx context.Context, request CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error)
// GetCapabilities List implemented capability modules
// (GET /capabilities)
GetCapabilities(ctx context.Context, request GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error)
// GetServerInfo Describe the server
// (GET /server)
GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error)
@ -1815,6 +1918,30 @@ func (sh *strictHandler) CreateAccessToken(w http.ResponseWriter, r *http.Reques
}
}
// GetCapabilities operation middleware
func (sh *strictHandler) GetCapabilities(w http.ResponseWriter, r *http.Request) {
var request GetCapabilitiesRequestObject
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.GetCapabilities(ctx, request.(GetCapabilitiesRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetCapabilities")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(GetCapabilitiesResponseObject); ok {
if err := validResponse.VisitGetCapabilitiesResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// GetServerInfo operation middleware
func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) {
var request GetServerInfoRequestObject

View file

@ -4,6 +4,9 @@ import (
"net/http"
"net/http/httptest"
"github.com/getkin/kin-openapi/openapi3"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/api"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@ -16,6 +19,17 @@ var _ = Describe("Router", func() {
router = New(&tests.MockDataStore{})
})
It("routes every operation in the embedded spec", func() {
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
Expect(err).ToNot(HaveOccurred())
mux := New(&tests.MockDataStore{}).Handler.(chi.Routes)
for path, item := range doc.Paths.Map() {
for method := range item.Operations() {
Expect(mux.Find(chi.NewRouteContext(), method, path)).To(Equal(path), method+" "+path)
}
}
})
It("returns a 404 problem for unknown paths", func() {
w := serve(router, httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil))
Expect(w.Code).To(Equal(http.StatusNotFound))

View file

@ -0,0 +1,10 @@
package apiv1
import "context"
func (rt *Router) GetCapabilities(context.Context, GetCapabilitiesRequestObject) (GetCapabilitiesResponseObject, error) {
return GetCapabilities200JSONResponse{
Core: &CoreCapability{Version: 1},
Password: &PasswordCapability{Version: 1},
}, nil
}

View file

@ -0,0 +1,53 @@
package apiv1
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("GET /capabilities", func() {
var ctx context.Context
var router *Router
BeforeEach(func() {
ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
resetDB()
router = New(realDS)
})
It("needs a token", func() {
w := serve(router, httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/capabilities", nil))
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
It("lists core and password for any valid token, even one with no scopes", func() {
body, _ := json.Marshal(map[string]any{"username": "admin", "password": "pw", "client": "c"})
setupReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(body))
setupReq.Header.Set("Content-Type", "application/json")
var gc GrantCreated
Expect(json.Unmarshal(serve(router, setupReq).Body.Bytes(), &gc)).To(Succeed())
tokReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v1/auth/token", bytes.NewReader([]byte(`{"scopes":[]}`)))
tokReq.Header.Set("Content-Type", "application/json")
tokReq.Header.Set("Authorization", "Bearer "+gc.Secret)
var at AccessToken
Expect(json.Unmarshal(serve(router, tokReq).Body.Bytes(), &at)).To(Succeed())
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/api/v1/capabilities", nil)
req.Header.Set("Authorization", "Bearer "+at.AccessToken)
w := serve(router, req)
Expect(w.Code).To(Equal(http.StatusOK))
var caps Capabilities
Expect(json.Unmarshal(w.Body.Bytes(), &caps)).To(Succeed())
Expect(caps.Core.Version).To(Equal(1))
Expect(caps.Password.Version).To(Equal(1))
})
})

View file

@ -18,6 +18,6 @@ func (rt *Router) GetServerInfo(ctx context.Context, _ GetServerInfoRequestObjec
ServerVersion: consts.Version,
SpecVersion: api.SpecVersion(),
SetupRequired: count == 0,
LoginMethods: []ServerInfoLoginMethods{ServerInfoLoginMethodsPassword},
LoginMethods: LoginMethods{Password: &PasswordLoginMethod{}},
}, nil
}

View file

@ -43,7 +43,8 @@ var _ = Describe("GET /server", func() {
Expect(info.ServerVersion).To(Equal(consts.Version))
Expect(info.SpecVersion).To(Equal(api.SpecVersion()))
Expect(info.SetupRequired).To(BeTrue())
Expect(info.LoginMethods).To(ConsistOf(ServerInfoLoginMethodsPassword))
Expect(info.LoginMethods.Password).ToNot(BeNil())
Expect(w.Body.String()).To(ContainSubstring(`"loginMethods":{"password":{}}`))
})
It("reports setupRequired=false once a user exists", func() {