Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b901f7844 |
6 changed files with 91 additions and 32 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
30
docs/api.md
30
docs/api.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue