Repository navigation
v3.45.0
Minor release. It carries the findings the v3.44.3 fleet sweep produced across all ten derived
applications, plus three new capabilities that came out of them. The largest change is structural:
authorization now runs before body validation on every route, and a new gate proves that every route
which does not refuse an anonymous caller is on a list that says why.
Added
- The audit log can be filtered by outcome.
GET /api/v1/audittakesoutcome=failedor
outcome=succeeded, and every row carries the status the writer recorded and the username the
request named. A rejected sign-in never authenticates, so those rows used to render asSystem
and were indistinguishable from background activity; the viewer now shows the account that was
attempted and the status as a badge beside the path. Both fields are reachable through field
selection, so an operator can alert on a burst of failures. - A dashboard share link can be revoked.
DELETE /api/v1/dashboards/:id/shareclears the token and
its expiry, andGETon the same path reports whether a link is live and when it stops working.
Revoke deliberately ignores thesharingEnabledsetting, because turning sharing off must not
strand links that were issued while it was on. The Share dialog shows the current state and offers
Revoke or Create new link. - A bug report can name the report that supersedes it.
PUT /api/v1/bugs/:id/superseded-bysets the
link andnullclears it, open to the reporter as well as to an administrator. Self-links, cycles
of any length, and successors naming no report are refused before anything is written, and the
link never touches either report's text or status. The default inbox leaves superseded reports out
of its rows and its total, and takesincludeSuperseded=trueto bring them back. test:public-route-surfacejoinssmoke:qc. It boots the application, enumerates every route
Elysia registered, and sends each one an anonymous request. Anything that answers has to appear in
scripts/lib/public-routes.tswith a written reason. Authorization travels as a route option, so a
route ships open by leavingrequireAuthoff and nothing looks wrong in the file; three route
modules hold guarded and unguarded handlers side by side, which is why this asks the application
rather than reading the source. The list is checked in both directions, so an entry naming a route
that no longer exists, or one whose route is guarded now, fails rather than sitting there as an
exemption nobody is using.
Security
- Authorization runs before body validation. Elysia validates a request body against the route schema
before it callsbeforeHandle, so guards living there ran too late: an anonymous caller who posted
a malformed body to a protected route was answered 400 with the field names and constraints of a
route they were never allowed to reach, and every rejected request had its body parsed and checked
before anyone asked who sent it. The guards now run at the transform stage. They travel as
requireAuth,requireRoleandrequireSelectedWorkspaceroute options carried by macros on the
plugins every route file already uses, so a route added later gets the ordering by writing the
option rather than by remembering a lifecycle rule. The shared-dashboard rate limiter moved with
them for the same reason. Converting the routes also fixed a latent bug: the old composite form
built a throwaway context object, so the rolerequireRoleFreshread back from the database never
reached the handler. - The workspace guards answer 404 for a workspace that is not there. A soft delete leaves the
membership rows behind and the membership queries do not join workspaces, so a member of a deleted
workspace passed every check: the member roster answered 200 with the full list, and adding and
removing members still succeeded. The existence check now runs last in each guard, so a caller who
was going to be refused hears 403 for a real workspace and for an absent one and the status cannot
be used to learn which ids exist. config/example.jsonno longer excludes localhost from the audit log. It shipped
audit.ipWhitelistas["127.0.0.1", "::1"], which is exactly the state the audit plugin's own
comment says must never happen, so a fresh clone started with no audit trail for anything reaching
it locally. The list is empty now, andcheck:configfails on any loopback entry, matching the
whole127.0.0.0/8range, the::ffff:mapped form and the namelocalhost. An address an
operator lists inconfig/<slug>.jsonis still honoured.
Changed
- The workspace header has one owner and one answer. A caller who left
X-Workspace-IDoff was told
different things by different routes, in two spellings of the header name, neither matching what
the CORS allow-list publishes or the client sends.backend/src/guards/workspaceHeader.tsnow owns
the header name, the missing-header message, a distinct message for a header that could not be
read, and the not-found answer. The scope rule is now plain: a listing follows the header whoever
sent it, and only a request naming no workspace gets the cross-workspace view. Previously the scope
was decided by re-reading the caller's role, so a SYSOP who named a workspace had that id discarded
and was answered with every workspace instead of the one they asked for. - A 404 is treated as an answer rather than a failure.
queryClient.tsdecides that in one predicate
that both the retry rule and the throw rule consult, so a page can render its own not-found state
instead of waiting through three retries and then handing an unnamed failure to the error boundary.
A page added later inherits this without opting in; everything else still throws. - The multipart transport ceiling now sits above
storage.maxFileSize. Both shipped at 10 MB, so a
file at exactly the file limit exceeded the transport limit once the multipart envelope was added,
and Bun answers that with an empty 413 rather than the message the route would have given.
Fixed
- An interrupted view transition no longer raises an unhandled rejection. The template opts every
navigation into a cross-document transition, and navigating again inside the 220ms animation skips
the running one, which rejects its promises with anAbortErrorthat nothing held. Clicking twice
on any page produced a console rejection, and a crawler navigating faster than the animation
produced one on every page.frontend/src/lib/viewTransitions.tsholds those promises and absorbs
only anAbortError, so a transition that failed for another reason is still reported. The CSS is
untouched: same rule, same timing, same reduced-motion path. - A bug report that is empty once trimmed is refused. The route checked
minLengthagainst the
description exactly as it arrived and the service trimmed it afterwards, so a description of
nothing but spaces was stored as a row titled(untitled)with an empty body, which could then
never be corrected or removed. The route trims in a transform hook now, so the value the schema is
asked about is the value that gets stored, and a padded email address is accepted rather than
refused for padding that would have been removed anyway. GET /api/v1/bugsanswers. The listing registered its path with a trailing slash beside the POST's
empty string, which puts two nodes in the router and resolved a GET of either URL to the node the
POST created, so both spellings returned 404.- Opening a dashboard that no longer exists shows the not-found state. It previously showed a
skeleton for about seven seconds and then a failure that named nothing. The same was true of a
shared dashboard whose link had expired. - The onboarding checklist reports the sysop password change instead of affirming it. Its first step
was hard-coded complete and signing in was what made it true, so the checklist claimed a password
change nobody had performed, on the one account whose default password is published in the
project's own documentation. Signing in and changing the password are now separate steps, and the
second reads the seeded SYSOP row so it closes only when the account's holder actually changes it. - Application errors reach
logs/<name>.error.log. That file is the spawned server's stderr, but the
logger sent every level to stdout, so it could only ever hold crashes and interpreter warnings, and
every tool that read it to judge a startup was reading a file that could not hold an application
error. Errors are copied to stderr rather than moved, so the main log still carries every level.
Transport targets are resolved to absolute paths, which fixes a logger loaded from outside
backend/dying with "unable to determine transport target for pino-pretty". - A binary upload is no longer rejected as a long text line. The line-length check ran over every
buffered upload before the switch that chose a text-format validator, so a binary body with no
newline in it was refused with "Line 1 exceeds maximum length of 10000 characters". It now runs
only for a MIME type that has a content validator. - A fractional
pageorlimitis refused. Every paginated route declared them ast.Numericwith
no integer constraint, so?page=1.5became a fractional SQLLIMIT/OFFSET: a driver error on
some backends and a silently misaligned 200 on others. Both parameters now come from
backend/src/schemas/pagination.tsand are declared ont.Integer. A value outside its range also
reported a validation failure naming a property that exists on no schema; the message names the
parameter the caller actually sent. Reported by nine of the ten derived applications during the
v3.44.3 sweep.
15 commits since v3.44.3, squashed into 71fc77b (#32).
Full Changelog: v3.44.3...v3.45.0