From 6859be688e8f64ea207a7fe883281d1d61b95352 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 3 Sep 2026 14:47:09 +0100 Subject: [PATCH 01/22] Add CHANGELOG; switch runbook rollback to tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG.md started (Keep a Changelog style), with the v0.1.0 release notes; versions track the git tags and __version__. - Runbook §7 now deploys a specific tag (git fetch --tags && checkout vX.Y.Z) and rolls back to the previous tag, with a note that rollback reverts code not data (restore the data backup if a migration ran). (The deploy kickoff brief lives in planning/, which is gitignored — it's a local hand-to-the-agent note, not shipped in the repo.) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 48 ++++++++++++++++++++++++++++++++++++ deploy/cloud_agent_deploy.md | 24 ++++++++++++------ 2 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9ce888e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +All notable changes to Daybook are recorded here. Format loosely follows +[Keep a Changelog](https://keepachangelog.com/); versions are SemVer and match +the git tags (`vX.Y.Z`) and `daybook_project.__version__`. + +## [Unreleased] + +_Nothing yet._ + +## [0.1.0] — 2026-09-03 + +First tagged release. + +### Artifacts & rendering +- Artifact kinds: `markdown`, `html_fragment`, `html_full`, `deck`, `json`, + `csv`, `image`, `pdf`, `binary`. +- `.xlsx` uploads convert server-side to a `csv` artifact (active sheet, + saved formula results). +- Markdown code blocks get syntax highlighting; tables (markdown & + html_fragment) render styled — monospace cells, sans-serif headers. +- `json`/`csv` render as an interactive collapsible tree / sortable table + (Grid.js + renderjson, vendored locally). +- Unified off-white document background; system sans-serif body text. + +### API +- `POST/GET/PATCH/DELETE /api/artifacts`, list filters (source/label/day/ + kind/q), `/api/assets` image upload, `/api/capabilities` (reports + `version`). Per-user bearer-token ingest; session-authed reads. +- Server-side validation: per-kind content shape, field length limits, + string-typed source fields, valid-JSON for `json`. + +### Sharing, quotas, security +- Per-artifact public share links (`/s/{token}`), owner-toggled, revocable. +- Per-user disk quotas with oldest-unkept auto-eviction and a Keep flag. +- Allowlist HTML sanitization (nh3) + `script-src 'none'` CSP for the themed + kinds; deep-JSON parse errors return 400 (not 500). + +### UI +- Multi-user auth; feed with source sidebar + label filtering; settings area + (Account, Storage, Demo Data, Users, About); light/dark toggle; app icon. + +### Deployment +- 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.1.0...HEAD +[0.1.0]: https://git.cygnus.systems/peter/daybook/releases/tag/v0.1.0 diff --git a/deploy/cloud_agent_deploy.md b/deploy/cloud_agent_deploy.md index d90f5db..daff899 100644 --- a/deploy/cloud_agent_deploy.md +++ b/deploy/cloud_agent_deploy.md @@ -202,25 +202,33 @@ Confirm the response includes `id`, `url`, and `api_url`, and that opening ## 7. Redeploy Loop (routine updates) -This is the command you run every time you're asked to ship a new version: +Releases are tagged `vX.Y.Z` (SemVer). Deploy a specific tag rather than a +moving branch, so what's live is always a known, named version — and note +the currently-deployed one before switching, for a clean rollback target: ```bash cd /opt/daybook -PREV_SHA=$(git rev-parse HEAD) -git pull +PREV_TAG=$(git describe --tags --exact-match 2>/dev/null || git rev-parse HEAD) # what's live now +git fetch --tags +git checkout v0.1.0 # the tag you were told to deploy (latest: git tag -l | sort -V | tail -1) docker compose -f deploy/docker-compose.yml up -d --build ``` -Then repeat the verification in step 6 (capabilities check at minimum). If it -fails: +Then repeat the verification in step 6 (and confirm `/api/capabilities` +reports the `version` you expect). If it fails, roll back to the previous +version and report the failure (build log / migration error / failed health +check) rather than retrying blindly: ```bash -git checkout "$PREV_SHA" +git checkout "$PREV_TAG" docker compose -f deploy/docker-compose.yml up -d --build ``` -to roll back, then report the failure (build log / migration error / failed -health check) back to the operator rather than retrying blindly. +Caveat: rollback reverts code, not data — a release that already applied a +DB migration may not cleanly downgrade against the migrated `db.sqlite3`. +For a routine forward release that's fine; if a rollback is needed after a +migration, restore the pre-deploy `deploy/data` backup (see §9) alongside +the code checkout. `deploy/.env` and the data directory (wherever `DAYBOOK_HOST_DATA_DIR` points, or `deploy/data` by default) are untracked, so `git pull` never touches them — From caeb2bedc7b2ce0cc845933a257ad51164c949ee Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 3 Sep 2026 15:43:58 +0100 Subject: [PATCH 02/22] Rewrite posting skill to be location-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill was authored in this dev workspace, where the Daybook server, the skill, and the posting agent all share one directory — so it read as if the agent always runs from a Daybook source checkout on localhost. - State up front that you post to a running instance that may be anywhere, need no source checkout, and are tied to it only via DAYBOOK_URL/TOKEN. - Drop "from the project root"; reference the bundled helper by absolute path (DAYBOOK_POST=.../this-skill/scripts/daybook_post.py). - Reframe DAYBOOK_URL as set-this-to-your-instance; label 127.0.0.1:8001 as the local dev fallback only. - Genericize the hardcoded localhost URL in the image-embed example. - Add a raw curl fallback for environments without the Python helper. - Keep frontmatter to name+description (the Skills-API-portable subset). Co-Authored-By: Claude Opus 4.8 --- skills/daybook-posting/SKILL.md | 120 ++++++++++++++++++++++++-------- 1 file changed, 90 insertions(+), 30 deletions(-) diff --git a/skills/daybook-posting/SKILL.md b/skills/daybook-posting/SKILL.md index 51f4dc5..c972af9 100644 --- a/skills/daybook-posting/SKILL.md +++ b/skills/daybook-posting/SKILL.md @@ -1,26 +1,13 @@ --- name: daybook-posting -description: Use when you have produced a finished deliverable such as a report, summary, brief, diagram, generated HTML, PDF, deck, or status update that a human should review later outside this chat. Publishes the artifact to Daybook and returns a URL. +description: Use when you have produced a finished deliverable such as a report, summary, brief, diagram, generated HTML, PDF, deck, or status update that a human should review later outside this chat. Publishes the artifact to a Daybook instance and returns a URL. --- # Daybook Posting Daybook is a private artifact journal for completed agent outputs. It stores, indexes, and displays artifacts for human review. It does not execute commands, run workflows, or continue conversations. -Always use the helper script. Do not hand-build curl, JSON, PowerShell multipart, or inline shell content. - -From the project root: - -```bash -python skills/daybook-posting/scripts/daybook_post.py --check -python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md -cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" -python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram" -``` - -The project also keeps a compatibility wrapper at `scripts/daybook_post.py`, but the canonical helper is bundled inside this skill. - -The artifact URL is printed as the final stdout line. +You post to a **running Daybook server**, which may be anywhere — a cloud VM, a container, or a workstation. You do **not** need a checkout of Daybook's source. The only things that tie you to a particular instance are two environment variables: `DAYBOOK_URL` (where the server is) and `DAYBOOK_TOKEN` (who you are). Set those and the rest of this skill works unchanged regardless of where you or the server run. ## Configuration @@ -30,12 +17,54 @@ config) — the helper reads `os.environ` directly. There is no `.env` file support; a `.env` file in the working directory is not read automatically. ```text -DAYBOOK_URL=http://127.0.0.1:8001 -DAYBOOK_TOKEN=your-personal-api-token -DAYBOOK_SOURCE=agent +DAYBOOK_URL=https://your-daybook-instance.example.com # your server's base URL +DAYBOOK_TOKEN=your-personal-api-token # from Settings -> Account +DAYBOOK_SOURCE=agent # a stable label for what's posting ``` -`DAYBOOK_TOKEN` is your personal per-user API token, from Settings → Account in the app (not a token shared by everyone). Artifacts posted with your token land only in your feed. +- **`DAYBOOK_URL`** — the base URL of the Daybook instance you're posting to. + Set it to your server. If it's unset, the helper falls back to + `http://127.0.0.1:8001`, which is only correct when Daybook is running + locally on the same machine (its development default). For any remote or + deployed instance you **must** set this. +- **`DAYBOOK_TOKEN`** — your personal per-user API token, from Settings → + Account in the app (not a token shared by everyone). Artifacts posted with + your token land only in your feed. +- **`DAYBOOK_SOURCE`** — a free-text label for whatever is posting + (agent-profile, script, or person name). Optional; can also be passed per + call with `--source`. + +## The Helper + +Always use the bundled helper script. Do not hand-build curl, JSON, +PowerShell multipart, or inline shell content (a `curl` fallback for +environments without the helper is documented at the end — the script is +still preferred everywhere it's available). + +The helper lives at **`scripts/daybook_post.py` inside this skill's own +directory** — not in any project you happen to be working in. Invoke it by +its absolute path. For brevity, the examples below assume you've captured +that path once: + +```bash +# Point this at the helper inside THIS skill directory (absolute path). +DAYBOOK_POST=/abs/path/to/this/skill/scripts/daybook_post.py +``` + +Then: + +```bash +python "$DAYBOOK_POST" --check +python "$DAYBOOK_POST" --kind markdown --title "Build Report" --content-file report.md +cat report.md | python "$DAYBOOK_POST" --kind markdown --title "Build Report" +python "$DAYBOOK_POST" --file diagram.svg --title "Architecture Diagram" +``` + +The artifact URL is printed as the final stdout line. + +(If you *are* working inside a Daybook source checkout, a compatibility +wrapper also exists at `scripts/daybook_post.py` in the repo root, but the +canonical helper is the one bundled in this skill.) ## Kind Cheat Sheet @@ -65,8 +94,9 @@ presentation. This script only **creates** artifacts — none of the following are possible through it. Updating an existing artifact's title/tags/metadata, -or deleting one, requires the raw API (see `docs/api.md`); enabling its -public share link is feed-UI-only, with no API equivalent at all yet. +or deleting one, requires the raw API (see the instance's `docs/api.md` or +`GET /api/capabilities`); enabling its public share link is feed-UI-only, +with no API equivalent at all yet. ## CDN Snippets For `html_full` @@ -119,11 +149,12 @@ artifact), upload it first and reference the returned URL: ```bash # 1. Upload -> prints the URL as the last line -python skills/daybook-posting/scripts/daybook_post.py --upload-image chart.png +python "$DAYBOOK_POST" --upload-image chart.png -# 2. Reference that URL in your markdown, then post as usual -# ![chart](http://127.0.0.1:8001/assets/ab12cd34ef56) -python skills/daybook-posting/scripts/daybook_post.py report.md +# 2. Reference that URL in your markdown, then post as usual. +# The printed URL is absolute against your DAYBOOK_URL, e.g. +# ![chart]($DAYBOOK_URL/assets/ab12cd34ef56) +python "$DAYBOOK_POST" report.md ``` The image is linked to the artifact that references it and is deleted along with it. @@ -136,19 +167,19 @@ just post the file positionally; auto-mode detects `.csv`/`.json` and picks the right kind automatically: ```bash -python skills/daybook-posting/scripts/daybook_post.py results.csv -python skills/daybook-posting/scripts/daybook_post.py --kind json --title "Run Summary" --content-file summary.json +python "$DAYBOOK_POST" results.csv +python "$DAYBOOK_POST" --kind json --title "Run Summary" --content-file summary.json ``` `.xlsx` spreadsheets work too, with no special flag needed — just `--file` (or post it positionally, same as any other file): ```bash -python skills/daybook-posting/scripts/daybook_post.py balance_sheet.xlsx +python "$DAYBOOK_POST" balance_sheet.xlsx ``` The server converts it to a `csv` artifact automatically. Known -limitations (see `docs/api.md` for the full list): +limitations (see the instance's `docs/api.md` for the full list): - Only the workbook's **active sheet** converts; other sheets are dropped. - Formula cells need to have been calculated and saved by real spreadsheet @@ -165,7 +196,7 @@ as a binary, use `--file path --as-markdown` to wrap it in a Markdown fenced code block (still gets syntax highlighting): ```bash -python skills/daybook-posting/scripts/daybook_post.py --file script.py --as-markdown --title "Generated Script" +python "$DAYBOOK_POST" --file script.py --as-markdown --title "Generated Script" ``` Without `--as-markdown`, it uploads as a downloadable binary file instead. @@ -189,8 +220,37 @@ context like `run_id` or `model` — these end up in the artifact's ## Rules - Run `--check` first if unsure Daybook is reachable. +- Make sure `DAYBOOK_URL` points at the right instance and `DAYBOOK_TOKEN` is set before posting anything real. - Use a stable `DAYBOOK_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact). - Use `--tags a,b,c` for filtering. - Use `--meta run_id=... --meta model=...` for machine-readable context. - Never inline multiline content into the shell command; use a file or stdin. - Add `--dry-run` to see exactly what would be sent (method, headers, payload) without actually posting — useful when unsure a payload is well-formed. + +## `curl` Fallback (No Helper Available) + +Prefer the helper above — it handles multipart, content-type inference, +JSON shaping, and provenance flags for you. Use raw `curl` **only** when you +genuinely can't run the bundled Python script (e.g. a minimal environment +with no Python). Text artifacts post as JSON to `POST /api/artifacts` with +bearer-token auth: + +```bash +curl -sS -X POST "$DAYBOOK_URL/api/artifacts" \ + -H "Authorization: Bearer $DAYBOOK_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"kind":"markdown","title":"Build Report","source":"agent","content":"# Hello\n\nBody text."}' +``` + +File/image artifacts use multipart instead: + +```bash +curl -sS -X POST "$DAYBOOK_URL/api/artifacts" \ + -H "Authorization: Bearer $DAYBOOK_TOKEN" \ + -F "kind=image" -F "title=Architecture Diagram" -F "source=agent" \ + -F "file=@diagram.png" +``` + +`GET $DAYBOOK_URL/api/capabilities` (no auth needed beyond a login/token per +that instance's config) lists supported kinds, methods, and the server +version if you need to confirm the contract before posting. From fb692612187f09bb32a5b36ea40e6553c5006b0e Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 3 Sep 2026 15:50:04 +0100 Subject: [PATCH 03/22] Add GET /api/skill endpoint serving the posting skill Serves skills/daybook-posting/SKILL.md so users/agents can fetch the posting instructions from the running instance instead of hunting for the file. The server rewrites the DAYBOOK_URL config line to the request's own origin, so the text handed to an agent already points at the right server; the recipient only supplies their token. - Same auth as /api/capabilities (session or bearer token); no user data. - Raw text/markdown by default, ?format=json returns {version, url, skill}. - Advertised via skill_url + a /api/skill methods entry in capabilities. - Ships in the Docker image (COPY . /app, skills/ not dockerignored). - Tests for auth, origin rewrite, JSON mode, session auth, capabilities ad. - docs/api.md + CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 11 ++++++++- artifacts/urls.py | 1 + artifacts/views.py | 46 +++++++++++++++++++++++++++++++++++++ docs/api.md | 34 +++++++++++++++++++++++++-- tests/api/test_artifacts.py | 37 +++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ce888e..4281694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,16 @@ the git tags (`vX.Y.Z`) and `daybook_project.__version__`. ## [Unreleased] -_Nothing yet._ +### Added +- `GET /api/skill` serves the bundled agent-posting skill as Markdown (or + `?format=json`), with `DAYBOOK_URL` pre-filled to the instance's own + origin so the instructions are ready to hand to an AI. Advertised as + `skill_url` in `/api/capabilities`. + +### Changed +- Rewrote the posting skill (`skills/daybook-posting/SKILL.md`) to be + location-agnostic: no longer assumes a local source checkout, reframes + `DAYBOOK_URL` as set-your-instance, and adds a raw `curl` fallback. ## [0.1.0] — 2026-09-03 diff --git a/artifacts/urls.py b/artifacts/urls.py index 98323e3..dae96dc 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path("assets/", views.asset_view, name="asset"), path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"), path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"), + path("api/skill", views.SkillView.as_view(), name="skill-api"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), path("api/artifacts/", views.ArtifactDetailView.as_view(), name="artifact-detail-api"), path("settings/", views.settings_home, name="settings-home"), diff --git a/artifacts/views.py b/artifacts/views.py index 555c121..a6ec867 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -322,11 +322,57 @@ class CapabilitiesView(APIView): "/api/artifacts": ["GET", "POST"], "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], "/api/assets": ["POST"], + "/api/capabilities": ["GET"], + "/api/skill": ["GET"], }, + "skill_url": request.build_absolute_uri(reverse("skill-api")), } ) +SKILL_MD_PATH = settings.BASE_DIR / "skills" / "daybook-posting" / "SKILL.md" + + +def render_posting_skill(origin): + """Return the bundled posting SKILL.md with its DAYBOOK_URL placeholder + rewritten to this instance's origin, so text handed to an agent already + points at the right server. Only the config-block assignment is touched; + the surrounding prose (which explains DAYBOOK_URL) is left alone.""" + text = SKILL_MD_PATH.read_text(encoding="utf-8") + return re.sub( + r"(?m)^(DAYBOOK_URL=)\S+", + lambda m: m.group(1) + origin, + text, + ) + + +class SkillView(APIView): + """Serve the bundled agent-posting skill (skills/daybook-posting/SKILL.md), + with DAYBOOK_URL pre-filled to this instance so a recipient posts to the + right server. Same auth as capabilities: a logged-in user can copy it, or + an agent can fetch it with its API token. + + GET /api/skill -> raw Markdown (text/markdown) + GET /api/skill?format=json -> {"version", "url", "skill"} + """ + + 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") + + origin = request.build_absolute_uri("/").rstrip("/") + try: + skill = render_posting_skill(origin) + except FileNotFoundError: + raise Http404("Posting skill is not bundled with this deployment") + + if request.GET.get("format") == "json": + from daybook_project import __version__ + + return Response({"version": __version__, "url": origin, "skill": skill}) + return HttpResponse(skill, content_type="text/markdown; charset=utf-8") + + FEED_WINDOW_DAYS = 7 diff --git a/docs/api.md b/docs/api.md index 8d95c6f..b3778c2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -529,8 +529,11 @@ Example response: "methods": { "/api/artifacts": ["GET", "POST"], "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], - "/api/assets": ["POST"] - } + "/api/assets": ["POST"], + "/api/capabilities": ["GET"], + "/api/skill": ["GET"] + }, + "skill_url": "http://127.0.0.1:8001/api/skill" } ``` @@ -545,6 +548,33 @@ endpoints, it accepts a bearer token as well as a browser session: curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/capabilities ``` +## Posting Skill + +Endpoint: + +```http +GET /api/skill +GET /api/skill?format=json +``` + +Returns the bundled agent-posting skill (`skills/daybook-posting/SKILL.md`) — +the same instructions you'd give an AI so it knows how to publish to this +instance. The server rewrites the `DAYBOOK_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 `DAYBOOK_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"}`. + +```bash +# Fetch the ready-to-use instructions for this instance +curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/skill +``` + +Discover it programmatically via `skill_url` in the capabilities response. + ## Current MVP Constraints - Each user has a personal ingest API token; there is no shared token. diff --git a/tests/api/test_artifacts.py b/tests/api/test_artifacts.py index 967ed4f..f801988 100644 --- a/tests/api/test_artifacts.py +++ b/tests/api/test_artifacts.py @@ -12,6 +12,7 @@ from django.utils import timezone from rest_framework.authtoken.models import Token from rest_framework.test import APITestCase +from daybook_project import __version__ from artifacts.models import Artifact User = get_user_model() @@ -78,6 +79,42 @@ class ArtifactAPITestCase(APITestCase): response = self.client.get(reverse("capabilities-api")) self.assertEqual(response.status_code, 200) + def test_capabilities_advertises_skill(self): + response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token)) + self.assertEqual(response.data["methods"]["/api/skill"], ["GET"]) + self.assertTrue(response.data["skill_url"].endswith("/api/skill")) + + # -- skill ---------------------------------------------------------- + + def test_skill_requires_auth(self): + response = self.client.get(reverse("skill-api")) + self.assertEqual(response.status_code, 403) + + def test_skill_returns_markdown_with_origin_filled_in(self): + response = self.client.get(reverse("skill-api"), **self.auth_headers(self.token)) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/markdown; charset=utf-8") + body = response.content.decode() + # Frontmatter is intact and the config placeholder is rewritten to this + # instance's origin (http://testserver under the test client). + self.assertIn("name: daybook-posting", body) + self.assertIn("DAYBOOK_URL=http://testserver", body) + self.assertNotIn("your-daybook-instance.example.com", body) + + def test_skill_json_format(self): + response = self.client.get( + reverse("skill-api"), {"format": "json"}, **self.auth_headers(self.token) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["version"], __version__) + self.assertEqual(response.data["url"], "http://testserver") + self.assertIn("name: daybook-posting", response.data["skill"]) + + def test_skill_with_session(self): + self.client.force_login(self.user) + response = self.client.get(reverse("skill-api")) + self.assertEqual(response.status_code, 200) + # -- create (POST) -------------------------------------------------- def test_create_markdown_artifact(self): From 5c46f2aa67a53b31f9da39ac32696536f2da0444 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 3 Sep 2026 16:13:19 +0100 Subject: [PATCH 04/22] Release v0.1.1: fix collectstatic crash-loop; ship /api/skill Fixes the deploy blocker found deploying v0.1.0 to cloud-vm-1: the vendored gridjs.umd.js ended with a //# sourceMappingURL=gridjs.umd.js.map trailer, but that .map isn't in the repo. The strict CompressedManifestStaticFilesStorage backend follows the reference and raises MissingFileError, aborting the entrypoint's collectstatic so the container never served. Stripped the debug-only trailer (no runtime effect); collectstatic now succeeds from a pristine checkout (169 copied, 481 post-processed). Also rolls up this release's already-committed work: GET /api/skill and the location-agnostic posting-skill rewrite. - Bump __version__ to 0.1.1. - Runbook: note that a post-build crash-loop is most likely a collectstatic MissingFileError (check docker logs), and how to clear on-server tracked drift before checking out a tag. - CHANGELOG: 0.1.1 entry. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 ++++++++++++- daybook_project/__init__.py | 2 +- deploy/cloud_agent_deploy.md | 14 ++++++++++++++ static/vendor/gridjs/gridjs.umd.js | 1 - 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4281694..6520e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ the git tags (`vX.Y.Z`) and `daybook_project.__version__`. ## [Unreleased] +_Nothing yet._ + +## [0.1.1] — 2026-09-03 + +### Fixed +- Deployment crash-loop: the vendored `gridjs.umd.js` referenced a + `gridjs.umd.js.map` sourcemap that isn't in the repo, so `collectstatic` + (strict manifest storage) failed on container start and v0.1.0 never came + up. Removed the dangling sourcemap trailer (debug-only, no runtime effect). + ### Added - `GET /api/skill` serves the bundled agent-posting skill as Markdown (or `?format=json`), with `DAYBOOK_URL` pre-filled to the instance's own @@ -53,5 +63,6 @@ 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.1.0...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...HEAD +[0.1.1]: https://git.cygnus.systems/peter/daybook/compare/v0.1.0...v0.1.1 [0.1.0]: https://git.cygnus.systems/peter/daybook/releases/tag/v0.1.0 diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index 29f4719..e27f5fc 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.1.0" +__version__ = "0.1.1" diff --git a/deploy/cloud_agent_deploy.md b/deploy/cloud_agent_deploy.md index daff899..c369211 100644 --- a/deploy/cloud_agent_deploy.md +++ b/deploy/cloud_agent_deploy.md @@ -234,6 +234,20 @@ the code checkout. or `deploy/data` by default) are untracked, so `git pull` never touches them — no need to back them up before a routine redeploy. +**If the container crash-loops right after a build** (`docker compose ps` +shows `restarting`, health never goes green), the most likely cause is the +entrypoint's `collectstatic` step failing — check `docker logs daybook` +before assuming an env/network problem. A `whitenoise ... MissingFileError` +there means a static asset references a file that isn't present (this is what +bit v0.1.0). No data or schema impact; fix the asset in the repo, tag, and +redeploy. + +**If `git checkout ` refuses because of local working-tree changes** to +tracked files (e.g. a previous on-server hotfix), reset those tracked files +first — `git checkout -- ` for a specific one, or `git stash` / +`git reset --hard` to clear all tracked drift. This never touches `deploy/.env` +or the data directory, which are untracked and therefore safe. + ## 8. Hardening For Internet-Facing Deployments If this VM is reachable from the public internet (not just a private diff --git a/static/vendor/gridjs/gridjs.umd.js b/static/vendor/gridjs/gridjs.umd.js index 31761d7..6e79315 100644 --- a/static/vendor/gridjs/gridjs.umd.js +++ b/static/vendor/gridjs/gridjs.umd.js @@ -1,2 +1 @@ !function(t,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t||self).gridjs={})}(this,function(t){function n(t,n){for(var e=0;et.length)&&(n=t.length);for(var e=0,r=new Array(n);e=t.length?{done:!0}:{done:!1,value:t[r++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var l;!function(t){t[t.Init=0]="Init",t[t.Loading=1]="Loading",t[t.Loaded=2]="Loaded",t[t.Rendered=3]="Rendered",t[t.Error=4]="Error"}(l||(l={}));var c,f,p,d,h,_,m,v={},g=[],y=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i;function b(t,n){for(var e in n)t[e]=n[e];return t}function w(t){var n=t.parentNode;n&&n.removeChild(t)}function x(t,n,e){var r,o,i,u={};for(i in n)"key"==i?r=n[i]:"ref"==i?o=n[i]:u[i]=n[i];if(arguments.length>2&&(u.children=arguments.length>3?c.call(arguments,2):e),"function"==typeof t&&null!=t.defaultProps)for(i in t.defaultProps)void 0===u[i]&&(u[i]=t.defaultProps[i]);return k(t,u,r,o,null)}function k(t,n,e,r,o){var i={type:t,props:n,key:e,ref:r,__k:null,__:null,__b:0,__e:null,__d:void 0,__c:null,__h:null,constructor:void 0,__v:null==o?++p:o};return null==o&&null!=f.vnode&&f.vnode(i),i}function P(t){return t.children}function S(t,n){this.props=t,this.context=n}function N(t,n){if(null==n)return t.__?N(t.__,t.__.__k.indexOf(t)+1):null;for(var e;n0?k(d.type,d.props,d.key,d.ref?d.ref:null,d.__v):d)){if(d.__=e,d.__b=e.__b+1,null===(p=y[c])||p&&d.key==p.key&&d.type===p.type)y[c]=void 0;else for(f=0;f0&&(this.callbacks[e].forEach(function(t){return t.apply(void 0,[].slice.call(n,1))}),!0)},t}();function Y(t,n){if(typeof t!=typeof n)return!1;if(null===t&&null===n)return!0;if("object"!=typeof t)return t===n;if(Array.isArray(t)&&Array.isArray(n)){if(t.length!==n.length)return!1;for(var e=0;e=e.__.length&&e.__.push({__V:ft}),e.__[t]}function gt(t){return lt=1,function(t,n,e){var r=vt(ot++,2);if(r.t=t,!r.__c&&(r.__=[Et(void 0,n),function(t){var n=r.__N?r.__N[0]:r.__[0],e=r.t(n,t);n!==e&&(r.__N=[e,r.__[1]],r.__c.setState({}))}],r.__c=it,!it.u)){it.u=!0;var o=it.shouldComponentUpdate;it.shouldComponentUpdate=function(t,n,e){if(!r.__c.__H)return!0;var i=r.__c.__H.__.filter(function(t){return t.__c});if(i.every(function(t){return!t.__N}))return!o||o.call(this,t,n,e);var u=!1;return i.forEach(function(t){if(t.__N){var n=t.__[0];t.__=t.__N,t.__N=void 0,n!==t.__[0]&&(u=!0)}}),!(!u&&r.__c.props===t)&&(!o||o.call(this,t,n,e))}}return r.__N||r.__}(Et,t)}function yt(t,n){var e=vt(ot++,3);!f.__s&&Ct(e.__H,n)&&(e.__=t,e.i=n,it.__H.__h.push(e))}function bt(t){return lt=5,wt(function(){return{current:t}},[])}function wt(t,n){var e=vt(ot++,7);return Ct(e.__H,n)?(e.__V=t(),e.i=n,e.__h=t,e.__V):e.__}function xt(){for(var t;t=ct.shift();)if(t.__P&&t.__H)try{t.__H.__h.forEach(St),t.__H.__h.forEach(Nt),t.__H.__h=[]}catch(n){t.__H.__h=[],f.__e(n,t.__v)}}f.__b=function(t){it=null,pt&&pt(t)},f.__r=function(t){dt&&dt(t),ot=0;var n=(it=t.__c).__H;n&&(ut===it?(n.__h=[],it.__h=[],n.__.forEach(function(t){t.__N&&(t.__=t.__N),t.__V=ft,t.__N=t.i=void 0})):(n.__h.forEach(St),n.__h.forEach(Nt),n.__h=[])),ut=it},f.diffed=function(t){ht&&ht(t);var n=t.__c;n&&n.__H&&(n.__H.__h.length&&(1!==ct.push(n)&&st===f.requestAnimationFrame||((st=f.requestAnimationFrame)||Pt)(xt)),n.__H.__.forEach(function(t){t.i&&(t.__H=t.i),t.__V!==ft&&(t.__=t.__V),t.i=void 0,t.__V=ft})),ut=it=null},f.__c=function(t,n){n.some(function(t){try{t.__h.forEach(St),t.__h=t.__h.filter(function(t){return!t.__||Nt(t)})}catch(e){n.some(function(t){t.__h&&(t.__h=[])}),n=[],f.__e(e,t.__v)}}),_t&&_t(t,n)},f.unmount=function(t){mt&&mt(t);var n,e=t.__c;e&&e.__H&&(e.__H.__.forEach(function(t){try{St(t)}catch(t){n=t}}),e.__H=void 0,n&&f.__e(n,e.__v))};var kt="function"==typeof requestAnimationFrame;function Pt(t){var n,e=function(){clearTimeout(r),kt&&cancelAnimationFrame(n),setTimeout(t)},r=setTimeout(e,100);kt&&(n=requestAnimationFrame(e))}function St(t){var n=it,e=t.__c;"function"==typeof e&&(t.__c=void 0,e()),it=n}function Nt(t){var n=it;t.__c=t.__(),it=n}function Ct(t,n){return!t||t.length!==n.length||n.some(function(n,e){return n!==t[e]})}function Et(t,n){return"function"==typeof n?n(t):n}function It(){return function(t){var n=it.context[t.__c],e=vt(ot++,9);return e.c=t,n?(null==e.__&&(e.__=!0,n.sub(it)),n.props.value):t.__}(fn)}var Tt={search:{placeholder:"Type a keyword..."},sort:{sortAsc:"Sort column ascending",sortDesc:"Sort column descending"},pagination:{previous:"Previous",next:"Next",navigate:function(t,n){return"Page "+t+" of "+n},page:function(t){return"Page "+t},showing:"Showing",of:"of",to:"to",results:"results"},loading:"Loading...",noRecordsFound:"No matching records found",error:"An error happened while fetching the data"},Lt=/*#__PURE__*/function(){function t(t){this._language=void 0,this._defaultLanguage=void 0,this._language=t,this._defaultLanguage=Tt}var n=t.prototype;return n.getString=function(t,n){if(!n||!t)return null;var e=t.split("."),r=e[0];if(n[r]){var o=n[r];return"string"==typeof o?function(){return o}:"function"==typeof o?o:this.getString(e.slice(1).join("."),o)}return null},n.translate=function(t){var n,e=this.getString(t,this._language);return(n=e||this.getString(t,this._defaultLanguage))?n.apply(void 0,[].slice.call(arguments,1)):t},t}();function At(){var t=It();return function(n){var e;return(e=t.translator).translate.apply(e,[n].concat([].slice.call(arguments,1)))}}var Ot=function(t){return function(n){return r({},n,{search:{keyword:t}})}};function jt(){return It().store}function Ht(t){var n=jt(),e=gt(t(n.getState())),r=e[0],o=e[1];return yt(function(){return n.subscribe(function(){var e=t(n.getState());r!==e&&o(e)})},[]),r}function Dt(){var t,n=gt(void 0),e=n[0],r=n[1],o=It(),i=o.search,u=At(),s=jt().dispatch,a=Ht(function(t){return t.search});yt(function(){e&&e.setProps({keyword:null==a?void 0:a.keyword})},[a,e]),yt(function(){r(i.server?new at({keyword:i.keyword,url:i.server.url,body:i.server.body}):new nt({keyword:i.keyword,columns:o.header&&o.header.columns,ignoreHiddenColumns:i.ignoreHiddenColumns||void 0===i.ignoreHiddenColumns,selector:i.selector})),i.keyword&&s(Ot(i.keyword))},[i]),yt(function(){if(e)return o.pipeline.register(e),function(){return o.pipeline.unregister(e)}},[o,e]);var l,c,f,p=function(t,n){return lt=8,wt(function(){return t},n)}((l=function(t){t.target instanceof HTMLInputElement&&s(Ot(t.target.value))},c=e instanceof at?i.debounceTimeout||250:0,function(){var t=arguments;return new Promise(function(n){f&&clearTimeout(f),f=setTimeout(function(){return n(l.apply(void 0,[].slice.call(t)))},c)})}),[i,e]);return x("div",{className:et(rt("search",null==(t=o.className)?void 0:t.search))},x("input",{type:"search",placeholder:u("search.placeholder"),"aria-label":u("search.placeholder"),onInput:p,className:rt(et("input"),et("search","input")),defaultValue:(null==a?void 0:a.keyword)||""}))}var Mt=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}o(n,t);var r=n.prototype;return r.validateProps=function(){if(isNaN(Number(this.props.limit))||isNaN(Number(this.props.page)))throw Error("Invalid parameters passed")},r._process=function(t){var n=this.props.page;return new J(t.rows.slice(n*this.props.limit,(n+1)*this.props.limit))},e(n,[{key:"type",get:function(){return K.Limit}}]),n}(tt),Rt=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}return o(n,t),n.prototype._process=function(t){var n={};return this.props.url&&(n.url=this.props.url(t.url,this.props.page,this.props.limit)),this.props.body&&(n.body=this.props.body(t.body,this.props.page,this.props.limit)),r({},t,n)},e(n,[{key:"type",get:function(){return K.ServerLimit}}]),n}(tt);function Ft(){var t=It(),n=t.pagination,e=n.server,r=n.summary,o=void 0===r||r,i=n.nextButton,u=void 0===i||i,s=n.prevButton,a=void 0===s||s,l=n.buttonsCount,c=void 0===l?3:l,f=n.limit,p=void 0===f?10:f,d=n.page,h=void 0===d?0:d,_=n.resetPageOnUpdate,m=void 0===_||_,v=bt(null),g=gt(h),y=g[0],b=g[1],w=gt(0),k=w[0],S=w[1],N=At();yt(function(){return e?(v.current=new Rt({limit:p,page:y,url:e.url,body:e.body}),t.pipeline.register(v.current)):(v.current=new Mt({limit:p,page:y}),t.pipeline.register(v.current)),v.current instanceof Rt?t.pipeline.on("afterProcess",function(t){return S(t.length)}):v.current instanceof Mt&&v.current.on("beforeProcess",function(t){return S(t.length)}),t.pipeline.on("updated",C),t.pipeline.on("error",function(){S(0),b(0)}),function(){t.pipeline.unregister(v.current),t.pipeline.off("updated",C)}},[]);var C=function(t){m&&t!==v.current&&(b(0),0!==v.current.props.page&&v.current.setProps({page:0}))},E=function(){return Math.ceil(k/p)},I=function(t){if(t>=E()||t<0||t===y)return null;b(t),v.current.setProps({page:t})};return x("div",{className:rt(et("pagination"),t.className.pagination)},x(P,null,o&&k>0&&x("div",{role:"status","aria-live":"polite",className:rt(et("summary"),t.className.paginationSummary),title:N("pagination.navigate",y+1,E())},N("pagination.showing")," ",x("b",null,N(""+(y*p+1)))," ",N("pagination.to")," ",x("b",null,N(""+Math.min((y+1)*p,k)))," ",N("pagination.of")," ",x("b",null,N(""+k))," ",N("pagination.results"))),x("div",{className:et("pages")},a&&x("button",{tabIndex:0,role:"button",disabled:0===y,onClick:function(){return I(y-1)},title:N("pagination.previous"),"aria-label":N("pagination.previous"),className:rt(t.className.paginationButton,t.className.paginationButtonPrev)},N("pagination.previous")),function(){if(c<=0)return null;var n=Math.min(E(),c),e=Math.min(y,Math.floor(n/2));return y+Math.floor(n/2)>=E()&&(e=n-(E()-y)),x(P,null,E()>n&&y-e>0&&x(P,null,x("button",{tabIndex:0,role:"button",onClick:function(){return I(0)},title:N("pagination.firstPage"),"aria-label":N("pagination.firstPage"),className:t.className.paginationButton},N("1")),x("button",{tabIndex:-1,className:rt(et("spread"),t.className.paginationButton)},"...")),Array.from(Array(n).keys()).map(function(t){return y+(t-e)}).map(function(n){return x("button",{tabIndex:0,role:"button",onClick:function(){return I(n)},className:rt(y===n?rt(et("currentPage"),t.className.paginationButtonCurrent):null,t.className.paginationButton),title:N("pagination.page",n+1),"aria-label":N("pagination.page",n+1)},N(""+(n+1)))}),E()>n&&E()>y+e+1&&x(P,null,x("button",{tabIndex:-1,className:rt(et("spread"),t.className.paginationButton)},"..."),x("button",{tabIndex:0,role:"button",onClick:function(){return I(E()-1)},title:N("pagination.page",E()),"aria-label":N("pagination.page",E()),className:t.className.paginationButton},N(""+E()))))}(),u&&x("button",{tabIndex:0,role:"button",disabled:E()===y+1||0===E(),onClick:function(){return I(y+1)},title:N("pagination.next"),"aria-label":N("pagination.next"),className:rt(t.className.paginationButton,t.className.paginationButtonNext)},N("pagination.next"))))}function Ut(t,n){return"string"==typeof t?t.indexOf("%")>-1?n/100*parseInt(t,10):parseInt(t,10):t}function Wt(t){return t?Math.floor(t)+"px":""}function Bt(t){var n=t.tableRef.cloneNode(!0);return n.style.position="absolute",n.style.width="100%",n.style.zIndex="-2147483640",n.style.visibility="hidden",x("div",{ref:function(t){t&&t.appendChild(n)}})}function qt(t){if(!t)return"";var n=t.split(" ");return 1===n.length&&/([a-z][A-Z])+/g.test(t)?t:n.map(function(t,n){return 0==n?t.toLowerCase():t.charAt(0).toUpperCase()+t.slice(1).toLowerCase()}).join("")}var zt,Vt=new(/*#__PURE__*/function(){function t(){}var n=t.prototype;return n.format=function(t,n){return"[Grid.js] ["+n.toUpperCase()+"]: "+t},n.error=function(t,n){void 0===n&&(n=!1);var e=this.format(t,"error");if(n)throw Error(e);console.error(e)},n.warn=function(t){console.warn(this.format(t,"warn"))},n.info=function(t){console.info(this.format(t,"info"))},t}());t.PluginPosition=void 0,(zt=t.PluginPosition||(t.PluginPosition={}))[zt.Header=0]="Header",zt[zt.Footer=1]="Footer",zt[zt.Cell=2]="Cell";var $t=/*#__PURE__*/function(){function t(){this.plugins=void 0,this.plugins=[]}var n=t.prototype;return n.get=function(t){return this.plugins.find(function(n){return n.id===t})},n.add=function(t){return t.id?this.get(t.id)?(Vt.error("Duplicate plugin ID: "+t.id),this):(this.plugins.push(t),this):(Vt.error("Plugin ID cannot be empty"),this)},n.remove=function(t){var n=this.get(t);return n&&this.plugins.splice(this.plugins.indexOf(n),1),this},n.list=function(t){var n;return n=null!=t||null!=t?this.plugins.filter(function(n){return n.position===t}):this.plugins,n.sort(function(t,n){return t.order&&n.order?t.order-n.order:1})},t}();function Gt(t){var n=this,e=It();if(t.pluginId){var o=e.plugin.get(t.pluginId);return o?x(P,{},x(o.component,r({plugin:o},t.props))):null}return void 0!==t.position?x(P,{},e.plugin.list(t.position).map(function(t){return x(t.component,r({plugin:t},n.props.props))})):null}var Kt=/*#__PURE__*/function(n){function i(){var t;return(t=n.call(this)||this)._columns=void 0,t._columns=[],t}o(i,n);var u=i.prototype;return u.adjustWidth=function(t,n,e){var o=t.container,u=t.autoWidth;if(!o)return this;var s=o.clientWidth,l={};n.current&&u&&(q(x(Bt,{tableRef:n.current}),e.current),l=function(t){var n=t.querySelector("table");if(!n)return{};var e=n.className,o=n.style.cssText;n.className=e+" "+et("shadowTable"),n.style.tableLayout="auto",n.style.width="auto",n.style.padding="0",n.style.margin="0",n.style.border="none",n.style.outline="none";var i=Array.from(n.parentNode.querySelectorAll("thead th")).reduce(function(t,n){var e;return n.style.width=n.clientWidth+"px",r(((e={})[n.getAttribute("data-column-id")]={minWidth:n.clientWidth},e),t)},{});return n.className=e,n.style.cssText=o,n.style.tableLayout="auto",Array.from(n.parentNode.querySelectorAll("thead th")).reduce(function(t,n){return t[n.getAttribute("data-column-id")].width=n.clientWidth,t},i)}(e.current));for(var c,f=a(i.tabularFormat(this.columns).reduce(function(t,n){return t.concat(n)},[]));!(c=f()).done;){var p=c.value;p.columns&&p.columns.length>0||(!p.width&&u?p.id in l&&(p.width=Wt(l[p.id].width),p.minWidth=Wt(l[p.id].minWidth)):p.width=Wt(Ut(p.width,s)))}return n.current&&u&&q(null,e.current),this},u.setSort=function(t,n){for(var e,o=a(n||this.columns||[]);!(e=o()).done;){var i=e.value;i.columns&&i.columns.length>0?i.sort=void 0:void 0===i.sort&&t?i.sort={}:i.sort?"object"==typeof i.sort&&(i.sort=r({},i.sort)):i.sort=void 0,i.columns&&this.setSort(t,i.columns)}},u.setResizable=function(t,n){for(var e,r=a(n||this.columns||[]);!(e=r()).done;){var o=e.value;void 0===o.resizable&&(o.resizable=t),o.columns&&this.setResizable(t,o.columns)}},u.setID=function(t){for(var n,e=a(t||this.columns||[]);!(n=e()).done;){var r=n.value;r.id||"string"!=typeof r.name||(r.id=qt(r.name)),r.id||Vt.error('Could not find a valid ID for one of the columns. Make sure a valid "id" is set for all columns.'),r.columns&&this.setID(r.columns)}},u.populatePlugins=function(n,e){for(var o,i=a(e);!(o=i()).done;){var u=o.value;void 0!==u.plugin&&n.add(r({id:u.id},u.plugin,{position:t.PluginPosition.Cell}))}},i.fromColumns=function(t){for(var n,e=new i,r=a(t);!(n=r()).done;){var o=n.value;if("string"==typeof o||d(o))e.columns.push({name:o});else if("object"==typeof o){var u=o;u.columns&&(u.columns=i.fromColumns(u.columns).columns),"object"==typeof u.plugin&&void 0===u.data&&(u.data=null),e.columns.push(o)}}return e},i.createFromConfig=function(t){var n=new i;return t.from?n.columns=i.fromHTMLTable(t.from).columns:t.columns?n.columns=i.fromColumns(t.columns).columns:!t.data||"object"!=typeof t.data[0]||t.data[0]instanceof Array||(n.columns=Object.keys(t.data[0]).map(function(t){return{name:t}})),n.columns.length?(n.setID(),n.setSort(t.sort),n.setResizable(t.resizable),n.populatePlugins(t.plugin,n.columns),n):null},i.fromHTMLTable=function(t){for(var n,e=new i,r=a(t.querySelector("thead").querySelectorAll("th"));!(n=r()).done;){var o=n.value;e.columns.push({name:o.innerHTML,width:o.width})}return e},i.tabularFormat=function(t){var n=[],e=t||[],r=[];if(e&&e.length){n.push(e);for(var o,i=a(e);!(o=i()).done;){var u=o.value;u.columns&&u.columns.length&&(r=r.concat(u.columns))}r.length&&(n=n.concat(this.tabularFormat(r)))}return n},i.leafColumns=function(t){var n=[],e=t||[];if(e&&e.length)for(var r,o=a(e);!(r=o()).done;){var i=r.value;i.columns&&0!==i.columns.length||n.push(i),i.columns&&(n=n.concat(this.leafColumns(i.columns)))}return n},i.maximumDepth=function(t){return this.tabularFormat([t]).length-1},e(i,[{key:"columns",get:function(){return this._columns},set:function(t){this._columns=t}},{key:"visibleColumns",get:function(){return this._columns.filter(function(t){return!t.hidden})}}]),i}(V),Xt=function(){},Zt=/*#__PURE__*/function(t){function n(n){var e;return(e=t.call(this)||this).data=void 0,e.set(n),e}o(n,t);var e=n.prototype;return e.get=function(){try{return Promise.resolve(this.data()).then(function(t){return{data:t,total:t.length}})}catch(t){return Promise.reject(t)}},e.set=function(t){return t instanceof Array?this.data=function(){return t}:t instanceof Function&&(this.data=t),this},n}(Xt),Jt=/*#__PURE__*/function(t){function n(n){var e;return(e=t.call(this)||this).options=void 0,e.options=n,e}o(n,t);var e=n.prototype;return e.handler=function(t){return"function"==typeof this.options.handle?this.options.handle(t):t.ok?t.json():(Vt.error("Could not fetch data: "+t.status+" - "+t.statusText,!0),null)},e.get=function(t){var n=r({},this.options,t);return"function"==typeof n.data?n.data(n):fetch(n.url,n).then(this.handler.bind(this)).then(function(t){return{data:n.then(t),total:"function"==typeof n.total?n.total(t):void 0}})},n}(Xt),Qt=/*#__PURE__*/function(){function t(){}return t.createFromConfig=function(t){var n=null;return t.data&&(n=new Zt(t.data)),t.from&&(n=new Zt(this.tableElementToArray(t.from)),t.from.style.display="none"),t.server&&(n=new Jt(t.server)),n||Vt.error("Could not determine the storage type",!0),n},t.tableElementToArray=function(t){for(var n,e,r=[],o=a(t.querySelector("tbody").querySelectorAll("tr"));!(n=o()).done;){for(var i,u=[],s=a(n.value.querySelectorAll("td"));!(i=s()).done;){var l=i.value;1===l.childNodes.length&&l.childNodes[0].nodeType===Node.TEXT_NODE?u.push((e=l.innerHTML,(new DOMParser).parseFromString(e,"text/html").documentElement.textContent)):u.push(G(l.innerHTML))}r.push(u)}return r},t}(),Yt="undefined"!=typeof Symbol?Symbol.iterator||(Symbol.iterator=Symbol("Symbol.iterator")):"@@iterator";function tn(t,n,e){if(!t.s){if(e instanceof nn){if(!e.s)return void(e.o=tn.bind(null,t,n));1&n&&(n=e.s),e=e.v}if(e&&e.then)return void e.then(tn.bind(null,t,n),tn.bind(null,t,2));t.s=n,t.v=e;var r=t.o;r&&r(t)}}var nn=/*#__PURE__*/function(){function t(){}return t.prototype.then=function(n,e){var r=new t,o=this.s;if(o){var i=1&o?n:e;if(i){try{tn(r,1,i(this.v))}catch(t){tn(r,2,t)}return r}return this}return this.o=function(t){try{var o=t.v;1&t.s?tn(r,1,n?n(o):o):e?tn(r,1,e(o)):tn(r,2,o)}catch(t){tn(r,2,t)}},r},t}();function en(t){return t instanceof nn&&1&t.s}var rn=/*#__PURE__*/function(t){function n(n){var e;return(e=t.call(this)||this)._steps=new Map,e.cache=new Map,e.lastProcessorIndexUpdated=-1,n&&n.forEach(function(t){return e.register(t)}),e}o(n,t);var r=n.prototype;return r.clearCache=function(){this.cache=new Map,this.lastProcessorIndexUpdated=-1},r.register=function(t,n){if(void 0===n&&(n=null),!t)throw Error("Processor is not defined");if(null===t.type)throw Error("Processor type is not defined");if(this.findProcessorIndexByID(t.id)>-1)throw Error("Processor ID "+t.id+" is already defined");return t.on("propsUpdated",this.processorPropsUpdated.bind(this)),this.addProcessorByPriority(t,n),this.afterRegistered(t),t},r.tryRegister=function(t,n){void 0===n&&(n=null);try{return this.register(t,n)}catch(t){}},r.unregister=function(t){if(t&&-1!==this.findProcessorIndexByID(t.id)){var n=this._steps.get(t.type);n&&n.length&&(this._steps.set(t.type,n.filter(function(n){return n!=t})),this.emit("updated",t))}},r.addProcessorByPriority=function(t,n){var e=this._steps.get(t.type);if(!e){var r=[];this._steps.set(t.type,r),e=r}if(null===n||n<0)e.push(t);else if(e[n]){var o=e.slice(0,n-1),i=e.slice(n+1);this._steps.set(t.type,o.concat(t).concat(i))}else e[n]=t},r.getStepsByType=function(t){return this.steps.filter(function(n){return n.type===t})},r.getSortedProcessorTypes=function(){return Object.keys(K).filter(function(t){return!isNaN(Number(t))}).map(function(t){return Number(t)})},r.process=function(t){try{var n=function(t){return e.lastProcessorIndexUpdated=o.length,e.emit("afterProcess",i),i},e=this,r=e.lastProcessorIndexUpdated,o=e.steps,i=t,u=function(t,n){try{var u=function(t,n,e){if("function"==typeof t[Yt]){var r,o,i,u=t[Yt]();if(function t(e){try{for(;!(r=u.next()).done;)if((e=n(r.value))&&e.then){if(!en(e))return void e.then(t,i||(i=tn.bind(null,o=new nn,2)));e=e.v}o?tn(o,1,e):o=e}catch(t){tn(o||(o=new nn),2,t)}}(),u.return){var s=function(t){try{r.done||u.return()}catch(t){}return t};if(o&&o.then)return o.then(s,function(t){throw s(t)});s()}return o}if(!("length"in t))throw new TypeError("Object is not iterable");for(var a=[],l=0;l=r)return Promise.resolve(t.process(i)).then(function(n){e.cache.set(t.id,i=n)});i=e.cache.get(t.id)}();if(o&&o.then)return o.then(function(){})})}catch(t){return n(t)}return u&&u.then?u.then(void 0,n):u}(0,function(t){throw Vt.error(t),e.emit("error",i),t});return Promise.resolve(u&&u.then?u.then(n):n())}catch(t){return Promise.reject(t)}},r.findProcessorIndexByID=function(t){return this.steps.findIndex(function(n){return n.id==t})},r.setLastProcessorIndex=function(t){var n=this.findProcessorIndexByID(t.id);this.lastProcessorIndexUpdated>n&&(this.lastProcessorIndexUpdated=n)},r.processorPropsUpdated=function(t){this.setLastProcessorIndex(t),this.emit("propsUpdated"),this.emit("updated",t)},r.afterRegistered=function(t){this.setLastProcessorIndex(t),this.emit("afterRegister"),this.emit("updated",t)},e(n,[{key:"steps",get:function(){for(var t,n=[],e=a(this.getSortedProcessorTypes());!(t=e()).done;){var r=this._steps.get(t.value);r&&r.length&&(n=n.concat(r))}return n.filter(function(t){return t})}}]),n}(Q),on=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}return o(n,t),n.prototype._process=function(t){try{return Promise.resolve(this.props.storage.get(t))}catch(t){return Promise.reject(t)}},e(n,[{key:"type",get:function(){return K.Extractor}}]),n}(tt),un=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}return o(n,t),n.prototype._process=function(t){var n=J.fromArray(t.data);return n.length=t.total,n},e(n,[{key:"type",get:function(){return K.Transformer}}]),n}(tt),sn=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}return o(n,t),n.prototype._process=function(){return Object.entries(this.props.serverStorageOptions).filter(function(t){return"function"!=typeof t[1]}).reduce(function(t,n){var e;return r({},t,((e={})[n[0]]=n[1],e))},{})},e(n,[{key:"type",get:function(){return K.Initiator}}]),n}(tt),an=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}o(n,t);var r=n.prototype;return r.castData=function(t){if(!t||!t.length)return[];if(!this.props.header||!this.props.header.columns)return t;var n=Kt.leafColumns(this.props.header.columns);return t[0]instanceof Array?t.map(function(t){var e=0;return n.map(function(n,r){return void 0!==n.data?(e++,"function"==typeof n.data?n.data(t):n.data):t[r-e]})}):"object"!=typeof t[0]||t[0]instanceof Array?[]:t.map(function(t){return n.map(function(n,e){return void 0!==n.data?"function"==typeof n.data?n.data(t):n.data:n.id?t[n.id]:(Vt.error("Could not find the correct cell for column at position "+e+".\n Make sure either 'id' or 'selector' is defined for all columns."),null)})})},r._process=function(t){return{data:this.castData(t.data),total:t.total}},e(n,[{key:"type",get:function(){return K.Transformer}}]),n}(tt),ln=/*#__PURE__*/function(){function t(){}return t.createFromConfig=function(t){var n=new rn;return t.storage instanceof Jt&&n.register(new sn({serverStorageOptions:t.server})),n.register(new on({storage:t.storage})),n.register(new an({header:t.header})),n.register(new un),n},t}(),cn=function(t){var n=this;this.state=void 0,this.listeners=[],this.isDispatching=!1,this.getState=function(){return n.state},this.getListeners=function(){return n.listeners},this.dispatch=function(t){if("function"!=typeof t)throw new Error("Reducer is not a function");if(n.isDispatching)throw new Error("Reducers may not dispatch actions");n.isDispatching=!0;var e=n.state;try{n.state=t(n.state)}finally{n.isDispatching=!1}for(var r,o=a(n.listeners);!(r=o()).done;)(0,r.value)(n.state,e);return n.state},this.subscribe=function(t){if("function"!=typeof t)throw new Error("Listener is not a function");return n.listeners=[].concat(n.listeners,[t]),function(){return n.listeners=n.listeners.filter(function(n){return n!==t})}},this.state=t},fn=function(t,n){var e={__c:n="__cC"+m++,__:null,Consumer:function(t,n){return t.children(n)},Provider:function(t){var e,r;return this.getChildContext||(e=[],(r={})[n]=this,this.getChildContext=function(){return r},this.shouldComponentUpdate=function(t){this.props.value!==t.value&&e.some(E)},this.sub=function(t){e.push(t);var n=t.componentWillUnmount;t.componentWillUnmount=function(){e.splice(e.indexOf(t),1),n&&n.call(t)}}),t.children}};return e.Provider.__=e.Consumer.contextType=e}(),pn=/*#__PURE__*/function(){function n(){Object.assign(this,n.defaultConfig())}var e=n.prototype;return e.assign=function(t){return Object.assign(this,t)},e.update=function(t){return t?(this.assign(n.fromPartialConfig(r({},this,t))),this):this},n.defaultConfig=function(){return{store:new cn({status:l.Init,header:void 0,data:null}),plugin:new $t,tableRef:{current:null},width:"100%",height:"auto",processingThrottleMs:100,autoWidth:!0,style:{},className:{}}},n.fromPartialConfig=function(e){var r=(new n).assign(e);return"boolean"==typeof e.sort&&e.sort&&r.assign({sort:{multiColumn:!0}}),r.assign({header:Kt.createFromConfig(r)}),r.assign({storage:Qt.createFromConfig(r)}),r.assign({pipeline:ln.createFromConfig(r)}),r.assign({translator:new Lt(r.language)}),r.plugin=new $t,r.search&&r.plugin.add({id:"search",position:t.PluginPosition.Header,component:Dt}),r.pagination&&r.plugin.add({id:"pagination",position:t.PluginPosition.Footer,component:Ft}),r.plugins&&r.plugins.forEach(function(t){return r.plugin.add(t)}),r},n}();function dn(t){var n,e=It();return x("td",r({role:t.role,colSpan:t.colSpan,"data-column-id":t.column&&t.column.id,className:rt(et("td"),t.className,e.className.td),style:r({},t.style,e.style.td),onClick:function(n){t.messageCell||e.eventEmitter.emit("cellClick",n,t.cell,t.column,t.row)}},(n=t.column)?"function"==typeof n.attributes?n.attributes(t.cell.data,t.row,t.column):n.attributes:{}),t.column&&"function"==typeof t.column.formatter?t.column.formatter(t.cell.data,t.row,t.column):t.column&&t.column.plugin?x(Gt,{pluginId:t.column.id,props:{column:t.column,cell:t.cell,row:t.row}}):t.cell.data)}function hn(t){var n=It(),e=Ht(function(t){return t.header});return x("tr",{className:rt(et("tr"),n.className.tr),onClick:function(e){t.messageRow||n.eventEmitter.emit("rowClick",e,t.row)}},t.children?t.children:t.row.cells.map(function(n,r){var o=function(t){if(e){var n=Kt.leafColumns(e.columns);if(n)return n[t]}return null}(r);return o&&o.hidden?null:x(dn,{key:n.id,cell:n,row:t.row,column:o})}))}function _n(t){return x(hn,{messageRow:!0},x(dn,{role:"alert",colSpan:t.colSpan,messageCell:!0,cell:new X(t.message),className:rt(et("message"),t.className?t.className:null)}))}function mn(){var t=It(),n=Ht(function(t){return t.data}),e=Ht(function(t){return t.status}),r=Ht(function(t){return t.header}),o=At(),i=function(){return r?r.visibleColumns.length:0};return x("tbody",{className:rt(et("tbody"),t.className.tbody)},n&&n.rows.map(function(t){return x(hn,{key:t.id,row:t})}),e===l.Loading&&(!n||0===n.length)&&x(_n,{message:o("loading"),colSpan:i(),className:rt(et("loading"),t.className.loading)}),e===l.Rendered&&n&&0===n.length&&x(_n,{message:o("noRecordsFound"),colSpan:i(),className:rt(et("notfound"),t.className.notfound)}),e===l.Error&&x(_n,{message:o("error"),colSpan:i(),className:rt(et("error"),t.className.error)}))}var vn=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}o(n,t);var r=n.prototype;return r.validateProps=function(){for(var t,n=a(this.props.columns);!(t=n()).done;){var e=t.value;void 0===e.direction&&(e.direction=1),1!==e.direction&&-1!==e.direction&&Vt.error("Invalid sort direction "+e.direction)}},r.compare=function(t,n){return t>n?1:t1&&(f=!0,c=!0):0===a?c=!0:a>0&&!e?(c=!0,f=!0):a>0&&e&&(c=!0),f&&(s=[]),c)s.push({index:t,direction:n,compare:o});else if(d){var h=s.indexOf(l);s[h].direction=n}else if(p){var _=s.indexOf(l);s.splice(_,1)}return r({},i,{sort:{columns:s}})}},yn=function(t,n,e){return function(o){var i=(o.sort?[].concat(o.sort.columns):[]).find(function(n){return n.index===t});return r({},o,i?gn(t,1===i.direction?-1:1,n,e)(o):gn(t,1,n,e)(o))}},bn=/*#__PURE__*/function(t){function n(){return t.apply(this,arguments)||this}return o(n,t),n.prototype._process=function(t){var n={};return this.props.url&&(n.url=this.props.url(t.url,this.props.columns)),this.props.body&&(n.body=this.props.body(t.body,this.props.columns)),r({},t,n)},e(n,[{key:"type",get:function(){return K.ServerSort}}]),n}(tt);function wn(t){var n=It(),e=jt().dispatch,o=At(),i=gt(0),u=i[0],s=i[1],a=n.sort,l=Ht(function(t){return t.sort}),c="object"==typeof(null==a?void 0:a.server)?K.ServerSort:K.Sort,f=function(){var t=n.pipeline.getStepsByType(c);if(t.length)return t[0]};return yt(function(){var t=f()||(c===K.ServerSort?new bn(r({columns:l?l.columns:[]},a.server)):new vn({columns:l?l.columns:[]}));return n.pipeline.tryRegister(t),function(){return n.pipeline.unregister(t)}},[n]),yt(function(){if(l){var n,e=l.columns.find(function(n){return n.index===t.index});e?(0===u&&(e.direction=null!=(n=t.direction)?n:1),s(e.direction)):s(0)}},[l]),yt(function(){var t=f();t&&l&&t.setProps({columns:l.columns})},[l]),x("button",{tabIndex:-1,"aria-label":o("sort.sort"+(1===u?"Desc":"Asc")),title:o("sort.sort"+(1===u?"Desc":"Asc")),className:rt(et("sort"),et("sort",function(t){return 1===t?"asc":-1===t?"desc":"neutral"}(u)),n.className.sort),onClick:function(n){n.preventDefault(),n.stopPropagation(),e(yn(t.index,!0===n.shiftKey&&a.multiColumn,t.compare))}})}var xn=function(t,n){var e;void 0===n&&(n=100);var r=Date.now(),o=function(){r=Date.now(),t.apply(void 0,[].slice.call(arguments))};return function(){var t=[].slice.call(arguments),i=Date.now(),u=i-r;u>=n?o.apply(void 0,t):(e&&clearTimeout(e),e=setTimeout(function(){o.apply(void 0,t),e=null},n-u))}};function kn(t){var n,e=function(t){return t instanceof MouseEvent?Math.floor(t.pageX):Math.floor(t.changedTouches[0].pageX)},r=function(r){r.stopPropagation();var u=parseInt(t.thRef.current.style.width,10)-e(r);n=xn(function(t){return o(t,u)},10),document.addEventListener("mouseup",i),document.addEventListener("touchend",i),document.addEventListener("mousemove",n),document.addEventListener("touchmove",n)},o=function(n,r){n.stopPropagation();var o=t.thRef.current;r+e(n)>=parseInt(o.style.minWidth,10)&&(o.style.width=r+e(n)+"px")},i=function t(e){e.stopPropagation(),document.removeEventListener("mouseup",t),document.removeEventListener("mousemove",n),document.removeEventListener("touchmove",n),document.removeEventListener("touchend",t)};return x("div",{className:rt(et("th"),et("resizable")),onMouseDown:r,onTouchStart:r,onClick:function(t){return t.stopPropagation()}})}function Pn(t){var n=It(),e=bt(null),o=gt({}),i=o[0],u=o[1],s=jt().dispatch;yt(function(){if(n.fixedHeader&&e.current){var t=e.current.offsetTop;"number"==typeof t&&u({top:t})}},[e]);var a,l=function(){return null!=t.column.sort},c=function(e){e.stopPropagation(),l()&&s(yn(t.index,!0===e.shiftKey&&n.sort.multiColumn,t.column.sort.compare))};return x("th",r({ref:e,"data-column-id":t.column&&t.column.id,className:rt(et("th"),l()?et("th","sort"):null,n.fixedHeader?et("th","fixed"):null,n.className.th),onClick:c,style:r({},n.style.th,{minWidth:t.column.minWidth,width:t.column.width},i,t.style),onKeyDown:function(t){l()&&13===t.which&&c(t)},rowSpan:t.rowSpan>1?t.rowSpan:void 0,colSpan:t.colSpan>1?t.colSpan:void 0},(a=t.column)?"function"==typeof a.attributes?a.attributes(null,null,t.column):a.attributes:{},l()?{tabIndex:0}:{}),x("div",{className:et("th","content")},void 0!==t.column.name?t.column.name:void 0!==t.column.plugin?x(Gt,{pluginId:t.column.plugin.id,props:{column:t.column}}):null),l()&&x(wn,r({index:t.index},t.column.sort)),t.column.resizable&&t.index0?(Vt.error("The container element "+t+" is not empty. Make sure the container is empty and call render() again"),this):(this.config.container=t,q(this.createElement(),t),this)},n}(Q);t.Cell=X,t.Component=S,t.Config=pn,t.Grid=Ln,t.Row=Z,t.className=et,t.createElement=x,t.createRef=function(){return{current:null}},t.h=x,t.html=G,t.useConfig=It,t.useEffect=yt,t.useRef=bt,t.useSelector=Ht,t.useState=gt,t.useStore=jt,t.useTranslator=At}); -//# sourceMappingURL=gridjs.umd.js.map From 19c1798f9031e364e9bb3b9198e42d95fd2e1923 Mon Sep 17 00:00:00 2001 From: peter Date: Fri, 4 Sep 2026 10:52:43 +0100 Subject: [PATCH 05/22] Release v0.2.0: multiple named API keys per user Replaces the single per-user DRF token with named, individually-revocable API keys, so you can run one key per agent and cut off a leaked one without disrupting the others. - New ApiKey model (user, name, dbk_-prefixed key, created_at, last_used_at). Data migration copies every existing authtoken 1:1 into a "default" named key with the same value, so no client (incl. the sibling test project's hardcoded test-user key) needs reconfiguring. - Auth (resolve_token_user) resolves against ApiKey and stamps last_used_at, throttled to one write/minute. - Settings -> API Keys: table with name/key/created/last-used + per-row revoke and a create form; keys viewable on demand. Settings -> Account now links here; Settings -> Users issues a named initial key. - Gated provisioning API for test installations: GET/POST /api/keys and DELETE /api/keys/{id}, controlled by DAYBOOK_KEY_PROVISIONING_API (unset -> follows DEBUG, so off in deployment). Advertised via key_provisioning_api in /api/capabilities; 403 when disabled. - Docs (api/deployment/quickstart/posting-helper), SKILL.md, and env.example updated from "token / Settings -> Account" to "key / Settings -> API Keys". - Tests: new API + UI suites for keys; existing token-based tests moved to ApiKey. Full suite 129 passing. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 25 +++- artifacts/migrations/0014_apikey.py | 31 ++++ .../0015_migrate_tokens_to_apikeys.py | 32 ++++ artifacts/models.py | 29 ++++ artifacts/urls.py | 3 + artifacts/views.py | 138 +++++++++++++++--- daybook_project/__init__.py | 2 +- daybook_project/settings.py | 11 ++ deploy/env.example | 5 + docs/agent_quickstart.md | 7 +- docs/api.md | 39 ++++- docs/deployment.md | 13 +- docs/llm_posting_helper.md | 2 +- skills/daybook-posting/SKILL.md | 8 +- static/daybook.css | 58 ++++++++ templates/artifacts/settings_account.html | 14 +- templates/artifacts/settings_api_keys.html | 66 +++++++++ templates/artifacts/settings_base.html | 1 + templates/artifacts/settings_users.html | 9 +- tests/api/test_api_keys.py | 109 ++++++++++++++ tests/api/test_artifacts.py | 7 +- tests/views/test_settings_api_keys.py | 45 ++++++ tests/views/test_settings_users.py | 6 +- tests/views/test_sharing.py | 5 +- 24 files changed, 602 insertions(+), 63 deletions(-) create mode 100644 artifacts/migrations/0014_apikey.py create mode 100644 artifacts/migrations/0015_migrate_tokens_to_apikeys.py create mode 100644 templates/artifacts/settings_api_keys.html create mode 100644 tests/api/test_api_keys.py create mode 100644 tests/views/test_settings_api_keys.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6520e9f..651b123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,28 @@ the git tags (`vX.Y.Z`) and `daybook_project.__version__`. _Nothing yet._ +## [0.2.0] — 2026-09-04 + +### Added +- **Multiple named API keys per user.** Each user can now hold several keys — + one per agent or script — named and revocable individually, managed from a + new **Settings → API Keys** page (a table with name, key, created, last-used, + and per-row revoke). Keys are viewable on demand (`dbk_`-prefixed). +- **Gated key-provisioning API** for automated test installations: + `GET/POST /api/keys` and `DELETE /api/keys/{id}`, controlled by + `DAYBOOK_KEY_PROVISIONING_API` (unset → follows `DJANGO_DEBUG`, so **off in + deployment**). Advertised as `key_provisioning_api` in `/api/capabilities`; + returns 403 when disabled. +- API keys track `last_used_at` (throttled to one write/minute) so stale keys + are visible before you revoke them. + +### Changed +- Auth now resolves bearer tokens against the new `ApiKey` model instead of the + single DRF `authtoken.Token`. Existing tokens are migrated 1:1 into named + ("default") keys with the same value — no client reconfiguration needed. +- Settings → Account no longer manages a single token; it links to API Keys. + Creating a user (Settings → Users) issues an initial named key. + ## [0.1.1] — 2026-09-03 ### Fixed @@ -63,6 +85,7 @@ 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.1.1...HEAD +[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.2.0...HEAD +[0.2.0]: https://git.cygnus.systems/peter/daybook/compare/v0.1.1...v0.2.0 [0.1.1]: https://git.cygnus.systems/peter/daybook/compare/v0.1.0...v0.1.1 [0.1.0]: https://git.cygnus.systems/peter/daybook/releases/tag/v0.1.0 diff --git a/artifacts/migrations/0014_apikey.py b/artifacts/migrations/0014_apikey.py new file mode 100644 index 0000000..09e31ce --- /dev/null +++ b/artifacts/migrations/0014_apikey.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.15 on 2026-09-04 09:35 + +import artifacts.models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0013_artifact_share_token'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ApiKey', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('key', models.CharField(db_index=True, default=artifacts.models.generate_api_key, max_length=64, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_used_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['created_at'], + }, + ), + ] diff --git a/artifacts/migrations/0015_migrate_tokens_to_apikeys.py b/artifacts/migrations/0015_migrate_tokens_to_apikeys.py new file mode 100644 index 0000000..46bb6e7 --- /dev/null +++ b/artifacts/migrations/0015_migrate_tokens_to_apikeys.py @@ -0,0 +1,32 @@ +from django.db import migrations + + +def copy_tokens_to_apikeys(apps, schema_editor): + """Carry every existing DRF authtoken over to a named ApiKey so keys issued + before the multi-key feature keep authenticating (incl. the permanent test + user and any deployed agents). Same key string -> no client reconfiguration.""" + Token = apps.get_model("authtoken", "Token") + ApiKey = apps.get_model("artifacts", "ApiKey") + for token in Token.objects.all(): + if ApiKey.objects.filter(key=token.key).exists(): + continue + api_key = ApiKey.objects.create(user_id=token.user_id, name="default", key=token.key) + # created_at is auto_now_add, so set it after the fact to preserve the + # original issue date (queryset.update bypasses auto_now_add). + ApiKey.objects.filter(pk=api_key.pk).update(created_at=token.created) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("artifacts", "0014_apikey"), + ("authtoken", "0004_alter_tokenproxy_options"), + ] + + operations = [ + migrations.RunPython(copy_tokens_to_apikeys, noop), + ] diff --git a/artifacts/models.py b/artifacts/models.py index 446454f..e77f143 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -1,7 +1,14 @@ +import secrets + from django.conf import settings from django.db import models +def generate_api_key(): + """`dbk_` prefix (recognizable as a Daybook key) + 40 hex chars.""" + return "dbk_" + secrets.token_hex(20) + + class Artifact(models.Model): HTML_FRAGMENT = "html_fragment" HTML_FULL = "html_full" @@ -92,6 +99,28 @@ class UserProfile(models.Model): return f"{self.user} quota={self.quota_bytes}" +class ApiKey(models.Model): + """A named, per-user API key for ingest. A user can hold several — one per + agent or script — and revoke them individually. Stored and shown in + plaintext: this is a self-hosted, single-operator app and keys are + viewable on demand by design. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_keys" + ) + name = models.CharField(max_length=100) + key = models.CharField(max_length=64, unique=True, default=generate_api_key, db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + last_used_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["created_at"] + + def __str__(self): + return f"{self.name} ({self.user})" + + class Asset(models.Model): """An image uploaded for embedding inside an artifact's content. diff --git a/artifacts/urls.py b/artifacts/urls.py index dae96dc..1cbd657 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -15,10 +15,13 @@ urlpatterns = [ path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"), path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"), path("api/skill", views.SkillView.as_view(), name="skill-api"), + path("api/keys", views.ApiKeyListCreateView.as_view(), name="api-keys"), + path("api/keys/", views.ApiKeyDetailView.as_view(), name="api-key-detail"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), path("api/artifacts/", views.ArtifactDetailView.as_view(), name="artifact-detail-api"), path("settings/", views.settings_home, name="settings-home"), path("settings/account/", views.settings_account, name="settings-account"), + path("settings/api-keys/", views.settings_api_keys, name="settings-api-keys"), path("settings/users/", views.settings_users, name="settings-users"), path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"), path("settings/storage/", views.settings_storage, name="settings-storage"), diff --git a/artifacts/views.py b/artifacts/views.py index a6ec867..aa3bfd0 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -29,14 +29,13 @@ from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.http import require_POST -from rest_framework.authtoken.models import Token from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView from .demo_data import create_demo_artifacts from .forms import DaybookLoginForm -from .models import Artifact, Asset, TagColor +from .models import ApiKey, Artifact, Asset, TagColor from .storage import ( QuotaExceeded, SIDECAR_SUFFIXES, @@ -309,6 +308,17 @@ class CapabilitiesView(APIView): return HttpResponseForbidden("Login or a valid API token is required") from daybook_project import __version__ + methods = { + "/api/artifacts": ["GET", "POST"], + "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], + "/api/assets": ["POST"], + "/api/capabilities": ["GET"], + "/api/skill": ["GET"], + } + if settings.DAYBOOK_KEY_PROVISIONING_API: + methods["/api/keys"] = ["GET", "POST"] + methods["/api/keys/{id}"] = ["DELETE"] + return Response( { "version": __version__, @@ -318,14 +328,9 @@ class CapabilitiesView(APIView): "raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL, Artifact.JSON, Artifact.CSV], "source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES], "auth": "bearer_token", - "methods": { - "/api/artifacts": ["GET", "POST"], - "/api/artifacts/{id}": ["GET", "PATCH", "DELETE"], - "/api/assets": ["POST"], - "/api/capabilities": ["GET"], - "/api/skill": ["GET"], - }, + "methods": methods, "skill_url": request.build_absolute_uri(reverse("skill-api")), + "key_provisioning_api": settings.DAYBOOK_KEY_PROVISIONING_API, } ) @@ -373,6 +378,71 @@ class SkillView(APIView): return HttpResponse(skill, content_type="text/markdown; charset=utf-8") +def serialize_api_key(api_key): + return { + "id": api_key.pk, + "name": api_key.name, + "key": api_key.key, + "created_at": api_key.created_at.isoformat(), + "last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None, + } + + +class ApiKeyListCreateView(APIView): + """Programmatic API-key management, for automated test installations that + provision their own agents. Gated by settings.DAYBOOK_KEY_PROVISIONING_API, + which is OFF in deployment (it hands out key material over the API). When + off, every method returns 403. Authenticated by session or an existing + bearer token, and only ever operates on that user's own keys. + + GET /api/keys -> list this user's keys (incl. key values) + POST /api/keys -> create {"name": ...} -> the new key + """ + + def _resolve_user(self, request): + if request.user.is_authenticated: + return request.user + return resolve_token_user(request) + + def get(self, request): + if not settings.DAYBOOK_KEY_PROVISIONING_API: + return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403) + user = self._resolve_user(request) + if user is None: + return HttpResponseForbidden("Login or a valid API token is required") + return Response([serialize_api_key(k) for k in user.api_keys.all()]) + + def post(self, request): + if not settings.DAYBOOK_KEY_PROVISIONING_API: + return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403) + user = self._resolve_user(request) + if user is None: + return HttpResponseForbidden("Login or a valid API token is required") + name = (request.data.get("name") or "").strip() + if not name: + return Response({"detail": "A non-empty 'name' is required."}, status=400) + api_key = ApiKey.objects.create(user=user, name=name) + return Response(serialize_api_key(api_key), status=201) + + +class ApiKeyDetailView(APIView): + """Revoke a single key by id, same gating and auth as the list/create view. + + DELETE /api/keys/{id} + """ + + def delete(self, request, key_id): + if not settings.DAYBOOK_KEY_PROVISIONING_API: + return Response({"detail": "Key provisioning API is disabled on this instance."}, status=403) + user = request.user if request.user.is_authenticated else resolve_token_user(request) + if user is None: + return HttpResponseForbidden("Login or a valid API token is required") + deleted, _ = ApiKey.objects.filter(pk=key_id, user=user).delete() + if not deleted: + raise Http404("No such key") + return Response(status=204) + + FEED_WINDOW_DAYS = 7 @@ -992,9 +1062,15 @@ def resolve_token_user(request): if not key: return None try: - return Token.objects.select_related("user").get(key=key).user - except Token.DoesNotExist: + api_key = ApiKey.objects.select_related("user").get(key=key) + except ApiKey.DoesNotExist: return None + # Stamp last-used so stale keys are visible in the UI, but throttle the + # write to at most once a minute to avoid a DB write on every request. + now = timezone.now() + if api_key.last_used_at is None or (now - api_key.last_used_at).total_seconds() > 60: + ApiKey.objects.filter(pk=api_key.pk).update(last_used_at=now) + return api_key.user def normalize_tags(tags): @@ -1250,16 +1326,36 @@ def settings_home(request): @login_required def settings_account(request): - token, _ = Token.objects.get_or_create(user=request.user) - if request.method == "POST" and request.POST.get("action") == "regenerate": - token.delete() - token = Token.objects.create(user=request.user) - messages.success(request, "API token regenerated.") - return redirect("settings-account") return render( request, "artifacts/settings_account.html", - {"token": token, "active_section": "account"}, + {"active_section": "account", "key_count": request.user.api_keys.count()}, + ) + + +@login_required +def settings_api_keys(request): + if request.method == "POST": + action = request.POST.get("action") + if action == "create": + name = (request.POST.get("name") or "").strip() + if not name: + messages.error(request, "Give the key a name so you know which agent it's for.") + else: + ApiKey.objects.create(user=request.user, name=name) + messages.success(request, f"Created API key '{name}'.") + elif action == "revoke": + key_id = request.POST.get("key_id") or "" + deleted, _ = ApiKey.objects.filter(pk=key_id, user=request.user).delete() + if deleted: + messages.success(request, "API key revoked.") + else: + messages.error(request, "No such key.") + return redirect("settings-api-keys") + return render( + request, + "artifacts/settings_api_keys.html", + {"active_section": "api-keys", "api_keys": request.user.api_keys.all()}, ) @@ -1269,7 +1365,7 @@ def settings_users(request): return HttpResponseForbidden("Admin access required") created_user = None - created_token = None + created_key = None if request.method == "POST": username = request.POST.get("username", "").strip() password = request.POST.get("password", "") @@ -1283,7 +1379,7 @@ def settings_users(request): if grant_admin: created_user.is_superuser = True created_user.save(update_fields=["is_superuser"]) - created_token = Token.objects.create(user=created_user) + created_key = ApiKey.objects.create(user=created_user, name="default") messages.success(request, f"Created user '{username}'.") return render( @@ -1293,7 +1389,7 @@ def settings_users(request): "active_section": "users", "users": User.objects.order_by("username"), "created_user": created_user, - "created_token": created_token, + "created_key": created_key, }, ) diff --git a/daybook_project/__init__.py b/daybook_project/__init__.py index e27f5fc..b5b7a6a 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.1.1" +__version__ = "0.2.0" diff --git a/daybook_project/settings.py b/daybook_project/settings.py index ee85175..c8987a1 100644 --- a/daybook_project/settings.py +++ b/daybook_project/settings.py @@ -9,6 +9,17 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me") DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"} ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()] +# Allow creating/listing/revoking API keys over the ingest API (POST /api/keys). +# Meant for automated test installations that provision their own agents. It +# exposes key material over the API, so it is OFF in deployment. Unset, it +# follows DEBUG (on locally, off in production); set DAYBOOK_KEY_PROVISIONING_API +# explicitly (0/1) to override either way. +_key_api_env = os.environ.get("DAYBOOK_KEY_PROVISIONING_API") +if _key_api_env is None: + DAYBOOK_KEY_PROVISIONING_API = DEBUG +else: + DAYBOOK_KEY_PROVISIONING_API = _key_api_env.lower() in {"1", "true", "yes", "on"} + # Needed when running behind a TLS-terminating reverse proxy (Apache/Nginx/etc.): # Django otherwise thinks every request is plain HTTP, which breaks CSRF checks # on login/POST. The proxy must forward X-Forwarded-Proto: https. diff --git a/deploy/env.example b/deploy/env.example index 5fe9ca3..1eb6d11 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -6,6 +6,11 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-vm-ip,your-domain.example # "CSRF verification failed" because Django can't tell the original request # was HTTPS. Set to the public HTTPS origin(s) users actually visit: DJANGO_CSRF_TRUSTED_ORIGINS=https://your-domain.example +# Leave OFF in deployment. When on, POST /api/keys can create/list/revoke API +# keys over the network (it returns key material), which is only appropriate +# for automated test installations. Unset, it follows DJANGO_DEBUG — so with +# DJANGO_DEBUG=0 above it is already off. Set to 1 only on a throwaway test box. +# DAYBOOK_KEY_PROVISIONING_API=0 DAYBOOK_DATA_DIR=/data DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store diff --git a/docs/agent_quickstart.md b/docs/agent_quickstart.md index d6ba619..0c78356 100644 --- a/docs/agent_quickstart.md +++ b/docs/agent_quickstart.md @@ -2,10 +2,11 @@ This is the shortest path for an agent to publish and retrieve artifacts from Daybook. -## 0. Get Your API Token +## 0. Get Your API Key -Log in to the app in a browser and copy your personal token from **Settings → Account** -(`http://127.0.0.1:8001/settings/account/`). Every artifact you post with it lands in your +Log in to the app in a browser and create a personal key under **Settings → API Keys** +(`http://127.0.0.1:8001/settings/api-keys/`). Name it after the agent so you can revoke it +later. Every artifact you post with it lands in your feed, and only yours. ## 1. Check Capabilities diff --git a/docs/api.md b/docs/api.md index b3778c2..581f8f5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,13 +8,13 @@ Base URL while running locally: http://127.0.0.1:8001 ``` -Authentication for write endpoints (POST/PATCH) — a personal per-user API token, from Settings → Account in the app: +Authentication for write endpoints (POST/PATCH/DELETE) — a personal API key, created in the app under Settings → API Keys. Each user can hold several (one per agent/script) and revoke them individually: ```http -Authorization: Bearer +Authorization: Bearer ``` -Every artifact belongs to the user whose token 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 logged-in browser session, not a bearer token — visit the app in a browser and log in first. @@ -575,9 +575,40 @@ curl -H "Authorization: Bearer " http://127.0.0.1:8001/api/ Discover it programmatically via `skill_url` in the capabilities response. +## API Keys (provisioning) + +Keys are normally created and revoked in the app under **Settings → API Keys**. +For automated test installations, a programmatic surface can provision them +too — but it is **gated** and **off in deployment**, because it returns key +material over the API. + +The gate is `DAYBOOK_KEY_PROVISIONING_API`. Unset, it follows `DJANGO_DEBUG` +(on locally, off in production); set it explicitly (`0`/`1`) to override. +`GET /api/capabilities` reports the current state as `key_provisioning_api`, +and only lists the `/api/keys` methods when it's enabled. When disabled, every +method below returns `403`. + +```http +GET /api/keys # list this user's keys (incl. key values) +POST /api/keys # body {"name": "hermes-agent"} -> the new key +DELETE /api/keys/{id} # revoke one key +``` + +Authenticated by a browser session **or** an existing bearer key, and only +ever operates on the authenticated user's own keys. + +```bash +# Create a new named key (test installations only) +curl -X POST -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "hermes-agent"}' \ + http://127.0.0.1:8001/api/keys +``` + ## Current MVP Constraints -- Each user has a personal ingest API token; there is no shared token. +- Each user can hold several personal API keys (Settings → API Keys), each + individually revocable; there is no shared key. - Reader/browser access requires login; feeds are private per user (except per-artifact public share links — see Sharing above). - Content modification/revisions are deferred (`PATCH` edits metadata only, diff --git a/docs/deployment.md b/docs/deployment.md index aafbfad..cf83192 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -64,9 +64,10 @@ DATABASE_PATH=/data/db.sqlite3 ARTIFACTS_ROOT=/data/artifacts_store ``` -There is no shared API token to configure — Daybook uses per-user tokens -(DRF `Token` model) issued from Settings → Account once a user exists. Do not -commit `deploy/.env`. +There is no shared API token to configure — Daybook uses per-user API keys +created under Settings → API Keys once a user exists (each user can hold +several, one per agent, each individually revocable). Do not commit +`deploy/.env`. Create the persistent data directory and bring the stack up: @@ -94,7 +95,7 @@ that's expected; it confirms the app is up and enforcing auth. (Don't add `curl -f` — it treats the 403 as an error. The container healthcheck uses the login page, which is 200 without auth.) Open `http://:8000/` in a browser and log in with the superuser you just -created, then grab a personal token from Settings → Account and use it for a +created, then create a personal key under Settings → API Keys and use it for a smoke-test post: ```bash @@ -191,8 +192,8 @@ Caddy: header_up X-Forwarded-Proto https - `docker compose -f deploy/docker-compose.yml ps` shows `daybook` healthy. - `GET /api/capabilities` returns 403 without auth, 200 with a valid token or session (check the status code — don't use `curl -f`, which errors on 403). -- At least one superuser exists (`manage.py createsuperuser`) with a token - from Settings → Account. +- At least one superuser exists (`manage.py createsuperuser`) with a key + from Settings → API Keys. ## Known MVP Constraints diff --git a/docs/llm_posting_helper.md b/docs/llm_posting_helper.md index 6cafeb6..8fc4194 100644 --- a/docs/llm_posting_helper.md +++ b/docs/llm_posting_helper.md @@ -24,7 +24,7 @@ DAYBOOK_TOKEN=your-personal-api-token DAYBOOK_SOURCE=agent ``` -`DAYBOOK_TOKEN` is a personal per-user token, from Settings → Account in the app — not a token shared across users. Override these per environment. +`DAYBOOK_TOKEN` is a personal API key, created under Settings → API Keys in the app (one per agent, individually revocable) — not a key shared across users. Override these per environment. ## Commands diff --git a/skills/daybook-posting/SKILL.md b/skills/daybook-posting/SKILL.md index c972af9..12dd9ff 100644 --- a/skills/daybook-posting/SKILL.md +++ b/skills/daybook-posting/SKILL.md @@ -18,7 +18,7 @@ support; a `.env` file in the working directory is not read automatically. ```text DAYBOOK_URL=https://your-daybook-instance.example.com # your server's base URL -DAYBOOK_TOKEN=your-personal-api-token # from Settings -> Account +DAYBOOK_TOKEN=your-personal-api-key # from Settings -> API Keys DAYBOOK_SOURCE=agent # a stable label for what's posting ``` @@ -27,9 +27,9 @@ DAYBOOK_SOURCE=agent # a stable label for wha `http://127.0.0.1:8001`, which is only correct when Daybook is running locally on the same machine (its development default). For any remote or deployed instance you **must** set this. -- **`DAYBOOK_TOKEN`** — your personal per-user API token, from Settings → - Account in the app (not a token shared by everyone). Artifacts posted with - your token land only in your feed. +- **`DAYBOOK_TOKEN`** — a personal API key, created in the app under Settings → + API Keys (name one per agent so you can revoke them individually). Not a key + shared by everyone; artifacts posted with it land only in your feed. - **`DAYBOOK_SOURCE`** — a free-text label for whatever is posting (agent-profile, script, or person name). Optional; can also be passed per call with `--source`. diff --git a/static/daybook.css b/static/daybook.css index 37002a3..203eacf 100644 --- a/static/daybook.css +++ b/static/daybook.css @@ -990,6 +990,64 @@ kbd { word-break: break-all; } +.muted { + color: var(--muted); +} + +.api-key-create { + align-items: end; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.api-key-create label { + display: flex; + flex-direction: column; + font-weight: 600; + gap: 0.3rem; +} + +.api-key-table { + border-collapse: collapse; + width: 100%; +} + +.api-key-table th, +.api-key-table td { + border-bottom: 1px solid var(--accent-border); + padding: 0.55rem 0.7rem; + text-align: left; + vertical-align: middle; +} + +.api-key-table th { + color: var(--muted); + font-size: 0.85em; + font-weight: 600; +} + +.api-key-cell { + align-items: center; + display: flex; + gap: 0.5rem; +} + +.api-key-cell .token-value { + font-size: 0.85em; +} + +.api-key-copy { + flex-shrink: 0; +} + +.api-key-revoke { + background: var(--danger-bg); + border: 1px solid var(--danger-text); + color: var(--danger-text); +} + .about-head { align-items: center; display: flex; diff --git a/templates/artifacts/settings_account.html b/templates/artifacts/settings_account.html index c5cec85..f8329b8 100644 --- a/templates/artifacts/settings_account.html +++ b/templates/artifacts/settings_account.html @@ -4,12 +4,10 @@

Account

Username: {{ request.user.username }}

-

API Token

-

Use this token as Authorization: Bearer <token> when posting artifacts.

-

{{ token.key }}

-
- {% csrf_token %} - - -
+

API Keys

+

+ Posting to Daybook uses named API keys — one per agent or script, each + revocable on its own. You have {{ key_count }} key{{ key_count|pluralize }}. +

+

Manage API keys →

{% endblock %} diff --git a/templates/artifacts/settings_api_keys.html b/templates/artifacts/settings_api_keys.html new file mode 100644 index 0000000..99dd069 --- /dev/null +++ b/templates/artifacts/settings_api_keys.html @@ -0,0 +1,66 @@ +{% extends "artifacts/settings_base.html" %} + +{% block settings_content %} +

API Keys

+

+ One key per agent or script. Name each key so you know which agent it's + for, copy it into that agent's DAYBOOK_TOKEN, and revoke keys + individually here. Use a key as + Authorization: Bearer <key> when posting. +

+ +

Create a key

+
+ {% csrf_token %} + + + +
+ +

Your keys

+ {% if api_keys %} + + + + + + {% for k in api_keys %} + + + + + + + + {% endfor %} + +
NameKeyCreatedLast used
{{ k.name }} + {{ k.key }} + + {{ k.created_at|date:"Y-m-d" }}{% if k.last_used_at %}{{ k.last_used_at|date:"Y-m-d H:i" }}{% else %}never{% endif %} +
+ {% csrf_token %} + + + +
+
+ {% else %} +

No keys yet. Create one above.

+ {% endif %} + + +{% endblock %} diff --git a/templates/artifacts/settings_base.html b/templates/artifacts/settings_base.html index f88f06f..40b3c01 100644 --- a/templates/artifacts/settings_base.html +++ b/templates/artifacts/settings_base.html @@ -25,6 +25,7 @@