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:
peter 2026-09-04 14:50:48 +01:00
parent d30e2436ab
commit 2b901f7844
6 changed files with 91 additions and 32 deletions

View file

@ -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()]