Compare commits

..

No commits in common. "v0.3.1" and "v0.3.0" have entirely different histories.

6 changed files with 32 additions and 91 deletions

View file

@ -9,17 +9,6 @@ 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
@ -128,8 +117,7 @@ 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.1...HEAD [Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.3.0...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 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. - 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.
- 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,14 +82,12 @@ 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):
user = resolve_request_user(request) artifacts = Artifact.objects.filter(owner=request.user)
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"):
@ -225,12 +223,10 @@ 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):
user = resolve_request_user(request) artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
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):
@ -308,7 +304,8 @@ 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):
# Public: capability/contract info only, no user data. if not request.user.is_authenticated and resolve_token_user(request) is None:
return HttpResponseForbidden("Login or a valid API token is required")
from daybook_project import __version__ from daybook_project import __version__
methods = { methods = {
@ -365,9 +362,9 @@ class SkillView(APIView):
""" """
def get(self, request): def get(self, request):
# Public: the skill is bootstrapping instructions (how to authenticate if not request.user.is_authenticated and resolve_token_user(request) is None:
# and post) and contains no secrets — an agent shouldn't need a key to return HttpResponseForbidden("Login or a valid API token is required")
# 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)
@ -1076,15 +1073,6 @@ 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.1" __version__ = "0.3.0"

View file

@ -16,15 +16,11 @@ 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}`) accept Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a
**either** a bearer token **or** a logged-in browser session, and are owner-scoped — a logged-in browser session, not a bearer token — visit the app in a browser and log in first.
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` and `GET /api/skill` are **public** — no auth required (they expose `GET /api/capabilities` accepts **either** a session or a bearer token, so agents can check
no user data), so an agent can discover the contract and fetch posting instructions before connectivity before posting.
it has a key.
## Artifact Kinds ## Artifact Kinds
@ -347,7 +343,7 @@ Endpoint:
GET /api/artifacts GET /api/artifacts
``` ```
Accepts a bearer token or a browser session. Returns only the authenticated user's artifacts. Requires a logged-in browser session. Returns only the current user's artifacts.
Supported filters (combine freely; all are ANDed): Supported filters (combine freely; all are ANDed):
@ -545,10 +541,11 @@ 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. It's public — no auth required: 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:
```bash ```bash
curl http://127.0.0.1:8001/api/capabilities curl -H "Authorization: Bearer <your-personal-token>" http://127.0.0.1:8001/api/capabilities
``` ```
## Posting Skill ## Posting Skill
@ -566,13 +563,14 @@ 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`.
**Public** — no auth required (like capabilities), so an agent can fetch its Same auth as capabilities (a browser session **or** a bearer token), so a
posting instructions before it has a key. The default response is raw Markdown logged-in user can copy it to hand to their agent, or an agent can fetch it
(`text/markdown`); add `?format=json` for `{"version", "url", "skill"}`. directly. The default response is raw Markdown (`text/markdown`); add
`?format=json` for `{"version", "url", "skill"}`.
```bash ```bash
# Fetch the ready-to-use instructions for this instance (no auth needed) # Fetch the ready-to-use instructions for this instance
curl http://127.0.0.1:8001/api/skill curl -H "Authorization: Bearer <your-personal-token>" 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,10 +58,9 @@ class ArtifactAPITestCase(APITestCase):
# -- capabilities ------------------------------------------------- # -- capabilities -------------------------------------------------
def test_capabilities_is_public(self): def test_capabilities_requires_auth(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, 200) self.assertEqual(response.status_code, 403)
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))
@ -86,10 +85,9 @@ class ArtifactAPITestCase(APITestCase):
# -- skill ---------------------------------------------------------- # -- skill ----------------------------------------------------------
def test_skill_is_public(self): def test_skill_requires_auth(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, 200) self.assertEqual(response.status_code, 403)
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))
@ -158,10 +156,9 @@ class ArtifactAPITestCase(APITestCase):
# -- list (GET) ------------------------------------------------------- # -- list (GET) -------------------------------------------------------
def test_list_requires_auth(self): def test_list_requires_session_login(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, 403) self.assertEqual(response.status_code, 302) # redirected to login
def test_list_scoped_to_owner(self): def test_list_scoped_to_owner(self):
self.create_artifact() self.create_artifact()
@ -172,15 +169,6 @@ 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"])
@ -232,27 +220,6 @@ 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(