The first tagged release: everything the server accumulated before a version number existed.
Added
-
Reported faults now name where in the server they happened (spec 035). An operator paged about a
failure could see which endpoint broke but not which line broke it, and had to open the internal
console and look the reference up to find out — while every fault on one endpoint arrived under a
byte-identical title, so a list of alerts could not be scanned at all. A reported fault now carries
the code location the internal record already held, is titled with the diagnostic message rather
than a synthetic endpoint string, and is located by a second line built from the method, the route
pattern and that code location. The file is also a searchable tag, so an operator can ask what is
failing in one part of the server rather than only inspecting one fault at a time. Grouping is
untouched: the store's own key is still the only thing the destination groups on, and it already
incorporated the code location. Nothing else was widened — the location joined the explicit list of
permitted outbound fields, which is now enforced one level deeper so a value added inside it later
cannot ship unnoticed, and a raw stack, an error object, or a frame still carrying the failure
message remain unsendable. -
Optional Sentry reporting for recorded faults (spec 034). The error store already kept a durable
record of every unexpected internal fault, but nothing told anyone one had happened — an operator
learned about a failure by going to look for it. Withsentry.enabledon and an ingestion
credential configured, each fault the store records is also reported to the operator's own Sentry
project, carrying the endpoint, the kind of failure, the client it is attributable to and the same
error_referencethe caller received, so an alert leads straight back to the full internal record.
The environment and release labels are read from what the deployment already declares —NODE_ENV
and thepackage.jsonversion — rather than typed into the console, because a label maintained by
hand is stale from the next deploy onwards, and a stale release label sends an investigation to a
build that never ran. No new environment variable was introduced for it; the console shows the
resolved values read-only instead.Off by default, one instance-wide destination, super-admin only, and it requires the error store:
the outbound event is projected from the internal record, so reporting is an additional
destination and never an alternative to recording. The ingestion credential is write-only through
the admin surface — a read reports only whether one is stored, and the audit trail records that it
changed without recording its value.The official
@sentry/elysiaplugin is deliberately not used, and that is the substance of the
work. Reading its source (v10.73.0) showed three disqualifying behaviours: its capture predicate
reads the response status and reports when it is stillundefined, so routineinvalid_grant/
invalid_clientrejections — the normal traffic of a token endpoint — would ship as unhandled
faults; it attaches the full request URL and headers unconditionally, which on/authorizemeans
state,code_challenge,id_token_hint,login_hint,request_uriand on an error redirect
code, plusAuthorizationandDPoP; and it writessentry-trace/baggageonto every response
while opening spans across all nine Elysia lifecycle phases. Instead@sentry/bunis used directly
withdefaultIntegrations: falseand no integrations, as an envelope-and-transport layer only.
lib/sentry/registers no Elysia hook at all, so there is no code on the request path to add a
header, add latency, or fail; the event is assembled from a named list of permitted fields
(excludingactoranduserAgent) rather than by scrubbing a captured request, and dispatch
happens from exactly one place — insidecaptureFault's record continuation, which runs only after
the fault is classified and locally recorded. Outbound volume is bounded bysentry.queueDepth
with counted drops, so an error storm cannot amplify into the monitoring channel. The default test
run performs no outbound delivery yet can inspect exactly what would have been sent, which is what
makes the data-protection guarantees assertions rather than review opinions.
wiki/concepts/sentry-plugin-not-used.mdrecords the reasoning;test/sentry/holds it. -
Brute-force protection on the password sign-in door (issue #9, spec 032).
POST /ui/:uid/login
accepted unlimited guesses against any address, each one buying a full password hash on a
shared-CPU machine — so the door was both a credential-stuffing opportunity and a cheap
CPU-exhaustion vector, and the only unbounded secret surface left in the server. Failed attempts
are now counted per${bucketId}:${email}in a newLoginThrottlearea; at the cap the door shuts
for that address and refuses everything — including the correct password — until the window
ends, and each further exhaustion shuts it for longer, doubling 15 → 30 → 60 minutes. That holds a
sustained attack to roughly 120 guesses a day against unlimited before, and a refused attempt does
no lookup and no hashing, so the CPU half of the issue closes with the guessing half.
The refusal is the door's existing invalid-credentials page, produced by the same expression the
two ordinary failure paths use rather than a copy of the wording, and failures are counted for
addresses that resolve to no account — so neither the response nor the existence of a counter is
evidence that an address is registered. Two things clear a counter, and both are proofs an attacker
guessing passwords does not hold: a password that verifies, and a completed password reset.
The second is why no "enter the code from your email" step was built — consuming the emailed secret
already proves control of the address, through a flow that has its own cooldown and cap, while a
dedicated step would have been an account-existence oracle, an email-bombing vector, a hard mail
dependency for sign-in, and unavailable to the one bucket with no reset at all. Requesting a reset
clears nothing.
Three decisions are worth reading the code for. The record's retention (24h from the last failure)
must outlive its own lockout window or the escalation silently never happens — a counter reaped
when the door reopens restores the opening allowance, so an attacker who waits is never escalated;
the boot validator enforces the ordering. The escalation ceiling is the first window wherever the
bucket setstotpRequired, read from the bucket's policy and never from its identity, because a
guessed password is not a sign-in there while the lockout it risks — the admin console, which has no
self-service reset — is the one nobody can undo. And the counter's key is built by one new
emailScopedIdhelper stating its rule as parity withfindByEmail's normalization: a key built
from the raw submission would have given a 16-letter address 65,536 independent counters, and every
test written in lower case would still have passed. That helper replaced three copies of the
expression and one inlined fourth in the end-user delete route which had dropped itstoLowerCase()
— under the in-memory adapter, whose user store does not normalise what it stores, that line was
already missing the email-scoped records of mixed-case accounts and reporting success.
Bounds areloginThrottle.failureCap,.windowSecondsand.windowCeilingSeconds, super-admin
editable and boot-only. There is deliberately nologinThrottle.enabled: unlike the per-origin
limiter this is persisted, holds across restarts and machines, and is therefore a security boundary
rather than a resource protection — a kill switch for it is a switch that reopens the vulnerability,
so the validator bounds the numbers to a range in which the protection still means something instead. -
The last three security headers issue #2 asked for (issues #10 and #2, spec 029).
Strict-Transport-Security: max-age=63072000; includeSubDomainsand aPermissions-Policydenying
seventeen high-privilege browser features now ride on every response, from the same pre-routing hook
that already carriednosniff,Referrer-Policyand the non-page content policy — so they reach the
error pipeline, the named admin instance and static assets, which after-the-fact response hooks miss
silently.preloadis deliberately omitted and the reason recorded: the deployment host is already
preloaded through the wholedevTLD, submission needs an apex domain this deployment does not own,
and the effect is global and slow to undo — a self-hoster's choice to add at their own edge. HSTS is
emitted unconditionally, including over plaintext, because TLS terminates at the proxy so the hop RFC
6797 governs is HTTPS, and the alternatives are either spoofable (X-Forwarded-Proto) or invisible
to the merge gate.clipboard-writeis not denied, and a named test enforces that: antd's
copyablereaches fornavigator.clipboard.writeTextfirst, so denying it would have stranded five
secret-copy surfaces — the TOTP enrolment secret among them — on the deprecatedexecCommandpath.
The legacyX-Frame-Options: DENYis emitted byhtmlResponseon rendered pages only, derived from
the same single evaluation asframe-ancestorsand therefore absent on the one deliberately framable
page, theform_posthand-off; a blanket emission was not merely inelegant but unimplementable,
since a returnedResponsecan override a merged header but never remove one and the header has no
permissive value, so it would have broken silent authentication with no downstream fix -
Per-origin request rate limiting (issue #1). An origin that spends its allowance inside a window is
refused with429and aRetry-After, before the endpoint does any work. Allowances are tiered by
route class rather than blanket — strict on the unauthenticated and expensive surface, loose on
static assets and discovery, the liveness probe exempt — declared as a third dimension on the route
table under the same two-way drift guard as the feature gate and CORS. Counting is per instance and
never persisted, so no storage area is added; the price is that the effective allowance multiplies
by concurrent machine count and clears on restart, which is why this is a resource protection and
not a security boundary. The per-identity throttles are unchanged, and the login door's brute-force
protection remains issue #9. NinerateLimit.*settings, editable from the console; invalid values
refuse the boot rather than serving with limiting silently absent.elysia-rate-limitwas evaluated
and not adopted: its single staticerrorResponsecannot produce this server's three channel
shapes, and its default refunds requests whose handler threw — which is every failed credential
guess -
The administration console can be put behind the second factor. A new settings resource under
/admin/api/adminscarries the reserved admin bucket'stotpRequired— audited as
admin.settings.update, exposed to MCP asadmin_settings_readandadmin_settings_update, with
a switch on the Admins page. The generic bucket routes still refuse that bucket; this is the
surface their 403 already pointed at. Nobody is locked out by turning it on: an administrator
without an authenticator enrols at their next sign-in -
TOTP second factor per user bucket:
totpRequiredmakes a password sign-in also require a
six-digit authenticator code, with enrolment at registration and at the first sign-in of an
existing account, RFC 4226/6238 implemented in-repo against the published test vectors, replay and
two-tier throttling,amr: ['pwd','otp']on the ID token, and operator recovery via
DELETE /admin/api/buckets/:id/users/:uid/totp(audited, ends sessions, exposed to MCP as
bucket_user_totp_clear). Federated sign-in is not gated (spec 027) -
CORS support: preflight handling, open CORS on discovery/JWKS, client-based CORS on the token
family driven by a per-projectcorsOriginsallow-list,cors.enabledsetting (spec 011) -
Hardening headers on every response that is not a rendered page —
nosniff,no-referrer, and a
content policy ofdefault-src 'none'plusframe-ancestors 'none'— across the protocol
endpoints, the admin API, MCP and the static surface, including responses built by the error
pipeline. Rendered pages keep their own derived policy (spec 026) -
Content-Security-Policy on every rendered page, derived per document —
script-src 'none'on
script-free pages, hashed inline styles; hydrated pages use precompiled antd CSS under
zeroRuntime(specs 018/032,74cc208,1a4628d) -
Admin audit trail covers all 23 mutating admin routes and became readable:
GET /admin/api/audit+ an Audit page in the console (spec 016) -
Rich Authorization Requests (RFC 9396) work end to end on the code and refresh flows: consent
display, grant persistence, working hook defaults, per-clientauthorizationDetailsTypesvia
the admin API (spec 015) -
Self-service end-user password reset, bucket-scoped, with throttling (spec 020)
-
Upstream OIDC federation per bucket — end users sign in through their own identity provider;
admin management plane included (spec 022) -
Admin MCP control plane: administer the instance from an AI agent at
POST /mcp, served as an
OAuth 2.1 protected resource of the server itself (spec 024) -
Durable server error store an operator can read — internal faults no longer vanish with the
console (spec 025) -
bun run db:setupprovisions every collection and index from a declared storage inventory under
a drift guard, including TTLs for verification areas and unique per-bucket email indexes
(spec 012)
Changed
- Groups read as themselves in the admin console, and a personal group is nobody else's to work in.
Four things were wrong at once, all of them about the same list. Every personal group displayed as
the bare word "Personal" — including in a super administrator's list, where N administrators
produced N identical rows and the owner's email that is stored as the group's name was thrown
away by both display sites. Personal groups appeared in the Groups table at all, which is a page
about the teams work is shared with.GET /admin/api/scopeoffered a super administrator every
group on the instance, other people's personal groups among them, andPUTaccepted them — so the
console could be pointed at one person's own workspace by somebody who was never in it. And the
reserved holding group was called "Unassigned", which reads like a data-quality problem rather than
the name of the one group that is not a tenant.
It is now: System (SYSTEM_GROUP_NAME,$setby the deployment seed so an existing database is
renamed too, and preferred over the stored name by the console so it does not wait ondb:setup);
no personal rows in the Groups table; and onegroupLabelhelper shared by the table and the scope
switcher, rendering "Personal" for your own and "Personal — owner@email" for a personal group you
were added to. Both scope routes now apply the same carve-out — the list never offers what the
switch would refuse — and all three of the switch's refusals still say one thing, so it cannot be
used to learn which ids are real or which of them are personal. Whether a personal group is your
own is answered by the server frommembers[0], the only place that can answer it: a shared
personal group may promote a second owner, andfindPersonalFormatches any personal group you are
a member of - Switching the console's active scope no longer writes to the audit trail.
PUT /admin/api/scope
changesAdminSession.activeGroupIdand nothing else, and grants no access a member did not already
have — while which scope a change was made from is already carried byownerGroupIdon that change's
own entry. It joinsPOST /admin/api/logoutinexcludedAdminRoutes, whose reason already described
it: session lifecycle, not a change to a managed entity.scope_switchis withdrawn from the agent
surface with it, asinapplicable— an agent has no console session, so the tool could only ever
answer 400 README.mdandAGENTS.mdnow describe the server that exists (issue #20, spec 031). The endpoint
table was wrong on six of eleven rows —/authorize,/introspect,/revoke,/register,
/session/endand/requestare really/auth,/token/introspect,/token/revocation,/reg,
/logoutand/par— and was missing twelve routes and every method on/userinfobutGET. It is
now derived fromlib/consts/route_classification.ts, which pairs each route with its governing flag
and is already guarded both ways againstelysia.routes, and it is split by availability because 23
of the 28 feature flags default off and a disabled endpoint is deliberately indistinguishable from
one that does not exist. Five Features bullets had presented opt-in capabilities as shipping
defaults, Client Credentials and Refresh Token among them; the Features list now separates what a
default install serves from what a deployment switches on, and drops the claim of static client
registration, which was removed when clients became DB-backed. The Standards table gained eleven
implemented specifications plus a flag column, and admits CIBA, JARM and OAuth 2.1, which its
RFC-only shape had excluded.AGENTS.mdhad pointed contributors and agents at/interaction/*
routes; login and consent are served under/ui/:uid/*. Two claims the issue asked for were dropped
after checking the code: RFC 8414 is not implemented, and no/.well-known/oauth-authorization-server
is served- BREAKING: feature flags now gate their endpoints — a disabled feature's routes answer 404
instead of staying silently live (spec 010) - BREAKING: deletion means what it reads as — projects/buckets refuse deletion while non-empty
(409 with machine-readable blockers); deleting a client or end-user cascades to their sessions,
grants and tokens (spec 019) - BREAKING: pairwise
subvalues derive from stored server state instead ofos.hostname().
They change exactly once, on first start of this version, for clients registered
subjectType: 'pairwise'— then never again across restarts and scale-out (spec 023) - BREAKING:
richAuthorizationRequests.typesis now a serializable descriptor map (label,
per-common-field constraints,allowUnknownFields) editable in the admin settings; enabling the
feature with an empty map fails validation. A code-registeredvalidateremains an optional
escape hatch (spec 015) - BREAKING:
allowOmittingSingleRegisteredRedirectUrimoved into the Application
Configuration (authorization.allowOmittingSingleRegisteredRedirectUri) and now defaults to
disabled; enable it in the admin settings and restart to restore the old behavior - Interaction UI: post-registration "check your inbox" notice renders, registration refusals are
styled pages, consent permissions carry headings and friendly labels, decorative Google button
removed (spec 021)
Removed
- Seven unreferenced modules deleted (issue #19, spec 030): the whole
lib/views/directory of legacy
interaction templates — safe because spec 015's'rar-detail'consent group had taken over the RAR
rendering that was its one unique job — plus theStub.tsxadmin placeholder,helpers/params.ts,
helpers/set_www_authenticate.tsandhelpers/_/pick_by.ts. The two addon functions the issue
suspected of being dead are kept:assertClaimsParameterand
assertJwtClientAuthClaimsAndHeaderare live override seams reached through the call-time registry
inlib/addon/index.ts, so an empty body is their default rather than an abandoned stub, and each
now carries a comment saying so.AGENTS.md's source-tree map lost three stale entries it had kept
describing —views/, the long-deletedprovider.ts, and CORS undershared/. Test counts, type-error
count and lint findings all held at their pre-change values.
Fixed
-
A super administrator's scope switch is no longer accepted and then silently discarded.
resolveActiveGroup(lib/admin/auth/rbac.ts) re-validated the session's choice against membership
alone, with no exception for the role that is allowed to switch without one — so a super
administrator switching into a group answered 200, and on the very next request their active scope
resolved to empty andassertActiveGroupsent everything they created to theunassignedholding
group while the console went on showing the group they picked. Their choice is now honoured after
one re-read of the group, which also refuses it if the group has since been deleted or is an
administrator's personal group -
An agent naming an operation the MCP surface withholds now hears why, instead of
Tool <name> not found. The refusal text existed and never ran: the call that delivered it sat in the tool
registration loop, where it can only fire for a name that is registered, and an excluded operation
never is. It now also runs in the transport, before the SDK and after the credential, answering a
failed tool call rather than a JSON-RPC error. A genuine typo still gets the SDK's not-found, so a
mistake is not dressed up as a policy decision -
The server could not boot against MongoDB: the DPoP nonce secret came back from the driver as a
BSONBinaryand failed its own round-trip check (71d9b53) -
PKCE accepts the full RFC 7636 verifier length range (43–128), not only 43 (
6dce3f7) -
Native clients can complete an interactive sign-in (
ba5629d) -
Interaction pages can hand off to a foreign callback under the CSP (
271d518) -
The settings audit records only the fields a save actually changed (
b630c73) -
Logging out of the admin console actually signs the operator out: it now ends the provider
session as well as the console's own, and clears both cookies with thePaththey were set with
(Elysia'scookie.remove()omitsPath, so the browser defaulted it to the request's directory
and cleared a different cookie) -
The interaction cookie is cleared at its own path and expires with the interaction: the clear went
out withPath=/while the cookie lives at/ui/<uid>(a different cookie, so a browser kept it),
and itsMax-Agewas set in milliseconds -- a ~41-day lifetime for a one-hour interaction -
Small-batch fixes: duplicate first-run admin setup surface removed, error pages carry the real
status and illustration, staleinteraction.returnTocorrected, unimplemented CIBApushmode
removed from the admin schema (spec 018)
Security
- The reserved admin bucket can no longer be gated on email verification. Both paths that create an
administrator writeverified: falseand no verification mail is ever sent for that bucket, so the
flag would have refused every administrator at the door with no way back short of editing the
database. It was unreachable through the API rather than prevented; it is now refused at the point
of enforcement - The end-user cookies (
_session,_interaction) carrySecure-- and the/ui/*responses,
which is where the authenticated_sessionis first written, now carry the full
HttpOnly; SameSite=Strict; Secureset instead of no attributes at all: that route family
declared a second, option-less cookie schema, so aSet-Cookiefrom it inherited nothing.
Both schemas now build from oneendUserCookieAttributesowner - The admin console verifies its id_token's signature (plus
nonce, expiry, audience) against the
live keystore before trusting it — previously a documented decode-only shortcut (spec 017) - The DPoP nonce secret is self-provisioned at startup, making the requireNonce-without-secret 500
state unrepresentable (spec 014)