Skip to content

Releases

v0.6.0-alpha

v0.6.0-alpha Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 07 Oct 07:33
6dfc008

Upgrading: one migration, nothing to change in .env

0.6.0 has one database migration and no new settings. Back up first, as always (Backups), then update as on Updating: download this release's five files into your plamotrack folder, keep your .env, run sha256sum -c SHA256SUMS, then docker compose up -d --no-build --wait.

  • The migration clears any negative shipping cost or low-stock threshold to blank, then the database refuses a negative in either. Only an import made with an earlier version could have stored one. The migrate log says how many it cleared, which is usually none. To go back to 0.5.2, restore your pre-update backup: the schema has moved.
  • If you shared a log from a startup settings error with an earlier version (an OIDC setting missing, a malformed MCP_OAUTH_SIGNING_KEY), rotate what it showed: the database password's first characters, or the end of MCP_OAUTH_SIGNING_KEY or OIDC_CLIENT_SECRET. From this release such an error names the setting without printing any of it.
  • If you use Pocket ID and registered your instance's /mcp with it as an API to make assistant links work, delete that registration after updating. Each assistant asks you to reconnect once.
  • PostgreSQL moves to 16.15 (from an earlier 16.x digest): the same major version and data format, nothing to do. The update test below ran across it on the same volume.
  • If an AI agent records orders for you: create_order now names the shop with retailer_id (from list_retailers) or retailer (a name). An id passed as retailer is refused instead of becoming a new shop's name. The Gunpla skill in this release's files knows this; refresh any skill or prompt built against an earlier version.

What's new

On a phone

  • Import from a phone. Settings → Data management on a phone now imports: tap Choose a file, pick a .zip archive or a .csv, preview it and apply it with Merge or Add only. That's the way from plamotrack for iPhone to your own server: export an archive in the app, import it here. Replace everything and the full template pack stay on a tablet or a computer; the starter sheet is on the phone too. (#304)
  • Every row in one list. Kits, Orders, Inventory and Retailers on a phone show every row instead of ten a page; search and the filters narrow it. Tablets and computers still show ten a page. Turning a tablet from the phone layout to the wider one opens the list on the page holding the row you were on. (#318, #321)

Lists and Home that fit, whatever they hold

  • Tables fold when they have to. From tablet width up, a list's table moves a column under the name, or a date under the status, when the whole table doesn't fit, and only then. It used to fold at fixed widths, so some valid rows could still push the row's edit button out of sight. Access tokens shows cards on a phone, as every list there does. (#329, #327)
  • A long word breaks. A name, category, manufacturer, grade, scale, note or shop address with no spaces in it, such as a product code run together, breaks across lines instead of widening the table. Inventory's Tools and Display tables fold like the other lists. Ordinary rows lay out exactly as before. (#323)
  • Home no longer scrolls sideways for a grade, scale, kit number, kit name or carrier with nowhere to break. A Backlog or Recently completed row whose details don't fit beside the name puts them under it, with the edit button beside both lines. (#326)
  • Sorted by the dates they show. Home's bench is in build-start order and Recently completed in completion order. A build with no date shows "—" and comes after the dated ones. Kits opens on Newest added, with Oldest added, Recently started, Recently completed and Name A–Z; "Newest first" is gone. list_kits takes sort newest, started and completed as well. (#247)

Import and Settings

  • An import outlives leaving the page. Before, leaving Data management while an import ran lost track of it, and the same file could be applied again. Now, coming back while it runs shows it under way; coming back after shows how it ended, once. Reloading or closing the tab while it runs asks first. (#315)
  • Out-of-range numbers are refused in the preview, naming the row and column, with the same rules as the app's forms: a rating outside 1 to 5, or a negative stock level, price, cost, shipping cost, threshold or quantity used. Before, most passed the preview and then failed the import. (#305)
  • An error is said beside the control that caused it in Settings: a failed download in its own card, a failed Save beside Save, a failed Revoke scrolled into view. (#316)
  • Dates and sizes in your saved format. Access tokens and Data management no longer show the default format when their data loads before your settings. (#331)

The keyboard

  • The keyboard stays where you pressed. The stock steppers, Export CSV, Save, Create token, Revoke and Sign in keep the keyboard while they wait. Where a button can't act again, the keyboard goes to the next sensible control: from − at zero to +, from a revoked token to the next one, from a finished Save to the field before it, from Create token to Copy. (#268)

AI assistants and sign-in

  • MCP OAuth with providers that enforce RFC 8707. In OIDC mode the proxy no longer forwards the client's resource to the provider, and always asks for openid. A provider that enforces RFC 8707, Pocket ID among them, now links an assistant with no extra setup. Pocket ID is a supported provider; see Pocket ID. (#294)
  • create_order names its shop by id or by name (see Upgrading). (#289)

Configuration

  • A POSTGRES_PASSWORD with punctuation in it (@ / + = # %, a space) no longer stops migrate with invalid interpolation syntax; nor does a percent-encoded password in DATABASE_URL. Nothing in .env needs changing. A settings error at startup names the setting without printing fragments of .env's secrets. For source runs, POSTGRES_HOST takes an IPv6 address, and a host with a port in it is refused at start, naming the setting. (#299, #300, #301)

Known limitations

  • An order dialog can stay loading if a catalog request fails (#238, since 0.4.0).
  • A sign-out request that fails leaves the page looking signed in while its writes are refused; reloading the page shows the real state (#333).

Reviewed and gated

Every change above was merged through a pull request with CI (Backend, Frontend and Integration) and automated review. Pull requests that changed shared machinery also had a briefed Codex review. Each pull request body records its tests, including tests run against the unfixed code and single-site mutants.

The release commit is 6dfc008 (#338).

Release candidate, run 37581036003, commit 6dfc008, attempt 1:

  • Both images were built once for linux/amd64 and linux/arm64 and pushed by digest.
  • The downloadable files were assembled from those digests and verified against the manifest.
  • Backend and Frontend passed. Integration ran on native amd64 and arm64 runners against the release files themselves (--no-build, migrations from an empty volume, the running images' identities checked, the authenticated ingress/MCP/log checks), and passed on both.

The update from v0.5.2-alpha's release files, the first run of that path from a release, on a separate Debian host:

  • Set up on 0.5.2: the owner claimed and signed in; a retailer, two kits, an order and a personal access token recorded. A consumable was imported through 0.5.2's importer with a low-stock threshold of −3.
  • Updated: the five files replaced, .env kept, sha256sum -c SHA256SUMS OK, docker compose up -d --no-build --wait. migrate exited 0 and logged consumables.low_stock_threshold: cleared 1 negative value(s) to null.
  • After: the version is 0.6.0; the browser session from 0.5.2 still signs in as the owner; the token from 0.5.2 reads and writes; the kits and the order are intact; the threshold is blank; the database volume is the same one; an MCP initialize and tools/list through the token lists 31 tools.

Deployment gate, on the release files (deployment_gate.py, no source build), a fresh Debian 13 host behind the reference Caddy with Let's Encrypt by DNS-01, then behind a Cloudflare Tunnel, every phase:

  • the ingress matrix: local mode 180 rows, OIDC mode 183 rows, through the tunnel 87 rows, 0 failing in each;
  • both lockouts and their recoveries; break-glass reset-password and revoke-sessions;
  • TRUSTED_PROXIES attribution behind Caddy and through the tunnel;
  • a held MCP stream on both spellings of /mcp, 75 s behind Caddy and 130 s through the tunnel;
  • OIDC sign-in, a real MCP client linked (registration, provider login, token, initialize, refresh), and the three restores from the two-part backup set (complete, without the .env secrets, without the store);
  • no run secret in the API or web logs.

The gate's results, as the driver wrote them (2026-10-07, the reference host; Caddy v2.11.7; Docker 29.8.2):

Phases: precheck, lockout, local, modern-hold, break-glass, trusted-proxies, oidc, t13, tunnel. Stream hold 75 s.

Step Result
services api, web, db healthy; migrate Exited (0)
host lxc; Debian GNU/Linux 13 (trixie)
versions Docker version 29.8.2, build 7fc2dff; 5.6.0; v2.11.7 h1:yj0Y4fYZGPkSvibBJ1sTWE33xC0fxztVyXEW5iIdUT4=
Caddyfile the host runs deploy/caddy/Caddyfile (name filled in)
TLS TLSv1.3, TLS_AES_128_GCM_SHA256; issuer Let's Encrypt / YE1; notAfter Dec 5 22:24:55 2026 GMT
lockout: name not listed 421 ingress.host_not_allowed
recovery: ALLOWED_HOSTS 200...
Read more

v0.5.2-alpha

v0.5.2-alpha Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 23 Sep 00:14
4586081

Upgrading: one .env change for a source installation

0.5.2 changes how plamotrack is packaged. The application is the same as 0.5.0: no schema migration, no authentication change, no new setting.

If you run from a Git checkout, add two lines to .env before you update. Since this release, docker-compose.yml on its own runs published images only. A checkout builds from source through docker-compose.build.yml, which .env selects:

COMPOSE_PATH_SEPARATOR=:
COMPOSE_FILE=docker-compose.yml:docker-compose.build.yml

A fresh cp .env.example .env already has them. An .env copied at 0.5.0 or earlier doesn't. Without them, docker compose up -d --build --wait stops with error from registry: denied, before changing anything. Then back up as always (Backups) and run git pull && docker compose up -d --build --wait. To roll back, run git checkout v0.5.0-alpha, delete those two lines from .env (0.5.0 has no docker-compose.build.yml, and Compose refuses a missing file), then run the same up. The database needs nothing either way. The steps are on Updating.

Keep updating a source installation from source. Moving an existing installation onto the release files below is not covered yet. Compose names a stack's database volume after its directory, so starting the release files in a new directory gives you a new, empty collection. That path, with a tested backup and restore, is future work.

What's new: install with Docker alone

Each release from this one carries five files on its release page:

File What it is
docker-compose.yml The stack, with every image pinned by digest: the API, the web front and PostgreSQL 16, each built for linux/amd64 and linux/arm64
env.example The configuration template: copy it to .env and set the database password
plamotrack-gunpla.zip The Gunpla skill for Claude Desktop, matching this release
release.json The version, the source commit, the image digests and the files' checksums
SHA256SUMS Checksums of the four files above

In an empty directory, with Docker and nothing else:

sha256sum -c SHA256SUMS
cp env.example .env         # then set POSTGRES_PASSWORD
docker compose up -d --no-build --wait

No registry login and no Git checkout are needed. The images are public on GitHub's container registry: ghcr.io/deusmaximus/plamotrack-api and plamotrack-web, tagged v0.5.2-alpha. There is no latest channel. Choosing another version is a deliberate update, and version tags are never moved.

The images were built once, tested, and then promoted without rebuilding. The digests in this release's docker-compose.yml are the ones that passed the checks below, on both architectures.

Why there is no 0.5.1

v0.5.1-alpha was the first run of this pipeline. It passed the candidate checks and the deployment gate, and its images were promoted. But GitHub renames an uploaded file whose name starts with a period, so its .env.example reached the draft release as default.env.example. The documented first step, sha256sum -c SHA256SUMS, then failed on the files a user would download. It was never published. The template now ships as env.example, and promotion downloads its own draft and verifies it before anyone can publish it. The v0.5.1-alpha tag and image tags remain, because version tags are never moved, but no release was published from them. Use 0.5.2.

Known limitations

  • On the stock steppers, keyboard focus falls to the page after each press (#268).
  • An order dialog can stay loading if a catalog request fails (#238, since 0.4.0).
  • Moving an existing source installation onto the release files: not covered yet (see above).

Reviewed and gated

The pipeline is #278:

  • built in #290, reviewed by GLM 5.3 and CodeRabbit, with their findings addressed there;
  • fixed after v0.5.1-alpha in #292, reviewed by Greptile with no findings. #292 includes tests that fail on the unfixed code, and five single-site mutants, all caught.

The release commit is 4586081.

Release candidate, run 35797496522, commit 4586081:

  • Both images were built once for linux/amd64 and linux/arm64 and pushed by digest.
  • The downloadable files were assembled from those digests and verified against the manifest.
  • Backend and frontend suites passed.
  • On native amd64 and native arm64 runners, each Integration job then:
    • downloaded the bundle;
    • created .env from its env.example;
    • pulled every image without registry credentials;
    • ran every migration onto an empty volume, with migrate exiting 0;
    • confirmed the running images carry this commit and v0.5.2-alpha;
    • ran the packaged ingress matrix: 0 failing checks on each runner;
    • checked that both MCP protocol generations report 0.5.2.

Deployment gate (T12/T13, deployment_gate.py --phase all plus the tunnel) on the gate host. This ran against the downloaded release files, not a source tree:

  • The host was reset to a fresh install: down -v, and the directory replaced by the five release files plus the gate's host files from 4586081.
  • The repository's own host-prepare.sh generated .env from env.example.
  • The stack started with up -d --no-build --wait, pulling the pinned digests with no registry login.
  • Every phase then ran from the workstation, through Caddy and through a Cloudflare Tunnel. As observed, with the network addresses and the host's name redacted:

Deployment gate (T12/T13) — 2026-09-23 09:46 AEST, the gate host

Phases: precheck, lockout, local, modern-hold, break-glass, trusted-proxies, oidc, t13, tunnel. Stream hold 75 s.

Step Result
services api, web, db healthy; migrate Exited (0)
host lxc; Debian GNU/Linux 13 (trixie)
versions Docker version 29.8.1, build 4a63305; 5.5.1; v2.11.4 h1:XKxkMTgNSizEvKG6QHue6cAsFOteU2qA61w2tKkCWi0=
Caddyfile the host runs deploy/caddy/Caddyfile (name filled in)
TLS TLSv1.3, TLS_AES_128_GCM_SHA256; issuer Let's Encrypt / YE1; notAfter Dec 5 22:24:55 2026 GMT
lockout: name not listed 421 ingress.host_not_allowed
recovery: ALLOWED_HOSTS 200
lockout: https Origin on a write 403 ingress.origin_not_allowed
recovery: PUBLIC_BASE_URL 401 auth.unauthenticated
nothing lost session state unclaimed throughout
local: claim was unclaimed; setup token read, claiming
hold /mcp/ held 75.0 s (asked 75); 5 chunk(s), 255 bytes; max gap 15.0 s; ended 0.0 s after DELETE
hold /mcp held 75.0 s (asked 75); 5 chunk(s), 255 bytes; max gap 15.0 s; ended 0.0 s after DELETE
matrix (local, https, --behind-proxy) 0 failing / 180 ok rows, 3 skipped (hostile Host: the proxy answers)
MCP client tools/list 31 tools, list_kits present
/api/readyz from outside 404
T10 log scan api and web access records seen; 6 run values absent
modern hold /mcp/ held 77.0 s (asked 75); 6 chunk(s), 90 bytes; max gap 15.0 s (< 30 s); no session id; aborted by close; backend pid(s) ['415'] held during, gone after abort
modern hold /mcp held 75.0 s (asked 75); 6 chunk(s), 90 bytes; max gap 15.0 s (< 30 s); no session id; aborted by close; backend pid(s) ['587'] held during, gone after abort
break-glass: reset-password old session now anonymous
break-glass: revoke-sessions new session now anonymous
TRUSTED_PROXIES=127.0.0.1 nginx $remote_addr before: 172.19.0.1; after: [the workstation's address]; this workstation: [the workstation's address]
audit client_address [the workstation's address]
OIDC login through the proxy setup token + provider sign-in → owner session
hold /mcp/ held 75.0 s (asked 75); 5 chunk(s), 255 bytes; max gap 15.0 s; ended 0.0 s after DELETE
hold /mcp held 75.0 s (asked 75); 5 chunk(s), 255 bytes; max gap 15.0 s; ended 0.0 s after DELETE
matrix (oidc, https, signed in) 0 failing / 183 ok rows, 3 skipped
T10 log scan api and web access records seen; 11 run values absent
T13 link DCR + provider login + token + initialize + get_meta + refresh; registrations 0→1, grants 0→1, client rows 0→1
T13 restore: complete set session owner, PAT 200, data intact, refresh 200, initialize 200, registrations after restore 0, grants 1
T13 restore: without the env secrets session owner, PAT 200, data intact, old access 401, refresh 401 invalid_client, relink ok
T13 restore: without the store session owner, PAT 200, data intact, old access 401, refresh 401 invalid_client, relink ok
tunnel: reachable 200
tunnel: TRUSTED_PROXIES=[the connector's address] nginx $remote_addr before: [the connector's address]; after: [the workstation's public address]; expected the visitor [the workstation's public address]
tunnel: OIDC login provider sign-in on the tunnel name → owner session
hold /mcp/ held 130.0 s (asked 130); 9 chunk(s), 459 bytes; max gap 15.0 s; ended 0.0 s after DELETE
hold /mcp held 130.0 s (asked 130); 9 chunk(s), 459 bytes; max gap 15.0 s; ended 0.0 s after DELETE
tunnel: matrix (oidc, signed in) 0 failing / 87 ok rows, 4 skipped; limiter checks skipped — a CDN's latency and URL normalisation make nginx's per-address, per-spelling keying unobservable here (proven at the packaged layer)
tunnel: audit client_address [the workstation's public address]
T10 log scan api and web access records seen; 5 run values absent
tunnel: restored WEB_BIND, PUBLIC_BASE_URL and TRUSTED_PROXIES back to mode R

Promotion, run 35800649160, on the tag v0.5.2-alpha:

  • copied the tested image indices to the version tags, without rebuilding;
  • created this release as a draft;
  • downloaded the...
Read more

v0.5.0-alpha — phone and tablet layout

Choose a tag to compare

@DeusMaximus DeusMaximus released this 20 Sep 05:01
ba770e3

Upgrading: nothing to migrate

0.5.0 is the interface on a phone and a tablet. There is no schema migration, no authentication change and no new setting: back up as you always would (Backups), then git pull && docker compose up -d --build --wait and sign in as before. Nothing you have stored is reinterpreted; tokens and linked MCP clients are untouched. Rolling back is git checkout v0.4.1-alpha and the same up — the database needs nothing either way.

One thing moves on the desktop. From 1280 px wide the layout is what it was, with one exception: between 1280 and 1361 px the Orders table now shows the order number under the retailer's name, where an ordinary row used to push the edit control off the table's edge. If a desktop page looks different from 0.4.1 anywhere else at those widths, that is a bug — please file it.

What's new: plamotrack on a phone and a tablet

The interface now lays itself out by the width of the window — no device sniffing, so an iPad and a narrow desktop window are the same case. There is a page for it on the docs site: Phone & Tablet.

  • Below 768 px — a phone. A tab bar under your thumb (Home, Kits, Orders, Inventory, More), lists as cards, every form a full-screen sheet with its buttons in a bar at the foot of the screen, order lines that stack into cards, and one Filter and sort sheet on Kits and Orders. Settings opens as a list of its sections.
  • 768 to 1279 px — a tablet, or a narrow window. A rail of icons in place of the sidebar, and tables that fold their columns to the width they have instead of scrolling sideways: a fold moves what a column said (the order number under the retailer, the dates under the status, tracking into the expanded lines) and never drops it.
  • Touch-sized controls on any touch screen, at any width.
  • Add it to your home screen — Safari's share menu on iOS and iPadOS, Install in Chrome's menu on Android — and it opens in its own window with its own icon, still signed in. It is still a web page talking to your server: there is no offline mode, and your collection is never stored on the device.
  • Data management on a phone offers the exports only. Importing and the blank templates need a screen at least 768 px wide: the import preview is wide tables, and Replace everything does not belong a thumb's width from a mis-tap. A link to it opened on a phone says so rather than failing.
  • The browser's own text size is respected. On a phone, rows wrap and long names break instead of pushing a control off the screen — held by tests at two and a half times the default size on a 320 px screen. Larger than that is not claimed.
  • The keyboard keeps its place when a tablet is turned or a window crosses a layout line — on a row, inside a dialog, and at the close of a dialog whose opener was redrawn while it was open (#275 was the last of these).

Also since 0.4.1: a default Git for Windows clone no longer breaks the bundled nginx (#255), the Access tokens table fits its pane (#262), and the brand mark is the favicon (#261).

Known limitations

  • On the stock steppers, keyboard focus falls to the page after each press (#268).
  • An order dialog can stay loading if a catalog request fails (#238, since 0.4.0).
  • Not checked by anyone: a screen reader, right-to-left languages, page zoom, Firefox, a browser text size above 40 px, and an iPhone (the phone layout was walked on an Android phone; iOS on an iPad and in the iOS Simulator).

Reviewed and gated

The milestone was five PRs straight to main, each shippable alone — #265 (the shells, touch sizes, home-screen install), #266 (list pages and table folds), #272 (dialogs as sheets), #274 (Home, Settings, sign-in; the large-text family), #276 (the screenshots; #275) — each through Codex (GPT-6) review rounds until a GO with nothing above a P3 remedy; the findings and what was and was not examined are in each PR's coverage record. Every PR owed "1280 px and wider unchanged" and showed it by pixel comparison against main on one seeded database.

On devices (the owner, 20 September 2026): a Pixel 10 Pro XL and an iPad Air 5 against the packaged stack behind Caddy — nothing found (reported as a whole, not page by page); both add plamotrack to the home screen as an app in its own window, still signed in. iOS Simulator passes on an iPhone 17 and an iPad mini (iOS 27) during the build.

Packaged stack, local (release gate step 4), commit 41a9fa4: built with docker compose up -d --build --wait under its own Compose project on a fresh volume — migrate ran every migration onto an empty database and exited 0 (head d5e9362140ea; nothing new since 0.3.0), api, web and db healthy; claimed with a throwaway credential through the packaged nginx; GET /api/meta → "version":"0.5.0"; with a read-only personal access token through nginx, a FastMCP 4 client in auto negotiated 2026-07-28 and in mode="legacy" 2025-11-25 — 31 tools each, serverInfo plamotrack 0.5.0 — and the anonymous client was refused; an archive exported through the stack carries app_version 0.5.0, schema_version d5e9362140ea, export_version 1. The local-mode ingress matrix against the packaged stack is CI's Integration job on the release PR, green.

Deployment gate (T12/T13, deployment_gate.py --phase all plus the tunnel) on the gate host — a fresh-install reset (down -v, the tree wiped and the release tree extracted, .env regenerated, host-prepare.sh, up -d --build --wait), then every phase from the workstation through Caddy and through a Cloudflare Tunnel, as observed (one public address redacted):

Deployment gate (T12/T13) — 2026-09-20 14:41 AEST, testhost.internal.tlgnet.net

Phases: precheck, lockout, local, modern-hold, break-glass, trusted-proxies, oidc, t13, tunnel. Stream hold 75 s.

Step Result
services api, web, db healthy; migrate Exited (0)
host lxc; Debian GNU/Linux 13 (trixie)
versions Docker version 29.8.1, build 4a63305; 5.5.1; v2.11.4 h1:XKxkMTgNSizEvKG6QHue6cAsFOteU2qA61w2tKkCWi0=
Caddyfile the host runs deploy/caddy/Caddyfile (name filled in)
TLS TLSv1.3, TLS_AES_128_GCM_SHA256; issuer Let's Encrypt / YE1; notAfter Dec 5 22:24:55 2026 GMT
lockout: name not listed 421 ingress.host_not_allowed
recovery: ALLOWED_HOSTS 200
lockout: https Origin on a write 403 ingress.origin_not_allowed
recovery: PUBLIC_BASE_URL 401 auth.unauthenticated
nothing lost session state unclaimed throughout
local: claim was unclaimed; setup token read, claiming
hold /mcp/ held 75.0 s (asked 75); 5 chunk(s), 255 bytes; max gap 15.0 s; ended 0.0 s after DELETE
hold /mcp held 75.0 s (asked 75); 5 chunk(s), 255 bytes; max gap 15.0 s; ended 0.0 s after DELETE
matrix (local, https, --behind-proxy) 0 failing / 179 ok rows, 3 skipped (hostile Host: the proxy answers)
MCP client tools/list 31 tools, list_kits present
/api/readyz from outside 404
T10 log scan api and web access records seen; 6 run values absent
modern hold /mcp/ held 77.0 s (asked 75); 6 chunk(s), 90 bytes; max gap 15.0 s (< 30 s); no session id; aborted by close; backend pid(s) ['425'] held during, gone after abort
modern hold /mcp held 77.0 s (asked 75); 6 chunk(s), 90 bytes; max gap 15.0 s (< 30 s); no session id; aborted by close; backend pid(s) ['597'] held during, gone after abort
break-glass: reset-password old session now anonymous
break-glass: revoke-sessions new session now anonymous
TRUSTED_PROXIES=127.0.0.1 nginx $remote_addr before: 172.19.0.1; after: 10.86.64.128; this workstation: 10.86.64.128
audit client_address 10.86.64.128
OIDC login through the proxy setup token + provider sign-in → owner session
hold /mcp/ held 75.0 s (asked 75); 5 chunk(s), 255 bytes; max gap 15.0 s; ended 0.0 s after DELETE
hold /mcp held 75.0 s (asked 75); 5 chunk(s), 255 bytes; max gap 15.0 s; ended 0.0 s after DELETE
matrix (oidc, https, signed in) 0 failing / 182 ok rows, 3 skipped
T10 log scan api and web access records seen; 11 run values absent
T13 link DCR + provider login + token + initialize + get_meta + refresh; registrations 0→1, grants 0→1, client rows 0→1
T13 restore: complete set session owner, PAT 200, data intact, refresh 200, initialize 200, registrations after restore 0, grants 1
T13 restore: without the env secrets session owner, PAT 200, data intact, old access 401, refresh 401 invalid_client, relink ok
T13 restore: without the store session owner, PAT 200, data intact, old access 401, refresh 401 invalid_client, relink ok
tunnel: reachable 200
tunnel: TRUSTED_PROXIES=10.1.1.155 nginx $remote_addr before: 10.1.1.155; after: [the workstation's public address, redacted]; expected the visitor [the workstation's public address, redacted]
tunnel: OIDC login provider sign-in on the tunnel name → owner session
hold /mcp/ held 130.0 s (asked 130); 9 chunk(s), 459 bytes; max gap 15.0 s; ended 0.0 s after DELETE
hold /mcp held 130.0 s (asked 130); 9 chunk(s), 459 bytes; max gap 15.0 s; ended 0.0 s after DELETE
tunnel: matrix (oidc, signed in) 0 failing / 87 ok rows, 4 skipped; limiter checks skipped — a CDN's latency and URL normalisation make nginx's per-address, per-spelling keying unobservable here (proven at the packaged layer)
tunnel: audit client_address [the workstation's public address, redacted]
T10 log scan api and web access records seen; 5 run values absent
tunnel: restored WEB_BIND, PUBLIC_BASE_URL and TRUSTED_PROXIES back to mode R

Not run for this release, the owner's call: the real-client MCP acceptance run (Claude, ChatGPT, Gemini, Mistral, Inspector). Nothing under /mcp has changed since v0.4.1-alpha, where it was run in full; ...

Read more

v0.4.1-alpha — MCP speaks both generations

Choose a tag to compare

@DeusMaximus DeusMaximus released this 14 Sep 21:42
971af7e

Upgrading: nothing to migrate

0.4.1 is the MCP layer. There is no schema migration, no authentication change and no new setting: back up as you always would (docs/operations.md → Backups), then git pull && docker compose up -d --build --wait and sign in as before. Nothing you have stored is reinterpreted. A client linked through your identity provider stays linked — its next refresh succeeds and nothing re-registers — and a personal access token keeps working. An instance upgraded from 0.3.x or 0.4.0 (or restored from a dump taken on one) may hold a stored copy of a web client's metadata document; it is the fallback until the first successful fetch and removed then — nothing to do. Rolling back is git checkout v0.4.0-alpha and the same up; the database needs nothing either way. Details: docs/operations.md → Upgrading to 0.4.1.

The documentation now lives at https://docs.gunp.la — installing, first run, everyday use, the deployment paths, authentication and the AI/MCP guides, with screenshots. The README points there.

What changes for anything that talks to /mcp/

  • Both MCP protocol generations from the one endpoint. The server runs on FastMCP 4.0.3 / MCP SDK 2.2.0 and serves the current 2026-07-28 revision — stateless, server/discover in place of a handshake, routing headers — beside the handshake revisions, decided per request from what each client sends. Nothing a 0.4.0 client sends stops working; a newer client gets the newer transport on its first call. The refusal contract on the mount is the RFC 6750 challenge in both eras.
  • A dynamic registration asking for private_key_jwt is refused at registration (400 invalid_client_metadata). No client in the tested set asks for it.
  • A client that brings its own metadata document (Claude web, ChatGPT web) is no longer stored — the instance fetches the document when the client shows up and keeps it in memory for as long as its cache headers allow, so a registration flood cannot lock those clients out. While such a document cannot be fetched, that client's own token refresh and revocation answer 401 invalid_client, its access token keeps working, and the same refresh succeeds once the host answers.
  • When a grant ends, the provider is asked to revoke its own refresh token through the browser login's client — the path FastMCP 4 would otherwise have silently lost (#241).
  • create_order's description now says what its schema enforces: every item line carries its own currency_code; only the order-level one may be omitted (#56).

What's new

Everything in this release is under the MCP endpoint; the interface, the data model and the REST API are 0.4.0's. The four PRs — #245 (#241), #246 (#243), #248 (#242), #250 (#244 + #56) — landed on the integration branch m6.1-fastmcp4 so a mid-milestone clone never ran a half-migrated OAuth proxy; each went through Codex (GPT-6) review rounds, and this release merged with a merge commit gated as below.

Stack. testhost (LXC 117, Debian) behind Caddy 2.11.4 and a Cloudflare Tunnel (plamotest.gunp.la → 10.1.1.129:8080, connector 10.1.1.155), .env in the documented tunnel shape (WEB_BIND=10.1.1.129, TRUSTED_PROXIES=10.1.1.155, PUBLIC_BASE_URL=https://plamotest.gunp.la), OIDC mode against the Keycloak 26.6.4 fixture at idp.testhost.internal.tlgnet.net. Baseline: a fresh install of v0.4.0-alpha (fastmcp 3.4.5 / mcp 1.29.0). Upgrade target: the m6.1-fastmcp4 tree at c3c356b (code identical to d57af8b; fastmcp 4.0.3 / mcp 2.2.0; app.__version__ still 0.4.0 — the bump is the release PR's). Eras were read off the plain-HTTP connector→nginx hop with a header-only packet filter (never Authorization), because neither access log records headers.

Sequence. Fresh 0.4.0 → owner claimed through the tunnel (setup token + Keycloak) → one write PAT minted → the owner linked four web clients at 0.4.0 → documented backup (dump + .env) → upgrade with the documented commands (docker compose up -d --build --wait, 28 s, migrate exit 0, no new migration) → round 1 → backup at 4.x → documented restore (down -v, up -d db --wait, pg_restore --clean --if-exists, up -d --build --wait; 31 s) → modern-hold phase through the tunnel → round 2 after every client's access token had expired → MCP Inspector by dynamic registration. API log: 0 warnings/errors/refusals for the whole run.

Client / path Version (observed) Client kind Negotiated era Result
Claude web (claude.ai) Claude-User, 2026-09-15 CIMD (https://claude.ai/oauth/mcp-oauth-client-metadata) 2026-07-28 — two stateless requests per question (server/discover, tools/call), posted to bare /mcp linked at 0.4.0; list + call after upgrade and after restore; refresh at /mcp/token 200
ChatGPT web (chatgpt.com) openai-mcp/1.0.0, refresh by openai-connectors-oauth/1.0 CIMD (https://chatgpt.com/oauth/<id>/client.json) 2025-11-25 (handshake, GET channel) same; refresh 200
Gemini Spark Google, OpenAuth for the refresh DCR (public, PKCE S256, public https callback) 2025-11-25; re-handshakes per step (7 initializes for one question) first link attempt registered a client and obtained a code it never redeemed (matches the public forum report); second attempt linked; then same as above; refresh 200
Mistral (chat.mistral.ai) MistralAI-MCPClient/1.0 DCR 2025-11-25; re-handshakes per step (19 initializes in round 2); reuses a session id after its own DELETE → a few 404s, identical on 0.4.0 linked; same; refresh 200
MCP Inspector 2.6.0 (Node 24.15.0), UA node DCR (register 201, loopback callback http://127.0.0.1:6274/oauth/callback, resource parameter) 2025-11-25 consent → Keycloak → callback → token 200 → grant issued → tools listed, get_meta called
Claude Code 2.1.270 (claude-desktop, agent-sdk/0.3.270) PAT, one-shot --mcp-config 2026-07-28 on all six requests (server/discover, prompts/list, resources/list, tools/list, tools/call ×2) version 0.4.0, 0 kits
Claude Desktop through mcp-remote mcp-remote 0.14.2, --header Authorization:${AUTH_HEADER} PAT on the wire: its own legacy initialize + GET channel (2025-11-25), then the client's calls relayed in 2026-07-28 (server/discover, tools/list, tools/call); it also probes the OAuth discovery documents despite the header 31 tools, get_meta ok
FastMCP 4.0.3 / SDK 2.2.0 client, through the tunnel python-httpx2/2.12.0 PAT auto → 2026-07-28; mode="legacy" → 2025-11-25, serverInfo 0.4.0 31 tools both eras; anonymous refused (MCPError -32603); identical before and after the restore
Modern hold through the Cloudflare Tunnel gate --phase modern-hold --hold 130 PAT 2026-07-28 /mcp/ and /mcp: held 130.0 s, 9 chunks, max gap 15.0 s, no session id, aborted by close, backend pid held during and gone after; flag disarmed
Conformance runner @modelcontextprotocol/conformance 0.1.16 against a source-run create_app() (authorization off) anonymous runner has server scenarios for 2025-06-18 and 2025-11-25 only — none for 2026-07-28 passed: server-initialize, ping, logging-set-level, tools-list, resources-list, prompts-list, server-sse-multiple-streams (2 checks), dns-rebinding-protection (2 checks). 20 scenarios need fixtures plamotrack does not have (test_* prompts/resources/tools, image/audio/embedded content, sampling, elicitation, completion, subscriptions, logging/progress notifications). tools-call-simple-text and tools-call-error report "passed" but only because the per-tool middleware's refusal text was counted as text content: tools/call over HTTP without a bearer is refused even on the pre-auth app (ToolScopeMiddleware.principal_for_call), which is also why server-sse-polling fails. Tool execution is proven by the real clients above, not by the runner.

Grant continuity, as stored. Before the upgrade: 4 grants (auth.mcp_grant_issued ×4), 5 client records (Claude and ChatGPT as persisted CIMD rows — the 0.4.0 shape — plus 3 DCR rows, one of them Gemini's orphan), 4 upstream / 4 refresh / 8 JTI rows. After the upgrade and round 1: counts unchanged, all 4 upstream-token rows re-put (the transparent refresh behind the first call, through httpx2 against Keycloak's Let's Encrypt chain), no new grant. After the restore and round 2: POST /mcp/token 200 from each of the four, 4 refresh tokens rotated, JTI 8 → 14, still no new grant or consent, and both CIMD rows deleted — the first document fetch retired them, as #242 specifies. The owner's pre-restore session cookie and the 0.4.0 PAT authenticate after the restore. Inspector adds the 5th grant. Final: 4 client records (3 DCR linked + the orphan, which expires 24 h after creation), 5 upstream, 5 refresh, 14 JTI.

Also observed (same on 0.4.0 and 4.x, so client habits, not regressions). ChatGPT sends one POST /mcp/ that earns 400 before its handshake; Mistral probes /.well-known/mcp/server-card/mcp/ (404, tolerated) at link time; Claude web posts to bare /mcp (nginx's rewrite is load-bearing, as #190 found); the Cf-Connecting-Ip visitor and the audit client_address agree throughout.

Not run. Claude Desktop's native connector (the same account-level connector as Claude web); the frozen FastMCP 3.4.5 client through the tunnel (CI proves it against the packaged stack); a 2026-07-28 conformance scenario, because the runner has none.

Reviewed and gated

Packaged stack, local (release gate step 4), tree ebc0f47 (commit 994f759, the branch tip; the merge commit carries the same tree): built with docker compose up -d --build --wait under its own Compose project on a fresh volume — migrate ran the 0.3.0 migrations onto an empty database and exited 0 (head d5e9362140ea; nothin...

Read more

v0.4.0-alpha — the Workbench interface

Choose a tag to compare

@DeusMaximus DeusMaximus released this 10 Sep 10:13
64d23de

Upgrading: nothing to migrate

0.4.0 is the interface. There is no schema migration, no authentication change and no new setting: back up as you always would (docs/operations.md → Backups), then git pull && docker compose up -d --build --wait and sign in as before. Nothing you have stored is reinterpreted. Rolling back is git checkout v0.3.0-alpha and the same up; the database needs nothing either way. Details: docs/operations.md → Upgrading to 0.4.0.

What changes for anything that talks to the API

Everything below is additive or a spelling; nothing a 0.3.0 client sends stops working.

  • Every order row carries a derived stage — received, in_transit, pre_ordered (every kit the order spawned is still a pre-order) or ordered — on REST and the list_orders / get_order tools. Derived on the way out, never stored.
  • GET /summary and the get_summary MCP tool: kits per pipeline status and orders per stage, from one function and one snapshot — what Home's headings show, and what an agent reads before asking for rows.
  • GET /kits and GET /orders, list_kits and list_orders, take sort and limit. sort=recent is a kit's status clock (status_updated_at) or an order's last status change — received, else shipped, else placed, a placement date read as its midnight in the instance's time zone; every generated kit status stamp is now the API's clock, so a create and a move never rank by two clocks.
  • /board redirects to /. The kanban board is gone; a bookmark lands on Home.
  • The Orders page's ?status= values are the wire's (ordered, pre_ordered, in_transit, received); the board.* catalogue namespace is home.* (a translation in flight loses those keys at its coverage check).

What's new

One look. The interface moved off the stock component look onto one house look, Workbench: warm near-black surfaces, one amber accent, hairline borders, flat surfaces, Inter as the only typeface, stroke icons from one set, no emoji. Every colour, radius and border is a semantic token, and the light theme is the same tokens under [data-theme="light"] — the light hues darkened until every text-on-chip pair clears 4.5:1, which a test holds. Light, dark or follow-the-device is a per-browser choice in the sidebar, applied before the first paint by a first-party head script (the bundled nginx serves the app under script-src 'self'), the one setting that belongs to the device asking rather than the instance.

Home replaces the board. The start page is status at a glance, the bench first: On the bench — every kit you are building as a wide card with its start date, a day counter and your latest note; Backlog and Recently completed — the six most recent of each with the true count beside the heading and a view all link into Kits filtered and sorted; In the mail — Pre-ordered, Ordered and In transit as order cards with the retailer, the date of the last status change, the first line, "and n more", the carrier and tracking once shipped. Every card carries one visible edit control opening the same dialog the list pages use; there is no drag-and-drop. The board was drawn for a dozen kits and stopped being useful somewhere around fifty (#122).

List pages with their state in the URL. Kits, Orders, Inventory and Retailers keep their tables and gain filter, sort, search and page as query parameters (/kits?status=building&sort=recent&page=2) — a view all link is a page state, and so is a bookmark. Each row carries one edit control; Delete moved inside the dialog beside the fields it destroys; the count sits beside the page title; ten rows a page. Orders' expandable lines show each kit line's status, note that a catalog line's stock lands on receipt, close with the shipping line and show the lines' converted total under the order's own.

Settings, About, sign-in in the same look, with a plain one-line description where the sidebar tagline was. The README's screenshots are the new interface, in both themes.

Reviewed and gated

The four PRs (#235, #236, #237, #239) landed on an integration branch so a mid-milestone clone kept the 0.3.0 interface; #235–#237 each went through independent Codex (GPT-6) review rounds — three findings in the order clock alone (the SQL session's zone, Postgres's zone files, PEP 495 ties) are why the recent sort is computed in the application — and this release PR merged with a merge commit gated as below.

Packaged stack, local (release gate step 4), tree 509f832 (commit 900e2ea, the branch tip; the merge commit carries the same tree): built with docker compose up -d --build --wait under its own Compose project on a fresh volume — migrate ran the four 0.3.0 migrations onto an empty database and exited 0 (head d5e9362140ea; nothing new to run), api, web and db healthy; the fresh stack reported itself unclaimed; the setup token read from the API log and the instance claimed through the packaged nginx; the ingress matrix in local mode 0 failing checks (ending in the /mcp/register burst that trips nginx's limiter, its 429s carrying the envelope and no-store); the refusal budget bounded the run's thirty hostile-Origin writes to 10 ingress.origin_rejected rows; GET /api/meta → "version":"0.4.0"; the MCP handshake through nginx with a personal access token → serverInfo plamotrack 0.4.0, 31 tools listed (get_summary among them), the anonymous handshake refused 401; an archive exported from the container whose manifest says app_version 0.4.0, schema_version d5e9362140ea, export_version 1; the T10 scan of both containers' logs found an access record from each service and none of the run's credentials or probes.

Deployment gate (T12/T13, deployment_gate.py --phase all) on testhost — a fresh-install reset (down -v, the tree wiped and the release tree extracted, .env regenerated, host-prepare.sh, up -d --build --wait), then every phase from the workstation through Caddy, as observed:

=== precheck ===
ok   services                           api, web, db healthy; migrate Exited (0)
ok   host                               lxc; Debian GNU/Linux 13 (trixie)
ok   versions                           Docker version 29.8.0, build 88096ef; 5.5.1; v2.11.4 h1:XKxkMTgNSizEvKG6QHue6cAsFOteU2qA61w2tKkCWi0=
ok   Caddyfile                          the host runs deploy/caddy/Caddyfile (name filled in)
ok   TLS                                TLSv1.3, TLS_AES_128_GCM_SHA256; issuer Let's Encrypt / YE1; notAfter Dec  5 22:24:55 2026 GMT
=== lockout ===
ok   lockout: name not listed           421 ingress.host_not_allowed
ok   recovery: ALLOWED_HOSTS            200
ok   lockout: https Origin on a write   403 ingress.origin_not_allowed
ok   recovery: PUBLIC_BASE_URL          401 auth.unauthenticated
ok   nothing lost                       session state unclaimed throughout
=== local ===
ok   local: claim                       was unclaimed; setup token read, claiming
0 failing check(s)
ok   hold /mcp/                         held 75.0 s (asked 75); 5 chunk(s), 255 bytes; max gap 15.0 s; ended 0.0 s after DELETE
ok   hold /mcp                          held 75.0 s (asked 75); 5 chunk(s), 255 bytes; max gap 15.0 s; ended 0.0 s after DELETE
ok   matrix (local, https, --behind-proxy) 0 failing / 179 ok rows, 3 skipped (hostile Host: the proxy answers)
ok   MCP client tools/list              31 tools, list_kits present
ok   /api/readyz from outside           404
ok   T10 log scan                       api and web access records seen; 6 run values absent
=== break-glass ===
ok   break-glass: reset-password        old session now anonymous
ok   break-glass: revoke-sessions       new session now anonymous
=== trusted-proxies ===
ok   TRUSTED_PROXIES=127.0.0.1          nginx $remote_addr before: 172.19.0.1; after: 10.86.64.128; this workstation: 10.86.64.128
ok   audit client_address               10.86.64.128
=== oidc ===
ok   OIDC login through the proxy       setup token + provider sign-in → owner session
0 failing check(s)
ok   hold /mcp/                         held 75.0 s (asked 75); 5 chunk(s), 255 bytes; max gap 15.0 s; ended 0.0 s after DELETE
ok   hold /mcp                          held 75.0 s (asked 75); 5 chunk(s), 255 bytes; max gap 15.0 s; ended 0.0 s after DELETE
ok   matrix (oidc, https, signed in)    0 failing / 182 ok rows, 3 skipped
ok   T10 log scan                       api and web access records seen; 11 run values absent
=== t13 ===
ok   T13 link                           DCR + provider login + token + initialize + get_meta + refresh; registrations 0→1, grants 0→1, client rows 0→1
ok   T13 restore: complete set          session owner, PAT 200, data intact, refresh 200, initialize 200, registrations after restore 0, grants 1
ok   T13 restore: without the env secrets session owner, PAT 200, data intact, old access 401, refresh 401 invalid_client, relink ok
ok   T13 restore: without the store     session owner, PAT 200, data intact, old access 401, refresh 401 invalid_client, relink ok

Both /mcp spellings held 75 s through Caddy with 15 s pings and a clean DELETE close, in local mode and in OIDC mode; the local-mode matrix 179 ok rows, the OIDC-mode matrix signed in 182 ok rows, 0 failing in each; the T10 scan found an access record from each service and none of the run's values (6 in local mode, 11 in OIDC mode); the three T13 restores from the two-part backup set behaved as docs/operations.md says.

The same stack through a Cloudflare Tunnel (the gate's tunnel phase, run last once the operator re-added the route plamotest.gunp.la → http://10.1.1.129:8080; connector on another host), as observed:

Deployment gate (T12/T13) — 2026-09-10 20:04 AEST, testhost.internal.tlgnet.net

Phases: tunnel. Stream hold 75 s.

Step Result
tunnel: reachable 200
tunnel: TRUSTED_PROXIES=10.1.1.155 nginx $remote_addr befor...
Read more

v0.3.0-alpha — the instance has an owner

Choose a tag to compare

@DeusMaximus DeusMaximus released this 08 Sep 05:31
c46b652

⚠️ Back up, upgrade, then claim your instance again

0.3.0 puts a lock on the door. After docker compose up -d --build --wait, an existing
instance comes up unclaimed, exactly as a fresh install does: every collection route
answers 401, the UI shows the setup screen, and the API prints a one-time setup token
to its log. Nothing in your collection changes — the four migrations only add the
authentication tables — but until you claim it nobody can read or write anything, and
that includes every script and MCP client you have pointed at it.

In order:

  1. Back up first: the database and .env (docs/operations.md → Backups).

  2. If you reach the instance by anything but localhost, ALLOWED_HOSTS must already
    name it (the 0.2.10 lockout, unchanged); behind a TLS proxy, PUBLIC_BASE_URL too.

  3. git pull && docker compose up -d --build --wait, then read the token and claim:

    docker compose logs api | grep -A6 "no owner yet"

    Open the instance in a browser and enter the token with the owner password you
    want. It is single-use; restart the api container for a fresh one if you lose it.

  4. Mint a personal access token for every script and MCP client — Settings →
    Access tokens. A Claude Desktop or Claude Code entry that pointed at /mcp/ without
    one is refused at the handshake until it sends Authorization: Bearer ptk_…. The
    README's Wiring up the MCP server has the exact configuration.

  5. /api/meta, /openapi.json and /api/docs no longer answer anonymously either.

Rolling back is the pre-upgrade backup restored onto git checkout v0.2.10-alpha.
Downgrading the live database instead (alembic downgrade f9979ec7b9cb inside the
api container) drops the authentication tables — the owner and password, every
session and access token, every MCP OAuth link — and leaves the collection as it is.
Details: docs/operations.md → Upgrading to 0.3.0.

What changes for anything that talks to the API

The full route authorization matrix is docs/design.md §5.5. The short form:

  • Every REST and MCP call needs a credential. Anonymous, a collection route is
    401 with the code auth.unauthenticated and a WWW-Authenticate: Bearer challenge.
    Only liveness (/api/healthz), the auth bootstrap (GET /api/auth/session), the
    auth actions and the app shell answer without one.
  • Browsers use the owner session — a cookie, with an Origin check and a
    session-bound X-CSRF-Token on every write. A session idles out after 14 days and
    ends after 30. Scripts and MCP clients use a personal access token in the
    Authorization header — never a query parameter, which is ignored as a credential and
    would land in the access log.
  • The two never cross. A session cookie does not authenticate /mcp/, however
    valid; an MCP OAuth token does not authenticate a REST route.
  • A credential that is presented and fails is 401 (auth.bearer_invalid), never a
    silent downgrade
    — expired, revoked, malformed, wrong secret. A token that is out of
    scope — a read-only token on a write; any token on the instance settings, a
    replace_all import or token management — is 403.
  • Unrouted /api/* paths and wrong verbs answer 401 to an anonymous caller rather
    than 404 or 405: nothing about what exists is disclosed until you are in.
  • Rate limits at the ingress. The bundled nginx throttles the login and setup
    actions, the auth bootstrap, liveness and the OAuth routes independently, per client
    address; a 429 carries Retry-After and the usual error envelope. The API keeps its
    own login-failure budget behind that.
  • A security audit trail in the audit_event table: claim, login success, failure
    and throttling, logout and session revocation, token mint, revoke and
    use-after-revoke, host-side recovery, MCP grants issued, refused and revoked, and
    app-layer Host/Origin refusals — never a body, a query string or a secret. Retention
    is yours: prune-audit --older-than-days N.
  • Access logs no longer record query strings, on the API or on nginx, and nginx no
    longer records headers — an OIDC callback's one-time code cannot end up in
    docker compose logs.

What's new

One owner, two ways to sign in. AUTH_MODE=local (the default) is the setup token
and a password. AUTH_MODE=oidc replaces the password with a sign-in at one OpenID
Connect provider — any with a discovery document; Keycloak and Google are the two it was
built against — with the owner bound to the provider's (issuer, subject) at the first
login that presents the setup token. Every other identity at that provider is refused
and audited. The modes are mutually exclusive and env-only; there is no "off".

Personal access tokens — Settings → Access tokens: read-only or read-and-write, an
optional expiry, shown once, stored as a digest, revocable with the row kept so you can
see when a leaked one was last used. The same token works on the REST API and on
/mcp/; per-tool scope is enforced at the MCP call, and no token reaches instance
administration.

MCP clients that sign in instead of pasting a token (OIDC mode). Claude web,
ChatGPT web and MCP Inspector complete OAuth against the instance — a proxy in front of
the same provider as the browser login, PKCE, dynamic client registration, the owner
identity checked at issuance and on every request — and Claude Desktop's Add custom
connector
takes an https:// instance directly. MCP_OAUTH_SIGNING_KEY (32 random
bytes) is installation identity; the OAuth state lives in the database, so the backup
set stays database + .env.

Four ways to run it, each with what was actually tested for it
(docs/operations.md → Four ways to run it): your own machine or a private network;
behind your own reverse proxy (a contract, not a tested leg); through a Cloudflare
Tunnel; on a VPS behind Caddy — the reference, deploy/caddy/Caddyfile, with Let's
Encrypt through Cloudflare's DNS-01 challenge. TRUSTED_PROXIES now does what its name
says: a forwarded client address is believed only from a listed peer, for rate-limit
keying and audit — and in the bundled stack a 127.0.0.1 entry also covers the Docker
gateway, which is where a proxy on the same host actually arrives from.

Recovery from the host, never over the network
(docker compose exec api python -m app.auth.recovery …): reset-password,
revoke-sessions, rebind-oidc (clears the provider binding, ends every session and
every MCP OAuth grant, and asks the provider to revoke what they held), prune-audit.

Two lockouts and their recoveries, run on every release: a name not yet in
ALLOWED_HOSTS is 421; a browser write behind a TLS proxy before PUBLIC_BASE_URL
names it is 403. Each is one .env line and docker compose up -d; nothing is lost.

A fresh-volume restore cannot land in the database's start-up gap any more (#228).
The bundled database healthcheck probes over TCP now: on a fresh volume the Postgres
image runs a temporary server for its own setup and shuts it down before the real one
starts, and the old socket probe let docker compose up -d db --wait return in between,
so the restore procedure's next command could meet "the database system is shutting
down". The release gate's restore phase met it once; the same probe change covers CI.

Bounded before the tag: the release candidate's security scan

A full-repository security scan of the release candidate (three source passes,
Codex Security, at ed48038) found no authentication bypass, injection, stored XSS
or credential exposure, and four medium availability findings — every one anonymous
and reachable in the supported topology. All four are fixed in this release (#221):

  • Request bodies to the anonymous routes are budgeted. The login, setup and OIDC
    start actions take 32 KiB at most; the OAuth protocol routes 16 KiB. The app refuses a
    body past the budget with 413 (ingress.body_too_large) before any parser holds a
    byte past it, and the bundled nginx refuses the same body on the same routes with the
    same envelope. Imports keep the 32 MiB cap.
  • MCP client registrations are bounded. A dynamically registered client — or a
    metadata document fetched for one — lives 24 hours unless a grant links it, after
    which it is kept; an address may register twenty clients an hour (429); the instance
    keeps at most 1024 live client records (503 with Retry-After until unlinked ones
    expire); expired OAuth state is deleted, where before it only stopped being read.
    None of this touches a linked client.
  • Refused requests cannot flood the audit table (#210). A Host or Origin refusal is
    recorded within a budget — ten rows a minute per address, sixty per instance — and the
    rest are counted into one ingress.refusals_suppressed row. AUDIT_RETENTION_DAYS
    (optional) prunes old rows automatically.
  • A guesser cannot lock the owner out of the login. The failure budget is now per
    address: a guesser's wrong passwords slow the guesser's address, doubling to five
    minutes, and the count resets after ten quiet minutes. The instance also bounds the
    password checks it performs — a general allowance of thirty at once, refilling at
    thirty a minute, and a reserved allowance of ten for a browser that still holds a
    session cookie the instance recognises, which a flood of guesses cannot touch. Before
    this release one wrong password every five minutes from anywhere kept a signed-out
    owner out for as long as the caller cared to continue. The runbook's 429 paragraph
    says exactly which browsers the reserved allowance covers and which compete.

Four low findings are filed and not fixed here: raw CSV exports preserve spreadsheet
formula syntax (#223); discovered OIDC endpoints are connected to without destination
validation (#224); MCP results do not mark collection text as untrusted data (#225);
the reference Ca...

Read more

v0.2.10-alpha — the instance knows its own name

Choose a tag to compare

@DeusMaximus DeusMaximus released this 03 Sep 08:36
7954e47

⚠️ Set ALLOWED_HOSTS before you upgrade if you reach plamotrack by anything other than localhost

0.2.10 makes the instance refuse requests for names it doesn't know, with
421 Misdirected Request. A LAN hostname, a container name, a mesh DNS name, the
name a reverse proxy forwards — each needs to be in ALLOWED_HOSTS in .env
before docker compose up -d --build --wait:

# .env
ALLOWED_HOSTS=nas.lan,plamotrack.home.arpa

localhost, 127.0.0.1, [::1] and a WEB_BIND address are always known.
WEB_BIND=0.0.0.0 names nothing, so list the address or name you actually type.
Ports and letter case don't matter; *.home.arpa wildcards work; a bare * (or a
* anywhere but the leading label) is refused at startup on purpose.

If you meet the 421 anyway: edit .env, docker compose up -d. Nothing was
written and nothing is lost. Details: docs/operations.md → Names it answers to.

This is the one change in the M6 hardening sequence that can lock an operator out of
their own instance, which is why it ships on its own, before any authentication, and
why the release is otherwise deliberately small. No migration. Nothing is
authenticated yet; the alpha warning still stands.

What else changed

The first item of the M6 threat model (docs/design.md §5, #186, absorbing #39 —
reviewed by Codex, GO with three configuration findings, all fixed before merge):

  • Writes from another site are refused. A POST, PATCH or DELETE arriving
    with an Origin the instance doesn't recognise as its own — listed, loopback against
    a loopback name, or equal to the request's own origin — is 403 with the code
    ingress.origin_not_allowed. A page in your browser can no longer drive your
    instance. Scripts, curl and MCP clients send no Origin and are unaffected.
  • New optional keys, documented in .env.example and docs/operations.md:
    PUBLIC_BASE_URL (the address a browser uses; its host is allowed automatically
    and, behind an HTTPS proxy, it is what lets the browser's https:// origin write),
    ALLOWED_ORIGINS (rarely needed), TRUSTED_PROXIES (nothing reads it yet).
  • /api/mcp/ and /api/openapi.json no longer answer — use /mcp/ and
    /openapi.json. Every root namespace of the API process is now one spelling at the
    ingress.
  • Trailing-slash REST spellings are 404 instead of a redirect that led to the app
    shell. A source-run MCP client needs /mcp/ with the slash; both spellings still
    work through the bundled ingress.
  • /api/readyz no longer answers from outside the container. The healthcheck
    probes it from inside; /api/healthz stays open.
  • Security headers on every response (X-Content-Type-Options, X-Frame-Options,
    Referrer-Policy, frame-ancestors 'none') and a Content-Security-Policy on the
    app bundle.
  • The bundled nginx config is now a template rendered at container start
    (frontend/nginx/), with the Host allowlist assembled from the same .env keys the
    API reads. The two derivations are held equal by a test that runs the real generator.

Upgrading

# 1. add ALLOWED_HOSTS to .env if you use any name but localhost (see above)
git pull
docker compose up -d --build --wait

Back up first as always (docs/operations.md → Backups). No migration in this
release, so a rollback is git checkout v0.2.9-alpha && docker compose up -d --build.

Milestone

M6 (secure remote access) continues: #187–#195 remain open by design. This release is
item 1 of ten; nothing here authenticates.

v0.2.9-alpha — instance settings & internationalisation foundation (M5.1)

Choose a tag to compare

@DeusMaximus DeusMaximus released this 29 Aug 04:21
f509c60

v0.2.9-alpha — instance settings & internationalisation foundation (M5.1)

One migration, and two behaviour changes to know about before your next import. Upgrading is git pull && docker compose up -d --build --wait; the migration creates the one-row instance_settings table and seeds it. Back up first — docs/operations.md.

Set Language & region after upgrading

plamotrack now keeps interface language, formatting locale, time zone, date style, hour cycle, and the reference currency as instance-wide settings — a single-owner instance has one owner, so these are not per-browser preferences. The migration seeds the row from your existing configuration where one exists (REFERENCE_CURRENCY from the environment is carried into the row; from now on the database row is the setting and the env var is only a first-run bootstrap) and documented defaults for the rest: en-AU interface and formatting, UTC, locale-driven date style and hour cycle.

Until you visit Settings → Language & region and set your time zone and locale, dates render — and naive CSV dates import — as UTC, and the interface stays en-AU. Do it once, right after upgrading.

Downgrading drops the settings table: the instance reverts to env-configured behaviour, and any settings you changed since upgrading are lost (a re-upgrade reseeds the defaults, not your choices).

Naive CSV datetimes now mean your instance's time zone (#114)

A datetime cell without an explicit offset used to be read as UTC no matter where you live. It is now read in the instance time zone, attached once at preview. plamotrack's own archives are unaffected — exports have always written explicit +00:00 offsets, so re-importing an old export is byte-for-byte unchanged — but a hand-written CSV with bare datetimes now means what your wall clock means. Explicit offsets always win, and changing the time zone between a preview and its apply makes the preview stale (409 — run it again).

One import diagnostic code moved (#178)

Order-match ambiguity in import previews now speaks its own code: import.order_match_ambiguous (params count, matched_by) instead of the shared import.match_ambiguous, which remains for generic natural-key ambiguity (params count, table). A client switching on the old code over order rows must follow the rename. The payoff: the browser now renders the actionable half the old shared entry had to drop — which natural key was ambiguous (retailer + order number or retailer + date + lines).

Instance-wide Language & region settings (#27, #24)

Settings → Language & region holds the five presentation settings; GET /meta advertises supported_interface_languages. The Settings page itself (from #24, also new since 0.2.8) gains sections — General, Language & region, Data management (absorbing the old Data page; /data redirects), About. Language is separate from regional formatting; canonical API/MCP/database/CSV identifiers stay untranslated; historical money is never reinterpreted by a settings change.

Structured errors and import diagnostics (#25, #26)

Every failed REST response now carries {detail, code, params} — additive on the wire, detail byte-identical to before, code one of 116 stable <domain>.<condition> identifiers shared with the browser's translation catalogue through a single registry. Import previews carry the same shape on every row problem, warning, and blocking error, so the browser renders them in the instance language while the English detail stays on the wire for API clients and agents. MCP tool errors remain the bare English sentence. The registry is held together by audits on both sides — every emission site's params are pinned to the declaration (exactly, for import diagnostics).

Interface translation foundation (#22)

The interface reads from an en-AU source catalogue — the canonical wording and permanent fallback. Additional languages ship from the repository through reviewed pull requests; docs/translating.md is the contributor path. No non-English translation ships yet, by design: the foundation had to come first.

Also

  • The M5.1 localisation sweep (#176): every visible number, date, and count renders through instance-aware formatters; physical left/right CSS became logical (RTL-ready).
  • The mutation harness grew from 204 to 251 tracked cases, including the first cases that mutate the shared error-code registry fixture and an audit itself.
  • Suite counts at the tag: 1230 backend, 469 frontend unit, 40 e2e (+1 skipped).
  • #179 (catalogue rendering drops context carried only in undeclared raise-site params) is filed and deliberately deferred — cosmetic context loss, no wrong data.

All changes went through independent review (Codex and GLM rounds per PR; fold-ins skipped per documented precedent). Alpha caveat unchanged: no authentication yet — keep instances on a trusted network.

v0.2.8-alpha — the dead ends open

Pre-release

Choose a tag to compare

@DeusMaximus DeusMaximus released this 25 Aug 22:14
ee2355d

v0.2.8-alpha

No migrations, no data reinterpretation. Nothing in this release changes the meaning of anything you already have stored; upgrading is git pull && docker compose up -d --build --wait, and downgrading is checking out the previous tag. (As always: back up first — docs/operations.md.)

An applied upgrade can be withdrawn (#61)

The permanent freeze is gone: recording an upgrade onto a kit used to be forever — the kit, its order line, and the upgrade itself all locked behind guards with no exit. Withdrawal now exists on every surface (the kit editor, DELETE /upgrades/{id}/applications/{application_id}, and the withdraw_upgrade_application MCP tool), and it asks you the one question plamotrack cannot answer: does the part go back into stock? Say yes for a mis-click or a wrong kit (it never left the box); say no for a decal that tore on the way down (it's gone either way). There is deliberately no default — on any surface, including for agents.

The kit editor now shows a kit's applied upgrades at all (GET /kits/{id}/applications; the MCP get_kit embeds them), which is also where the withdrawal lives.

An order edit can no longer revert a kit changed elsewhere (#67)

Editing a line's price from an Orders page that had been sitting open could silently revert a kit edit made in another tab or by an MCP agent — the form echoed kit details it was merely displaying, and the server couldn't tell an echo from a correction. The editor now hydrates from a fresh read on every open and states kit details only when you actually edited them; what it doesn't state, it can't revert. (MCP agents: update_order's docstring now says to omit kit on lines you aren't changing, for the same reason.)

Keyboard users can use the catalog picker (#104)

Tabbing onto a search result used to close the list under you; Enter selected nothing. The picker now closes only when focus genuinely leaves it, and Enter/Space select — the select-or-create control works end-to-end without a mouse.

A pre-0.2.4 dead end is now escapable (#63)

A database that ran the pre-0.2.4 unlocked catalog delete can hold an order line pointing at a catalog row that no longer exists. Such an order could not be deleted, received, or repointed — every path 409'd. Deleting the order now works (the un-reversible line is skipped and logged; the item and its stock left together), which is the undo-and-re-enter escape. Receives and edits still refuse, and their message now tells you about the escape. Fresh databases can't produce this state; this is recovery for old residue only.

Data-bearing migrations are now tested against data (#54)

Until now every migration was only ever exercised against an empty schema. A new harness walks the test database to each data-bearing revision, seeds era-appropriate rows, and asserts both directions — including the currency backfills' exact amounts and the display-items downgrade guard across all four of its states. One long-standing cosmetic quirk is now documented rather than repaired (docs/operations.md): a database from the first public day can hold a received order whose kit still says Ordered — drag it where it belongs and that's the whole fix.

Also

  • CSV exports stay byte-faithful, documented (#53): cells beginning =, +, - or @ are not escaped — escaping would corrupt round-trips — and the caveat about opening archives from strangers now lives in the bundled README.txt and docs/import-export.md.
  • The mutation harness grew from 176 to 204 tracked cases, including the first cases that mutate migrations and schemas.
  • Suite counts at the tag: 1088 backend, 109 frontend unit, 29 e2e.

All changes reviewed (Codex rounds on #54, #67, #63; precedent-based skips documented per PR). Alpha caveat unchanged: no authentication yet — keep instances on a trusted network.

v0.2.7-alpha — the importer keeps its promises

Choose a tag to compare

@DeusMaximus DeusMaximus released this 24 Aug 20:07
921c4f1

The biggest release so far: orders got a real timeline, the collection got display gear, agents got a full set of tools, and the importer finished becoming the writer that keeps its promises. Two milestones land together — M5 hardening (v0.2.6 was folded in; there is no v0.2.6 tag) and the v0.2.7 consolidation.

Nothing here reinterprets data you already have. Three schema additions migrate on upgrade (build dates and series on kits, a shipped date on orders, and the new display-items table); all three are additive. One rollback caveat, disclosed below under Upgrading.


Orders have a timeline now

  • A shipped date, and shipping is a transition (#95). Orders record when they shipped, kits advance to In Transit stamped with that instant, and the table shows elapsed transit time live. Suppliable at entry for history you're backfilling.
  • Receipts backdate everywhere (#93). An arrival logged late carries the day the box actually came — on entry, on receive, on correction, and by import — and the kits it advances carry the same instant.
  • Build dates are yours (#94). Started/completed stamps can be set and corrected rather than being whenever you clicked. A free-text series field with a distinct-values picker groups kits the way you think about them (#96).
  • Status editing lives in one place (#120). The redundant inline status dropdown and the separate Ship/Receive modals are gone; the Edit dialogs do it all, and pre-order is one order-level toggle at creation.
  • Agents can read and fix orders (#97): get_order, update_order and retailer tools over MCP, with the same guards as everything else.

Display gear, and a catalog you can actually query

  • Display items are the fourth catalog type (#126): stands, bases, diorama scenery — quantity-tracked, orderable, importable, with their own Inventory tab.
  • Categories are a real vocabulary (#127): stored under one canonical spelling, filterable server-side, offered as type-ahead everywhere you enter one.
  • Agents can build the collection (#98): create kits and catalog items, list and search the catalog, and read instance settings (#99) — the full MCP surface the README now documents.
  • A packaged Gunpla skill (#131) teaches any MCP-connected agent this hobby's conventions — grades, scales, what counts as an upgrade.

The importer keeps its promises

The whole #44 class is now closed: every guard the app's own writers enforce, the importer enforces too, and the preview is binding.

  • What the preview shows is what the apply does — including the derived effects. Kits an order flip would advance are resolved at plan time, shown in the preview (a count plus per-order messages), and bound by the plan hash: if a kit moved in the app between preview and apply, the apply refuses and asks you to preview again (#119).
  • Receipts by import can't strand stock. Marking an order received or un-received by CSV is refused where stock would go unaccounted (#44), and — new — a catalog line can't be added to an order that already arrived (#87): add it in the app, which applies the stock, or restore a full archive.
  • Fan-out has a total budget (#77): one request or import creates at most 10,000 kits from order lines, however many individually-legal lines ask for them. Archives of any size still restore — explicit kit rows never counted against it.
  • Partial sheets read the stored row first (#90), so a line that omits a column no longer forgets what it already was while resolving what it points at.
  • Starter sheets carry everything on every row (#112): rating, notes, build dates and series survive on rows that also name a retailer.
  • Names are identities: a retailer or catalog name another row already holds is refused on every write surface (#107), and matching is by equality — 100% Zaku no longer matches like a wildcard (#49).
  • Money and integers hold their meaning: import matching carries currency and units (#46), replace_all resolves references against the upload alone (#45), and every integer a request can set is bounded by what the column holds (#74).

One collection, many writers, no collisions

  • A collection-wide write gate serializes every writer — UI, REST, MCP agents, and the importer — so a plan can't go stale between reading and writing (#80), and the starter sheet's fan-out reads received-order state through it (#47).
  • Exports read one instant (#48): every file in an archive comes from the same database snapshot, so a write landing mid-export can no longer produce an archive that contradicts itself.

Also

  • Dialogs and the order disclosure are fully keyboard-operable (#51).
  • Kanban board, tables and forms picked up the smaller consolidations that came with the above.

Known limits

  • Standalone starter rows duplicate on re-import (#137). A starter row with no retailer imports fresh kits each time the same file is applied. Any synthesized identity would risk merging genuinely distinct kits, which is worse — so for now, don't re-apply a starter sheet whose retailer-free rows already imported. Under discussion.
  • Nothing is authenticated yet. An alpha instance belongs on a trusted network — a home LAN or a VPN — not on the open internet. Authentication is Milestone 6.

Upgrading

docker compose down          # never -v; -v destroys the database
git pull
docker compose up -d --build --wait
docker compose logs migrate  # Exited (0) is success

Three additive migrations run on upgrade. Back up first (docs/operations.md) —
and one rollback caveat: downgrading past the display-items migration refuses while
any display data exists, so a downgrade after using the new tab means exporting,
clearing display items, and restoring after.