From 2b901f7844ae890cabb131c2bac3b6c2c51661e5 Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 4 Sep 2026 14:50:48 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 14 +++++++++++- CLAUDE.md | 2 +- artifacts/views.py | 30 +++++++++++++++++-------- daybook_project/__init__.py | 2 +- docs/api.md | 30 +++++++++++++------------ tests/api/test_artifacts.py | 45 ++++++++++++++++++++++++++++++++----- 6 files changed, 91 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8acf954..60b5f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ former name, "Daybook".) _Nothing yet._ +## [0.3.1] — 2026-09-04 + +### Changed +- Artifact **read** endpoints (`GET /api/artifacts`, `GET /api/artifacts/{id}`) + now accept a bearer token as well as a browser session, owner-scoped — an + agent can read back what it posted with the same token. Unauthenticated API + requests return a JSON `403` instead of a 302 redirect to the browser login. +- `GET /api/skill` and `GET /api/capabilities` are now **public** (no auth) — + they expose no user data, so an agent can fetch the contract and posting + instructions before it has a key. + ## [0.3.0] — 2026-09-04 ### Changed @@ -117,7 +128,8 @@ First tagged release. - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a runbook (`deploy/cloud_agent_deploy.md`) and health check. -[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.1...HEAD +[0.3.1]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...v0.3.1 [0.3.0]: https://git.cygnus.systems/peter/daybook/compare/v0.2.1...v0.3.0 [0.2.1]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...v0.2.1 [0.2.0]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...v0.2.0 diff --git a/CLAUDE.md b/CLAUDE.md index c0fa33e..5db5bbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ The core product principle is: agents publish content, not files. The server own - Each user has a personal ingest API token (DRF `Token` model), viewable/regenerable from Settings → Account. - Require `source` in every artifact payload — a free-text label identifying what posted it (agent-profile, script, or person name). Ownership of the resulting artifact comes from whichever user's token authenticated the POST, not from the `source` value itself. - `source` and `tags` together form one unified **label** system: every artifact shows all its labels next to the title, and a single `label` filter/query param matches against either. -- Require login (Django session auth) for the whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions. +- Require login (Django session auth) for the browser feed/UI. The JSON API accepts a per-user bearer token for both ingest (POST/PATCH/DELETE) and artifact reads (GET list/detail), owner-scoped — an agent reads back with the same token it posts with; API requests never redirect to a browser login (JSON 403 instead). `GET /api/capabilities` and `GET /api/skill` are public (no user data), so agents can bootstrap before they have a key. - Exception to the above: an artifact can be explicitly opted into public sharing (per-artifact, owner-toggled from the feed UI) via a high-entropy `share_token`, served unauthenticated at `GET /s/{share_token}`. Not exposed via the ingest API yet — UI-only. Turning sharing off immediately invalidates the old link. Images embedded in a shared artifact are also served unauthenticated while it's shared, so inline content doesn't break for a link recipient. - User accounts can be created from Settings → Users (superuser-only in-app page) or via Django admin. Regular users are not staff and cannot access `/admin/`. Creating a user via Settings → Users also issues their API token immediately, shown once. - `TagColor` (label color overrides) stays global/shared across all users. diff --git a/artifacts/views.py b/artifacts/views.py index d1978ed..99f326b 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -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()] diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index 665c74e..72aff5b 100644 --- a/daybook_project/__init__.py +++ b/daybook_project/__init__.py @@ -1,4 +1,4 @@ # Single source of truth for the app version. Bump this, commit, then tag the # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in # GET /api/capabilities ("version"). -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/docs/api.md b/docs/api.md index b3f6ee8..e804074 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,11 +16,15 @@ Authorization: Bearer Every artifact belongs to the user whose key authenticated the POST — feeds are private per user. -Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a -logged-in browser session, not a bearer token — visit the app in a browser and log in first. +Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) accept +**either** a bearer token **or** a logged-in browser session, and are owner-scoped — a +token reads only its own user's artifacts (the same rule POST/PATCH/DELETE enforce). So an +agent can read back what it posted with the same token. Unauthenticated API requests get a +JSON `403`, never a browser-login redirect. -`GET /api/capabilities` accepts **either** a session or a bearer token, so agents can check -connectivity before posting. +`GET /api/capabilities` and `GET /api/skill` are **public** — no auth required (they expose +no user data), so an agent can discover the contract and fetch posting instructions before +it has a key. ## Artifact Kinds @@ -343,7 +347,7 @@ Endpoint: GET /api/artifacts ``` -Requires a logged-in browser session. Returns only the current user's artifacts. +Accepts a bearer token or a browser session. Returns only the authenticated user's artifacts. Supported filters (combine freely; all are ANDed): @@ -541,11 +545,10 @@ This is generated from the server's actual configuration, so it's the authoritative source if this document and a running instance ever disagree. Agents should call this endpoint if they need to adapt to the current AgentFeed instance, or -simply to check the instance is reachable and their token is valid. Unlike the other read -endpoints, it accepts a bearer token as well as a browser session: +simply to check the instance is reachable. It's public — no auth required: ```bash -curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/capabilities +curl http://127.0.0.1:8001/api/capabilities ``` ## Posting Skill @@ -563,14 +566,13 @@ instance. The server rewrites the `AGENTFEED_URL` line to **this instance's own origin**, so the text a caller receives already points at the right server; a recipient only has to fill in their `AGENTFEED_TOKEN`. -Same auth as capabilities (a browser session **or** a bearer token), so a -logged-in user can copy it to hand to their agent, or an agent can fetch it -directly. The default response is raw Markdown (`text/markdown`); add -`?format=json` for `{"version", "url", "skill"}`. +**Public** — no auth required (like capabilities), so an agent can fetch its +posting instructions before it has a key. The default response is raw Markdown +(`text/markdown`); add `?format=json` for `{"version", "url", "skill"}`. ```bash -# Fetch the ready-to-use instructions for this instance -curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/skill +# Fetch the ready-to-use instructions for this instance (no auth needed) +curl http://127.0.0.1:8001/api/skill ``` Discover it programmatically via `skill_url` in the capabilities response. diff --git a/tests/api/test_artifacts.py b/tests/api/test_artifacts.py index cd7d558..34193b7 100644 --- a/tests/api/test_artifacts.py +++ b/tests/api/test_artifacts.py @@ -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(