Site built from commit 16f191bb, ahead of v1.1.0 (unreleased). 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
Added
- An application’s primary colour (
primaryColoron the client routes and the MCP client tools;nullremoves it). The sign-in, registration, two-step and consent pages paint it for the organization’s own people, in both colour schemes, shifted to the nearest shade that keeps WCAG AA; the response’spaintedPrimaryColorshows what they will see. A registration or metadata document cannot set it. The consent logo now follows the same rule per sign-in, which also keeps it off a device’s consent page that asks for administrator rights. No migration and no new storage area.
1.1.0 - 2026-10-16
Provider buttons on the sign-in page fit any number of providers, the console says when one is missing from it and why, and a super administrator can send a test message from the mail settings. No migration and no new storage area.
Added
- Send test email on the mail settings (
POST /admin/api/settings/smtp/test, MCPsmtp_settings_test). A super administrator sends a test message through the saved settings to their own address, never anyone else’s; a refusal answers with the mail server’s own reply, such as535 Authentication failed. Audited assmtp.settings.test. - Warnings for confusing provider buttons. The providers list gives each provider
signInWarningsand a provider’s detailwarnings:duplicate_brandfor a second enabled Google, Apple or GitHub connection, andnamed_like_brandfor an unrecognised provider named like a recognised one. Shown in the console and to agents.
Changed
- Provider buttons on the sign-in page fit any number of providers. Every provider is one full-width button of the same size with a mark (a neutral one for an unrecognised provider). Past six, five are shown and the rest fold under More sign-in options, which works without JavaScript and opens after a cancelled provider sign-in; Google, Microsoft, Apple and GitHub are never folded. Two connections to one brand show their names as a second line. The sign-in card fits a 320 px phone, and the console’s Sign-in preview draws the buttons with their frame.
Fixed
- The console says why a connected provider is missing from the sign-in page. While sign-in through
providers is switched off on the instance (
federation.enabled, off by default), no provider button appears, and nothing said so. The providers list now gives each provideronSignInPage, a provider’s detail warnsfederation_off, and the console shows a notice on the providers list, the provider’s page and the sign-in preview, with a link to the setting for a super administrator. - A message the mail server refuses is now logged with the server’s reply. Before, a refused verification, password-reset or invitation message left nothing in the log. Sends also time out (15 seconds to connect, 30 of silence) instead of holding a request for minutes. The console’s hint to set up mail names the right place, Settings → Integrations → Email (SMTP).
1.0.1 - 2026-10-16
A new instance tells applications the person’s email address. No migration and no new storage area; an
instance that saved its claims setting adds the email scope there.
Fixed
- A new instance releases
emailandemail_verifiedunder theemailscope. The shipped claims map had noemailscope, so on a default instance no application learned whom it signed in or whether the address was proven. An instance that saved its claims setting keeps what it saved: add"email": ["email", "email_verified"]under Settings → Claims there. db:setupno longer tells an operator to readdress the instance’s own projects. On every fresh database it reported the administrators’ and default projects as using reserved addresses; theiradminanddefaultnames only name a cookie and never become a path.
1.0.0 - 2026-10-16
The first stable release, and the one that makes FoxAuth fit to be offered to strangers. A project, formerly a
bucket, is the one container: it is its own issuer and holds its applications, APIs and people for life. A team,
formerly an administrator group, owns projects. A project can live on a subdomain of the instance. Plans put
limits on what a team can create. The gaps one team could use against another are closed, and email_verified is
true only for an address that was proven. From this release on the version numbers mean what Semantic Versioning
says: an incompatible change to the protocol endpoints, the admin API, the MCP tools or the settings comes only in
a major release.
The upgrade asks a backup, db:setup (or db:setup:pg) for the new TeamInvitationThrottle area and the unique
index on project slugs, and db:migrate for five migrations (2026-10-15 to 2026-10-19), some of which cannot
be undone. What an integration meets, with no aliases kept: admin API paths, fields and MCP tool names say project
and team; the old /admin/api/projects routes are gone; can.createHost is can.addressForms; a team owner
invites rather than adds; team invitations, signing keys, names and request bodies are bounded; and new end users
arrive with email_verified: false until they prove their address. The
upgrade guide walks through it.
Added
-
Plans and Free-tier limits (spec 102), off by default (
plans.enabled). A project is on a plan from a catalogue super administrators edit (Settings → Plans & limits,/admin/api/plans, theplan_*andproject_plan_settools); the built-in Free plan allows 100,000 monthly active users per project, 2 projects per administrator, 10 applications and 10 APIs per project, and social sign-in only, and generates the project’s address name.plans.regularTeamsPerAdministrator(1) caps regular teams. Limits gate only new creations and never a super administrator or a System project; at the active-user cap only new self-service sign-ups pause, never a sign-in. A refusal is a 409 whose body carriescode: 'plan_limit'and thelimit;canonGET /admin/api/mestates what is still allowed. The migration2026-10-18-projects-record-planrecords Free on every existing project (rundb:migrate). -
A project can live on a subdomain of the instance (spec 101):
https://acme.auth.example.com, derived from its slug, beside a path and a hostname of its own. A new setting,projects.addressForms(pathby default,subdomainorboth), decides which of the two a team may choose; super administrators may give any form, and changing the setting moves no project. The operator points one wildcard DNS record and certificate at the instance. Subdomains the deployment keeps for itself (www,api,mcp, …), reserved hostnames andxn---style labels are refused.
Changed
-
email_verifiedis true only for an address that was proven (spec 104). A person who signs up, or whom an administrator creates, now starts unverified in every project; before, a project that did not require verification recorded them verified and told every applicationemail_verified: truefor an address nobody had proven. Sign-in is unchanged. Applications may notice new unproven people arriving withemail_verified: false. Existing accounts keep their recorded value (nothing records why one is verified); turning verification on asks whoever is not. -
can.createHostis nowcan.addressFormsonGET /admin/api/me(spec 101): the list of forms the caller may give a project, the first one preselected. Project slugs are now unique by an index thatdb:setupcreates; it reports and skips the index if two projects already share a slug. Address-change audit entries record the old and new issuer rather than the bare name. -
Buckets are called projects, administrator groups are called teams (spec 100). Everywhere an administrator or an agent reads or types a name, a bucket is a project, an administrator group a team (a personal group a personal team), and a bucket group a group of the project’s end users; storage, the protocol and issued tokens are unchanged. Breaking, with no aliases:
/admin/api/buckets…is/admin/api/projects…and/admin/api/groups…is/admin/api/teams…; the MCP tools areproject_*,project_user_*,project_group_*,project_key_*andteam_*; fields areownerTeamId,projectId,teamIdandactiveTeamId, and the audit filter isproject. The audit trail shows its history in the new names, entries about the projects removed by spec 099 asretiredproject.*. The settingbuckets.reservedHostnamesisprojects.reservedHostnames, moved by the migration2026-10-17-projects-reserved-hostnames(rundb:migrate). Old console addresses redirect. -
The bucket is the only container; projects are removed (spec 099). An application (OAuth client) and an API (declared protected resource) belong to one bucket for life, recorded on the client as a server-only
bucketIdthat no registration can set; a client’s allowed browser origins are its owncorsOrigins; machine tokens for an API go to the administrator-created applications of its bucket. Breaking: every/admin/api/projectsroute and theproject_*andbucket_resource_listMCP tools are removed — applications and APIs live under/admin/api/buckets/:id/clientsand/admin/api/buckets/:id/resources, and the console shows them as a bucket’s Applications and APIs tabs. The migration2026-10-16-remove-projectsmoves each project’s bucket and origins to its clients and declarations (rundb:migrate); sign-ins are unchanged, and it refuses before writing if a client is listed by projects on different buckets. It also clears “skip consent” on applications of a tenant’s project on the Default bucket, where it has not been honoured since 0.7.0, so their consent screen stays as it is. -
Every project names its bucket (spec 098).
POST /admin/api/projectsandproject_createnow requirebucketId, and only a super administrator may name the Default bucket, for a project in any group — on a public instance a project left without one put its people into a pool shared by every tenant and managed by none of them. Breaking:DELETE /admin/api/projects/:id/bucketandproject_bucket_clearare removed. The migration2026-10-15-projects-require-bucketwrites the Default bucket into every project that named none (rundb:migrate); sign-ins are unchanged. The console’s new-project form can create the bucket in the same step.
Fixed
-
A project’s own SCIM endpoint is refused as a declared API at every address the project is served on, not only at the instance’s own origin. At a subdomain or a hostname of its own it was accepted as a declaration that could never take effect, because the built-in SCIM endpoint answers first.
-
A team now reads its project’s identity provider changes in the audit trail. Creating, changing, reordering, trying and deleting a project’s identity provider, and removing a person’s linked identity, were recorded without the project’s team, so only a super administrator could read them while the team read every other change to the same project. Entries written before this fix stay super-administrator-only, because the trail is never rewritten.
Security
- Tenant hardening before open administrator sign-up (spec 103). Accepting a team invitation no longer joins an
account someone else registered under the invitee’s address: administrators’ sign-up may be open only with email
verification on (a 409 otherwise, where it used to be an advisory), and while verification is required, accepting
an invitation for an account whose address was never confirmed asks for a password, replaces the account’s, and
removes its authenticator, linked providers, sessions and tokens. Only a super administrator adds someone to a team
directly; owners invite. Team invitations are bounded (one per address and team every 10 minutes and 5 a day, 100 a
day per inviter, 50 open per team), an owner invitation is judged against the plan only when accepted, keys are
bounded at 12 held and 20 made a day per project, request bodies at 1 MiB, and project, application and provider
names and lists have maximum lengths. New project paths may not use the reserved subdomain words or
account,health,ready;jwks_uriandbackchannel_logout_urimust be https when set; the console’s own clients are accepted only at the instance root. Upgrade: rundb:setup(new areaTeamInvitationThrottle) anddb:migrate;2026-10-19-admin-signup-requires-verificationcloses administrators’ sign-up where it was open without verification — reopen it with verification on.
0.12.0 - 2026-10-14
The pages people see are rebuilt for the people who see them. The consent page says who is asking, which account
is agreeing and what the application will be able to do, in plain sentences; people can leave out what they do not
want to give and change their mind later; the organization’s own applications show their logo and policy links;
and every bucket has a page where people see and remove the access they gave. Every end-user page follows the
device’s light or dark scheme, and every end-user page and email is shown in English, Russian or Simplified
Chinese, chosen from the person, the application’s ui_locales and the browser.
The upgrade asks a backup and db:setup (or db:setup:pg) for the new ClientLogo area. There is no schema
migration. What an integration meets: the consent form now carries the account it was shown for and is refused
for any other, so a script that posts the consent form must send accountId; the consent page’s wording and
markup changed; end-user pages declare their language and answer with Content-Language and
Vary: Accept-Language, Cookie; and both discovery documents publish ui_locales_supported.
Added
-
End-user pages and emails in English, Russian and Simplified Chinese (spec 097). The language comes from the person’s own choice, the application’s
ui_locales(query, request object or PAR), the account’s stored locale, then the browser, and is decided once per sign-in; Traditional Chinese falls back to English rather than to the wrong script, and no value of any signal causes an error. A no-script switch under the card offers English and back to a visitor whose own language is not English; the choice is kept in a_langcookie and saved to the account at sign-in (not for directory-provisioned accounts). Emails follow the account’s language, else the page that asked for them, and their links open pages in the email’s language. Both discovery documents publishui_locales_supported. Protocol answers (error,error_description, JSON) are unchanged. The Chinese translation has not been reviewed by a native speaker. Also: the error page now has a heading of its own, and the connected-apps page writes dates in the reader’s language. -
Every end-user page follows the device’s light or dark scheme (spec 096). Sign-in, registration, two-step verification, consent, sign-out, device code, error, password reset, email verification and the connected-apps page all turn dark when the device prefers it, with no script, cookie or setting; the admin console is unchanged. Application logos and the TOTP QR code stay on a light plate, and Apple’s and GitHub’s marks turn white. Colours now meet WCAG 2.2 AA in both schemes, so the light pages change too: a darker blue, red and secondary grey, and field borders that stand out from the field.
- Fixed along the way: the hydrated sign-in pages painted without antd’s colours until their script ran (and for good with scripts off); the sign-out and device pages scrolled sideways at 320 px.
-
People see and remove the access they gave applications (spec 095). Every end-user bucket has
/account/appsat its own address. It lists each application holding access: what it can do, in the consent page’s words; when access was given; when it last got a token; and whether it keeps access after sign-out. One confirmed removal ends that application’s grants, tokens and sign-in, the same as the administrator’s “sign out of this app”, and is audited asself-service. The page reads the bucket’s own sign-in and signs a signed-out person in through a built-in client that can get no code or token. Removal needs a sign-in no older than 15 minutes and a same-origin form. The consent page now links to this page.- Fixed along the way: grants now record their bucket; the console’s per-application dates are no longer decades ahead; plain pages no longer scroll sideways at 320 px.
-
The organization’s own applications show their logo and policy links on the consent page (spec 094). An administrator uploads a client’s logo (
logo: a PNG, JPEG or WebPdata:URL of at most 64 KiB, checked by its bytes; console, admin API and MCP) and setspolicyUri/tosUri(https only). The page shows them only for this organization’s own app (never a self-registered one or another group’s) — the logo never on a device or loopback page, the links only on one of the app’s redirect hosts. A client’s ownlogo_uriis never fetched or shown. Logos live in a newClientLogoarea: re-rundb:setup/db:setup:pg. -
People can choose what an application gets (spec 093). For a client an administrator marks with
partialConsent(console, admin API and MCP; never settable by dynamic registration or a metadata document), the consent page gives every permission beyond signing in its own unticked checkbox, and the application’s tokens, ID token and userinfo carry only what was ticked. A refused permission is offered again when the application sendsprompt=consent; before, a refusal recorded on a grant could never be granted again.
Changed
- The consent page says who is asking, which account is agreeing and what the app will do (spec 092). It is
headed by the bucket’s new
displayName(or its address), names the account by email instead of its internal id, and words requested data and declared-resource scopes in plain sentences grouped as “see”, “act for you” and “not described”. Apps that registered themselves are never headed as the organization’s own, every app’s redirect host is shown (the MCP requirement), at most one warning appears, a request for the administrative MCP shows an administrator-rights block, long-lived access states its end date, and “Switch account” is offered. Operators describe scopes withscopeDescriptionson a declared resource and the newconsent.scopeDescriptionssetting. The consent form now carries the account it was shown for and is refused for any other.
0.11.0 - 2026-10-12
The admin console is rebuilt around addresses, pages and previews. Every screen has a link; buckets, identity providers, provisioning connections, groups and people each have a page; and a change that can lock people out or break applications says whom it affects, counted, before it applies. Deleting a bucket can be undone for 30 days, a signing key rotates in one step and can be revoked at once, administrators can sign in through their organisation’s identity provider, and end users can be invited, signed out of one application or changed a hundred at a time. Usage is open to every administrator, over their own groups’ buckets. Security fixes close a leak of administrators’ authenticator secrets, a verification bypass through an email change, and the repointing of an identity provider under the accounts linked to it.
Upgrading asks four things of an operator. Take a backup. Run db:setup (or db:setup:pg) for the new
provisioningRefusals area and the new indexes. Then run db:migrate: migration
2026-10-12-static-token-expiry gives every SCIM static token that has no expiry one, a year from its issue and
never sooner than 90 days from the upgrade, and cannot be undone. Administrators who enrolled an authenticator
before this release should enrol again. An integration meets these changes: the users list answers
{ users, totalResults } a page at a time; setting or clearing a project’s bucket, tightening a bucket’s sign-in
and handing a group to a directory answer 409 with a preview and apply only when repeated with confirm: true and
the same expect; deleting a bucket schedules its destruction instead of destroying it; new buckets are closed to
sign-up unless asked otherwise; and a user’s profile now wins over a stored claim of the same name.
Fixed
- A group’s administrators can read what was done to their bucket’s people. The audit entries for end-user changes made in the console or through MCP carried no owning group, so only a super administrator could read them, on the Audit page or on a person’s Activity tab. New entries carry the bucket’s group. Entries already written stay as they are.
Security
-
A host-addressed bucket’s sign-out confirmation and device-code pages can no longer be framed. Their forms posted to an absolute address the page policy took for another site, so the pages lost
frame-ancestors. The forms now post to an origin-relative path. -
A password-reset link no longer survives an email change (spec 089). Before, a link sent to a person’s old address still worked after an administrator or a directory changed it. Opened from the old mailbox, it set the password and marked the new address verified. The link now dies with the address it was sent to.
-
The federation callback refuses a response from another issuer (spec 088). An upstream’s RFC 9207
issparameter is now compared with the issuer the sign-in started with, and required when the provider’s metadata says it sends one — the mix-up defence RFC 9700 §2.1 asks of a client that talks to several providers. -
Static SCIM tokens expire (spec 088, RFC 7644 §7.4). A token is issued for 90 days or a year (
lifetimeDays); the upgrade’s migration2026-10-12-static-token-expirygives existing tokens a year from issue but never less than 90 days from the upgrade, so none stops working that day. Runbun run db:migratebefore starting. -
Administrators’ authenticator secrets no longer leave the server. The administrator list and the answers to creating and updating an administrator carried each enrolled administrator’s TOTP secret, so any super administrator, or an agent acting for one (
admin_list,admin_update), could generate another administrator’s second-factor codes. They now go through the same presenter as end users. Administrators who enrolled an authenticator before this fix should re-enrol. Updating an administrator withactive: falsenow refuses deactivating yourself, as the deactivation route does, andadmin_updateis a high-consequence agent tool, so it no longer bypasses the confirmationadmin_deactivaterequires. -
An identity provider can no longer be repointed under the accounts linked to it. A link records the provider and the upstream subject, not the issuer, so changing a provider’s issuer (or a Microsoft provider’s tenant), or deleting a provider and creating another with the same id, let the new upstream’s users with a matching subject sign in to existing accounts. Changing the issuer or tenant is now refused while anyone is linked — add a new provider instead — and deleting a provider removes its links.
-
Linking an upstream identity to an administrator always asks for their password. In the console’s own bucket a provider trusted for an address could otherwise sign its holder in as that administrator, a super administrator included, once password sign-in was off or the account already held another identity. A locally locked administrator is now also refused at the console’s own callback.
-
A rejected admin request is no longer echoed back. The admin plane’s validation answer (422) carried the whole submitted body, a typed password included; it now names the first problem and the field, nothing more.
Added
-
The Sign-in tab shows the sign-in page people will see (spec 091). A card beside the switches draws the bucket’s real sign-in page and changes as the switches change, before saving: the password form, the “Forgot password” and “Register now!” links and the provider buttons in their order. It is decided by the same rule the server renders the page with, so the two cannot disagree.
-
Supporting end users without inventing their passwords (spec 089).
- An administrator can invite a person by email: a single-use link, valid 7 days, sets their own password and confirms their address.
- An administrator can mark an address verified by hand.
- A person’s page lists the applications they are signed in to, and one of them can be signed out without the others.
- Local users get the directory’s profile fields (name, locale, time zone, phone), delivered as standard claims.
- Up to 100 selected users can be deactivated, activated, signed out or deleted at once, each with their own audit entry.
- A group’s members can be searched.
-
Administrators can sign in through your organisation’s identity provider (spec 080). Super administrators connect providers to the Administrators bucket; a first sign-in links an existing administrator after a password proof, or creates one — with a personal group, never the super administrator privilege — only while the console takes sign-ups. Password sign-in can be turned off only from a session that came in through a linked provider, and nothing can then leave every super administrator without a way in;
bun run admin:password-sign-inturns it back on from the server. Agents may change the console’s providers only by confirming each change.
Changed
-
Usage is open to every administrator (spec 091). It lists the buckets owned by the groups they belong to, with those groups as the customers and every total computed over those buckets alone; the reserved buckets stay a super administrator’s. A deleted bucket’s row and history stay readable by the group that owned it last, since its active users counted in that group’s months; everyone else is refused as for an id that never existed. The
activity_overviewMCP tool follows. -
A user’s profile now wins over a stored claim of the same name (spec 089). Before, a
given_namein the claims document beat the profile’s. Now the profile supplies the claims it sets, and stored claims supply the rest, so for a directory’s people the directory’s value is what applications receive. -
Deleting a bucket can be undone for 30 days (spec 090). Delete now switches the bucket off at once and destroys it 30 days later. In between, nothing in it answers, and nothing in it is destroyed. Restore brings it back with every session and refresh token still valid. Delete now (
POST /admin/api/buckets/:id/purge) destroys a scheduled bucket at once. An hourly pass destroys the rest, recorded in the audit trail as the server. Agents may restore (bucket_restore) but neither schedule nor destroy. -
Replacing a SCIM secret or static token is no longer an outage; a provider can be tried before anyone relies on it (spec 088). The previous value keeps working until the directory has used the new one and stopped using the old one for an hour, at most 24 hours;
overlap: falsestops it at once, and retiring it early (provisioning_credential_retire) ends the tokens it obtained and only those. Keys and a change of kind still replace at once. Try sign-in on a provider’s page signs in at the provider through the bucket’s real callback and shows what it answered and what a real sign-in would do — link, create, refuse and why — writing nothing; it works on a disabled provider and is console-only. Held provisioning connections show as a badge on Buckets (provisioning_hold_list); the provisioning wizard speaks Entra’s and Okta’s own field names from a server-side guide (provisioning_directory_list); sign-in buttons can be moved up and down (federation_provider_move). -
A key rotates in one step, a compromised key is revoked at once, and new things are created in the group you choose (spec 087). Rotate (
bucket_key_rotate,jwks_rotate) makes the algorithm’s oldest ready key sign and publishes the next one, or only publishes the next one when none may sign yet. Revoke (bucket_key_revoke,jwks_revoke, high-consequence) hides a key immediately — a newrevokedstate, never published again — so nothing it signed verifies; revoking the signer puts a ready key, or a new key without the publication window, in its place in the same act. Audit entries name every key an act touched (keys). Creating a project or bucket takes an optionalgroupId, and a project an optionalbucketIdof that group; the console’s creation dialogs choose both. -
Signing keys read by algorithm; Activity reads like Usage; a deleted bucket’s history has a page (spec 086). A bucket’s and the instance’s keys show one card per algorithm, each key’s state in words (can sign at, signed until, verifies until) with Promote inline and Retire behind the typed word; the key read adds
signedUntiland the listretiredKeyLifetimeSeconds. A bucket served at the root explains that it signs with the instance keys and lists their published ids, and the instance Keys page names every bucket depending on them. The bucket Activity tab uses Usage’s charts, month chooser and CSV; Usage links into buckets instead of a drawer;bucket_activity_getaddsname,deletedAtandlastOwner, and a super administrator gets a deleted bucket’s history at/admin/usage/buckets/:id. -
Changing a project’s bucket shows who breaks first; groups have a page; audit answers “what happened here” (spec 085). Setting or clearing a project’s bucket now answers 409 with a preview — its applications by name, the issuer before and after, that people signed in through them sign in again, and every declared resource that moves or collides — and applies only with
confirm: trueand the sameexpect; API clients that did it in one request must now confirm, andproject_bucket_assign/project_bucket_clearare high-consequence. Token behaviour is unchanged. A project has a Settings tab, project rows carry their bucket and application count, and a bucket’s declared resources are read withbucket_resource_list. An administrator group has a page where any owner invites colleagues and every removal states what the member loses; group reads give members their colleagues’ emails and what the group owns, and a refused group deletion names projects and buckets as such. The audit trail filters bybucket,connectionandactionPrefix, records the key id of key operations and the created connection as its target, and the address before and after an address change (passed but never stored until now), and names directories; the bucket Overview and connection page show recent changes. -
Identity providers, provisioning connections and user groups each have a page (spec 084). Connecting a provider or a directory is a side-panel wizard that cannot finish without choosing who a first sign-in may create, or what happens when the directory removes many people at once; a provider page asks before a save that lets more people in, and secrets are shown once, in place, with an acknowledgement. While a connection is held, each removal it refuses is recorded per person, read with
provisioning_hold_getand cleared by the release (whose audit entry keeps the count); the hold email links to the connection’s page. New: one provider’s read (federation_provider_get: callback, logout signals, bound connection, linked people), and the connection read addsmanagedGroupsandavailableCredentialKinds. Handing a group to a directory now answers 409 with a preview naming the members it does not manage and applies only withconfirm: trueand the same counts. -
Tightening a bucket’s sign-in is previewed with counts before it applies (spec 083). Turning passwords off, requiring verification or an authenticator, or opening sign-up on the default or administrators’ bucket now answers 409 with what it does to whom (
consequences,expect) and applies only when repeated withconfirm: trueand the same counts; agents get the preview throughbucket_update. The bucket summary addswithoutEnabledLink,passwordWithoutAuthenticatorand, on the default bucket,projects. Requiring verification is refused on every bucket until mail delivery is configured, and new buckets are created closed to sign-up unless asked otherwise. A super administrator can sign a colleague out everywhere and reset their authenticator (admin_sign_out,admin_totp_clear, both high-consequence). In the console, sign-in is a bucket tab with one save bar, refusals shown beforehand and the server’s counts in the question; the administrators’ bucket became the Administrators page (Accounts, Sign-in, Identity providers, Activity, Settings). -
A bucket’s users are searched and paged by the server (spec 082). Breaking: the users list (
GET /admin/api/buckets/:id/users,bucket_user_list) now answers{ users, totalResults }in email order, a page at a time, withq(the start of an email or username in any case, or an exact id) and status, managed-by, verified and authenticator filters. New: reading one person (bucket_user_get, with who locked them), changing their address (bucket_user_email_change, high-consequence) and mailing them a fresh verification message (bucket_user_verification_send); setting a password can sign the person out everywhere, a person can be created without one, and an administrator’s create now follows the bucket’s verification policy. In the console each person has a page (Profile, Sign-in methods, Groups, Activity), and no save spans more than one request. Fixed on the way: a verification link or code sent to an old address no longer verifies the new one. -
A bucket opens on an Overview and keeps its settings in one place (spec 081). The Overview lists what needs attention (a held deprovisioning, an unreached host, verification without mail…), the issuer and discovery addresses to copy, sign-in at a glance and the counts; Settings holds the name, the address with its DNS record, the owning group, and a Danger zone. Changing an address previews every application, connection, provider and resource it affects and is refused if that set changed before confirming. A group owner may change an unused bucket’s path; only an owner (or a super administrator) may delete a bucket that still holds accounts.
-
Every console screen has an address (spec 079). Links, Back, reload and switching group keep your place, and signing in returns to the page you asked for. Bucket and project responses state what the caller may do (
can), so the console hides what the server would refuse and disables, with the reason, what the current state blocks — the console now offers group members the bucket creation the server always allowed them. Confirmations follow one scale, from none to a typed word or the server’s preview; outcomes, refusals and faults are reported the same way everywhere; each row has one way in and its actions in one menu.
Fixed
- A directory can no longer give an account an empty sign-in address. A SCIM user whose
emailslisted a blank entry before a real one passed the “at least one email” check and was then given the blank entry as its login; the login is now chosen among the entries that hold an address. db:setup:pgno longer breaks a reserved bucket’s address while repairing it. On a PostgreSQL database provisioned before buckets had addresses, the repair stored the console’s and the default bucket’s slug with its quotes ("\"admin\""), so neither could be found by its address. The value is now written as text, and the nextdb:setup:pgheals a slug the old repair damaged.- A bucket moved to a new host no longer reads as reached from the old host’s traffic, and owners now see their group’s bucket deletions and address changes in their audit trail.
- Invitation links work. The link in an invitation email opened a missing page; it now opens a page to accept, and a new invitee who first submits without a password no longer spends the invitation.
0.10.0 - 2026-10-10
An instance can now be run as a service for other people. Every bucket counts its monthly and daily active users, a super administrator’s Usage page shows each bucket beside the customer that owns it, and a bucket moves to another administrator group together with its projects. The administrators’ bucket is configured like any other, with open sign-up, email verification and the second factor, and a personal group has exactly one member again. A DPoP proof dated ahead of the server’s clock can no longer be replayed, and foxauth.dev is now also in Russian and Simplified Chinese.
Upgrading asks three things of an operator. Take a backup. Run db:setup (or db:setup:pg) for the new
activityMarks and activityFigures areas and the audit trail’s new index. Then run db:migrate: migration
2026-10-09-personal-groups-single-member removes every member but the owner from each personal group and cannot
be undone, so list the shared ones (group_list, kind: personal) first. An agent that used admin_settings_read
or admin_settings_update now uses bucket_get/bucket_update on the administrators’ bucket, and a setting
edited into the database by hand with the wrong type now stops startup until it is fixed.
Added
- The administrators’ bucket is configured on the ordinary bucket settings by a super administrator:
open sign-up for other administrators (each gets a personal group and nothing else), require them to
verify their email (refused until mail delivery is configured and your own address is verified — the
console’s “Verify my address” sends the message), and the second factor. A super administrator can
correct another administrator’s address. In every bucket, signing in with the right password to an
unverified account now sends the link or code instead of only saying “check your inbox” (with “Send the link again” for a lost letter), and sign-in
pages link to registration and password reset only where the bucket allows them.
Breaking for agents:
GET/PATCH /admin/api/admins/settingsand the MCP toolsadmin_settings_read/admin_settings_updateare removed; usebucket_get/bucket_updateon the administrators’ bucket. Spec 078. - The super administrator’s Usage page is a dashboard: every bucket’s monthly active users beside the
customer that owns it (the group, or the group that owned it when it was deleted), a “by customer” view
with owners as contacts, instance totals, 13-month and day-by-day charts, the buckets that changed sharply
between two closed months, search and filters every total follows, and a formula-safe CSV export.
GET /admin/api/activity(MCPactivity_overview) answers all of it; a super administrator can read a reserved or deleted bucket’s history by id; a deleted bucket’s tombstone now records its owner. Spec 077. - site: foxauth.dev in Russian (
/ru/) and Simplified Chinese (/zh-cn/) — the marketing pages, the docs landing page and Get started, and the whole blog — shown only to a reader whose browser puts that language first, with a switch to English and back; everyone else sees the English site unchanged. A blog post is published in every language or none, a stale translation says so on the page, and the build checks each page’s language, its Chinese title lengths and that every section keeps its English id. Spec 074. - Moving a user bucket, with every project that uses it, or a project with no bucket, to another
administrator group —
PUT /admin/api/buckets/:id/owner,PUT /admin/api/projects/:id/owner, a Move action in the console and thehighMCP toolsbucket_owner_change/project_owner_change. It needs an owner of the group left and a member of the group joined; a call withoutconfirmpreviews what moves; end users, clients and sign-in addresses are untouched, and the audit entry is read by both groups. Spec 075. - Monthly and daily active users per bucket, the default and administrators buckets included: every
person a bucket issued tokens to, counted once per UTC month and day and broken down into local sign-in,
upstream sign-in, renewal and directory-provisioned. A closed period is frozen and never changes, and the
history survives deleting people and the bucket itself. Read on the bucket’s page and on a super-admin
Usage page, through
GET /admin/api/buckets/:id/activityandGET /admin/api/activity, and by the MCP toolsbucket_activity_get/activity_overview. Re-runbun run db:setup/db:setup:pgfor the newactivityMarksandactivityFiguresareas. Spec 076. eventBusis typed:ServerEvents,ServerEventNameandServerListenerare exported from the package entry, so a subscriber to an event the server does not emit, or one expecting arguments it does not pass, fails to compile instead of never running.
Changed
- A personal group has exactly one member. Adding a member, inviting someone and accepting an earlier
invitation into a personal group are refused; shared work goes into a regular group and is moved there.
Upgrade: migration
2026-10-09-personal-groups-single-memberremoves every member but the owner from each personal group, one audit entry per group, and cannot be undone — list the shared personal groups (group_list,kind: personal, more than one member) before runningdb:migrate, then re-rundb:setupfor the new audit index. Spec 075. richAuthorizationRequests.typesis shape-checked even while RAR is off, because the consent screen reads its labels on every render; an instance whose stored map is malformed now refuses to start until it is fixed. AgetResourceServerInfooverride must answer ascopestring and anaccessTokenFormatofjwtoropaque; anything else is refused when the resource is resolved instead of minting a token with no scopes.- A stored setting override whose type is not its setting’s — a flag stored as the string
"false", a number stored as text — now refuses startup, naming the key. The settings API never wrote one; a value edited into the database by hand would otherwise have been trusted as typed. - The
rotateRefreshTokenandrarFor*addon defaults are async, so an override of one may now be async too; every caller already awaited them. A Set-valued setting holding a member that is not a string now refuses startup instead of being stored.
Fixed
- Two registrations for the same address arriving together could answer one of them with a server error; both now get the same non-committal answer, in every bucket. Spec 078.
- An agent calling a two-step admin tool without
confirm—bucket_address_change, and now the move tools — receives the route’s preview (the clients that will stop validating tokens, the projects that will move) aspreview; it was answered onlyconflict: the operation was refused. - A bucket addressed by its own hostname no longer has its session cookie expired on every request. Its
cookie is the bare
_session, which the server still cleared as a pre-bucket legacy name, so an anonymous session not re-issued in the same response was dropped. GET /session/endwith anid_token_hintwhose claims set is JSONnullanswered 500, and filed a defect in the error store, to an unauthenticated caller; a JWT whose header or claims set is not an object is now refused as undecodable (invalid_request).- Changing a protected resource’s access-token lifetime in the admin console failed with 422: the field sent the number as a string. It is a number input now.
Security
- A DPoP proof dated ahead of the server’s clock can no longer be replayed after five minutes. A proof
without a nonce is accepted while its
iatis within 300 seconds of now, but itsjtiwas remembered for 300 seconds from first use, so a captured request whose proof came from a fast client clock was accepted again once the record lapsed. The record now lasts as long as the proof is acceptable (RFC 9449 §11.1). The nonce check also counts matches instead of XOR-ing comparison results, which was correct only by coincidence.
0.9.0 - 2026-10-07
A sign-out at the upstream provider now reaches this server. A bucket accepts OpenID Connect Back-Channel Logout from the Keycloak, Auth0 or Ping providers an administrator opts in, and ends the sessions that came from the upstream session it names; signing in through Keycloak, which could not complete before, does. Releases now deploy themselves as the signed image they published, the image takes Alpine’s security patches on every build again, and a Compose deployment upgraded by pulling a newer image runs its migrations instead of refusing to start.
Upgrading asks one thing of an operator: run db:setup (or db:setup:pg) for the new UpstreamSession area.
This release declares no migration. A Compose deployment that skipped db:migrate when moving to 0.7.0 or 0.8.0
gets it from the updated docker-compose.yml; take the backup those releases asked for first.
Added
- A sign-out at Keycloak, Auth0 or Ping now signs the person out here. A bucket receives OpenID Connect
Back-Channel Logout from upstream providers an administrator opted in (
acceptsBackChannelLogout), at<bucket issuer>/federation/backchannel-logout, and ends the sessions that came from the upstream session named, telling its relying parties and keeping offline access. Signatures are checked as global token revocation’s are, with no setting to relax them. Rundb:setup/db:setup:pgfor the newUpstreamSessionarea; sessions from before the upgrade are not reached.
Fixed
- Signing in through Keycloak completes. Keycloak adds
session_stateto every return, and the federation callback refused any parameter it did not declare, so no Keycloak sign-in could finish; unknown return parameters are now ignored, as RFC 6749 §4.1.2 requires. Found by running back-channel logout against a real Keycloak 26.8.0. - Upgrading a Compose deployment runs the migrations too. The shipped
docker-compose.ymlanddocker-compose.postgres.ymlran onlydb:setup, so pulling 0.7.0 or 0.8.0 over an existing database left a server that refused to start; theirsetupservice now runsdb:migrateafter it, asfly.tomldoes, and the Kubernetes and Atlas guides say the same.
Security
- The image takes Alpine’s security patches on every build again. Its
apk upgradelayer was served from the build cache, so 0.8.0 shipped zlib 1.3.2-r0 after Alpine had published 1.3.2-r1 (CVE-2026-85091). The upgrade now runs in the final stage and is re-run by every release, scan and deploy build, while the dependency and build stages stay cached.
Changed
- A release deploys itself, and only a release reaches production. A version tag’s signed image is deployed by digest, its signature verified first, to the conformance instance and then (pre-releases excepted) to production, with nothing rebuilt or retested. The manual deploy is now the conformance instance’s alone, from source, once CI has passed for the commit. The image installs its production dependencies in a stage cached by the lockfile, so a code change no longer reinstalls them.
0.8.0 - 2026-10-07
An enterprise directory can now run a bucket’s users. Microsoft Entra ID, Okta or any SCIM 2.0 client creates,
updates, deactivates and deletes them and their groups through a provisioning connection, an upstream provider
can sign one of them out of everything, and a guard holds a connection that starts deprovisioning too many at
once. Roles become groups, which relying parties read in a groups claim, and ending a user’s access — by
deactivation, a lock or deletion — now ends their sessions and tokens at once rather than only their next
sign-in. The instance’s signing keys rotate without a restart, and the rest is the protocol held to its specs:
amr in every ID token, the grant types discovery advertises and no others, and a client, assertion, DPoP proof
or request object checked against the bucket it was sent to.
Upgrading asks three things of an operator. Take a backup, then run db:setup and db:migrate in that
order: this release carries two schema migrations — the root signing keys move into the store a bucket’s keys
use, and every role and its holders become a group and its members — and neither can be undone. A deployment
whose clients refresh without the offline_access scope among the supported scopes will see
unsupported_grant_type from now on, and refreshToken.enabled is gone. And a script or agent that sets
roles on an administrator, a bucket or an end user — through the admin API or over MCP — now grants membership
of Super administrators (admin_super_grant, admin_super_withdraw) or of a bucket group instead. The
upgrade guide walks through the steps.
Added
-
An upstream identity provider can sign a user out of everything here (Okta Universal Logout). With
globalTokenRevocation.enabledon and a federation provider opted in, a request to<bucket issuer>/global-token-revocationsigned with the provider’s own keys ends every session and token the user holds, with back-channel logout to relying parties; the account stays active. It implements an expired individual draft (draft-parecki-oauth-global-token-revocation-06) in the shape Okta sends, so it is off by default. Administrators get the same action as Sign out everywhere (console, admin API, MCPbucket_user_sign_out). -
A mass-deprovisioning guard per SCIM connection. An optional threshold (deprovisionings per rolling window) holds the connection once exceeded: further deactivations and deletions answer
429and are retried by the directory after an administrator releases the hold; everything else keeps flowing, and the bucket’s administrators are emailed. Re-runbun run db:setup/db:setup:pgfor the newDeprovisionSlotarea. -
Roles are replaced by groups. A bucket’s roles become bucket groups — named sets of end users, kept by an administrator or owned by a SCIM connection — with their own routes, console tab and agent tools, and relying parties read them from a built-in
groupsscope and claim (display names; above 200, a userinfo reference). SCIM gains/Groups(IPSIE AL2 §6.2). The administratorsuper_adminrole becomes membership of a reserved Super administrators group, granted and withdrawn by operations of their own;project_admin, which granted nothing, is gone. Runbun run db:migrate: it turns every role and assignment into a group and membership, losing none. -
An enterprise directory can provision a bucket’s users over SCIM 2.0. An administrator connects Microsoft Entra ID, Okta or any SCIM client to a bucket through a provisioning connection bound to one of its sign-in providers; the directory then creates, updates, deactivates and deletes users at
<bucket issuer>/scim/v2, and they sign in through the same provider, matched by the directory’s own identifier rather than by email. Targets the OpenID IPSIE SCIM profile (AL1, AL2), with each deviation behind a setting — seeCONFORMANCE.md. Off by default (scim.enabled). -
The claims each scope releases are a setting an operator can edit.
claimsjoins the settings catalog as a JSON value, soprofile,email,addressandphone— or any scope of your own — can be defined from the console or by an agent and are advertised in discovery as soon as they are saved. A definition that is neither a claim nor a list of claim names is now refused, at boot and on save. -
An end user’s claims can be set from the console and by an agent. Creating or editing an account now takes the claims it releases — name, locale, address and the rest — replacing the whole set. The identity claims (
sub,email,email_verified) and the protocol claims the server asserts itself are refused, and the audit trail records that claims changed, never their values. The agent tool’s description no longer promises an email change it never made. -
The console and agents register every client dynamic registration can.
private_key_jwtandclient_secret_jwt, a key set or its URL, and the per-client protections FAPI asks for — pushed authorization, DPoP-bound tokens, signed Request Objects and their algorithm — plus ID Token and JARM signing algorithms, back-channel logout and pairwise subjects. Each is checked by the same validator as a self-registered client; on an edit, null removes one. -
The instance’s signing keys rotate without a restart. They take the lifecycle a bucket’s keys have: a generated key is published first, can be promoted to sign once every instance serves it, and a retired key keeps verifying for a day and is then hidden — kept on record, never deleted. Promote and retire are in the console, the admin API and MCP (
jwks_promote,jwks_retire, both confirmed twice); the restart-gated delete andjwks_deleteare gone. -
Sign-out by form post.
POST /logoutanswers as the GET does, at every bucket’s address, as RP-Initiated Logout requires. A cross-site post arrives without theLaxsession cookie, so it is re-posted once from this origin rather than handled bare — handled bare it would say “signed out” and replace the sign-in with an empty one. -
Discovery says how to authenticate to introspection and revocation.
introspection_endpoint_auth_methods_supported,revocation_endpoint_auth_methods_supportedand their signing-algorithm companions (RFC 8414) are published while each endpoint is enabled.
Changed
- The token endpoint accepts exactly the grant types discovery advertises.
refresh_tokenis supported whileoffline_accessis a supported scope; without it a refresh token is refused asunsupported_grant_type, one issued earlier included.refreshToken.enabled, which changed nothing observable, is removed. - Retiring a key names it. A retire, instance or bucket, is refused unless its body carries
{ "confirm": "<kid>" }, and the console makes the operator type the kid. - One key signs per algorithm, not per key type, so an issuer can sign RS256 for its default clients and PS256 for a FAPI client at once. The bucket keys’ required-algorithm refusal is gone: no step can leave an algorithm without a signer any more.
- Upgrading: run
bun run db:migrate. Migration2026-09-30-root-keys-lifecyclemoves the stored root keys into the new store, keeping the key that signed as the signer of its algorithm (on PostgreSQL, the lowest kid) and printing its choice; startup refuses a database that has not run it. - The dependency audit gates on what the image ships. It audits a production install of the server, as the Dockerfile makes one; an advisory against development tooling or the website no longer fails the run, and Scorecard no longer reads the website’s lockfile.
Fixed
- Deactivating, locking or deleting an end user ends their access at once. Deactivation used to block
only new sign-ins: sessions stayed alive, access tokens introspected as active, no relying party was told.
Now each session gets a back-channel logout and every session, grant and token is swept. Alongside it
(spec 069, groundwork for SCIM): an emergency lock only an administrator lifts, a username, external
identifier and profile on the user — released under
profile/phone, now mapped by default on new instances — and users a provisioning connection owns are read-only in the console. - A client registered at a bucket’s address belongs to that bucket. A bucket’s discovery
advertises its own
/reg, but a client registered there was refusedunauthorized_clientat that bucket’s/authand accepted at the root. The registration now records the bucket, from the address alone; a registration at the root is unchanged. - A client assertion is checked against the bucket it was sent to. At a bucket’s address the
accepted
audvalues were the instance’s, soprivate_key_jwtandclient_secret_jwtclients were refused when they named the bucket’s issuer or token endpoint, and accepted when they named the root. An assertion naming the root is now refused at a bucket’s address. - A DPoP proof is checked against the bucket it was sent to. Its
htuwas compared with the instance’s URL at every address, so every DPoP-bound exchange at a bucket’s address failed withinvalid_dpop_proof. It is now compared with the addressed bucket’s endpoint. - A request object is checked against the bucket it was sent to. Its
audwas compared with the instance’s identifier, so a signed request object pushed to a bucket’s PAR endpoint was refused. Arequest_urione issuer hands out is no longer accepted by another. - A bucket’s endpoints know which endpoint they are. At a bucket’s address the request context
carried the route pattern
/:bucket/par, so checks against the endpoint’s name failed there: a pushed request with an unregisteredredirect_uriwas refused asinvalid_redirect_urirather thaninvalid_request, andpar.allowUnregisteredRedirectUrisnever applied. - The sign-out pages are styled on their first showing. Their styles were extracted before the page was rendered, so the first one after a restart came out unstyled and later ones carried whatever earlier renders had left behind.
- A requested claim value is honoured (OIDC Core §5.5.1). With
claimsParameter.enabled, a subject named only insub.valueswas ignored and a code issued for whoever was signed in; it now fails likesub.value. A claim whose value differs from a requestedvalue/valuesis left out of the ID token and userinfo (sub,acrandamrkeep their own rules). - ID tokens state how the end user signed in (
amr). Recorded since the second factor shipped but never emitted, it is now in every ID token from a sign-in — code, device, CIBA and refresh alike — as["pwd"], or["pwd", "otp", "mfa"]with a one-time code; a federated sign-in carries none, and a CIBA integration’s reported methods are passed through. It is listed inclaims_supportedwhatever the stored claims setting holds. Behaviour change: password-only ID tokens gain"amr": ["pwd"]; test forotpormfa, not for the claim’s presence (#46). - A device can be approved by someone who is not already signed in. The verification page now sends
the session its anti-forgery check needs, and signing in or consenting during a device sign-in finishes
it instead of failing with
invalid_redirect_uri. Every failure on that step — an unmet authentication context, a withdrawn resource, a fault — now reaches the device’s next poll with its own error code instead of leaving it onauthorization_pendinguntil the code expires. - This server can no longer be used to forward users to an attacker-registered site. For a client
whose redirect URIs no operator vouched for — created by dynamic registration or described by a
metadata document — an authorization error (a malformed request, a declined consent, a silent
prompt=none) is offered on this server’s page, naming the destination host, instead of redirected (RFC 9700 §4.11.2). Administrator-created clients and successful responses are unchanged. - Errors after sign-in reach the client (#47). Declining consent, an unmet authentication context,
a resource withdrawn mid-flow or a fault while completing sign-in now redirect to the client’s
redirect_uriwith the error,stateandiss, in the requested response mode, instead of leaving the user on an error page; a cancelled interaction can no longer be resumed into a code. A client or redirect URI removed while the user was signing in is refused on-page and is no longer sent a code. Faults delivered asserver_errorare now recorded in the error store, and a redirectederror_descriptionstays within RFC 6749’s character set. - A confirmed sign-out returns to the relying party. The confirmation page’s
form-action 'self'had Chrome block the 303 topost_logout_redirect_uri, after the session had already ended, so the user stayed on “Do you want to sign-out?”. The page now admits the validated address’s origin. - A sign-out at a path-addressed bucket can be confirmed. Its confirmation form posted to the
default bucket’s
/logout/confirm, which holds none of that sign-out’s state, so it failed with “could not find logout details”. - A signing key in a new algorithm is usable without a restart. Generating, say, the ES256 key a FAPI 2.0 deployment needs signed at once, but discovery did not advertise ES256 and a client could not register for it until the server restarted. The advertised algorithms now follow the live key set.
- Seven settings no longer claim to need a restart. The rate limiter, the three sign-in throttle settings, federation, the MCP control plane and Sentry reporting all apply on save, as the console already reported, while their descriptions still ended “Applied at startup”. A catalog guard now refuses a description that contradicts the setting’s own apply behaviour.
- A refused client registration reaches the console as the admin plane’s error. It arrived as
400in the protocol’s shape, with no message to show, although the admin routes’ tests expected422— they mounted the routes alone. It is now422with the validator’s reason. - A Request Object is held to its own rules, not a client assertion’s.
jtiis optional andaudmay be a list naming this server (RFC 9101 §4, RFC 7519 §4.1.3), and a missing or malformed registered claim answersinvalid_request_objectrather thaninvalid_request(RFC 9101 §6.2); a bad authorization parameter inside the object is still refused as that parameter. The first alone refused every signed push in FAPI 2.0 Message Signing. - A sign-out without
id_token_hintcan be confirmed on MongoDB. The pending sign-out’s unset members were stored as nulls, the session then failed its schema and read as no session, and the confirmation answered “could not find logout details”. Protocol records are now written with undefined members absent, as the admin stores already were. Found by the conformance suite’s RP-initiated logout plan; the in-memory adapter the test suite runs on could not show it. - A federated sign-in refuses an ID token with no
iat, and follows an upstream key rotation. The claim is REQUIRED (OIDC Core §2) and was checked only when present; a rotation refused sign-ins for 30 seconds, or for ten minutes when the new key carried nokid. Both found by the conformance suite’s client plans.
0.7.0 - 2026-09-29
Tenants stop sharing what should have been theirs alone. A declared protected resource is unique per issuer rather than across the instance, and a bucket with an address of its own signs with keys of its own, so one tenant can neither claim another’s MCP server nor mint a token that verifies at another’s resource server. A security audit found the rest: sign-ins that another browser could continue, single-use artifacts and attempt caps that parallel requests got through, outbound requests that reached private addresses, client metadata that named internal attributes, an agent token that worked on the management API directly, and a consent waiver and password policy that did not hold for every bucket. Each is fixed below, and so are signed responses from a named bucket that named the root issuer.
Upgrading asks four things of an operator. Take a backup, then run db:setup and db:migrate in
that order: this release carries the first schema migration, it re-keys declared resources, and it
cannot be undone. A resource server of a bucket with an address must verify against that bucket’s own
jwks_uri, which the bucket’s metadata now names; tokens the bucket signed before the upgrade stop
verifying there, and the instance issuer’s keys and metadata are unchanged. A deployment reading a
forwarded client certificate switches on mTLS.trustProxyCertificateHeader once its proxy sets
Client-Cert and strips any incoming copy — no certificate is read until then. And declaring a
resource for a project without a bucket of its own is now a super administrator’s; give such a project
a bucket with an address to declare it yourself. The upgrade
guide walks through each.
Security
- A sign-in can be continued only in the browser that began it. The sign-in screens checked that an
interaction cookie was present but never compared its value, so anyone holding an interaction’s
address — which appears in every URL of the flow — could continue it from another browser. After the
end user’s password in a bucket requiring a second factor, that exposed the new authenticator secret
and let the other browser enrol its own and leave signed in as the account. Regression since
53341c6; a foreign cookie is now refused exactly as an unknown address is. - Client metadata can no longer name the model’s internal attributes. A dynamic registration
body, a registration update or a client ID metadata document that spelled a canonical key had it
copied into the client:
clientIdoverwrote any stored client, the console’s own included,consent.require: falseswitched off the consent screen, and a document’sclientSecretsurvived the rule that such clients hold no secret. Wire input now carries snake_case metadata only; those keys are dropped as unrecognised. - A self-registered client stays marked as one after updating its registration.
PUT /reg/:idrebuilt the record from the body alone, dropping the self-registration marking — which lifted the refusal of such clients on the administrative MCP surface — and the record of its first completed authorization, which returned a client in use to the sweep of unused registrations. - An enrolment secret completes only the sign-in of the account it was offered to. The password step can be submitted again inside one interaction, and the secret was keyed by the interaction alone: someone holding a victim’s password but not their authenticator could sign in as an unenrolled account of their own, take its secret, submit the victim’s password and prove that secret, leaving signed in as the victim with two factors recorded.
- A federated sign-in no longer links to an account that holds only a password on its own. The assertion proved control of the address, not of whoever set the matching account’s password, and a bucket that does not verify addresses lets anybody register one: registering a victim’s address first turned the victim’s first federated sign-in into access for the registrant. The identity now waits on the interaction and is linked once that account’s own sign-in completes, second factor included; an account that already holds an upstream identity links as before.
- An agent’s token no longer works on the management API directly. What an agent is withheld
from and asked to confirm is decided in the
/mcptransport, but the management API accepted the same token from the network too, so an agent able to make an HTTP request could delete a project, bucket or group, permit another client identity onto the administrative plane, or delete a signing key without confirmation. The token is now accepted there only for requests the agent surface dispatches itself. The threat model also states that the confirmation step is answered by the same agent it asks. - A client credentials token for a declared resource goes only to that project’s clients. No end
user consents to such a token, and the grant took its bucket from the request address, so any
confidential client of any tenant, or one that registered itself, could mint a token carrying another
tenant’s resource as its audience, that tenant’s issuer and its scopes. It is now refused with
invalid_targetunless the client belongs to the project that declared the resource. - Every address a client supplies is fetched through one egress boundary. The sector document,
the client’s key set and the CIBA and back-channel logout notifications went through plain
fetch: redirects followed anywhere, private and link-local addresses reachable, no bound on time or size, and the sector refusal repeated the status the target answered. They now share the address check, per-hop redirect check, timeout and streaming byte bound the client ID metadata document fetch had, and the sector refusal is one message whatever went wrong. - Codes, rotating refresh tokens, pushed requests and assertion
jtis are single use under concurrency. Each was checked on a copy read earlier in the request and then spent with an unconditional write, so parallel requests all won: one authorization code yielded five sets of tokens, a rotating refresh token forked into several chains without tripping reuse detection, and a captured client assertion or DPoP proof authenticated every concurrent replay. The adapter’sconsumenow answers whether this call spent the record, a newcreatestores only when the id is free, and every caller decides on that answer, in all three backends. - Attempt caps hold against a burst of parallel guesses. The emailed verification code, the TOTP
failure window and the password door each read their count, verified, and wrote the count plus one,
so a burst advanced them by one: fifteen passwords were hashed against a cap of five, and the right
code was accepted after fifteen wrong ones. Each now counts the attempt first through a new atomic
incrementand admits it only within the cap; the verification code’s expiry is also checked on read. - Being added to someone else’s personal group no longer changes your default scope. An administrator’s personal group was found as any personal group they belonged to, so after a colleague added them to theirs, sign-in could land them in the colleague’s group and their new projects, clients and buckets were created there. It is now the one whose first member, its owner, they are, in every backend and in the scope fallback.
- The password reset request no longer reveals whether an address has an account. Its cooldown and daily cap are kept only for addresses with an account, and refusing with a “please wait” page meant that asking twice within a minute told anybody the address was registered; the SMTP send, awaited only for such an address, told the same by response time. Every outcome now renders the one accepted page, which states the limits, and the mail is sent after the response.
- A datastore’s duplicate-key error no longer carries an end user’s address into the error store
or Sentry. Two registrations of one address racing past the lookup made MongoDB raise
E11000 … dup key: { email: "…" }, and the fault’s message is stored and sent. The user stores now raise their value-free “already exists” error for it, and any duplicate-key message is recorded asduplicate key. - First-run setup, reset links and group invitations are single use under concurrency. Each checked, then acted, then marked, so parallel requests all got through: two setups made two super administrators, two submissions of one reset link both set a password, and two acceptances of one invitation both applied. Each is now claimed once, atomically, before anything is written.
- A forwarded client certificate is read only when the operator says the proxy sets it. With the
mTLS features on, the default hook took the certificate from any
x-client-certheader, and a certificate is public, so a caller the proxy did not strip the header from could authenticate as a self-signed TLS client or present a stolen certificate-bound token. The newmTLS.trustProxyCertificateHeadersetting, off by default, gates it; when on, RFC 9440’sClient-Certis read, withx-client-certas the fallback. - Requests to an upstream identity provider go through the egress boundary. Discovery, the code
exchange, GitHub’s token exchange and the upstream key set used plain
fetch, although any group member can set a provider’s issuer and an unauthenticated visitor re-triggers the requests by starting a sign-in; they now share the address, redirect, time and size checks, and discovery refuses a document naming any endpoint that is not https. - A client that skips consent does so only for its own group’s users. Any group member could
create a project with no bucket and a consent-free client in it; such a client signs into the shared
default bucket, where it received any signed-in user’s claims with
prompt=noneand no page shown. The flag is now honoured only when the sign-in bucket belongs to the client’s project group. - A bucket’s password, reset, registration and second-factor policy holds for a client in no project. Those doors resolved the bucket from the client alone while the sign-in used the resource the request named, so a federated-only bucket reached through a declared resource verified passwords, mailed resets that set a password on a federated account, and accepted registrations, while its required enrolment looped back to the login page. They now resolve the same bucket, and a bucket with password sign-in off also refuses a reset link issued before it closed.
- The outbound address check no longer depends on how an address is spelled. It matched text, and
URL parsing rewrites
https://[::ffff:169.254.169.254]/to the host[::ffff:a9fe:a9fe], which the dotted-form pattern let through to the cloud metadata endpoint; NAT64, 6to4 and compatible forms and several reserved ranges passed too. It now compares numerically and judges the IPv4 an IPv6 address carries. - The server image no longer takes in environment files or the repository’s history. The image is
built with
COPY . .and.dockerignoreexcluded only four conventional dotenv names, so a.env.productionon the building machine would have been baked in and loaded; it now excludes every.env*,.git, CI and agent configuration, specs, wiki and docs. - A tenant can no longer claim another tenant’s MCP server. A declared protected resource was unique across the instance and first come, first served, so any group member could declare someone else’s server, leave its owner a permanent conflict and route its clients into their own bucket. A declaration is now unique within its issuer: a bucket with an address of its own declares without any outbound check, and at the shared root issuer only a super administrator declares.
- A token from one bucket no longer verifies at another bucket’s resource server. Every bucket
signed with the instance keys, so tenant separation there rested on the resource server checking
iss. A bucket with an address now signs with keys of its own and publishes them at its ownjwks_uri; the root issuer’s keys and metadata are unchanged. - An end user’s own password is held to eight characters. Registration and the reset form enforced
the minimum only through the form’s
requiredattribute, so a direct POST set any password, and an empty one reached the hash and answered 500. Both now refuse a short password as a form to correct, the same eight every administrator-set password is held to.
Added
- Per-bucket key rotation. A bucket’s owning group generates, promotes and retires its keys from the bucket’s page or through MCP; a new key is published before it may sign and a retired one stays published until every token it signed has expired.
- Whether a resource vouches for its declaration. The console and the
resource_vouching_checktool report whether a declared resource’s own RFC 9728 metadata lists the issuer its tokens carry, following the full MCP discovery order. It blocks nothing.
Fixed
- The first schema migration would never have run. Both provisioning scripts marked every
declared migration applied on every run, and the release command runs them before
db:migrate; they now baseline only a database they provisioned from empty, anddb:migratehands a step its handle. - A resource declared with a significant trailing slash keeps it. Declaration canonicalized with the default options, so the flag had no effect and both spellings were issued tokens.
- Every signed response from a named bucket carries that bucket’s issuer. The signed userinfo
response, the JWT introspection response, the JWT-secured authorization response and the back-channel
logout token were minted without the bucket and named the root issuer, so a relying party checking
issagainst the bucket’s metadata rejected them. The logout token now names the bucket the ended session signed in to.
0.6.0 - 2026-09-28
The server stops taking its own data on trust. Every record read back from storage — a token, a
session, an account, a bucket — is checked against its schema on the way out, and the request context,
request parameters and clients are typed from the schemas the endpoints validate against instead of
asserted. Checking what is actually stored found what the assertions had hidden: on PostgreSQL every
account update and every group-filtered audit read failed, and a lost race for a bucket hostname
answered 500; MongoDB stored an undefined member as null; a host-addressed bucket forgot its
sign-ins; and a client’s default_max_age of 0 depended on whether a clock second had passed. Error
codes at the token, device and CIBA endpoints, and several request parameters, now follow their RFCs.
Upgrading asks two things of an integration. Extension functions, policy checks, registration
policies and RAR validators receive the request context itself rather than { oidc }, with no shim;
and a client reading its registration with the access token in the query string sends it in the
Authorization header instead. A stored record that does not match its schema now fails its request as
a server error the error store records, rather than passing as a malformed value — the shapes 0.5.0
writes are accepted, and no schema migration is declared. Deployments that lengthened a token lifetime
will see older browser refresh-token chains sign in once more.
Fixed
- authorization: a client registered with
default_max_age: 0is asked to sign in again on every request, asmax_age=0is. The default was applied as the string"0", which skipped the re-authentication rule and left it to whether a clock second had passed since sign-in. - MongoDB: a member a writer leaves undefined is stored as absent rather than as null — a console session’s refresh token, a host-addressed bucket’s slug, a federation provider’s secrets.
- PostgreSQL: account updates (roles, password, TOTP, federated links) and group-filtered audit reads
failed on every call — a JS array was bound as text PostgreSQL cannot read as an array. A lost race
for a bucket hostname answered 500 instead of “taken”, because Bun reports SQLSTATE in
errno. - Device verification refuses a confirmation when the session holds no form secret, instead of comparing two absent values as a match; resuming an interaction after an account change re-saves it with the same TTL floor the sign-in screens use, so a record about to expire is not left non-expiring.
- Host-addressed buckets: a sign-in at the bucket’s host wrote a session cookie the host never read
(so it was forgotten), and authorization responses and tokens named
ISSUER/<id>instead of the bucket’s origin its discovery document advertises;GET /logoutthere read the default bucket’s session. The bucket record now keeps its host, and/logoutpasses the request host. An interaction aborted back to the client names the bucket’s issuer, not the instance’s. - ID Token claims beside a resource-bound access token: with
conformIdTokenClaims, the authorization-code, device and CIBA grants read the access token’s audience off the instance (always empty) and so left profile claims to UserInfo even when the access token, bound to a resource server, cannot call it; they now read it from the token’s payload, as the refresh grant already did. - CIBA: the
verifyUserCodeaddon receives the request’suser_code; it was handed the login hint instead, so a deployment’s user-code check never saw the code the client sent. - request parameters follow their specifications:
ui_localesandclaims_localesare one space-separated string each (OIDC Core), no longer an array, andui_localessent twice is refused;registration, and a disabledrequest/request_uri, are refused with the OIDC Core §3.1.2.6 codes instead ofnot_supported; several or malformedresourcevalues at/tokenareinvalid_target(RFC 8707), notinvalid_request; a parameter sent twice toGET /logoutis refused. - breaking — registration read (
GET /reg/:clientId) no longer accepts the registration access token in the query string (RFC 6750 §2.3, OAuth 2.1); send it in the Authorization header. - token, device authorization and CIBA endpoints answer as RFC 6749 §5.2 says: an unknown
grant_typeisunsupported_grant_type(wasinvalid_request), and a grant the client is not registered for isunauthorized_client(wasinvalid_request). - tokens: a browser application’s rotated refresh tokens again expire when their chain’s first token
would have, instead of each rotation granting a fresh lifetime — dead since
54ba556, masked by the equal default grant lifetime.ttl.*now take(token, client). Deployments that lengthened a lifetime will see older browser chains sign in once more. - buckets: device authorization and CIBA started at a named bucket now use its verification page,
session and
iss, and refuse another bucket’s client asunauthorized_client; registration there returns a management URI beneath it. AdeviceInfooverride now takes effect (the default records the caller’s address as a string). - admin: editing a client in the console or through the agent’s
client_updateno longer drops every attribute the console does not display. A pairwise client stayed pairwise only until its first edit, and a client authenticating with a private key could not be edited at all. - claims: a client requesting
claims.id_token.subas a non-object (true, a string, a number) now gets a code, as fornull, instead ofserver_error. - interactions: when a different account signed in during an interaction, confirming the sign-out
continues it again. The confirmation always answered 400
could not find logout details.
Changed
- authorization_details: declared in its RFC 9396 §3 wire form (
t.ArrayString: one parameter, the JSON array as text) at/auth, PAR, device and CIBA, and a repeat is refused as RFC 6749 §3.1 requires. A repeated parameter used to be accepted as one detail per copy. - models: a stored record becomes a model only if it satisfies that model’s schema, now a static
schemaon the class; one that does not (a malformed or foreign record) is treated as not found. Until now nothing checked a model’s own schema, on construction or on read. - storage: records read without a model class — federation state, the login, reset and resend throttles, the verification and reset challenges, the TOTP records — are checked against their own schema too, and one that does not match fails the request (a throttle read as absent would be a fresh allowance).
- storage: every PostgreSQL and MongoDB store document (accounts, buckets, groups, projects, sessions, audit, errors, keys, settings) is checked against its schema when read; one that does not match fails the request as a server error, recorded by the error store, instead of passing as the type. On PostgreSQL, nested dates (a TOTP enrolment’s, a federated link’s) now come back as dates.
- types: the typed (Eden) client sees what the endpoints answer —
/tokendeclares its 400/401 errors and one token body, registration and introspection declare their RFC members, the request headers includeacceptandx-client-cert, and the admin client schema’s grant types and auth method are no longernever(unions built from a mapped array); the interaction pages (/ui/*) and discovery declare their answers, and every protocol route its 500. No response changes on the wire. - breaking — extension functions, policy checks, registration policies and RAR validators receive
the request context itself (
issueRefreshToken(oidc, client, code),check: (oidc) => …), not{ oidc }. Request-scoped events carry it first;grant.revokedis(oidc?, grantId)and fires once (a partial sign-out emitted it twice),code_verification.erroris(oidc, error). No shim. - request context:
OIDCContextdeclares what it holds — built from an init object with a requiredbucket, typed entities,oidc.require(name). A getter is a guarantee:oidc.clientandoidc.sessionthrow when absent (authenticatedClientis gone); optional entities are read asoidc.entities.X?, replacing thegrant,accountanddeviceCodegetters. Unread fields removed. - request context: request parameters are typed from the schemas the endpoints validate against —
PipelineParamsfor the authorization, device and backchannel pipeline,TokenParamsfor the grant handlers — and the type is closed, so TypeScript overrides reading their own extension parameter need an explicit widening.claimsmembers areunknownuntil read throughclaimRequest(); the schema now declares them as records, accepting exactly the same inputs. JavaScript overrides are unaffected. - client: the client’s type names its closed value sets (authentication method, CIBA delivery mode, signing algorithms) from the lists the configuration check uses, is read-only all the way down to match the freeze.
- client: the validated client has a type that matches it — the attributes a default always fills are
required,
client_name,contacts,default_acr_valuesandclient_id_issued_atare declared — and the request context carries it typed (oidc.client; see the request-context entry above). - client: a client is written the same way from every surface, through
registerClient, and its sector identifier document is checked there and only there. Resolving a stored client no longer retrieves it, so a pairwise client stays usable while its sector host is down; the console, which stored without the check, now refuses an edit the document does not cover. A document-described client keeps the check on resolution. - client: a resolved client is frozen data — no methods, no prototype — and is used through functions
(
redirectUriAllowed(client, uri),clientMetadata(client), …).Clientis an object holdingfind/tryFind; back-channel logout and CIBA ping moved tolib/shared/client_notifications.ts. - client: a client’s key material is derived beside it by
clientKeys(client)instead of hanging off the object, and its key set no longer writes certificate thumbprints onto the client’s own keys. TheclientAuthMethod/clientAuthSigningAlgaliases are gone; read the registered attribute names. - client: each registration attribute’s validation rules are declared once, in
lib/consts/client_attributes.ts, replacing five parallel lists that nothing kept in step. No behaviour change, verified against 33,159 registrations; three rules that could never fire, and one check that never ran, are removed.
0.5.0 - 2026-09-21
A user bucket finishes becoming a tenant. 0.4.0 gave it a path beneath the server; this release lets
it hold a hostname of its own instead — acme.auth.example.com rather than auth.example.com/acme —
which is what puts its sign-in cookie on an origin of its own and publishes one metadata location
where a path-bearing issuer forces two. A bucket holds one form of address or the other, never both,
since two addresses would be two issuer identifiers for one population. Changing an address is its
own operation and an administrator of the instance’s alone: it names every client that will stop
validating tokens before anything changes, and completes only on a second call.
Signing in to a bucket stops being only a password. Google, Microsoft, Apple and GitHub can each be connected to a bucket by name, asking only for what that provider actually issues, and the console now shows the callback address to register — which differs per bucket and appeared nowhere before. Projects and user buckets can also be deleted from the console, which the management API has always accepted and nothing in the console reached; a container may take its contents with it, on an election made separately from confirming, and neither deletion completes by clicking.
Upgrading signs your administrators in once. The session cookie is now named after the bucket the
client signs into rather than the address the request arrived at, because the administrators’ bucket
and the default one are both served at the root and only the client says which population a sign-in
is for. That is what fixes a console login failing with interaction session and authentication session mismatch whenever the browser already held an end-user sign-in from the same server; the
price is that a console session held from before this release is not read, and the administrator
signs in again. End users are unaffected, no schema migration is declared, and nothing is lost —
accounts, grants, consent and refresh tokens are untouched.
Added
-
A user bucket can be addressed by a hostname of its own rather than a path beneath the server, and holds one form or the other — never both, since two addresses would be two issuer identifiers for one population. A host isolates the bucket’s sign-in cookie by origin, which path addressing cannot do at all, and publishes one metadata location instead of the two a path-bearing issuer forces. It costs a DNS record and a certificate the operator provides; the console names the record to create and reports whether any request has yet arrived, and claims nothing about whether the name resolves. Changing an address is its own operation, available only to an administrator of the instance: it names every client that will stop validating tokens before anything changes and completes only on a second call. Spec 056.
-
site: a blog article can carry inline SVG diagrams. Three colour tokens in the stylesheet make a diagram follow the site palette and the reader’s theme, and a diagram’s labels are left out of the page’s extracted text, so they neither run together in the Markdown alternate nor get judged as prose by the rules that read it. The first article, on DPoP, is rewritten around three of them.
-
site: the public site has a blog, and publishing to it is writing one file. The page, the index entry, the sitemap entry with its date, the social card, the plain-text alternate, the
llms.txtentry and the feed item are all derived from that file, so nothing is remembered and nothing is half-applied. The blog is not exempt from anything: every rule that already fails the build for a marketing page fails it for an article, and the rule that catches copy claiming the server stores its data in one datastore now reaches articles too — an article genuinely about one backend earns its exemption by printing a line telling the reader it is scoped, rather than by being added to a list of excused pages. A draft or a future-dated article exists nowhere: no page, no sitemap entry, no feed item, no card. Articles are attributed to the project, carry their publication and revision dates where a reader can see them, and say so themselves once they are old enough to deserve a second look. -
admin: Microsoft, Apple and GitHub can be connected to a user bucket by name, alongside Google. Each asks only for what it actually issues — two values for GitHub, three for Microsoft (including who may sign in: one organisation or any Microsoft account, stated with its consequence and not chosen for you), four for Apple. Apple needs no client secret: it supplies a signing key and this server produces the credential Apple wants on every exchange, so nothing expires and nothing is ever renewed by hand. GitHub is not an OpenID Connect provider and asserts no identity, so the signed-in person is read back from GitHub — including the primary verified address of an account whose profile hides one, and never an unverified one. Each provider gets its own branded button, rendered from an inline mark so loading a login page still tells no provider that somebody is there.
-
admin: Google can be connected to a user bucket by name. The console now shows what to do at Google, links straight to it, and — the part that was missing entirely — the exact callback address to register, which differs per bucket and appeared nowhere before; the administrator supplies only the client id and secret Google issued. Everything else comes from a recognised-provider catalogue that is data, not behaviour: the stored provider is indistinguishable from one configured by hand, so nothing can branch on how it was made, and a Google provider configured by hand years ago now renders with its own branded button without a migration. The same route serves agents, so an agent connects it the same way, with the same checks and one audit entry. A bucket with no slug is told its callback address is provisional, because assigning a slug later invalidates it.
-
admin: projects and user buckets can be deleted from the console, which was impossible before — the management API accepted both deletions and nothing in the console reached them. A container may now take its contents with it, a project its OAuth clients and a bucket its end-user accounts, on an election the administrator makes separately from confirming; the request carries what they reviewed, so a client or account that arrived since is refused rather than destroyed on a stale consent. Neither deletion completes by clicking: the administrator types
delete. A project never reaches the bucket it pointed at, under any election, because buckets are shared and hold people no project knows about. One deletion writes one audit entry carrying what went by kind and count — replacing one entry per withdrawn protected-resource declaration, which a bucket-sized deletion would have turned into a trail nobody could read.
Fixed
-
admin: signing in to the console no longer fails with
interaction session and authentication session mismatchwhen the browser already holds an end-user sign-in from the same server, and no longer asks for the password on every authorization request. The default bucket and the administrators’ bucket are both served at the root, so the address alone cannot say which population a sign-in is for — only the client can, which is how the rest of the sign-in has always decided it. Naming the session cookie from the address meant the authorization request and its resumption read two different cookies and saw two different sessions. -
A client can no longer sign an end user out of a user bucket it does not belong to. A sign-out ends whatever sign-in the address it was requested at holds, and nothing checked that the client asking shared that sign-in — an
id_token_hintis validated against the issuer, and a bucket served at the root shares the server’s own, so a token minted for one population could end another’s. Buckets that share a session cookie still end each other’s sign-in, which is what their clients have always done at the bare endpoints; anything else is refused. -
admin: two administrators assigning one hostname at the same moment now both get an answer they can act on. The route asks the store whether a name is free and writes after the answer, so both reads can return “free” and the datastore’s own constraint refuses the second write —
UniqueValueTaken, a class introduced precisely so that refusal would be a 409 rather than an internal fault. Nothing caught it, on either the create or the address-change route, so the operator who lost the race got a 500 and a recorded defect. Found by a coverage report rather than by a person: the class was raised by all three bucket stores and reached by nothing. -
ci: a commit that touches the PostgreSQL adapter no longer fails the coverage gate for doing so.
bun testruns on the in-memory adapter, so a store’s function bodies need a live server and can only run underdatabase/verify_postgres.ts, which the default run deliberately cannot reach; they report 4–17% rather than 0% because the import-safety sweep executes their top level and nothing else. Seventeen stores and the plumbing they issue SQL through held the project number at 90.92% — which Codecov then holds every later commit to — and one duly failed at 84% of a diff whose 62 worst lines sat in a single such file. Thecodecov.ymlignore list now names the connection-bound files and only those: everything under that directory which runs in process stays measured, because a low number there is a real gap, and MongoDB’s stores stay measured as well, having no equivalent verification run to be judged in instead. -
test: the constant-time comparison cases now judge a timing difference by its size rather than by its statistical significance. Welch’s t divides by the machine’s noise floor, so the 0.1–0.4% residue a correct comparison always leaves — where the strings landed, not what the comparison did, as two candidates with identical content produce the same spread — scores about three times higher on a Linux runner than on the Windows machine whose 100 ns clock the threshold was calibrated against. That reddened CI on an unmodified tree, and it meant load made the cases pass while a quiet, precise machine failed them. A leak is instead a fraction of the work, and that fraction transfers between platforms: 52% on Linux and 59.5% on Windows for a first-difference mutant, against a floor of 2%.
-
federation: the authorization code is now bound to the request that asked for it at every recognised upstream that supports the binding. Support was read from what a provider publishes, and three of the four publish nothing — Microsoft recommends the binding while advertising no method, GitHub has supported it since July 2025 while publishing no metadata at all — so those legs were silently going without. A provider nobody has vouched for is still judged by its metadata, and nothing is sent where support is unknown, because an unrecognised parameter breaks sign-in for everyone.
-
admin: an Apple signing key is masked on every read of a provider and of the bucket containing it, the same rule the client secret follows and in the same place, so a second credential could not repeat the leak the first one had.
-
admin: the console’s dialogs offered the administrator’s own saved credentials. A browser reads an address or an identifier sitting beside a password field as a sign-in form, so creating an admin, creating an end user, resetting somebody’s password and entering an upstream provider’s credentials were all autofilled from the password store — one distracted confirmation away from creating an account with the operator’s own email and password, or from overwriting a working provider secret.
-
admin: dialogs sat a flat 100px below the top of the window whatever its height, which on a laptop is a sixth of the screen and pushed taller dialogs off the bottom. Short viewports now get a small offset; large monitors are unchanged.
-
interactions: a mistyped password removed the provider buttons from the login page. The refusal re-rendered from defaults rather than from the bucket’s options, so a page that had just offered a federated sign-in came back offering only a password box; the unverified-email refusal did the same.
-
admin: the administrators’ bucket and the default end-user bucket could be deleted by a super administrator while empty. The bucket deletion route does not go through the loader that carries the reserved-bucket guard, so it reached no such guard at all; both are now refused before the bucket is loaded, whatever is elected and whoever asks.
0.4.0 - 2026-09-16
One thing defines this release: a user bucket is a tenant of its own. It is addressed in the URL,
publishes its own metadata, mints tokens carrying itself as iss, and holds its own sign-in — so a
browser can be signed in to two buckets at once and neither disturbs the other. The default bucket
keeps the bare issuer and the bare paths, which is what makes the change deliverable to an existing
deployment without a flag day.
Two sign-in defects found on the way are fixed with it, and both had the same shape: a browser rule
no headless test could see. A sign-in that started at a relying party answered 422 for every real
client while the identical URL opened by hand returned 200, because SameSite=Strict withholds a
cookie on exactly the navigation the flow is made of. And the “Remember me” checkbox had never
decided anything.
Upgrading signs your end users in once. A session cookie now carries the bucket it belongs to;
one written before this release does not, is never read, and is expired on the first request that
presents it. Nothing is lost — accounts, grants, consent and refresh tokens are untouched — and the
upgrade guide says what to expect. The admin console
stays at /admin.
Added
- buckets: a user bucket is now a tenant with its own issuer. An operator gives a bucket an address
and it is served beneath it —
https://auth.example.com/acmepublishes its own metadata at both well-known locations a path-bearing issuer has, serves every protocol endpoint, and mints tokens carrying itself asiss. A client integrates with one exactly as with any authorization server, and can finally tell one population’s tokens from another’s. A client of an addressed bucket is refused at any other bucket’s address, and a token records which bucket issued it, so a token presented to a bucket that did not issue it is reported inactive as RFC 7662 §2.2 requires. The default bucket is unchanged: it keeps the bare issuer and the bare paths, so nothing integrated before this needs reconfiguring and tokens in circulation stay valid. Two reserved buckets — the default one and the administrators’ — are served at the root by design: they are not addressable and their tokens carry the instance’s own issuer, which is why the admin console stays at/adminand why an agent’s connection to the administrative MCP plane keeps working. The console’s Buckets table reports those two as served at the root rather than at the slug they hold, because the Address column is what an operator copies when pointing a client at a bucket. A bucket’s address is fixed once chosen; renaming would invalidate every client integrated with it and wants an operation of its own.
Fixed
-
buckets: a browser can hold a sign-in in more than one user bucket at a time. The session cookie is now named after the bucket that wrote it, so two sign-ins are two cookies and neither disturbs the other. Before this, signing in to a second bucket overwrote the first bucket’s sign-in and the end user was silently signed out of an application they had never touched — the buckets were isolated from each other, which is what the earlier work proved, but they could not coexist, which nothing had checked. A sign-out now ends the sign-in of the bucket its address names and no other, tells no other bucket’s applications about it, and “Remember me” declined in one bucket says nothing about another’s lifetime. The administrators bucket is a population like any other here: signing out of the console ends its sign-in rather than every sign-in the browser holds. Everyone signed in when this is deployed signs in once more: the old cookie carried no bucket, is never read, and is expired on the first request that presents it.
-
buckets: reaching a second user bucket in one browser no longer fails. An authorization request for one bucket from a browser signed into another answered
server_error, leaving the end user with nothing to retry: the sign-in check read the account identifier the session happened to carry rather than the account that actually resolved, so it suppressed the sign-in prompt for a request that had no account at all, and the first consent check to reach inside the absent grant faulted. And where a sign-in as the second person did complete, it was read as an account change and answered with a sign-out confirmation whose only working button ended every sign-in the browser held — a demand to abandon one product in order to use another. A sign-in now records the bucket it belongs to, and buckets are compared before account identifiers, because identifiers from two buckets are always unequal and say nothing about who is signing in. -
interactions: sign-in works again when it starts at a relying party. The
_interactionand_sessioncookies were writtenSameSite=Strict, which a browser withholds on a cross-site-initiated top-level navigation — the shape of the whole flow, since the relying party navigates to/auth, which sets the cookie and redirects to/ui/:uid/login, whose guard requires it. Every such sign-in answered422 Invalid interaction cookie; opening or reloading the identical URL by hand returned 200, because a browser-initiated navigation counts as same-site, which is what hid it._sessionshared the constant and failed more quietly: invisible at/auth, an established session made a second relying party re-prompt a user who was already signed in. Both are nowLax, which still withholds them on the cross-site POSTs and subresource requests that carry the CSRF property. The admin console’s cookie is a separate constant and staysStrict. -
admin: a project’s assigned user bucket can now be changed and removed. Assigning was one-way — the only route took a bucket id and had no value meaning “none” — and the console had no control for it at all, so a bucket could be set only through the MCP surface and never unset, however wrong it was. The Projects table now has a Bucket editor offering the buckets that share the project’s owning group — the only ones the server will accept — plus “Not set”, which is how the default bucket is chosen: it belongs to no group, so it is assignable to nothing and needs to be, since a project with no bucket already signs its users in from it. Clearing is its own operation on both surfaces (
DELETE /admin/api/projects/:id/bucket, published to agents asproject_bucket_clear). A project with no bucket signs its users in from the default bucket, as it always has. Clearing is its own audited action rather than an assignment carrying an empty value, because the audit trail records field names and never values: one action could not have told an operator which of the two had happened. This also makes the documented procedure in “Your first client” true — it described a console control that did not exist. -
admin: the reserved administrators’ bucket can no longer be assigned as an ordinary project’s user bucket. It appeared to be protected by the rule that a project and its bucket share an owning group, but that rule did not cover it: the admin bucket sits in the reserved System group, and a super administrator whose active scope is empty creates projects into that same group — so the comparison was
unassignedagainstunassigned, which passes. Such a project’s end-users were the accounts that administer the instance. The route now refuses the reserved bucket outright, the same refusal the bucket routes already make. -
mcp:
settings_updatenow tells an agent what each server setting accepts. Its published schema described no setting at all — the route behind it validates every value in its handler rather than in its body schema, so there was nothing there to publish — and a client with no type to check a value against sends the text it was handed:par.enabled: truearrived as"true"andscopesas one string, both correctly refused, with no encoding available that satisfied caller and server at once. Two unrelated clients hit it. Every catalogue key is now published with its type, its allowed values where it has them, and a one-line description, all derived from the same catalogue the handler enforces. The published schema describes without enforcing, so a setting the catalogue does not declare still reaches the handler and is refused by name rather than silently dropped. -
interactions: the “Remember me” checkbox on the sign-in screen now decides how long the sign-in lasts. It never had, on any path: the answer was recorded under one name and read under another, the answer that was recorded was inverted, nothing anywhere read the resulting flag, and a sign-in that asked to be remembered could not clear an earlier decline — four independent breaks on one path, each sufficient alone. Declining now yields a session cookie the browser discards when it closes; accepting keeps today’s behaviour, up to the configured sign-in lifetime. The choice survives a one-time-code step, and a path that offers no checkbox — federated sign-in — still keeps the sign-in. Nobody who leaves the box ticked sees any change. (spec 049, issue #45)
0.3.0 - 2026-09-14
Three things define this release. The server runs on PostgreSQL as well as MongoDB, behind a
schema-migration layer that gates startup on either. The OpenID Foundation conformance suite was run
against it for the first time — twelve plans, 16 680 conditions — and the twelve defects those runs
found are fixed here, from the PAR response’s content type to the code_verifier alphabet, with
CONFORMANCE.md recording where it stands and the five that are still open. And a server setting
now takes effect when it is saved, without a restart.
Added
-
admin: a server setting now takes effect when it is saved, without restarting the server. Every setting carries a class on its console descriptor — applied on save by default, or waiting for the next start with a written reason — and the console’s standing “waiting for a restart” banner is replaced by one that names only the settings genuinely waiting, of which there are none today. The settings state reports what is in force on the instance that answered, so a deployment running more than one instance is told which values that instance is actually running; the agent-facing surface reports the same. A refused submission still changes neither the store nor the running server, and a change that would leave a running process holding a configuration it could not have booted with is withheld rather than half-applied. Fixes the Sentry card, which claimed “applies immediately” while its own endpoint reported the opposite.
-
conformance: the server now reports which authentication context a sign-in satisfied. A password sign-in, one with a second factor and one delegated to an upstream provider are distinguished, and the
acrValuessetting changes from a free-form list to a map from those three to the value each is reported as — so an operator can name the vocabulary their relying parties already expect, whileacr_values_supportedis derived from those values and cannot advertise a context no sign-in produces. Two behaviour changes an integrator could notice:acr_values_supportedand theacrentry inclaims_supportednow appear where they were absent, and an unmeetable essentialacrrequest answers with the registeredunmet_authentication_requirementsinstead of looping on the login page — withprompt=nonethat replaceslogin_required. On the backchannel path the same requirement, unmet, ends the transaction astransaction_failedrather than issuing a token whose context does not match. A request that asks for no context is unchanged. -
conformance:
pkce.required, a super-admin setting that relaxes the proof-of-possession demand for clients which authenticate at the token endpoint. On by default, so nothing changes for a deployment that leaves it alone. It exists because the OpenID Connect Basic profile sends a code challenge in one of its 35 modules, so the other 34 were refused before they tested anything, and OAuth 2.1 §7.5.1.1 provides for exactly this carve-out — for a confidential client only. A client registered with no client authentication is still refused for omitting a challenge whatever the setting says, which keeps this server’s own public console and agent clients protected by an operator’s conformance choice. -
conformance:
CONFORMANCE.mdrecords what the OpenID Foundation suite says about this server — twelve plans and 16 680 conditions across two instance profiles, the nine OpenID Provider plans among them carrying no failure attributable to this server, and the five defects still open written out with the evidence for each. It also records the setup a run needs, because most of the cost of a conformance run is not the run. Certification has not been applied for;SECURITY.mdand the assurance page now say that, rather than that the suite has never been run. -
test: coverage for six properties the suite claimed and did not hold — the MongoDB secret round trip through the real BSON codec, the PostgreSQL document-column encoding, the timing of both constant-time secret comparisons, the discovery document following
ISSUERrather than a forwarded header, and every published agent tool having a route the dispatcher serves. Each was verified by reintroducing the defect and confirming the gate fails. -
test: a request to an origin no spec registered used to reach the real network; it is now refused by name, with interception installed for every spec so the runner’s file order stops deciding it. A per-case bound also means a wedged case names itself in twenty seconds instead of idling in silence.
-
storage: PostgreSQL is now a supported datastore alongside MongoDB. A deployment picks one by which connection string it sets —
POSTGRES_URLorMONGODB_URI— and setting both is refused at startup rather than resolved by precedence, since a server that quietly chose the other database is indistinguishable from total data loss.bun run db:setup:pgprovisions the schema and--checkreports on it without writing; documents are stored asjsonb, and expired rows are reclaimed by a sweeper because PostgreSQL has no TTL index. Nothing above the storage layer changed, and existing MongoDB deployments are unaffected. -
storage: a schema-migration layer for both backends, applied by
bun run db:migrate(--planto gate a deploy). Migrations are declared as an ordered set with a checksum, recorded as they run, and serialised by a renewable lease so two replicas rolling at once cannot both apply a step. The server refuses to start against a database that is behind, ahead, or holding a record for a migration whose declaration has since changed. The one-offmanagedBy → ownerGroupIdconversion has been retired rather than carried into it: a deployment predating ownership groups must upgrade through an earlier release first. -
ops:
GET /readyjoinsGET /health, splitting “don’t route to me” from “restart me”. Liveness answers from the process alone and stays exempt from the rate limiter; readiness reaches the datastore, is metered aspublic, holds its last answer for a second so a probe storm cannot become database load, shares one outstanding probe rather than starting one per caller, and recovers on its own. The probe carries its own five-second deadline, because a driver’s timeout bounds establishing a connection and not a query on one it already holds: measured against a database that was up, connected and no longer answering, an unbounded probe took 30 seconds to report anything. Pointing both probes at/healthturns a database outage invisible, since the process is alive and the probe passes while requests keep arriving; pointing liveness at/readyrestarts healthy processes for a database’s outage. Both are the mistake this split exists to prevent. -
ops: two Fly configurations instead of one.
fly.tomlnow describes a deployment that must always answer — machines are never stopped for want of traffic, deploys are blue-green so a single-machine release costs no downtime, and both probes are wired — while the newfly.conformance.tomldescribes a separate app for the OpenID conformance suite that stops when idle and keeps its own database.ISSUERandDATABASE_NAMEmoved out of secrets into each file’s[env], since neither is a credential and a config that names its own issuer lets the deploy workflow check the advertised one against it. The workflow takes the target as an input, and the release command now runsdb:migrateafterdb:setupso a release carrying its first migration cannot deploy an image that refuses to boot. -
security: the release assets now carry provenance of their own.
docs-export.jsonand theCHANGELOG.mda release ships are covered by a single signed SLSA v1 statement, produced the same keyless way as the image’s and attached to the release asrelease-assets.intoto.jsonl, so a download can be verified without reaching back to GitHub for the statement; the release fails if the bundle it is about to attach does not verify. The previous argument — that documentation nobody executes needs no signature — held for the changelog but not for reference data another project’s build reads, which is worth a statement naming the commit and the run behind it.
Changed
- site: a copy-edit of the marketing, comparison and documentation prose for voice (spec 048).
Sentence rhythm now varies, em-dashes are roughly halved, the repeated “X, not Y” contrast is
thinned, and judgments (advice, a diagnosis, an assessment of another product) carry a hedge
where statements of what the server does, a protocol rule or a licence term do not. No
instruction, command, protocol statement, numeric claim or quoted competitor text changed; the
build’s title, description, FAQ and datastore rules all still pass.
website/DESIGN.md§Voice records the conventions so a new page does not reintroduce the habits.
Fixed
-
protocol: a request parameter the server does not define no longer refuses the request. Ignoring one is a
MUSTin RFC 6749 §3.1 and §3.2, RFC 8628 §3.1 and CIBA §7.1, and RFC 9126 §2.1 inherits it for PAR — so/auth,/par,/device/auth,/backchanneland/tokenall answer a request carrying an extension parameter exactly as they answer one without it, and a Request Object may carry extension claims as RFC 9101 §4 permits. The undeclared keys are dropped before validation rather than carried, so nothing a client invents is persisted in a pushed request object or an interaction record. Because absence from a schema now means “ignore”, parameters this server must reject are declared explicitly instead:request_uriat the pushed endpoint,requestandrequest_uriinside a Request Object, andauthorization_detailsat the token endpoint, where silently ignoring a client’s attempt to narrow its grant would return a broader token than it asked for. Elysia’s ownnormalizewas measured as the alternative and rejected: it also cleans request headers, which strips the client certificate and breaks certificate-bound tokens. -
claims: a
claimsrequest value carrying a top-level member the server does not define is ignored rather than refused, at every surface that accepts one. It was declared a closed object, so a value namingid_token,userinfoand anything else was rejected outright where OIDC Core §5.5 says other members MAY be present and unrecognised ones MUST be ignored — the defect fixed in1437341for request parameters, one level deeper, inside a parameter’s value. The refusal also named the wrong cause: one error string covered every object-level failure, so it reporteduserinfoandid_tokenas unsatisfied while both were present. Unknown members are now dropped before anything persists the request, so a client cannot make the server keep arbitrary content. -
protocol: a request the schema refuses now answers
400, not the framework validator’s422. The body was already correct (invalid_requestwith a description); only the status was the framework’s rather than the protocol’s, and RFC 6749 §5.2 defines it as400. Scoped to the OAuth endpoints: the admin API and/mcpkeep422, where distinguishing a well-formed request with an invalid body is what the console and the agent act on. -
pkce: a
code_verifiercontaining.or~is accepted. RFC 7636 §4.1 defines the verifier over RFC 3986’sunreservedset, which includes both; the pattern here was base64url, the alphabet the challenge is encoded in and the one this project happens to generate verifiers with. A client whose verifier used the full range was refused at schema validation before the grant ran, so its code could not be redeemed by any verifier — and since PKCE is mandatory here, that client could not use the authorization code grant at all. This is the second correction to the same pattern after its length, and both survived for the same reason: nothing this server or its suite produces is outside base64url, so it could never trip over itself. -
par: a successful push answered with a JSON body and no
Content-Type, which goes out asapplication/octet-stream, and with status 200 rather than the 201 RFC 9126 §2.2 requires — a client that checks the media type before parsing, as a FAPI client must, got nothing usable out of therequest_uri. Only the success path was affected; refusals were already correct. The handler now returns the object and lets the declared response schema serialize it, which also puts that schema to work for the first time: it was bypassed by the hand-built response, and thestatus: 201beside it was never a hook Elysia reads. -
par: a pushed
request_uriis spent by the authorization response it produces even when the user had to sign in or consent along the way. One-time use was implemented and correctly placed, but the lookup that finds the pushed request after an interaction readparJtias a top-level property where the value lives on the payload, so it silently found nothing and the record was never marked. In practice arequest_urisurvived its own flow and was good for one more authorization — the replay RFC 9126 §7.3 describes. The same read appeared in the carry-forward between interactions, so a sign-in followed by a consent lost the link entirely. Four reads corrected; no new mechanism. -
userinfo: a request carrying no credentials is answered the way a protected resource must answer one —
401with aWWW-Authenticatechallenge, and no error information at all. It was being treated as an ordinary schema refusal, so the caller got a status RFC 6750 does not use and no challenge to act on; the challenge advertisesDPoPalongsideBeareronly when DPoP is switched on. A credential that is present and unusable is unchanged: that one is refused withinvalid_tokenand keeps its error body. -
userinfo: the access token may be presented in a form-encoded POST body, which OpenID Connect Core §5.3.1 describes alongside the header form. The body form is honoured only under the conditions RFC 6750 §2.2 attaches — a POST whose body is form-encoded — and a request that uses both methods at once is refused with the
invalid_request§3.1 specifies; the query-parameter form of §2.3 is still not implemented, since OAuth 2.1 removes it and a token in a URL reaches access logs and browser history. A DPoP-bound token cannot be presented this way, because RFC 9449 defines no body form for it. Two consequences of the same change: a request carrying no credential at all is still answered with the bare RFC 6750 §3 challenge, now raised by the handler rather than by the header schema, which can no longer require the header; and the route’s DPoP proof check assertedhtm: "GET"whatever the method was, so a conforming proof on aPOST /userinfowas refused as anhtmmismatch — an existing defect nothing had exercised. -
jwks: key generation offers every asymmetric signing algorithm the server knows —
RS256/384/512,PS256/384/512,ES256/384/512,EdDSAandEd25519— in the console and through the agent tool, read from the algorithm register rather than restated beside it. It offered only the three RSARS*algorithms, which meant a FAPI 2.0 deployment could not be assembled through this server’s own management surface at all: the profile requires PS256 or ES256, and the operator had to write a key straight into the key store. An existing RSA key is still not made to sign PS256 — RFC 7517 §4.4 makes a key’salgthe algorithm it is intended for, and honouring a key past its declared intent would let a deployment that pinned a key to one algorithm quietly use another. Generating the key you need is the route. The key page also now says when a generated algorithm is not yet advertised: discovery’s algorithm lists are built at startup, so such a key signs immediately but no client asks for it until a restart — previously the page reported no restart required, which was true of the key and misleading about the deployment. -
conformance: two findings from the OpenID Foundation suite runs. The
form_postdelivery page submits itself with a classic script placed after the form rather than a module script in<head>, so a user agent that runs scripts but not ES modules — some embedded webviews, and the suite’s own scripted browser — is no longer left on a page with no way forward at all; the module-capable and scripting-disabled paths are unchanged. And a pushed authorization request refused for an unregisteredredirect_urinow answersinvalid_request, orinvalid_request_objectwhere the value arrived inside a request object, instead ofinvalid_redirect_uri— a dynamic-registration code (RFC 7591) that has no definition in a PAR response, where RFC 9126 §2.3 prints the former. The authorization endpoint and dynamic registration are untouched. A third item reported alongside them — that client assertion audiences are accepted too widely — was investigated and is not a defect: RFC 9126 §2 requires a non-FAPI deployment to accept its issuer identifier, token endpoint URL and PAR endpoint URL alike, and the narrow FAPI 2.0 rule was already implemented and is now covered for all three audience shapes.CONFORMANCE.mdrecords the correction and namesfapi.enabledamong the settings a FAPI conformance target needs.
0.2.0 - 2026-09-08
Two things define this release. The server is now an authorization server for third-party MCP servers rather than only for its own administrative plane, and SECURITY.md has evidence behind it: a written threat model, a scanning pipeline, and a container image that is signed and ships an SBOM and build provenance. A plain OAuth 2.1 client can also discover a deployment at last, through RFC 8414 metadata.
Added
-
mcp: the server is now an authorization server for MCP servers, not only for its own admin plane. An administrator declares a third-party MCP server as a protected resource of a project — its canonical identifier, the scopes it recognises, how its tokens are verified, their lifetime — and the token endpoint mints audience-bound tokens for it with no source change and no restart; previously every audience but
${ISSUER}/mcpwas refused, so protecting one meant writing an addon override. A client with no prior relationship gets its project from the declared resource the request names, which is what lets it sign in the right deployment’s end-users. Client ID Metadata Documents are supported as the mechanism the current MCP authorization revision names first: aclient_idthat is an https URL resolves by retrieval and validation and creates no client record (clientIdMetadataDocument.enabled, off by default). The administrative plane admits document identifiers through a super-admin allowlist read live, so a withdrawal takes effect on the agent’s next call; a dynamically registered client can never administer the instance. A new guide, Protect your MCP server with OAuth, walks it end to end, and the README’s compatibility note is replaced by which identity works where. -
security: published evidence behind SECURITY.md, which until now was a policy with nothing standing behind it. A written threat model names the assets, trust boundaries and attackers (RFC 9700 §3) and, for each threat, the control in the code and the test that holds it — including the limitations that are true today (secrets unencrypted at rest, CSRF resting on
SameSite=Strict, the image running as root), because a threat model that lists only the good news is not one to plan around. ASecurityworkflow runs CodeQL (TypeScript and the workflows themselves),bun auditon both lockfiles failing on high, dependency review on pull requests and a Trivy scan of the built image, on every push and weekly; aScorecardworkflow publishes an OpenSSF Scorecard; a Dependabot configuration keeps the lockfiles and actions current. An assurance page says where each result is read and states plainly what does not exist: no external audit, no paid bounty, no OpenID Foundation certification. SECURITY.md gained the same section and the README the badges. -
security: the published container image is now signed, and ships an SBOM and build provenance.
release.ymlsigns it withcosignover the digest — keyless, so verification names this repository, workflow and tag instead of a key we ask you to trust — and BuildKit attaches an SBOM and amode=maxprovenance document to the pushed index that the signature covers; a signed SLSA v1 statement also goes to a transparency log forgh attestation verifyand admission controllers. The release verifies its own signature before finishing, so an unverifiable one fails the release rather than reaching an operator. The SBOM matters here because theDockerfilerunsapk upgrade, which makes the package set unrecoverable from the repository. Release assets stay unsigned on purpose: documentation, not something anyone executes. Verification commands are on the assurance page. -
protocol: authorization server metadata at
GET /.well-known/oauth-authorization-server(RFC 8414, spec 038, issue #30). A plain OAuth 2.1 client that is not an OpenID Connect relying party looks for this path and no other, so until now it could not discover a deployment at all — the endpoint URLs had to be configured by hand, one path away from where the server was already publishing them. Served unconditionally, readable from any origin and in the public rate class, for the reason the OIDC document is: metadata a client cannot read is metadata it cannot use.The document is pruned rather than a copy. Both documents come from one builder, so they cannot disagree on a shared member, but the OAuth one drops the fourteen members whose subject matter is OpenID Connect — the userinfo endpoint and its algorithms, ID token algorithms, subject types, ACR values, the claims members, RP-initiated logout and back-channel logout. Membership is decided by the registering specification, never by presence in the IANA registry: RFC 8414 invites other specifications into that registry and OIDC Discovery accepted, so all 51 members are registered there and the obvious check would have admitted the exact document the pruning exists to avoid. Twelve members an OpenID specification registered are kept anyway, because an OAuth-registered member is unreadable without them — request-object algorithms beside RFC 9101’s
require_signed_request_object, the CIBA endpoint beside the CIBA grant ingrant_types_supported, the JARM algorithms beside thejwtresponse modes. Each records that anchor as data, and a guard fails naming the member if the anchor is removed or reclassified, so the justification cannot outlive what it depends on. -
site: the SEO guardrail now checks that structured data is present, not only that it is correct (spec 037). It could tell whether a description was well-formed and truthful but not that one should exist, which is how the comparison pages shipped with no article markup past twenty passing rules.
STRUCTURED_COVERAGEsays what each kind of page must carry, an unclassified route fails the build naming the file to edit, and both were shown failing before being trusted.Around that: the comparison pages went from one inbound link each to twelve, anchored on the competitor’s name and derived from the collection so a new one appears everywhere on arrival; the pricing and comparison pages publish their questions and answers as structured data from the same array the page renders, so the two cannot drift and the existing overclaim rule proves it; the documentation index grew from 30 words to a real orientation page and the comparison index gained a prose summary; and
llms.txt, generated in the previous release and pointed at by nothing, is now named inrobots.txtand linked from the footer. Claims about other products are reported as due for review after 180 days, in the build log and on the page itself — without failing a build, because staleness is the passage of time rather than a mistake to block on. Three comparisons were added — Ory Hydra, Zitadel and authentik — each researched against that product’s own documentation, recording a capability their documentation does not describe as not documented rather than as absent. -
site: search and AI discoverability, enforced by the build (spec 036). Every page now carries a checked title and summary, a per-page
lastmodfrom git, structured data and its own social card — documentation pages included, which had none of it because Starlight builds its own head. For assistants,/llms.txtlists every page where it listed none,/llms-full.txtcovers the marketing and comparison pages it previously omitted, each page is available as Markdown at its address plus.md, androbots.txtnames ten AI crawlers with the source and date each was checked against. Screenshots are declared in an image sitemap.Indexing is now one decision in
website/src/data/seo.ts— the styleguide used to be excluded by anoindexin one file and an unrelated string match in another — and twenty rules check the shipped HTML, failing the build with the page and the rule. Two defects it caught on its first run are fixed: the licence page had two top-level headings, and the settings reference skipped a level.
Fixed
-
security: three latent injection sinks closed, none of them reachable today, which is the only reason this is a hardening note and not an advisory. The
WWW-Authenticatechallenge escaped the quote in a parameter value and left the backslash alone — the one combination that fails, because a value ending in a backslash then escaped the closing quote instead: the quoted string ran on and everything after it parsed as further auth-params. Values are now stripped rather than escaped, which is what RFC 6750 §3 asks for (NQCHAR holds neither character), and nothing dynamic reaches a 401 description today only by accident. The rendered error page and the device pages interpolated their document title raw, where the plain interaction pages already escaped the same position; there is now one escaper inlib/html/escape.tswith all four callers on it, rather than one file getting it right and its neighbours not.The reason the title mattered more than an unreachable sink usually does: the content security policy is derived from the finished document, so an injected inline script would have been hashed and then authorized by the very header meant to stop it. That chain is now pinned by a test that was watched failing — it emitted a
sha256-foralert(1)— rather than left as an argument.Separately, every tag and attribute matcher in the policy derivation now reads a document by the grammar a browser uses rather than a stricter one. Three spellings of that mistake were found, and each of the last two only after the previous was fixed: a tag name is case-insensitive, so
<SCRIPT>was a different tag to the deriver than to the parser; and an end tag may carry both whitespace and attributes, so</script >and</script foo="bar">ended a script everywhere except here. All three failed the same way — the block went unrecognized, its hash was never issued, the browser blocked a script the page still believed it served, and the page rendered perfectly with the capability silently gone. None is a hole; all are the kind of wrong that reports itself nowhere.The end-tag pattern is now taken from the corpus the analyser itself checks a regex against rather than reasoned about one spelling at a time, which is what produced two of the three rounds. It deliberately does not accept
</scriptfoo>— a tag namedscriptfoo— since reading that as a close would hash the wrong span rather than none. -
test: the invariants behind the escaping and the policy derivation are now checked against generated input rather than examples (
test/properties/invariants.spec.ts,fast-checkas a dev dependency). Both had just cost three rounds each of the same shape: a fix was written for the one spelling that had been reported, the suite went green, and the next spelling arrived on the next scan. An example answers “does it handle this?”; these state what is true of every input — a challenge carries no character that could end a parameter early, every inline script a document serves is authorized by the policy derived from it whatever the tags are spelled,</scriptfoo>is never read as a close, the escaper leaves nothing that could escape its position, and a base32 secret survives being retyped in a different case with spaces in it.Each property was checked by reintroducing the bug it exists to catch, which is the only way to know a property has teeth — and the first draft did not. It passed against the restored escaping bug, because a full-Unicode generator has no reason to favour one ASCII character and that invariant is about exactly two of them; five hundred runs produced no
"at all. Generating from the characters that actually break the function fails it on the fifth case and shrinks the counterexample to a single quotation mark. The lesson is in the file, next to the generator. -
build: the container image no longer ships known-vulnerable OpenSSL, and the base it inherits is now a reference rather than a moving target. One package accounted for twenty of the twenty-five open scanner alerts —
libssl3/libcrypto33.5.7-r0, two high, six medium, twelve low — and the standing plan of waiting for the base image to be rebuilt turned out not to work: measured on a fresh pull,oven/bun:alpinestill carried 3.5.7-r0 while Alpine’s v3.22 repository already served the fixed 3.5.8-r0, so cutting a release would have rebuilt from the same vulnerable layer.FROMis now pinned by digest, and to a versioned tag rather than the floatingalpine, because that is the only form anything keeps current: Dependabot moves a digest when the tag’s version changes and has no mechanism for “same tag, newer digest” (dependabot-core#1971), soalpine@sha256:...would have frozen the image at the day it was written — which is exactly why it had been left unpinned. Adockerentry in the Dependabot configuration keeps the pin moving, andapk upgrade --no-cachetakes the distribution’s patches at build time rather than inheriting the unpatched half of the digest. Reproducibility over time is the deliberate cost; within a run the scan and the release still build identically, andbun.lockstill pins the application’s own dependencies. Verified with the Security workflow’s own Trivy flags: zero fixable findings at every severity, down from twenty. -
ci: the release workflow no longer grants every job write access.
contents: writeandpackages: writesat at the top level, so the test job — which runs whatever a version tag points at — held a token that could push to the repository and publish to the registry, for no reason beyond the two jobs beside it needing one each. Read at the top, write only where a write happens, the shape the security workflow already used. CodeQL also stops scanningtest/: thirteen of twenty high-severity alerts were test assertions — aredirect_uriwritten as a regex with an unescaped dot, a<script>string a CSP test looks for, a password hashed by a fixture — ranked beside two real findings inlib/that nobody could see for the noise. An alert list is a queue, and a queue that is two-thirds false is not read. -
ci: a commit that touches only the console’s React components no longer fails the coverage gate. Those components are pulled into the coverage run by the shell’s module graph but never rendered by it, so they report between 1% and 10%, and their 5,923 lines drag the project number from 97% to 82% — which Codecov then holds every other commit to. One duly failed at 63% of a diff that was a single
.tsxfile. Acodecov.ymlignores the browser bundle and changes no target, because every commit scored on server code has hit 100% of its diff; the two modules beside those components thatbun testdoes exercise stay measured. They are verified where they run, by the Playwright capture in the site build. -
test: the login throttle specs no longer sit one second away from bun’s 5s default timeout. Each spends the failure cap and each attempt costs an argon2 verification, so the file runs at roughly 3s per test and contention from the rest of the suite decides the rest — which it did once, and a timeout is reported as a failing assertion, reading like a broken throttle rather than a slow one. The budget is stated once at the top of the file.
-
site: the home page no longer scrolls sideways on a phone. Below the
lgbreakpoint the hero grid declared no base column count, so it formed a single implicitautotrack sized to its content — the page laid out 839px wide inside a 375px viewport, with the headline running off-screen. A grid track’s automatic minimum ismin-content, so one unbreakable string sets the width of the whole page;overflow-x-autoon the element does not save it, because by then the ancestor track has already grown. Every grid now carries an explicitgrid-cols-1, the settings reference usesminmax(0,1fr)rather than1frfor the same reason, and long identifiers in prose break instead of widening the page. Three pages were affected, the settings reference and the changelog beside the home page; all thirteen now measure no overflow at fourteen widths from 320px up, with the desktop layout unchanged.
Security
-
ci: every GitHub Action is pinned to a full commit SHA with the version beside it in a comment, where all twenty were previously mutable tags and one — the Fly deployment’s
setup-flyctl— was themasterbranch. A tag is a pointer its owner can move, so the workflows granted whatever those repositories held at the moment a job started, including the ones holding the registry credential and the release token. This is the OpenSSF ScorecardPinned-Dependenciescheck, which scored 0 against the badge the README publishes, and the gap was invisible in review precisely because a version tag reads like a version. Dependabot updates a pinned SHA and its comment together, so the pins stay current rather than merely frozen.Landed with the five action upgrades this made necessary to resolve —
checkoutto v7.0.1,upload-artifactto v7.0.1,upload-pages-artifactto v5.0.0,metadata-actionto v6.2.0 andaction-gh-releaseto v3.0.3.upload-pages-artifactstops including dotfiles as of v4, which the site build does not emit;deploy-pagesstays on v4, which that version still requires.
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)