Skip to content

API reference, OpenAPI spec, and a coverage test that can fail (#51) - #99

Merged
CaYatur merged 2 commits into
mainfrom
feat/api-docs
Jul 28, 2026
Merged

API reference, OpenAPI spec, and a coverage test that can fail (#51)#99
CaYatur merged 2 commits into
mainfrom
feat/api-docs

Conversation

@CaYatur

@CaYatur CaYatur commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Closes #51.

Three descriptions of the integration surface, all generated from one table
(src/shared/apiSurface.ts):

docs/openapi.json machine-readable OpenAPI 3.1, checked in
GET /api/v1/openapi.json the same document, served by the app
GET /api/v1/docs a self-contained reference page
docs/api-reference.md the human entry point, linking the prose docs

The part worth reviewing: the coverage test

#51 asks that every /api/v1 route appear in the spec. The obvious way to check
that cannot fail — a test that walks the spec's own paths and confirms each
one exists only finds routes that were documented and then deleted, never the
reverse, which is the failure that actually happens.

So the assertion reads the route literals out of handlePanel's own source
(path === '/api/…', sub ===, action ===, rest ===) and requires the
table to document each one — then checks the other direction, so the table
cannot invent a route either.

It found GET /servers/:id/store/admin/ledger on its first run: undocumented
since it shipped. That is the evidence the test is not vacuous.

Serving it unauthenticated

The index, the spec and the docs page are the only routes answered before
authentication. All three are descriptions of the software, built from a
constant table — and the smoke asserts that neither the spec nor the page
contains this install's server id or name, so "no credential" stays a
convenience rather than a disclosure. Requiring one would also make the docs page
useless to the thing that needs it most: a browser, which cannot set an
Authorization header on a navigation.

No CDN, no Swagger bundle: the panel's CSP forbids external assets, and
documentation that needs an internet connection is a poor way to document a LAN
tool. Asserted.

Deliberate

Body shapes are prose in each operation's description, not sixty invented JSON
schemas — sixty more things to keep true, when what an integrator needs is the
method, the path, the gate, and what the call does.

Verify

All twelve gates exit 0. MSMS_SMOKE_WEB checks: the v1 rewrite answers
identically to the unversioned path and is anchored (/api/v2 is not v1), the
served spec is byte-identical to the generated one, coverage in both directions,
no install data in either artefact, and no external asset on the page. It also
rewrites docs/openapi.json, so the checked-in copy cannot go stale.

Three descriptions of the surface, generated from one table
(shared/apiSurface.ts): docs/openapi.json, GET /api/v1/openapi.json, and a
reference page at GET /api/v1/docs. Plus docs/api-reference.md as the entry
point tying them to the prose that says why each rule is what it is.

The interesting part is the coverage test. #51 asks that every v1 route appear
in the spec, and the obvious way to check that cannot fail: a test that walks
the spec's own paths and confirms each exists only finds routes documented and
then deleted, never the reverse — which is the failure that actually happens.

So the assertion reads the route literals out of handlePanel's OWN SOURCE and
requires the table to document each one, then checks the other direction so the
table cannot invent a route either. It found GET /store/admin/ledger, which had
been undocumented since it shipped, on its first run.

The spec is generated rather than hand-kept for the same reason: a spec beside
a router drifts, and the half that drifts is always the spec, because nobody
notices a document that is quietly wrong until an integrator does.

The index, the spec and the docs page are the only routes served before
authentication. All three are descriptions of the software — the same bytes on
every install, built from a constant table — and the smoke asserts that neither
the spec nor the page contains this install's server id or name. Requiring a
credential would also make the docs page useless to a browser, which cannot set
an Authorization header on a navigation.

No CDN on the page: the panel's CSP forbids external assets, and documentation
that needs the internet is a poor way to document a program that manages a
server on a LAN. Asserted too.

Body shapes are documented as prose in each operation's description rather than
as sixty invented JSON schemas, which would be sixty more things to keep true.

All twelve gates green.
Copilot AI review requested due to automatic review settings July 28, 2026 12:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…request

The per-IP bucket from #50 is wired into handleSite only; on the admin
listener the sole pre-auth guard is the login limiter. So the three doc routes
I added were reachable with no credential and no ceiling, and the spec rebuilt
a ~120 KB object graph and stringified it on every one of them - a way to
spend the event loop from anywhere that can reach the port, which is the shape
of thing #50 exists to stop.

Both artefacts derive from a constant table, so they are built once now, and
the routes spend from the same per-address bucket as the public storefront
reads. Cache-Control says public, max-age=300 rather than no-store: these
describe the software, and telling a client never to cache them was wrong on
its own terms.

Asserted with twenty consecutive fetches that must be byte-identical and
average under 25ms.

getPanelHtml and getPublicSiteHtml rebuild their strings per request too, and
the panel page is also unauthenticated. Pre-existing and larger than this
change - flagged in the PR rather than folded into it.
@CaYatur

CaYatur commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Self-review

I added three unauthenticated routes to the panel listener and made two of
them expensive.

The per-IP token bucket from #50 is wired into handleSite only. On the admin
listener the only pre-auth guard is rateLimited(ip) on /api/login. So as
written, /api/v1/openapi.json rebuilt a ~120 KB object graph and stringified
it per request, with no credential and no limit — anyone who can reach the
port gets to spend the event loop, which is precisely the shape of thing #50
exists to stop. /api/v1/docs was the same, smaller.

Two fixes, because either alone is half a fix:

  • Built once. Both derive from a constant table, so the output cannot change
    while the process runs. Serving is now a buffer write.
  • Limited per address, sharing the bucket the public storefront reads spend
    from, so even a buffer write has a ceiling.

Also Cache-Control: public, max-age=300 instead of no-store: these are
descriptions of the software, and telling a client never to cache them was
wrong on its own terms.

Asserted: twenty consecutive fetches must all return the identical body and
average under 25ms each. Before the fix that loop measured the real cost; the
threshold is the assertion, not the comment.

Not fixed here, and worth stating rather than leaving implied: getPanelHtml()
and getPublicSiteHtml() also rebuild their whole string per request, and the
panel page is likewise unauthenticated. That is pre-existing and larger than
this PR — flagged, not smuggled in.

All twelve gates green after the fix.

@CaYatur
CaYatur merged commit 354b00c into main Jul 28, 2026
@CaYatur
CaYatur deleted the feat/api-docs branch July 28, 2026 13:04
CaYatur added a commit that referenced this pull request Jul 28, 2026
**The assertion I wrote to prove the cache works could not fail.**

It measured twenty requests and required an average under 25ms. So I measured
the alternative instead of assuming it:

| | per request |
|---|---|
| cached | 10.85 ms |
| **uncached** | **13.30 ms** |

The loopback round trip dwarfs the work by an order of magnitude. The threshold
had ~12ms of headroom over the failure it was supposed to catch, so the test
would have passed with the cache deleted — it asserted nothing. Same shape as
the two dead assertions earlier in this run, and the reason to measure rather
than pick a number that "looks safe".

Replaced with a **build counter**: sixty requests across all three cached
artefacts must cause **zero** rebuilds. Deterministic, machine-independent, and
it fails loudly with the name of whatever rebuilt.

It immediately earned that: the first run reported `rebuilt 1 artefact(s):
docs`, because my baseline was taken before the docs page's *first* request, so
a legitimate first build looked like a miss. Every artefact is warmed before the
baseline now. A timing threshold would never have surfaced that at all.

Also added: after `_resetPageCache()` the next request must rebuild **exactly
once** and return the identical body — so the memo is a memo, not a one-shot
that would serve an empty page if invalidation is ever added.

The same dead-timing assertion was in #99 for the spec. It is removed here
rather than left behind in a merged PR.

### One process note

Midway through this I edited `smoke.ts` with
`Get-Content | -replace | Set-Content`. PowerShell 5.1 read it as cp1254 and
wrote UTF-8 with a BOM and CRLF, mojibaking every non-ASCII character in the
file — including the `Ç` in the console-decoder fixture, which is a test that
exists precisely because multi-byte characters break when something re-encodes
them carelessly. Typecheck and build both passed; only `MSMS_SMOKE` caught it.
Restored from git and reapplied through the editor. Worth recording, since the
tool that broke it is the same class of bug the test was written for.

All twelve gates green.
CaYatur added a commit that referenced this pull request Jul 28, 2026
* Build the two app shells once, and limit their routes (#100)

Both pages were rebuilt from their template literals on every request - a few
hundred KB of string work each - and both are served before any
authentication, on listeners whose only pre-auth limit was /api/login. With
bindLan on that is a way to make the panel unresponsive from anywhere on the
network without holding a credential.

Caching an HTML page is only correct if the page cannot change, and the reason
this one cannot is worth stating: every interpolation in both pages is a
module constant, and everything an operator can change - theme, languages,
posts, logo - is fetched by the page at runtime. So the assertion is not that
the page is fast, it is that the page is not config-derived: change the site's
accent and name, and the HTML must come back byte-identical. A later change
that makes a page depend on config fails that, rather than this cache quietly
serving yesterday's theme.

Cache-Control: no-cache with an ETag, not max-age: the browser keeps its copy
and asks, an unchanged shell costs a 304 with no body, and an upgrade is not
followed by users pinned to a stale panel.

Both page routes and the /uploads/ reads beside them now spend from the same
per-address bucket as the public storefront API. Cheap is not free, and these
are the only routes on either listener anyone can reach.

* ## Self-review

**The assertion I wrote to prove the cache works could not fail.**

It measured twenty requests and required an average under 25ms. So I measured
the alternative instead of assuming it:

| | per request |
|---|---|
| cached | 10.85 ms |
| **uncached** | **13.30 ms** |

The loopback round trip dwarfs the work by an order of magnitude. The threshold
had ~12ms of headroom over the failure it was supposed to catch, so the test
would have passed with the cache deleted — it asserted nothing. Same shape as
the two dead assertions earlier in this run, and the reason to measure rather
than pick a number that "looks safe".

Replaced with a **build counter**: sixty requests across all three cached
artefacts must cause **zero** rebuilds. Deterministic, machine-independent, and
it fails loudly with the name of whatever rebuilt.

It immediately earned that: the first run reported `rebuilt 1 artefact(s):
docs`, because my baseline was taken before the docs page's *first* request, so
a legitimate first build looked like a miss. Every artefact is warmed before the
baseline now. A timing threshold would never have surfaced that at all.

Also added: after `_resetPageCache()` the next request must rebuild **exactly
once** and return the identical body — so the memo is a memo, not a one-shot
that would serve an empty page if invalidation is ever added.

The same dead-timing assertion was in #99 for the spec. It is removed here
rather than left behind in a merged PR.

### One process note

Midway through this I edited `smoke.ts` with
`Get-Content | -replace | Set-Content`. PowerShell 5.1 read it as cp1254 and
wrote UTF-8 with a BOM and CRLF, mojibaking every non-ASCII character in the
file — including the `Ç` in the console-decoder fixture, which is a test that
exists precisely because multi-byte characters break when something re-encodes
them carelessly. Typecheck and build both passed; only `MSMS_SMOKE` caught it.
Restored from git and reapplied through the editor. Worth recording, since the
tool that broke it is the same class of bug the test was written for.

All twelve gates green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

API reference + OpenAPI spec for the integration surface

2 participants