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

@ -58,9 +58,10 @@ class ArtifactAPITestCase(APITestCase):
# -- capabilities -------------------------------------------------
def test_capabilities_requires_auth(self):
def test_capabilities_is_public(self):
# Capability/contract info only, no user data — public, no auth needed.
response = self.client.get(reverse("capabilities-api"))
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 200)
def test_capabilities_with_token(self):
response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token))
@ -85,9 +86,10 @@ class ArtifactAPITestCase(APITestCase):
# -- skill ----------------------------------------------------------
def test_skill_requires_auth(self):
def test_skill_is_public(self):
# Bootstrapping instructions, no secrets — public, no auth needed.
response = self.client.get(reverse("skill-api"))
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 200)
def test_skill_returns_markdown_with_origin_filled_in(self):
response = self.client.get(reverse("skill-api"), **self.auth_headers(self.token))
@ -156,9 +158,10 @@ class ArtifactAPITestCase(APITestCase):
# -- list (GET) -------------------------------------------------------
def test_list_requires_session_login(self):
def test_list_requires_auth(self):
# No session and no token -> JSON 403, never a browser-login redirect.
response = self.client.get(reverse("artifact-api"))
self.assertEqual(response.status_code, 302) # redirected to login
self.assertEqual(response.status_code, 403)
def test_list_scoped_to_owner(self):
self.create_artifact()
@ -169,6 +172,15 @@ class ArtifactAPITestCase(APITestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual([a["title"] for a in response.data], ["Hello World"])
def test_list_readable_with_bearer_token(self):
# Agents read back with the same token they post with; owner-scoped.
self.create_artifact()
self.create_artifact(token=self.other_token, title="Not mine")
response = self.client.get(reverse("artifact-api"), **self.auth_headers(self.token))
self.assertEqual(response.status_code, 200)
self.assertEqual([a["title"] for a in response.data], ["Hello World"])
def test_list_filter_by_label(self):
self.create_artifact(tags=["urgent"])
self.create_artifact(title="Second", tags=["later"])
@ -220,6 +232,27 @@ class ArtifactAPITestCase(APITestCase):
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
self.assertEqual(response.status_code, 200)
def test_detail_readable_with_bearer_token(self):
# The api_url returned by POST is fetchable with the posting token.
artifact_id = self.create_artifact().data["id"]
response = self.client.get(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["id"], artifact_id)
# ...but only the owner's token; another user's token gets 404.
response = self.client.get(
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.other_token)
)
self.assertEqual(response.status_code, 404)
def test_detail_requires_auth(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
self.assertEqual(response.status_code, 403)
def test_patch_updates_title_and_tags(self):
artifact_id = self.create_artifact().data["id"]
response = self.client.patch(