v0.3.1: bearer-token reads; public skill + capabilities
Fixes an inconsistency an agent hit: it could POST/PATCH/DELETE with its token
but GET (readback) required a session and 302-redirected to the browser login.
- GET /api/artifacts and GET /api/artifacts/{id} now accept a bearer token OR
a session, owner-scoped (a token reads only its owner's artifacts). Agents
can read back what they posted with the same token.
- Unauthenticated API requests return a JSON 403, never a browser-login
redirect (resolve_request_user helper; dropped @login_required on the JSON
read views).
- GET /api/skill and GET /api/capabilities are now public (no user data), so
an agent can fetch the contract + posting instructions before it has a key.
- Docs (api.md, CLAUDE.md) updated; tests for bearer readback, owner-scoping,
and the JSON-403-not-redirect behavior. Suite: 132 passing; verified live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d30e2436ab
commit
2b901f7844
6 changed files with 91 additions and 32 deletions
|
|
@ -82,12 +82,14 @@ DEFAULT_THEMES = {
|
|||
}
|
||||
|
||||
|
||||
@method_decorator(login_required, name="get")
|
||||
class ArtifactListCreateView(APIView):
|
||||
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
||||
|
||||
def get(self, request):
|
||||
artifacts = Artifact.objects.filter(owner=request.user)
|
||||
user = resolve_request_user(request)
|
||||
if user is None:
|
||||
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
|
||||
artifacts = Artifact.objects.filter(owner=user)
|
||||
if source := request.query_params.get("source"):
|
||||
artifacts = artifacts.filter(source=source)
|
||||
if label := request.query_params.get("label"):
|
||||
|
|
@ -223,10 +225,12 @@ def asset_view(request, asset_id):
|
|||
return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream")
|
||||
|
||||
|
||||
@method_decorator(login_required, name="get")
|
||||
class ArtifactDetailView(APIView):
|
||||
def get(self, request, artifact_id):
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
|
||||
user = resolve_request_user(request)
|
||||
if user is None:
|
||||
return Response({"detail": "Authentication required (session or bearer token)."}, status=403)
|
||||
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||
return Response(serialize_artifact(artifact, request))
|
||||
|
||||
def patch(self, request, artifact_id):
|
||||
|
|
@ -304,8 +308,7 @@ class CapabilitiesView(APIView):
|
|||
so agents can check connectivity before posting. Exposes no user data."""
|
||||
|
||||
def get(self, request):
|
||||
if not request.user.is_authenticated and resolve_token_user(request) is None:
|
||||
return HttpResponseForbidden("Login or a valid API token is required")
|
||||
# Public: capability/contract info only, no user data.
|
||||
from daybook_project import __version__
|
||||
|
||||
methods = {
|
||||
|
|
@ -362,9 +365,9 @@ class SkillView(APIView):
|
|||
"""
|
||||
|
||||
def get(self, request):
|
||||
if not request.user.is_authenticated and resolve_token_user(request) is None:
|
||||
return HttpResponseForbidden("Login or a valid API token is required")
|
||||
|
||||
# Public: the skill is bootstrapping instructions (how to authenticate
|
||||
# and post) and contains no secrets — an agent shouldn't need a key to
|
||||
# learn how to use one.
|
||||
origin = request.build_absolute_uri("/").rstrip("/")
|
||||
try:
|
||||
skill = render_posting_skill(origin)
|
||||
|
|
@ -1073,6 +1076,15 @@ def resolve_token_user(request):
|
|||
return api_key.user
|
||||
|
||||
|
||||
def resolve_request_user(request):
|
||||
"""The acting user from either a browser session or a bearer API key.
|
||||
Used by the JSON read endpoints so agents can read back with the same token
|
||||
they post with (owner-scoped). None if neither authenticates."""
|
||||
if request.user.is_authenticated:
|
||||
return request.user
|
||||
return resolve_token_user(request)
|
||||
|
||||
|
||||
def normalize_tags(tags):
|
||||
if isinstance(tags, str):
|
||||
return [tag.strip() for tag in tags.split(",") if tag.strip()]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue