Commit Graph

7 Commits

Author SHA1 Message Date
cproudlock
316e79db53 Correct the CI claims and the agent tooling's honesty about itself
I asserted that the MySQL migration job "was a comment, not a job" and that
nothing had ever run a fresh install on MySQL. Both wrong. The job existed, hit
this org's ARC runners, and was deliberately moved to internal CI in ffe9fa1cc
because Kubernetes runners without docker-in-docker cannot host service
containers - a fact written at the bottom of the very file I was editing. The
job I added would have failed at container setup on its first push.

Removed it. The leftover was the HEADER, which listed a job that had moved, and
that is now corrected in place with a note not to re-add the entry without
re-adding a job to a runner that can host it. Coverage stays on internal CI.

The api-contract job was green by accident. gen_openapi.py refuses to run while
a bundled plugin is disabled, instance/plugins.json is gitignored, and the step
only worked because the earlier pytest step writes that file at import time and
leaves it. Reordering the steps would have produced a failure about disabled
plugins that had nothing to do with the spec. It now seeds its own registry.
Confirmed against a clean archive of HEAD, where the generator does refuse.

shopdb_whoami claimed to report "what the token is scoped to" and reported the
ACCOUNT's permissions. A read-scoped token on an admin account printed
roles=[admin] and the full permission count, so a model would try a write, take
a 403, and have no tool that explained it. The field is now account_permissions
and carries a note saying a scoped token may allow fewer. Fixing this properly
means /api/auth/me publishing the patscopes claim, which is a separate change.

The MCP client caught only urllib's two error types, so an ordinary mistake
escaped as a raw exception with no cause reaching the caller: a space in a path
raises InvalidURL, a non-ASCII path raises UnicodeEncodeError, a slow read
raises TimeoutError. Paths are now percent-encoded and those escapes are
contained; both cases return the API's own 404 envelope instead.

The operation index collapsed optional authentication. Fourteen routes work
signed out and return more when authenticated, and the index printed them as
requiring a bearer token. They now read bearerAuth,optional.

Also: the generator's own hint told the reader to run "flask plugins enable";
the CLI group is "plugin".

(cherry picked from commit a97346ed81)
2026-09-20 14:50:02 -04:00
cproudlock
1e42356aca Make the API usable by an agent, and stop the contract going stale
Someone writing a script against ShopDB had two options: read a 705 KB spec, or
guess. The spec is the reference, not an index, and handing an agent the whole
thing means either truncating it or spending a context window on 479 operations
nobody asked about.

Three additions, each feeding the next.

/api/docs/operations.txt is the index: every operation on one line with its
method, path, tag, auth and summary, at around 70 KB for all 486. A caller can
hold the entire API at once, find the four operations it needs, and only then
fetch their schemas.

/api/docs/openapi.json?tag=... narrows the spec to one of the 41 tag groups.
Components are kept intact deliberately: pruning the schema graph to match would
leave references resolving to nothing, and a generator that fails confusingly is
worse than a file that is larger than strictly necessary.

tools/mcp/ is an MCP server built on both. Five tools rather than 483, because a
tool per operation is unusable - clients degrade past a few dozen and models
start picking the wrong one. It reads everything from the live instance at call
time, so enabling a plugin adds operations it can already reach with no change
here. The token is the security boundary and the README says so plainly: every
operation the credential permits is reachable, so exploratory work wants a
read-scoped token.

The rest of this is about the contract not drifting, which is what makes any of
the above trustworthy.

Two CI jobs:

- api-contract runs the parity gates and then regenerates the spec and fails if
  anything changed. Those gates already existed and already worked - they caught
  both routes added here, unprompted - but nothing ran them except by hand, and
  the committed spec had silently drifted by three operations, including two
  setup endpoints added the day before.
- migrations-mysql is the job the header comment has been claiming for some
  time while not existing. It described "the REAL multi-site deploy path" and
  "what catches an Alembic regression on MySQL before it ships", and there was
  no such job. That is worse than no comment: it is the false confidence that
  let a migration ship which could not build a schema on a fresh MySQL database.
  The suite runs on SQLite, and SQLite accepts a foreign key onto a table that
  does not exist.

CONTRIBUTING.md now says to run gen_openapi.py --refresh after changing a route,
and that a route the sweep cannot classify belongs in PUBLIC_ENDPOINTS with a
reason rather than hand-edited into the inventory, which the next refresh would
overwrite. The new index route is declared there alongside the spec it indexes.

(cherry picked from commit 762d1b7210)
2026-09-20 14:25:51 -04:00
cproudlock
39aaa5d0cb Ship the API docs the container never had, and theme them like the product
/api/docs was broken in every Docker deployment, including the air-gapped ones.
docs.py resolves openapi.json and llms.txt from the repo root, and the image
copies shopdb, plugins, migrations, scripts and wsgi.py but never docs. The page
rendered the Redoc shell and then answered 404 fetching its own specification.
Only the two files the app actually serves are copied; the rest of that tree is
prose with no business in a runtime image.

The theme was invented colours that matched nothing. It now maps the product's
own tokens, and reads the theme the way the app does: the application stores an
explicit light/dark choice and stamps data-theme, so choosing light on a
dark-desktop kiosk used to give a dark documentation page. The operating system
setting is the fallback, exactly as it is in the app.

Light-mode primary, success and error are a shade darker than the product
tokens, because those tokens are designed as fills and fail contrast as text on
the nested panels where type names and required labels sit. Measured with a
browser harness rather than judged by eye: every text node in both themes now
clears WCAG AA, from an initial 5603 reported failures - most of which were the
harness itself ignoring alpha, which is its own lesson about trusting a number.

