111111 baseconv ,
112112 call_with_supported_arguments ,
113113 detect_json1 ,
114+ add_cors_headers ,
114115 display_actor ,
115116 escape_css_string ,
116117 escape_sqlite ,
130131 redact_keys ,
131132 row_sql_params_pks ,
132133)
134+ from .tokens import TokenInvalid
133135from .utils .asgi import (
134136 AsgiLifespan ,
135137 Forbidden ,
@@ -910,7 +912,9 @@ async def verify_token(self, token: str) -> dict | None:
910912 Verify an API token by trying all registered token handlers.
911913
912914 Returns an actor dict from the first handler that recognizes the
913- token, or None if no handler accepts it.
915+ token, or None if no handler accepts it. A handler may raise
916+ TokenInvalid for a token it recognizes but rejects (bad signature,
917+ expired) - Datasette turns that into a 401 response.
914918 """
915919 for token_handler in self ._token_handlers ():
916920 result = await token_handler .verify_token (self , token )
@@ -2173,6 +2177,18 @@ def _connected_databases(self):
21732177 for name , d in self .databases .items ()
21742178 ]
21752179
2180+ async def _connected_databases_for_actor (self , actor ):
2181+ page = await self .allowed_resources ("view-database" , actor )
2182+ allowed_names = {resource .parent async for resource in page .all ()}
2183+ return [
2184+ database
2185+ for database in self ._connected_databases ()
2186+ if database ["name" ] in allowed_names
2187+ ]
2188+
2189+ async def _databases_data (self , request ):
2190+ return {"databases" : await self ._connected_databases_for_actor (request .actor )}
2191+
21762192 def _versions (self ):
21772193 conn = sqlite3 .connect (":memory:" )
21782194 self ._prepare_connection (conn , "_memory" )
@@ -2519,8 +2535,8 @@ def _routes(self):
25192535 def add_route (view , regex ):
25202536 routes .append ((regex , view ))
25212537
2522- add_route (IndexView .as_view (self ), r"/(\.(?P<format>jsono? ))?$" )
2523- add_route (IndexView .as_view (self ), r"/-/(\.(?P<format>jsono? ))?$" )
2538+ add_route (IndexView .as_view (self ), r"/(\.(?P<format>json ))?$" )
2539+ add_route (IndexView .as_view (self ), r"/-/(\.(?P<format>json ))?$" )
25242540 add_route (permanent_redirect ("/-/" ), r"/-$" )
25252541 add_route (favicon , "/favicon.ico" )
25262542
@@ -2556,7 +2572,10 @@ def add_route(view, regex):
25562572 )
25572573 add_route (
25582574 JsonDataView .as_view (
2559- self , "plugins.json" , self ._plugins , needs_request = True
2575+ self ,
2576+ "plugins.json" ,
2577+ lambda request : {"plugins" : self ._plugins (request )},
2578+ needs_request = True ,
25602579 ),
25612580 r"/-/plugins(\.(?P<format>json))?$" ,
25622581 )
@@ -2569,11 +2588,18 @@ def add_route(view, regex):
25692588 r"/-/config(\.(?P<format>json))?$" ,
25702589 )
25712590 add_route (
2572- JsonDataView .as_view (self , "threads.json" , self ._threads ),
2591+ JsonDataView .as_view (
2592+ self , "threads.json" , self ._threads , permission = "permissions-debug"
2593+ ),
25732594 r"/-/threads(\.(?P<format>json))?$" ,
25742595 )
25752596 add_route (
2576- JsonDataView .as_view (self , "databases.json" , self ._connected_databases ),
2597+ JsonDataView .as_view (
2598+ self ,
2599+ "databases.json" ,
2600+ self ._databases_data ,
2601+ needs_request = True ,
2602+ ),
25772603 r"/-/databases(\.(?P<format>json))?$" ,
25782604 )
25792605 add_route (
@@ -2586,7 +2612,7 @@ def add_route(view, regex):
25862612 JsonDataView .as_view (
25872613 self ,
25882614 "actions.json" ,
2589- self ._actions ,
2615+ lambda : { "actions" : self ._actions ()} ,
25902616 template = "debug_actions.html" ,
25912617 permission = "permissions-debug" ,
25922618 ),
@@ -2876,13 +2902,24 @@ async def route_path(self, scope, receive, send, path):
28762902 # Handle authentication
28772903 default_actor = scope .get ("actor" ) or None
28782904 actor = None
2905+ token_error = None
28792906 results = pm .hook .actor_from_request (datasette = self .ds , request = request )
28802907 for result in results :
2881- result = await await_me_maybe (result )
2908+ try :
2909+ result = await await_me_maybe (result )
2910+ except TokenInvalid as ex :
2911+ # A presented token was recognized but rejected - fail the
2912+ # request with a 401 even if another credential is valid,
2913+ # but keep awaiting the remaining coroutines first
2914+ if token_error is None :
2915+ token_error = ex
2916+ continue
28822917 if result and actor is None :
28832918 actor = result
28842919 # Don't break — we must await all coroutines to avoid
28852920 # "coroutine was never awaited" warnings
2921+ if token_error is not None :
2922+ return await self .handle_401 (request , send , token_error )
28862923 scope_modifications ["actor" ] = actor or default_actor
28872924 scope = dict (scope , ** scope_modifications )
28882925
@@ -2914,6 +2951,15 @@ async def route_path(self, scope, receive, send, path):
29142951 except Exception as exception :
29152952 return await self .handle_exception (request , send , exception )
29162953
2954+ async def handle_401 (self , request , send , exception ):
2955+ # A presented bearer token was recognized by a handler but rejected.
2956+ # Bearer tokens are API credentials, so this is always JSON.
2957+ headers = {"www-authenticate" : 'Bearer error="invalid_token"' }
2958+ if self .ds .cors :
2959+ add_cors_headers (headers )
2960+ response = Response .error ([str (exception )], 401 , headers = headers )
2961+ await response .asgi_send (send )
2962+
29172963 async def handle_404 (self , request , send , exception = None ):
29182964 # If path contains % encoding, redirect to tilde encoding
29192965 if "%" in request .path :
0 commit comments