Repository navigation
Releases: phoxmor/phoxtail
Release list
v0.5.0
This release stops a missing icon from breaking a page: a block variant that
names an icon removed in an earlier release now renders without it and logs
a warning, instead of answering with a server error.
Upgrading from 0.4.0 — read this first:
dashboard_icon_pathis gone (see Removed). Templates of your own that
use it, or passicontophoxtail_core/copy_key_button.html, must switch
to{% icon %}.
Removed
- The
dashboard_icon_pathtemplate tag is removed, and so is theicon
parameter ofphoxtail_core/copy_key_button.html. Every icon is now drawn
with{% icon "<name>" %}, so a missing name renders nothing and logs a
warning everywhere instead of failing the page in the dashboard navigation.
Templates that used{% dashboard_icon_path name as path %}{% include path %}
should use{% icon name %}.
Fixed
- A page whose block variant names an icon that no longer exists now renders
without that icon instead of failing with a server error. The{% icon %}
tag logs a warning naming the missing icon (Icon '<name>' not found), so
stored variants that still use a name removed in an earlier release can be
found and fixed.
v0.4.0
This release fixes a leak: images built by phoxtail 0.2.0 and 0.3.0 (and by
installs from the git repository since 2026-06-10) carry the project's
production .env once the project has been deployed with
phoxtail server deploy
(GHSA-52f8-g7r4-m363).
It also names database backups after the site and environment they hold, and
lets phoxtail db backup back up a server.
Upgrading from 0.3.0 — read these first:
- Every deployed project must act (see Security): regenerate
.dockerignore, release, change the credentials in.env, and delete the
old images. Until.dockerignoreis regenerated,phoxtail docker pushand
phoxtail docker releaserefuse the project's image. phoxtail db backup,db pull,db pushanddb restorestop without
DJANGO_ENVin the.envnext to the database they touch, and their files
have new names.
Security
-
Production images no longer carry the project's secrets
(GHSA-52f8-g7r4-m363).
The generated Dockerfile copies the project folder into the image, and the
generated.dockerignoreexcluded.envonly at the project root, while
phoxtail server deploykeeps the production.env(withSECRET_KEYand
the database password) in.phoxtail/deploy/. Anyone able to pull an image
built after a project's firstserver deploycould read those files, and
other files the project folder held but git does not, such as uploads under
.phoxtail/and hidden files at the root. The.dockerignoretemplate now
excludes.envfiles at any depth,
.phoxtail/, every hidden file or folder at the project root and database
dumps (*.sql), as well as Python caches,node_modulesand editor files at
any depth, so bytecode compiled on the developer's machine stays out too.
phoxtail docker pushandphoxtail docker releasenow refuse to push an
image with a.env,.env.*,.phoxtailor.sqlfile under/app.Existing projects:
- Upgrade to phoxtail 0.4.0 first: the refusal runs in the CLI that
pushes. - Run
phoxtail docker create dockerignore --force, then re-apply any
edits of your own. - Run
phoxtail docker release, thenphoxtail server deploy <ip>. - Change the credentials the
.envholds, in both copies (on the server
and in.phoxtail/deploy/), then runphoxtail server deploy <ip>
again:- the email password first, since it works from anywhere: create a new
one with your provider, deploy it, check that mail is sent, then
revoke the old one; SECRET_KEY. Everyone is logged out, and links already emailed stop
working. Do not keep the old key inSECRET_KEY_FALLBACKS;- any key you added yourself;
- the database password only if its port was ever reachable from
outside the server (the generated compose file publishes none).
Change it in the database first, then in.env, or the site cannot
connect.
- the email password first, since it works from anywhere: create a new
- Delete the old image versions from your registry, keeping the one the
server now runs.
- Upgrade to phoxtail 0.4.0 first: the refusal runs in the CLI that
Added
phoxtail db backup <ip>backs up a server's database: it dumps it into the
server'sdb-backups/, keeps it there, and copies it into the local
db-backups/. Without an IP it backs up the local database as before.
Changed
phoxtail db backupnow names its files<project-slug>_<DJANGO_ENV>_<UTC time>.sql, for examplemysite_production_2026-10-28_10-19-28.sql, so a
backup says which site and which environment it holds even after it is
copied elsewhere. The environment is read from the.envnext to the
database that is dumped, and the command stops with an error if
DJANGO_ENVis missing. A failed dump leaves no file behind. Existing
backups keep their names anddb restorestill accepts them.- The copy that
db pull,db restoreanddb pushtake of a database
before replacing it is now named<project-slug>_<DJANGO_ENV>_before-<step>.sql
(for examplemysite_production_before-push.sql) instead of
pre_<step>_safety.sql, so it says which site and environment it holds.
Each step still keeps one copy, overwritten by its next run, and the copy
is written whole or not at all. The commands stop before changing anything
ifDJANGO_ENVis missing from the.envnext to the database they
replace.db restorerefuses its ownbefore-restorecopy, since taking
the copy first would overwrite it. Oldpre_*_safety.sqlfiles are no
longer written and can be deleted. - A hatched project now commits its
uv.lock: the template's.gitignoreno
longer ignores it, so the exact version of every installed package is in the
project's history and each upgrade shows as a diff. An existing project picks
this up by removing theuv.lockline from its own.gitignore.
Fixed
- The MCP server and API token checks no longer fail in production images that
carry no.phoxtailfolder, such as those built with the new.dockerignore.
Importing the MCP server tried to create.phoxtail/mcp/uploadsunder/app,
which the app user cannot write there, and every API token check imports it.
The screenshot tools now still return the image when they cannot also save a
copy under.phoxtail/vision/. phoxtail server sslnow replaces an outdated certificate-renewal cron line
instead of keeping it: it shows the server's line next to the current one and
asks before replacing it, leaving every other cron entry as it is. On a
server set up before 0.3.0, runningphoxtail server ssl <ip>is now how the
--no-depsrenewal line arrives; nocrontab -eedit is needed.phoxtail db pull,db pushand the other server commands now read a piped
answer: the ssh call that checks the server's identity used to swallow it,
soecho y | phoxtail db push <ip>aborted at the prompt.
v0.3.0
This release puts a permission on every door: each API endpoint and MCP tool now
names the Django permission its act needs, tokens are held to their scopes, and
the site becomes an OAuth 2.1 authorization server so outside clients can act
for a person with their consent. It also brings a shared component layer
(surfaces, buttons, fields, drawers, list pages), Greek, and the authentication
pages on django-allauth's own templates.
Upgrading from 0.2.0 — read these first:
- An endpoint that declares no
auth=now refuses everyone (see Security).
Annotate each of your own endpoints withguarded(...),scoped(...)or
authenticated(). - Every list the API serves answers
{"items": [...], "total": N}one page at
a time, and several API paths moved (/api/content/v1/is now/api/cms/v1/).
Update clients and runcollectstatic. - Tokens issued with
"*"stop working wherever a scope is checked; re-issue
them as unrestricted or with the scopes they need. request.authis anAuthorizationContext, not aUser.- An app declares its API and MCP tools one way only, by convention; the
api_version_routerattribute, thephoxtail.mcp_modulesentry point,
[mcp] extra_modulesandpage_schema_contributorsare gone. - Field templates moved to
phoxtail_core/forms/fields/, several fields were
folded intoinput,choicesandselect, and thefw-classes are now
phx-. - Deployed sites: regenerate the Dockerfile (uv 0.13.5) and the production
compose (pinned nginx and certbot), and replace the certificate-renewal cron
line (see Fixed).
Added
-
The site is now an OAuth 2.1 authorization server (django-oauth-toolkit, in the
engineextra), declared as a surface ofphoxtail.tokens: the RFC 8414
discovery document at/.well-known/oauth-authorization-serverand the
endpoints under/o/. A hatched project picks it up on upgrade with nothing
edited — the dependency, the URL mount and theOAUTH2_PROVIDERdefaults
arrive through the app protocol, andmigrateruns on every start as it
already does. Itsissueris the exact name the MCP server advertises.- Outside clients ask for bundles, never permission codenames: one
<app>:read/<app>:writepair per phoxtail app, derived from the
codenames the app's endpoints and tools already name (write includes read),
so a renamed model changes no issued credential's meaning. The discovery
document and the MCP server's protected-resource document list every
bundle; a request for anything else — the library's placeholderread, a
codename, or nothing at all — is refused asinvalid_scope. The consent
screen is phoxtail's: it names the client by the host of its metadata URL
when it has one, names the host the browser will be sent to, says so when
that is this device, and lists what the client would be able to do.
Approval takes the requested set whole. - A client the site has never met can introduce itself, granting nothing
either way: by document — itsclient_idis anhttpsURL the site fetches
through the library's SSRF-hardened fetcher, and the consent screen names
that host; grant types the server does not offer are narrowed away, as
RFC 7591 allows — or anonymously at/o/register/(RFC 7591, with RFC 7592
read-back). Both create a public client that still sends every person to
the consent screen. An operator who wants only listed hosts to register by
document sets the library's allowlist class and hosts in settings. - Public clients and loopback callbacks. The discovery document lists
noneamong the token-endpoint authentication methods (a phone app or a CLI
proves itself with PKCE), a client listening onhttp://localhost:<any port>matches one that registered another loopback port (RFC 8252), and
every authorization response carriesiss(RFC 9207). - Every RFC 9700 gate the library defines is decided. The implicit grant,
the password grant and plain PKCE challenges are refused; a key in the query
string is refused; replay protection, exact redirect matching and required
PKCE are deploy-check errors. A plaintext redirect scheme stays allowed only
because a CLI's callback ishttp://localhost: anhttpredirect to any
other host is refused whatever a client registered. A test fails if a
future library release adds a gate nobody has decided. - Tokens live short and are stored hashed: an hour for access, a week for
refresh (renewed on every use), and a replayed refresh token revokes
everything descended from its grant. There is no grace window for a retried
refresh.manage.py sweeptokensremoves dead token rows of both families
after 90 days (--retention-days); nothing schedules it yet. - System checks
E004–E007fail startup if a project's own settings
displace any of this: the scope backend or validator, the resource check,
either registration door, or the kept redirect gate.
- Outside clients ask for bundles, never permission codenames: one
-
The API reads a key the authorization server issued after a person's consent,
so a client holding one can act within the bundles approved and the person's
own permissions. OneAuthorization: Bearerheader is routed by the key's
shape (phoxtail's own keys carry thephxt_prefix), and either kind is read
into one credential. Bundles are valid on phoxtail's own keys too —
cms:readbeside a codename in the admin or the CLI. A key issued for the MCP
server (resource=) is honoured at the API behind it, compared canonically, so
a key for another host is refused. -
The MCP server is reachable from the internet, at
https://mcp.<domain>/mcp
in production andhttp://mcp.<slug>.localhost/mcplocally. Its401points
at its protected-resource metadata (RFC 9728), which names the site as the
authorization server, so a remote client finds where to authenticate from the
MCP URL alone. Themcpservice joins the production compose;
phoxtail nginx create productionwrites a server block that passes requests
through exactly as sent;phoxtail ssl obtainandphoxtail server ssladd the name to the
site's certificate (--expand)./o/register/and/o/authorize/are
rate-limited per client address at the edge (ten a minute, burst twenty).
phoxtail env create productionlistswebinALLOWED_HOSTS. An
already-deployed project needswebadded toALLOWED_HOSTSby hand, the DNS
record formcp.<domain>, a regeneratednginx.conf, the certificate
obtained again, and both containers restarted. -
Token scopes are enforced. An endpoint names the codename of its act once with
auth=guarded("wagtailcore.publish_page"), which asks both halves: whether
the person holds the permission, and whether their credential covers it.
scoped(...)asks only the credential, andauthenticated()admits any
caller whatever their credential's ceiling, for an endpoint that genuinely
asks for no permission (an anonymous caller still gets 401). A narrowed
credential and a missing permission are refused with different messages.
Whether the person may act is still also decided where it already was —
Wagtail resolves publishing per page subtree, and that check is unchanged. -
A token's scopes are validated when it is issued: a scope names a real Django
permission or a bundle, and a typo is refused with the correct spelling. The
API token create form offers an Unrestricted checkbox. -
MCP tools are held to the same rule:
phoxtail.mcp.authorization.scoped()
names a tool's codename, and a tool its caller's credential does not cover is
left out of that caller's catalogue. Over HTTP the MCP server introspects the
bearer atGET /api/whoami/before anything runs and answers a request with
no credential401with the standard challenge; a local (stdio) session is
offered only the tools its stored token covers.phoxtail_whoamireports who a
local session acts as and which tools were withheld, with the permission each
would need. -
GET /api/whoami/reports the identity and ceiling of the credential making
the request — email, user uuid, superuser flag, whether the token is
unrestricted, its scopes and bundles, and its expiry. Every credential can
reach it, so a caller can discover its own limits. -
Phoxtail speaks Greek. The dashboard, the authentication pages, the profile,
the admin's snippets and settings, and the page bar and chatbot follow the
active language, with one Greek catalogue; text the bar's and chatbot's scripts
build in the browser is still English. Every password field reads
"Συνθηματικό", the Wagtail admin's included, and Django's password rules read in
better Greek.Translations live in a new app,
phoxtail.translations, whichwire_apps
installs first; a project that lists its apps itself must put it above every
app with translations of its own (allauth, Wagtail, Django's), or
phoxtail_translations.W001names the apps in the way. It also holds
phoxtail's wording for sentences allauth and Django write
(packages/allauth/el.po).make translationsgathers and compiles both.Model fields and models carry translatable labels. Each app gains a
*_translatable_labelsmigration that changes no table; a package with its
own models onUUIDMixinorTimestampMixinsees a pending migration of the
same kind until it generates one. -
{% languages %}(inphoxtail_core_tags) lists the languages a page can be
read in, each with that page's address, and
phoxtail_core/language_menu.htmldraws them as a menu; both are empty on a
site that serves one language. -
One surface model for every page: the page or drawer is the ground
(phx-ground: grey, near-black in dark mode,--phx-ground-background), what
stands on it rises (phx-card, white with a light shadow, with
--clickable,--flush,__title...
v0.2.0
Upgrade notes below — a dependency that moved, renamed CSS classes, an app that
must change position in INSTALLED_APPS — address projects built against an
earlier version of the package. A fresh install needs none of them.
Added
- Shared blocks can now be pinned to a site-wide slot (
Block.site_slot: head/body
anchors in the page template) and render automatically on every page of a site that
fills in their shared content — no per-page placement needed; combined with
page-type restrictions it scopes to every page of those types. A page that places
the block itself takes over (its own position and variant, or a "hide on this page"
toggle), variants cascade page → site (SharedBlock.variant) → block default,
missing locales fall back to the default locale's content, and
Block.render_in_previewkeeps tracking scripts out of previews and screenshots.
The streams v1 API, the MCP studio tools, and the cross-project push envelope all
carry the new fields. - Platform menus: an editor writes a site's navigation in the Wagtail admin, scoped
to a site and a language, under a new Platform section. Entries point at a page, a
named route, an address elsewhere, or a group of those. The menu renders along the
top of the dashboard and inside the small-screen drawer, with dropdowns,
separators, and the entry for the page being read marked as current; unpublished
pages are shown only to editors, linked to their draft. A site with no menu written
for it shows no bar and no drawer section. Menus are also readable and writable
over/api/dashboard/v1/menus/and through thephoxtail_dashboard_*MCP tools,
so an agent can list, create, update and delete them — updates take anIf-Match
ETag, and an entry type the menu does not define is refused rather than silently
dropped. - Dashboard widgets are declarative: give
add_widget()a title, description, icon
andurl_nameand phoxtail renders the card, so an app no longer ships a template
and a context function for a link. Cards are grouped under a heading per app
(DashboardModule(verbose_name=...)), and the Account group opens with a card for
the Wagtail admin, shown only to users who may reach it. A site can amend what
other apps contribute —registry.get_module(),unregister(),
DashboardModule.remove_widget()andremove_nav_item()— override the index view
itself through the newPHOXTAIL_DASHBOARD_VIEWsetting, and shadow any phoxtail
template from a project-leveltemplates/directory, which hatched projects now
get. - The dashboard is served under a language prefix (
/en/dashboard/) and carries a
language switcher offering every language the project serves. A language with no
menu written for it still opens the platform — the menu bar is simply absent, and
the dashboard's own screens are translated regardless. UrlMountacceptsi18n=True, mounting an app's URLs under a language prefix
(/en/shop/). Set it for anything a person reads; machine endpoints stay
unprefixed so their callers need not learn a locale. When at least one app opts in,
Django'sset_languageview is published ati18n/alongside it.phoxtail hatchandphoxtail installtake a--sourceoption that says where a
package is resolved from, using uv's own locator syntax:git+ssh://…@refor
git+https://…@tagfor a repository, a directory for a local checkout (added as an
editable path), or a.whl/.tar.gzURL for a built artefact. PyPI stays the
default and nothing changes without the flag. Both commands check a non-default
source before writing anything, and neither treats an unanswered check as a
failure: with no network the check is skipped and uv, which can resolve from its
own cache, has the final say.phoxtail upgrade phoxtailnow compares the project's phoxtail-owned declarations
inpyproject.tomlagainst the current project template and offers to apply the
difference before locking. Previously it only advanced the lockfile, so a project
kept declaring whatever extras it was hatched with even after the template moved a
dependency to a different one. Projects hatched before the dependency-groups split
get the[dependency-groups]table created for them.TRADEMARK.mdsets out the permitted use of the Phoxtail name, wordmark and logo,
which the BSD licence does not grant. Saying a product is built with Phoxtail needs
no permission; naming a product after it does.SECURITY.md— how to report a vulnerability privately.
Changed
-
Wagtail 8.0 is now the supported version, raising the
engineextra from
wagtail>=7.4.2,<8.0towagtail>=8.0,<9.0. Django stays at<6.0and the Python
floor stays at 3.11: Wagtail 8 runs on Django 5.2, so neither has to move yet.
wagtailmediais raised to>=0.18.1, which carries a permission fix for the
media chooser — it previously exposed a media item's id, title and edit URL to
users without access to it.Two Wagtail 8 changes are worth knowing about. Image renditions no longer convert
AVIF and WebP to PNG, so sites serving those formats will now emit them directly;
setWAGTAILIMAGES_FORMAT_CONVERSIONSto restore the old behaviour. And
menu_orderis now respected between viewsets inside aViewSetGroup, which
reorders some admin menus. -
Breaking for projects built against an earlier version.
playwrightand
Pilloware no longer installed by default. They moved to a newstudioextra,
which carries the screenshot tools inphoxtail.mcp.studioand nothing else — a
base install no longer pulls a browser stack it may never use.Newly hatched projects get
phoxtail[studio]in theirdevdependency group, and
projects still on thephoxtail[dev]alias keep it because that alias now includes
studio. Projects that already moved to thedev-tools+ dependency-group layout
must add it themselves —phoxtail upgradeadvances the lockfile without rewriting
pyproject.toml:[dependency-groups] dev = [ "phoxtail[dev-tools]", "phoxtail[studio]", ]
Then run
uv lock; the Dockerfile syncs with--frozenand refuses a lockfile
that no longer matches. Without the entry the development image fails at
playwright install. Production images are unaffected, and lose a browser stack
they were never using. -
htmx is no longer bundled with phoxtail. It now comes from
django-htmx, which
phoxtail already depends on, via its{% htmx_script %}tag — one copy instead of
two, at 2.0.10 instead of 2.0.8. Any project template that loads htmx with
<script src="{% static 'phoxtail_core/js/htmx.min.js' %}">must switch to
{% htmx_script %}; the static file is gone, and under
ManifestStaticFilesStoragea stale reference raises at render time. -
Every declared dependency floor now names a version phoxtail is tested against,
replacing inherited minimums that had never been installed — several of which did
not work. Installing phoxtail alongside an older pinned dependency will now fail to
resolve rather than fail at import. -
phoxtail installno longer decides on its own whether a package belongs in
INSTALLED_APPS. It previously treated any importable top-level module as a Django
app, so installing something likehttpxorredisregistered it and left Django
importing a non-app at startup. A package now has to carry anAppConfig, models,
or migrations to be considered one — and because no inspection can rule out a
Django app carrying none of those, the result is offered as the default answer
rather than acted on. Answer no and nothing is registered; answer yes for a package
that was not detected and you are asked for the module name.--app <module>and
--no-appanswer ahead of time, and a non-interactive run acts on the detection
result without prompting. The module name is read from the installed
distribution's metadata rather than guessed from the package name, so apps whose
names differ —django-taggitinstallstaggit— are registered instead of
silently skipped. The stream-population step follows the same answer: declining
registration skips it instead of running it against an app Django does not know
about. -
The profile view is borderless: every control — the content panel, the
account-settings links, the personal-information rows, the Edit button, the avatar
and the file field — is now a shape defined by its fill against the fill behind it,
in both colour schemes. The information rows are tiles rather than lines, the two
columns share one fill, the page heading has lost its panel and the icon beside its
subtitle, buttons are pills on the core button's metrics, and the row icons are the
dashboard's widget icon. The stylesheet that dressed it moved with it — see
Removed. -
The annotations shipped under
py.typedare now verified rather than asserted —
the package type-checks clean, and CI fails on any error. Two signatures were
corrected against their callers in the process:collection_idon the studio
client'screate_variantacceptsNone, andProvidersubclasses now declare
their one-argument constructor on the base class. -
The
agentapp's migration0003was folded into0001: it set a model's
verbose_nameand nothing else, so it emitted no SQL. No action required — a
database that already applied it is unchanged, and Django ignores the leftover
history row. -
LICENSEnames the copyright holder as a person rather than a trade name.
Removed
- The dashboard stylesheet
phoxtail_dashboard/css/ui.cssis gone. Only the profile
view rendered its components, yet every dashboard page linked it. Its rules now
live inusers/css/profile.cssunder ausr-prefix, and its two genuinely shared
rules,.dash-pageand.dash-page-inner, moved into
phoxtail_dashboard/css/dashboard.css. A site using any of th...