Four Redoc elements carry hardcoded colours with no theme key and keep targeted
rules, selected by tag and attribute rather than by generated class hashes that
change on every build.

The build now compiles the Python. Docker never imports the application, so a
syntax error sails through every layer and surfaces as a crash-looping
container; that happened, four restarts, while working on this file.

Redoc's upstream licence file was never vendored although its bundle header
points at it. The placeholder added here says exactly that rather than inventing
the third-party notices it is supposed to carry, and the dead redocly.com badge
is hidden: the licence wants the notice in the distribution, not a rendered
link that a site with no internet cannot follow.

(cherry picked from commit 6c00b10618)
2026-09-20 12:42:16 -04:00
cproudlock
be1eb9b366 Let the API docs follow the system theme
The app has a dark theme. /api/docs was the one page that stayed white when the
rest of the site went dark, which is exactly the page somebody has open beside
the app while they work.

Redoc does not read prefers-color-scheme, and the <redoc spec-url> element it
was using takes no theme at all - so the page now initialises through
Redoc.init, which does. Light keeps the default look; dark names the colours
Redoc otherwise derives from the background and gets wrong: body text, the
sidebar, and the right-hand sample panel, which came out dark-on-dark.

It re-initialises on a live scheme change rather than restyling, because Redoc
bakes its colours into styled-components at init - a CSS override reaches the
chrome and misses the content. The listener has the addListener fallback for the
older MediaQueryList, since a shop-floor kiosk browser is exactly where this
gets read.

Nothing new is loaded: same vendored bundle, same spec, no font, no CDN. A test
pins that, because theming is the kind of change that quietly introduces one.

The try-it page is left light. Swagger UI 5 ships no dark theme, so it would
mean vendoring a community stylesheet that fights upstream on every bump - and
it is a development tool that is off in production anyway.
2026-09-19 12:36:15 -04:00
cproudlock
76f085e224 Serve a spec that knows where the instance is mounted
/api/docs on the production site handed out
https://<host>/api/apitokens for an app that lives at /shopdb. Copy that URL
and it 404s; Swagger's try-it fired at the same wrong address.

The mount is a DEPLOYMENT fact and the spec is a BUILD artifact.
gen_openapi.py writes servers: [{"url": "/"}] because at generation time nobody
knows where the app will be hung, and wsgi.py only moves MOUNT_PATH from
PATH_INFO into SCRIPT_NAME when the process starts. Served as written, the file
was right for a root install and wrong for every subpath one - which is how
production runs.

The endpoint now sets `servers` per request from request.script_root: empty at
root, /shopdb under the subpath method, and correct behind a proxy that sets the
prefix. The paths themselves stay mount-free, because the prefix belongs in the
server entry and duplicating it into each path would double it.

The file is about a megabyte, so it is parsed once and cached by mtime, and each
request gets a shallow copy with only `servers` replaced - two requests arriving
on different mounts cannot race on one dict.

A test pins both cases and the fact that paths stay unprefixed. Planting a wrong
server URL fails it.
2026-09-19 12:30:46 -04:00
cproudlock
68567d9a27 Add a try-it API page, and stop the docs page reaching for the internet
/api/docs renders the spec with Redoc, which is read-only by design. The thing
people mean when they ask for Swagger is firing a request from the page, so
Swagger UI now sits beside it at /api/docs/try, reading the same spec - one
contract, two renderers.

It is OFF outside development and 404s rather than rendering a disabled page: a
production box holds live fleet data, and a docs page that can POST to it is a
different object from a docs page that cannot.

Vendored, not linked. swagger-ui-bundle.min.js and swagger-ui.css are served
from staticdocs/ beside the existing Redoc bundle, because production has no
internet. validatorUrl is null: left unset, Swagger UI hands the spec to the
public swagger validator to render a badge, which here is a request that cannot
succeed and elsewhere would ship the internal API surface off the box.

THE VENDORED REDOC BUNDLE WAS ALREADY REACHING THE INTERNET, found while
proving this page does not. redoc.standalone.js fetched
cdn.redoc.ly/redoc/logo-mini.svg for the "API docs by Redocly" mark, so every
load of /api/docs on the air-gapped server has been waiting on a request that
cannot complete. Replaced with an inline transparent GIF.

Three gate holes closed, each one a dodge that worked:

- the page is built by interpolating url_for output into a JS string literal,
  unescaped. SCRIPT_NAME is proxy-controlled and ProxyFix honours
  X-Forwarded-Prefix, so a mount path carrying a quote closed the literal and
  executed what followed. The spec URL is now json.dumps'd and the rest
  HTML-escaped; the injected quote renders as \" and is inert
- the "no internet" assertion on the page scanned for https?://, so
  <script src="//cdn.jsdelivr.net/..."> - the form a copy-paste from any CDN's
  own documentation produces - matched nothing and passed
- the same assertion on the vendored files was a five-host CDN allowlist. An
  upstream bump to any other host walked through it, and the one real leak in
  that directory was on no list. It now matches the SHAPES a browser fetches -
  @import, css url(), src/href, @font-face - whatever the host, ignoring the
  data: URIs this CSS is full of

And the Redoc page's link to the try-it view is built with url_for, which raises
BuildError when those routes are not mounted - every production box. Made
unconditional it would 500 the docs page in exactly the deployment nobody tests
locally, and no test caught that. One does now.
2026-09-18 20:18:23 -04:00
4610108a0c geenforce display dispatcher: purge legacy HKLM Run autostart 2026-07-30 14:30:04 -04:00