Site built from v0.1.0. Releases: GitHub.
Changelog
All notable changes to this project are documented here.
The format is based on Keep a Changelog and versions follow
Semantic Versioning. Spec numbers refer to the (untracked) specs/
directories; open work is tracked as GitHub issues; full retrospectives live in the git history of
the retired TASKS.md and in the knowledge base at wiki/.
Unreleased
Nothing yet.
0.1.0 - 2026-09-03
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. With
sentry.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 sameerror_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_ENVand 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/authorizemeansstate,code_challenge,id_token_hint,login_hint,request_uriand on an error redirectcode, 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.queueDepthwith 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/loginaccepted 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 newemailScopedIdhelper 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’scopyablereaches 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 with
429and 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 asadmin.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 viaDELETE /admin/api/buckets/:id/users/:uid/totp(audited, ends sessions, exposed to MCP asbucket_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-project
corsOriginsallow-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 underzeroRuntime(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-client
authorizationDetailsTypesvia 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/scopechangesAdminSession.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-serveris 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 registeredsubjectType: '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:assertClaimsParameterandassertJwtClientAuthClaimsAndHeaderare 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 BSON
Binaryand 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 the
Paththey 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 with
Path=/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, stale
interaction.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 write
verified: 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 fullHttpOnly; 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)