Compare commits

..

1 commit

Author SHA1 Message Date
2b901f7844 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>
2026-09-04 14:50:48 +01:00
6 changed files with 91 additions and 32 deletions

View file

@ -9,6 +9,17 @@ former name, "Daybook".)
_Nothing yet._ _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 ## [0.3.0] — 2026-09-04
### Changed ### Changed
@ -117,7 +128,8 @@ First tagged release.
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a - Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
runbook (`deploy/cloud_agent_deploy.md`) and health check. 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.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.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 [0.2.0]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...v0.2.0

View file

@ -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. - 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. - 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. - `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. - 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. - 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. - `TagColor` (label color overrides) stays global/shared across all users.

View file

@ -82,12 +82,14 @@ DEFAULT_THEMES = {
} }
@method_decorator(login_required, name="get")
class ArtifactListCreateView(APIView): class ArtifactListCreateView(APIView):
parser_classes = [JSONParser, FormParser, MultiPartParser] parser_classes = [JSONParser, FormParser, MultiPartParser]
def get(self, request): 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"): if source := request.query_params.get("source"):
artifacts = artifacts.filter(source=source) artifacts = artifacts.filter(source=source)
if label := request.query_params.get("label"): 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") return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream")
@method_decorator(login_required, name="get")
class ArtifactDetailView(APIView): class ArtifactDetailView(APIView):
def get(self, request, artifact_id): 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)) return Response(serialize_artifact(artifact, request))
def patch(self, request, artifact_id): def patch(self, request, artifact_id):
@ -304,8 +308,7 @@ class CapabilitiesView(APIView):
so agents can check connectivity before posting. Exposes no user data.""" so agents can check connectivity before posting. Exposes no user data."""
def get(self, request): def get(self, request):
if not request.user.is_authenticated and resolve_token_user(request) is None: # Public: capability/contract info only, no user data.
return HttpResponseForbidden("Login or a valid API token is required")
from daybook_project import __version__ from daybook_project import __version__
methods = { methods = {
@ -362,9 +365,9 @@ class SkillView(APIView):
""" """
def get(self, request): def get(self, request):
if not request.user.is_authenticated and resolve_token_user(request) is None: # Public: the skill is bootstrapping instructions (how to authenticate
return HttpResponseForbidden("Login or a valid API token is required") # 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("/") origin = request.build_absolute_uri("/").rstrip("/")
try: try:
skill = render_posting_skill(origin) skill = render_posting_skill(origin)
@ -1073,6 +1076,15 @@ def resolve_token_user(request):
return api_key.user 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): def normalize_tags(tags):
if isinstance(tags, str): if isinstance(tags, str):
return [tag.strip() for tag in tags.split(",") if tag.strip()] return [tag.strip() for tag in tags.split(",") if tag.strip()]

View file

@ -1,4 +1,4 @@
# Single source of truth for the app version. Bump this, commit, then tag the # 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 # commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
# GET /api/capabilities ("version"). # GET /api/capabilities ("version").
__version__ = "0.3.0" __version__ = "0.3.1"

View file

@ -16,11 +16,15 @@ Authorization: Bearer <your-personal-key>
Every artifact belongs to the user whose key authenticated the POST — feeds are private per user. 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 Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) accept
logged-in browser session, not a bearer token — visit the app in a browser and log in first. **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 `GET /api/capabilities` and `GET /api/skill` are **public** — no auth required (they expose
connectivity before posting. no user data), so an agent can discover the contract and fetch posting instructions before
it has a key.
## Artifact Kinds ## Artifact Kinds
@ -343,7 +347,7 @@ Endpoint:
GET /api/artifacts 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): 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. 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 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 simply to check the instance is reachable. It's public — no auth required:
endpoints, it accepts a bearer token as well as a browser session:
```bash ```bash
curl -H "Authorization: Bearer <your-personal-token>" http://127.0.0.1:8001/api/capabilities curl http://127.0.0.1:8001/api/capabilities
``` ```
## Posting Skill ## 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; origin**, so the text a caller receives already points at the right server;
a recipient only has to fill in their `AGENTFEED_TOKEN`. a recipient only has to fill in their `AGENTFEED_TOKEN`.
Same auth as capabilities (a browser session **or** a bearer token), so a **Public** — no auth required (like capabilities), so an agent can fetch its
logged-in user can copy it to hand to their agent, or an agent can fetch it posting instructions before it has a key. The default response is raw Markdown
directly. The default response is raw Markdown (`text/markdown`); add (`text/markdown`); add `?format=json` for `{"version", "url", "skill"}`.
`?format=json` for `{"version", "url", "skill"}`.
```bash ```bash
# Fetch the ready-to-use instructions for this instance # Fetch the ready-to-use instructions for this instance (no auth needed)
curl -H "Authorization: Bearer <your-personal-token>" http://127.0.0.1:8001/api/skill curl http://127.0.0.1:8001/api/skill
``` ```
Discover it programmatically via `skill_url` in the capabilities response. Discover it programmatically via `skill_url` in the capabilities response.

View file

@ -58,9 +58,10 @@ class ArtifactAPITestCase(APITestCase):
# -- capabilities ------------------------------------------------- # -- 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")) 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): def test_capabilities_with_token(self):
response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token)) response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token))
@ -85,9 +86,10 @@ class ArtifactAPITestCase(APITestCase):
# -- skill ---------------------------------------------------------- # -- 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")) 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): def test_skill_returns_markdown_with_origin_filled_in(self):
response = self.client.get(reverse("skill-api"), **self.auth_headers(self.token)) response = self.client.get(reverse("skill-api"), **self.auth_headers(self.token))
@ -156,9 +158,10 @@ class ArtifactAPITestCase(APITestCase):
# -- list (GET) ------------------------------------------------------- # -- 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")) 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): def test_list_scoped_to_owner(self):
self.create_artifact() self.create_artifact()
@ -169,6 +172,15 @@ class ArtifactAPITestCase(APITestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual([a["title"] for a in response.data], ["Hello World"]) 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): def test_list_filter_by_label(self):
self.create_artifact(tags=["urgent"]) self.create_artifact(tags=["urgent"])
self.create_artifact(title="Second", tags=["later"]) 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])) response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
self.assertEqual(response.status_code, 200) 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): def test_patch_updates_title_and_tags(self):
artifact_id = self.create_artifact().data["id"] artifact_id = self.create_artifact().data["id"]
response = self.client.patch( response = self.client.patch(