Changelog
v2.6.2 (2026-10-21)
Rebuilds the Docker image on a fresh node:22-alpine base for its security fixes.
Fixed
- The container healthcheck probes
127.0.0.1instead oflocalhost. BusyBoxwgetresolvedlocalhostto::1without falling back to IPv4, so the check failed with "Connection refused" whenever the server listened on IPv4 only (e.g.HOST=0.0.0.0). Applies to theDockerfileand both Coolify compose files.
Other
- The Coolify compose files declare port 3000, which silences Coolify's "Unrecognized internal port" warning. The port stays internal.
v2.6.1 (2026-10-20)
Fixed
- The UI switches to the user's language right after sign-in. A user whose stored language differs from the sign-in page's (a new device, or a language an admin set for them) saw their account page in English until they reloaded. It now comes up in their language straight away, and a server that had not yet rendered that language no longer falls back to English for it.
v2.6.0 (2026-10-20)
Added
- Admins can set a user's language. The user detail page has a Language select next to the name fields, shown when
NUXT_SUPPORTED_LOCALESlists more than one language. The choice decides the user's transactional emails and their UI once signed in. Automatic clears it, so cookie,Accept-LanguageandNUXT_DEFAULT_LOCALEdecide again.PATCH /api/admin/users/:idtakeslocale(a supported tag ornull, anything else → 400) and the change is logged inadmin.user.updated. See Admin → User detail.
Fixed
- Saving on the admin user page blanked the language and the suspend state until a reload: PATCH returned a narrower object than GET. Both now return the same one, including
supportedLocales. POST /api/admin/usersaccepted any string aslocale. It now has to be one ofNUXT_SUPPORTED_LOCALES, like everywhere else.
v2.5.0 (2026-10-20)
Fixed
prompt=consentno longer loops on the account chooser. An app that sent it never got a code: every pick of the account opened a fresh interaction. Schleuse still has no consent screen; picking the account is the consent, withprompt=consentas without it.
Changed
- Redirect URIs are matched exactly (oidc-provider 9.11.4+).
redirectUrisandpostLogoutRedirectUrisare compared character for character:https://app.exampleno longer matcheshttps://app.example/, nor does a different host case, an explicit default port or other percent-encoding. A client that only matched after that normalisation now fails withinvalid_redirect_uri. Copy the value the app actually sends. See OIDC → Client configuration. NUXT_SMTP_REQUIRE_TLS=autokeeps the SMTP login off a plaintext session (nodemailer 10). When the relay advertises STARTTLS and then rejects it, mail still goes out unencrypted, but the credentials are not sent, so a relay that requiresNUXT_SMTP_USERlogin refuses it. A relay that doesn't advertise STARTTLS at all behaves as before.- Node ≥ 22.21 is required (the Nuxt 4.6 CLI). The Docker image already ships a newer Node 22.
/metricsgains two default series,nodejs_eventloop_utilization_histogramand_summary. Nothing is renamed.
Internal
- Every dependency is on its latest version: nuxt 4.6, oidc-provider 9.12, zod 4.6, @simplewebauthn 14, better-sqlite3 13, nodemailer 10, argon2 0.45, knex 3.3, vitest 5. TypeScript stays on 6.x, since 7.x ships no compiler API for vue-tsc and the editor tooling.
prom-clientis deprecated upstream and is replaced by its renamed successor@prometheus-io/client.- New password hashes write their argon2 parameters as
m,p,t. Existingm,t,phashes keep verifying. - Passkey registration keeps offering EdDSA, ES256 and RS256 only, so the offer does not depend on whether the host's Node supports ML-DSA.
v2.4.1 (2026-10-20)
Fixed
- Magic link: no second click. Choosing Email me a sign-in link after entering the address on the sign-in page now sends the link straight away (or as soon as the CAPTCHA is solved), with the address locked. Opening
/magic-linkdirectly is unchanged.
v2.4.0 (2026-10-20)
Added
- Sign in with a magic link. Step 2 of sign-in offers Email me a sign-in link: a single-use link, valid for 15 minutes, that signs the user in without a password.
- It exists only with SMTP configured, and is on by default. Upgrading an instance that sends mail therefore adds a sign-in method; turn it off with
NUXT_MAGIC_LINK_ENABLED=falseif a mailbox alone must not open an account. - It never bypasses TOTP: an account with an authenticator app still enters its code. The emailed code is not offered as a second factor after a link.
- The link opens a confirm page and a GET never signs in, so mail scanners neither burn nor use it. The page signs in by itself after a 5-second countdown, but only in the browser that requested the link. Anywhere else, the user has to press the button.
- No mail is sent to unknown, unverified or suspended accounts, and the answer is the same in every case. A password change, force-logout or suspension voids every outstanding link.
- New email template (
NUXT_EMAIL_MAGIC_LINK_*), audit and webhook eventsmagic_link.requested/magic_link.login, andmagicLinkEnabledon/api/ready. - See Security → Magic-link sign-in.
- It exists only with SMTP configured, and is on by default. Upgrading an instance that sends mail therefore adds a sign-in method; turn it off with
v2.3.1 (2026-10-18)
Fixed
- Requests that asked for an HTML page still dumped the raw error to stderr.
- A request whose
Acceptincludedtext/html,/api/**included, went through Nuxt's error handler. That handler bypassed the suppression from 2.x, so for example a failed login against a broken database wrote the submitted address unscrubbed. - In production nothing is dumped any more, whichever format the request asks for.
- Responses and the error page are unchanged, and development keeps its stack trace.
- See Security → Logging.
- A request whose
v2.3.0 (2026-10-18)
Added
schleuseuser CLI in the image and the repo, for a locked-out admin, scripted setups and quick operations without a browser:docker compose exec app schleuse user list|show|create|set-password|admin|verify-email|suspend|unsuspend|logout|reset-2fa|delete(pnpm build && pnpm cli user …in a checkout).- It runs in-process against the configured database, opens no port and runs no migrations.
- Every change is audited like its admin-panel twin (
via CLI). - The last admin can be neither demoted nor deleted.
- Passwords are generated or read from stdin, never taken as an argument.
- See Console (CLI).
- Themed error page.
- Unknown URLs and emailed links whose handler fails (e.g. an expired verification link) render in the operator's theme, with a localised title per status class and a way back to sign-in, instead of Nuxt's unstyled default.
- JSON error responses are unchanged.
Fixed
- Erasure left a deleted user's address in some audit rows. Rows written without an actor (
bootstrap.admin.created, CLI changes) were never scrubbed. Earlier deletions are not covered, seedocs/FOLLOW_UP.md. - Sessions minted by accept-invite, password reset and verify-email signed a fixed 15-minute JWT under a cookie that followed
NUXT_SESSION_MAX_AGE_MINUTES. Both now follow the setting. - Passkey login now rotates the CSRF token, like every other login path.
- Switching accounts inside an OIDC interaction (
prompt=login→ "use another account") no longer fails with400 interaction session and authentication session mismatch. - The
admin.user.deletedaudit record is written in the same transaction as the erasure. - A visitor without a
localecookie no longer sees the page switch language after load. It used to switch from their browser language toNUXT_DEFAULT_LOCALE. - 500s that should have been 4xx:
- a token of the wrong type is now a
401; - deleting an unknown user id is now a
404; - a malformed
/api/me/referrerquery is now a400.
- a token of the wrong type is now a
- Smaller fixes:
- The audit-log event filter lists every event.
- Page titles are consistent.
- The mobile admin drawer closes on a logo tap.
- Two i18n keys were missing.
pnpm db:migrate:statusworks again.
Other
- Internal simplification: net −1,200 lines in
server/andapp/, with shared helpers for request parsing, session minting, guards, CSRF fetches, the auth flow and passkey ceremonies. No intended behaviour change, except that admin dates now follow the UI language and/metricsaccepts extra whitespace in the bearer header.
v2.2.0 (2026-10-17)
Added
- CAPTCHA on sign-up and forgot-password.
NUXT_PUBLIC_CAPTCHA_PROVIDER=turnstile|recaptcha|hcaptcha|capplusNUXT_PUBLIC_CAPTCHA_SITE_KEY,NUXT_CAPTCHA_SECRETand, for Cap,NUXT_PUBLIC_CAPTCHA_CAP_URL. Off by default — an untouched deployment is unchanged and loads nothing new. Gates/api/auth/register,/api/webauthn/signup/start,/api/auth/password/forgotand/api/auth/resend-verification(callers must sendcaptchaToken); sign-in is not gated. Verification fails closed, including reCAPTCHA's over-quota "success"; a half-configured provider refuses to start. Cap is the self-hosted option: widget and solver load from your own instance, so the sign-up page stays free of third-party requests. See Configuration → CAPTCHA. - The address typed on the sign-in page is prefilled on Forgot password (and back) — held in memory, never put in the URL.
v2.1.0 (2026-10-04)
Added
- The logo on the account page now links to
NUXT_PUBLIC_HOME_REDIRECT_URLwhen set — same destination as visiting/directly.
v2.0.0 (2026-10-03)
A security release: a full-app security review (the report ships in the repo, docs/reviews/security-review-2026-10-02.md) and the eleven work packages that close every finding, plus one follow-up fix the re-review surfaced. Two deliberate breaking changes and one changed default — read Breaking before deploying. The database migrates itself (one column widened); in-flight email links keep working across the upgrade.
Breaking
JWT access tokens now require a per-client allowlist. A client may only request a
resource=audience itsallowedResourcesnames — a client without the field can request none (previously: any configured audience, which let any client mint tokens for any other app's API). Add"allowedResources": ["<audience>"]to each client that uses JWT access tokens; an audience no client lists is named in a startup error with the one-line fix. Clients created by the Service-Client wizard were always pinned and are unaffected. New optionalgateClientIdon a resource entry enforces that app's login role gate before a token for its audience is issued — including on authorizations that skip the login page entirely.Login responses no longer echo the session JWT. The httpOnly cookie is the only session transport; the
tokenfield is gone from the bodies of login, WebAuthn login and all three 2FA finishers. The bundled frontend never read it — only API-direct consumers that parsed the body field are affected, and reading it back was precisely the XSS hazard being closed.STARTTLS is now required for SMTP by default.
NUXT_SMTP_REQUIRE_TLSdefaults totrue(wasauto): the mail hop must offer STARTTLS and present a valid certificate. Two different failures, two different fixes — a relay with no STARTTLS needsNUXT_SMTP_REQUIRE_TLS=auto; a relay with a self-signed/expired certificate needsNUXT_SMTP_TLS_REJECT_UNAUTHORIZED=false, which keeps STARTTLS mandatory. A.envcopied from.env.examplepinsautoand is unaffected.With
NUXT_APP_ROLES_REQUIRE_GRANT=true,GET /api/apps/rolesis now grant-gated like the write path (404 indistinguishable from an unknown user). Only deployments that opted into the flag see the change — a grant opt-in that gated writes while reads leaked the same data was not a semantic anyone chose.
Security
Each item links to its record under docs/implementation/.
- OIDC interactions are ownership-bound. The email-driven resume flows (verify-email, password reset, invite) and the login page's own completion call honour an interaction uid only in the browser that was actually routed through
/oidc/authorizefor it. This closes the review's one account-takeover path — an attacker could previously bind your login to an authorization flow they started, via a reset email or a single phished "continue as" click. - Client IPs are resolved once, correctly. Rate limiting, audit rows and new-sign-in detection share one
TRUST_PROXY-gated resolver using the rightmostX-Forwarded-Forhop; hostile oversized headers can no longer make an audit insert fail silently. - Second factors can't be downgraded or silently swapped. The methods a login may complete with are sealed into the pending token (a TOTP account never falls back to mailbox access just because SMTP is configured); replacing an active authenticator app requires a code from the current one; removing the last passkey requires the password or a passkey check; every factor-confirmation oracle shares a per-user rate limit with audited failures; emailed codes are stored hashed.
- Password guessing got strictly more expensive. Per-account lockout (correct password included), shared per-IP buckets no longer reset on anyone's success, passkey-login start is throttled, unknown-email 401s pay the same argon2 cost as a wrong password, and passwords are capped at 256 characters.
- Token hygiene. Reset emails carry a SHA-256 fingerprint instead of the password hash; session JWTs carry a positive
typeclaim no other token kind can satisfy; the pending-2FA token moved out of the URL; all JWT verifies pin HS256; the timing-safe comparator is digest-based. - WebAuthn challenges are single-use. A captured challenge+assertion pair replays zero times — the authenticator counter alone was a no-op for synced passkeys, which always report 0.
- Logout asks before it acts.
/oidc/session/endauto-submits only for a validatedid_token_hintmatching the signed-in user; a bare cross-site navigation gets a confirmation page and clears nothing. RPs: always sendid_token_hintfor a click-free logout. - Headers. A baseline Content-Security-Policy on every response (
form_post-aware under/oidc/) andCache-Control: no-storeon every/api/response. - Admin role/metadata changes are audited as the acting admin; audit-log filters are validated (400, never a database 500).
Fixed
- Expired
oidc_tokensrows are pruned. A periodic sweep deletes rows past their expiry instead of letting the table grow unbounded.
Upgrade notes
- The
email_codes.codecolumn is widened automatically at startup. In-flight plaintext login codes (≤5 minutes old) stop verifying at the deploy — the user requests a new code. - Password-reset links minted before the deploy keep working for their remaining hour; existing session cookies stay valid (the strict session
typerequirement lands next release). - Operators running directly exposed with
NUXT_TRUST_PROXY=falsewill see socket addresses (not spoofed header values) in new audit rows — that is the fix, not a regression.
v1.11.1 (2026-09-28)
Two fixes, no migration and no configuration change. One of them is a silent data-loss bug in the admin Configurator — if you have ever exported a configuration from it, read the first item before your next deploy.
Fixed
The Configurator dropped three OIDC client fields from its export. Editing any client stripped
roleGroup,metaGroupandpkceExemptfrom every client in the generated docker-compose /.env/ shell output. The editor rebuilt each client from a hand-maintained list of fifteen keys while the schema accepts eighteen, and re-serialised the whole list on any edit — so renaming one redirect URI on one app quietly deleted another app's configuration.Each loss is an outage for someone. Without
roleGroup, clients stop sharing a user's role assignment, so existing assignments become invisible and any client withloginRequiresRolerefuses the user. WithoutmetaGroup, an app's stored user metadata is orphaned under the old namespace. WithoutpkceExempt, a confidential client that cannot sendcode_challengeis refused at the authorization endpoint. Nothing warned, because those fields were never rendered on the page.If you deployed a configuration exported from the Configurator, compare it against your source of truth before the next restart — the fix stops future exports losing fields, but it cannot restore one already written.
Fields the editor has no input for are now carried through verbatim, including any added in future versions. The guard is a test that reads the key list off the Zod schema itself rather than copying it, so the two cannot drift apart again.
Nitro's raw error dump put an email address on stderr. Beside Schleuse's own scrubbed, correlated log line, the framework wrote an unstructured
[request error] …line carrying the whole error object — and because Knex interpolates the bound parameters into the message, a failing user lookup carried the address that was tried, and a failing credential write would have carried the password hash. Measured on a production build: four occurrences of the address in one request's stderr, none of them in the pino output.That dump is now suppressed for every request that does not ask for HTML, which is all ordinary API traffic, leaving the scrubbed record as the only error output. The HTTP response is unchanged — the framework's own handler still produces it, byte for byte. Two limits, both deliberate: a request whose
Acceptistext/htmlis still rendered and dumped by Nuxt's error handler, and development keeps the dump because it is the only output with source-mapped stack frames.
v1.11.0 (2026-09-24)
Outbound webhooks: any audit event, to any endpoint, signed. One removal to check before upgrading if you used a client's webhookUrl — see Changed.
Added
Webhooks for every audit event.
NUXT_WEBHOOKS_JSONtakes a list of subscriptions; Schleuse POSTs a JSON body to each one whoseeventsmatch.json[{ "id": "crm", "target": "https://crm.example.com/hooks/idp", "events": ["register", "account.deleted", "admin.user.*"], "secret": "…" }]The vocabulary is not new: subscriptions name audit events, all 40 of them, the same names the admin already shows you. Name one exactly, name a prefix glob that breaks on a dot (
admin.user.*matchesadmin.user.suspended, neveradmin.userdata.changed), or name*. A name that does not exist fails the boot rather than silently never firing.Signed deliveries. Set
secreton a subscription and every request carriesX-Schleuse-Signature: t=<unix>,v1=<hmac>— HMAC-SHA256 over"<timestamp>.<body>", Stripe's shape, over the exact bytes sent. Receivers compare it timing-safe against their own copy of the secret; the Webhooks guide has the ten-line verifier. Without a secret the receiver cannot tell Schleuse from anyone else, which is fine on a private network and documented rather than forbidden.A test button in the Configurator. Admin → Configurator → Webhooks edits the subscription list, generates secrets, and sends a real signed
webhook.testdelivery to one endpoint, reporting status, duration and the first bytes of the answer —POST /api/admin/webhooks/:id/test, admin-gated, CSRF-gated, rate-limited, and matching the id exactly against the configured list rather than taking a URL. A signature you cannot try out is a signature you debug in production.NUXT_WEBHOOK_RETRY_DELAYS_MS— comma-separated backoff before each re-attempt, empty (single attempt) by default. Bounded and in-memory, like the email and back-channel-logout retries: pending retries are lost on restart and there is no delivery table. If you need guaranteed delivery, put a queue behind your endpoint. That is the design, not an oversight.
Changed
- A client's
webhookUrlis gone. It fired on two events, carried no signature and lived in a second place. Everything it did, and thirty-eight more events, is now a subscription inNUXT_WEBHOOKS_JSON. If you set the key on an OIDC client, move it before upgrading: a client still carrying it logs a warning at startup and does nothing else.
Security
- The admin's masked placeholder is no longer accepted as a secret. The Configurator shows
<secret — set on source instance>in place of a real secret, and the exported.envcarried it through as an ordinary value. At 33 characters it passed every length rule, so an operator who edited a subscription and deployed the export unchanged would have signed every delivery with a constant that is public in this repository. Both fields now refuse it by name and say what to paste instead: the webhooksecret, and an OIDC client'sclientSecret— which predates this release and was the worse of the two, since that value authenticates a client to the IdP. Found by a security review of the webhook branch, fixed in the same commit.
v1.10.0 (2026-09-20)
Machine-to-machine tokens, an admin panel that is finally translated end to end, and a front end that stops shipping what you will never read. One behaviour change to check before upgrading — see Changed below, and GET /api/ready in particular.
Added
Service clients — machine-to-machine tokens. A client that declares
client_credentialsnow gets its own access token: no user behind it, no refresh token, no ID token. Itssubis theclient_id, and withresource=<audience>it is a JWT your API verifies exactly like a user's, against the same JWKS.json{ "clientId": "nightly-sync", "grantTypes": ["client_credentials"], "scopes": ["openid"], "public": false, "clientSecret": "…", "allowedResources": ["https://api.example.com"] }The grant is off unless a client asks for it — the client list is the only switch, so it can never be configured-but-dead. Authorisation is by resource-server scope, not by role: a service token cannot reach
/oidc/userinfo, so arolesclaim could never arrive, whileNUXT_OIDC_RESOURCE_SERVERS_JSONalready carries per-API permission names that do. And a public client is refused the grant outright: it authenticates with no secret at all, so the client id alone would have minted tokens. Startup now also names, by client, an unsupported grant type or anauthorization_codeclient with no redirect URIs — configurations that used to boot silently and fail with a400at first use. See Service clients.A guided path for them in the admin. Configurator → New service client asks four things — the service's name, the API it calls, what it may do there, and the token lifetime — then writes both
NUXT_OIDC_CLIENTS_JSONandNUXT_OIDC_RESOURCE_SERVERS_JSON(merging an audience that already exists) and generates the client secret the editor had no field for. It is shown once, and it is the only unmasked value on that page, because a secret nobody can read is not a credential. The Integration page stops handing service clients an authorization URL with an inventedredirect_uriand shows the token request instead.The admin speaks one language. All 88 environment-variable descriptions now exist in German, French and Italian, as do the OIDC client editor, the service-client wizard and the category headings. The exported
.envcarries its comments in the language you are reading. A test fails the build if a new variable ships without its three translations.Configurable session lifetime —
NUXT_SESSION_MAX_AGE_MINUTES(default15) andNUXT_SESSION_REMEMBER_ME_MAX_AGE_DAYS(default7), the values that were hardcoded until now. Token and cookie always share a lifetime and the server re-issues both at half-life, so this is an idle timeout: an active user is never signed out mid-work, an abandoned tab stops working after the configured time.schleuse_build_info{version="…"}on the bearer-gated/metrics— the machine-readable place to read the running version.
Changed
GET /api/readyreturns less. Its body is now exactlystatus,database,oidcConfigured,registrationEnabledandpasswordRules.version,cookieSecureandsmtpEnabledare gone. Nothing in the UI read them; an unauthenticated caller did — the exact release is what turns into a list of published advisories to try. If a dashboard of yours scrapes those fields, move it to/metrics(bearer-gated) before upgrading. The endpoint itself stays open: a readiness probe that needs a credential fails during the outage it exists to report, and the container healthcheck is unaffected.The running version is no longer in an anonymous page. It left
runtimeConfig.publicand the footer of the sign-in card; the account and admin layouts still show it, sourced from the authenticated/api/me.The UI catalogue loads per language and per area.
/loginwent from 139.7 KB to 101.8 KB of JavaScript (gzip): a visitor no longer downloads four languages, nor the admin panel's vocabulary, nor the server-only email copy. Nothing changes for operators. Contributors adding a locale now write three files (app/i18n/<locale>/{base,admin,email}.ts) — see Multilingual.NUXT_OIDC_RESOURCE_SERVERS_JSON'sscopestakes an array too."a b"and["a", "b"]now mean the same thing; the string form stays valid forever. The sibling variable spells a client's scopes as an array, and the two disagreeing was a papercut.
v1.9.0 (2026-09-18)
Three additions, no migration and no configuration change required. Two of them are documentation-site work that ships with the Pages deployment rather than the container; the only change inside the app image is the new pool knob, and it defaults to exactly what was hardcoded before.
Added
The connection pool is configurable:
NUXT_DATABASE_POOL_MIN/NUXT_DATABASE_POOL_MAX. Until nowbuildKnexConfigpinnedmin: 2, max: 10for PostgreSQL with no way to change it. That is fine for one instance and quietly dangerous for several:max_connectionsis a server-wide budget, the default is 100, and ten simultaneously busy instances at the old ceiling exhaust it — after which Postgres refuses new connections for everyone on that cluster, not just for the instance that caused it. If staging, production and another application share a database server, each can now be given a smaller share:bashNUXT_DATABASE_POOL_MIN=0 # a quiet instance holds no connection at all NUXT_DATABASE_POOL_MAX=3Measured against a real cluster under 30 parallel requests: 10 connections with the variables unset, 3 with
MAX=3, and 0 while idle withMIN=0. The defaults are unchanged, so an instance that sets neither behaves exactly as it did in 1.8.x. SQLite ignores both — one file, one connection.A maximum below the minimum is refused before the first request: the process exits at startup with
Tarn: opt.max is smaller than opt.min. Values are bounded at 1000 as a typo guard, not as policy — raise the ceiling if a tuned cluster calls for it. See Configuration → Connection pool.The documentation is now published as markdown, for agents.
/llms.txtis a curated index whose one-line descriptions say what each page answers; every page is also served as its own source (/guide/oidc.md— 36 KB, against 108 KB of HTML for the same page); and/llms-full.txtis all twelve guide and reference pages in one 210 KB fetch.No claim is made about search rankings —
llms.txtdemonstrably does nothing there. It is read agent-to-agent, by the assistants that the people deploying a self-hosted IdP actually have open, and that is the whole reason it exists here.The brand mark in the documentation nav bar. The Kammer mark already led the hero, the tab icon, the login card, the admin sidebar and the email header; the nav bar carried only the wordmark.
Other
- Internal:
docs/FEATURES.mdand the plan/implementation records now cover all three items; no operator-visible effect.
v1.8.1 (2026-09-01)
A bug-fixing round: four fixes, no migration and no configuration change. One of them turns on a documented feature that had never worked, so if you have NUXT_PUBLIC_CUSTOM_DISPLAY_NAME=true set today, expect it to start doing something.
Fixed
NUXT_PUBLIC_CUSTOM_DISPLAY_NAME=truedid nothing. The display-name field never appeared, on any surface, however the variable was set — while Configuration has documented it since 1.0. Nuxt runsNUXT_PUBLIC_*values throughdestr, sotruearrived as a boolean while the code compared it against the string'true'.The half that mattered most was invisible: the same comparison in
PATCH /api/medecides whether the display name is recomposed from first + last. An account that had a custom display name would have lost it the moment the user edited their name. Now fixed at the source — the default is a real boolean — with a guard test so no public flag can be compared against a string again.An unexpected
500gave an operator nothing to debug with. The request log is written when the response finishes, from the status code, so it never saw what was thrown. There is now a second line beside it carrying the error's message, stack and status, stamped with the same correlation ID.It is deliberately not the raw error. Knex rewrites the error message to
<sql> - <driver message>with the bound parameters already interpolated, so an unfiltered line would have put an email address in your log on a failing user lookup and an argon2 hash on a failing credential insert. Quoted SQL literals are replaced with'?'; the table, the columns and the driver's reason survive. Expected4xxresponses are not logged twice.Not fully closed
Nitro's own error handler still prints the unredacted error to stderr, before ours runs and independently of it. Suppressing that means taking over the error response as well, so it is tracked separately. If you ship logs off-host, know that a failing query can carry its parameters into them.
Two simultaneous registrations of the same address returned
500. The duplicate check and the insert are separate steps, and with password hashing between them the window is wide. The loser hit the database's unique constraint and reached the client as an unhandled error instead of the ordinary "email already registered". Now handled on both PostgreSQL and SQLite — the two report a constraint violation quite differently — and on all three paths that create a user: self-registration, passkey signup and admin create-user.Some
404responses said it twice —"User not found not found", and"Client not found not found"from the client-secret endpoint. Thirteen endpoints across the admin, app and provisioning APIs; the message text is the only change.
Other
- PAR is documented. Pushed authorization requests (RFC 9126) have been enabled and advertised in the discovery document since 1.0, and described nowhere. OIDC → Pushed authorization requests now covers what the endpoint does and why a client might use it: the request parameters never travel through the browser, and they are authenticated at the push, so
scopeandredirect_uricannot be altered in flight. Entirely optional — nothing changes for a client that ignores it.
v1.8.0 (2026-09-01)
Two new environment variables and a friendlier password field. No migration, no breaking change — both variables default to off, and every existing deployment behaves exactly as before.
Added
Bootstrap an admin from the environment. The first administrator was the one part of a Schleuse deployment that could not live in a compose file: creating it needed a human with a browser, which an unattended install (CI, Ansible, a k8s manifest) does not have. And with
NUXT_REGISTRATION_ENABLED=falsethere was no route at all — the auto-promotion lives only in the two signup handlers, and the provisioning API deliberately cannot mint admins — so the documented answer wasUPDATE users SET is_admin = true. That advice is now gone from the docs.yamlNUXT_BOOTSTRAP_ADMIN_EMAIL: admin@example.comLeave the password out and a 24-character one is generated and written to the log once, on the single start that creates the account:
bashdocker compose logs app | grep oneTimePasswordSet
NUXT_BOOTSTRAP_ADMIN_PASSWORDinstead if you ship logs off-host — a password you supply is never logged, and it must satisfy your password policy or the service refuses to start rather than creating an account nobody can sign in as.It is create-only, and that is the important part. If the address already has an account, nothing happens: no password reset, no promotion, no profile edit. Changing
NUXT_BOOTSTRAP_ADMIN_PASSWORDafterwards therefore has no effect — an environment variable that reset a password on every boot would be a backdoor rather than a bootstrap. To change it, sign in and use the account page.One interaction worth knowing: a bootstrapped admin already occupies the "first user" slot, so the next person to self-register is an ordinary user even with
NUXT_AUTO_ADMIN_FIRST_USER=true. See Admin panel → First-admin bootstrap.The password rules tick off as you type. The hint under every password field stated the policy but never said whether what you had typed satisfied it — the first feedback was a server error on submit. Each part now turns green with a
✓the moment its rule is met, in your language, on all five password fields.The check predicts the server's verdict rather than approximating it: it uses the server's own character classes, and a parity test builds the real validation schema for each policy preset and asserts the two agree. Which means it will show you some things you may not expect, because they are what the policy actually does —
Abcd ef1satisfies "special character" (a space is not a letter or a digit), andÜsatisfies neither "upper" nor "lower". Both were always true; they were simply never visible before.The checklist covers the policy, not the whole server. A rejection it cannot predict — reuse of a previous password, where
NUXT_PASSWORD_HISTORY_COUNTis in force — still arrives on submit.
Fixed
Registration took a password once, with no confirmation.
/registerwas the only place in Schleuse that set a password without asking for it twice;/reset-password, the invite page and the account dialog all confirm. A typo therefore created an account whose password nobody knew — and on an instance withNUXT_SMTP_HOSTempty, where registration auto-verifies and no reset mail exists, there was no self-service way back in. There is now a Confirm password field, in every bundled language.Tabbing out of "Last name" landed on the sign-up method buttons, not in the password field — two unexpected stops in the middle of one form. The "With password / With passkey" strip now sits above the profile fields, where a mode switch belongs, so the keyboard walks the form in the order you would expect. Both buttons remain fully keyboard-reachable.
Security
- One deliberate exception to "passwords are never logged". The bootstrap admin's generated password is written to the log, once, because an unattended install has no other channel for it. It is never logged on subsequent starts, and a password you supply yourself is never logged at all. If your logs are shipped or retained off-host, set
NUXT_BOOTSTRAP_ADMIN_PASSWORDexplicitly. Either way: sign in, change it, remove the variables. Documented at Security → Password handling.
v1.7.0 (2026-08-27)
The logo in your emails. No migration, no configuration change — but one visible default moves: an install that has customised no branding now shows the bundled Schleuse mark in its mail header, where it previously showed the app name as text. If you have set NUXT_PUBLIC_THEME_NAME or NUXT_PUBLIC_THEME_LOGO_URL, nothing about your mail changes except that a relative logo path now works.
Added
The bundled mark now appears in email, under the login page's rule. A stock install showed the 🔐 mark on
/loginand only uppercase text in the mail it sent. Two real obstacles kept it that way, and both are now handled: a mail client has no origin against which to resolve a path like/logo.svg, and Gmail and Outlook render no SVG at all. So the mark ships a second time aspublic/logo.png— a 128×128 raster copy, twice the 36px it displays at — and the mail carries it as an absolute URL underNUXT_APP_URL.The rule for when it appears is shared with the login page rather than reimplemented, which is the part that matters:
Your configuration Mail header shows nothing customised the bundled mark NUXT_PUBLIC_THEME_LOGO_URLsetyour logo NUXT_PUBLIC_THEME_NAMEset, no logoyour name, as text The third row is the point. Schleuse does not put its own logo above someone else's brand — in email no more than on the login page — and because both surfaces now call the same function, they cannot drift into disagreeing about it.
The honest ceiling: Gmail, Outlook and Apple Mail all block remote images until the recipient asks for them. This raises what can be shown, not what always is. The
alttext carries your app name either way, and nothing in any email depends on the image loading. See Email templates → Logo in email.for custom layouts — the resolved image URL: your logo, or the bundled mark when you have customised nothing. It is empty in the third case above, where the built-in layout falls back to text, so a custom layout using it must handle empty itself.is unchanged: still "the value you configured", still empty when you have set none, so no custom template inherits our mark.
Fixed
A relative
NUXT_PUBLIC_THEME_LOGO_URLwas a broken image in every email, silently. The value went into<img src>verbatim./brand.pngis correct on the login page and correct in the admin sidebar — and in a mail client, which has no origin to resolve it against, it renders as a broken image with no warning anywhere. The documentation said to use an absolute URL; nothing enforced or repaired it, and the failure was invisible from inside the app.Relative values are now resolved against
NUXT_APP_URLbefore being sent:bashNUXT_APP_URL=https://id.example.com NUXT_PUBLIC_THEME_LOGO_URL=/brand.png # sent as https://id.example.com/brand.pngAn absolute URL passes through untouched, so a logo on a CDN is unaffected. Still use a PNG or JPEG — the SVG limitation is a mail-client fact, not something this fix changes.
Other
- The MCP authorization-server plan was retired into follow-ups. Written before the v1.6.0 MCP work shipped, it had been overtaken: the discovery aliases landed in that release, the
application_typeloopback gap was already filed as a bug, and it asked for a/.well-known/oauth-authorization-serverroute that v1.6.0 documents as a deliberate404. What survived is the finding that Client ID Metadata Documents are native tooidc-provider9.8.0, so the open work there is a consent screen rather than client resolution — kept as a follow-up, since nothing needs it yet. No code changed.
v1.6.1 (2026-08-26)
One fix, and it is about the moment a container starts. No migration, no configuration change — but if anything in your deployment waits for /api/ready before letting traffic through, read the entry: that gate now actually holds.
Fixed
/api/readyreported ready before the migrations had run. The server starts listening before the startup migrations finish — Nitro does not awaitasyncplugins — and readiness could not see it: the check wasSELECT 1, which succeeds against a database with no tables at all.Measured on a cold start against an empty PostgreSQL 16: the first
200arrived with two tables present (knex_migrationsand its lock), the ten application tables ~300 ms later. APOST /api/auth/loginfired at that instant returned 500 in three of three runs.Docker's own healthcheck rarely lands inside that window, because it polls every 15 s. Everything that waits for the first success does:
depends_on: condition: service_healthy, a Kubernetes readiness probe, a deploy script's wait loop. On a remote database the window is seconds, and on a first deploy it is the whole migration run.Readiness now answers
503until the schema is in place:json{ "status": "not ready", "database": "migrating", "message": "Database migrations are still running" }What this changes for you: a
503right afterdocker compose upis the migrations running, not a fault — wait for it to clear. Two things worth checking in your setup:- Point liveness probes at
/api/health, not/api/ready. A liveness check that fails during migrations kills the container and restarts it into the same window, forever./api/healthis unchanged and answers200throughout. - A long first migration needs a longer start period. Failures inside the image's 30 s
--start-periodcost nothing; past it, the three retries apply. Locally the run takes 0.32–0.46 s. If a remote database exceeds the budget, raise--start-period(or the composehealthcheck.start_period) — never weaken the check.
A database outage is still reported as before, and stays distinguishable:
"database": "disconnected", not"migrating". See Deployment → Health checks.- Point liveness probes at
v1.6.0 (2026-08-26)
Securing an MCP server with Schleuse, made possible and then written down. No migration, no configuration change — both additions are opt-in and nothing about an existing deployment moves.
The security-relevant one is pkceExempt: a way to waive PKCE for a single confidential client. The default is unchanged, public clients are never exempt, and every exemption is announced in a startup warning. Read the Added entry before using it.
Added
pkceExempt— waive PKCE for one confidential client. Schleuse enforces PKCE on every authorization request, which is stricter than Okta, Auth0, Entra ID and Keycloak, all of which leave it optional for confidential clients. That strictness locked out real integrations: a gateway that acts as an OIDC client but sends nocode_challengeupstream failed with a bareinvalid_requestin a redirect URL.OAuth 2.1 §7.5.2 permits waiving PKCE when the client is confidential and the server has "reasonable assurance" it implements the OpenID Connect
nonceproperly — so the blanket policy was the recommended posture, not the only conformant one. That assurance has no protocol signal (anoncein the request proves the client sent one, not that it validates the ID token's), so nothing here tries to detect it.pkceExempt: truerecords your decision about a specific client.json{ "clientId": "some-gateway", "public": false, "clientSecret": "…", "pkceExempt": true }Per client, never global — one switch would silently weaken every confidential client at once. And never for public clients: the specification allows no exemption there, so the flag is ignored rather than honoured for them, and a client entry copied from a confidential one cannot open a hole.
What you accept: nothing checks whether the exempt client sends a
nonce. If it sends neither, that client has no protection against authorization-code injection on its redirect. Schleuse names every exempt client in a warning on each start, so the decision stays visible. See OIDC → PKCE.A guide for putting an MCP server behind Schleuse, at guide/mcp. This already worked — a pre-registered public client, one resource-server entry and about twenty lines of token verification — but nothing said so, and
NUXT_OIDC_RESOURCE_SERVERS_JSONgave no hint that it is the load-bearing piece. The page is the whole recipe, and its verification snippet is tested against a real token rather than proof-read.Alongside it, the discovery document is now also served at
/.well-known/oauth-authorization-server/oidcand/.well-known/openid-configuration/oidc.NUXT_ISSUERcarries a path, and RFC 8414 §3.1 puts that component after the well-known segment — so a conformant client probes those two first and previously got404twice before finding the third. All four locations return the same bytes./.well-known/oauth-authorization-serverwithout the suffix stays404: it describes a root issuer, which Schleuse never has.
v1.5.0 (2026-08-26)
A per-user audit trail in the admin panel, and the privacy fix it unblocked. One migration — a nullable column and an index on audit_log, applied automatically at startup. No data is rewritten, no configuration changes.
Read the Fixed entry if you have ever answered an erasure request: a deleted user's email address could survive in the audit log, and this release both stops that happening and provides the sweep that removes it from existing rows.
Added
A user's audit trail on the admin user page, plus a link into the full log filtered to that account. It shows what the account did and what admins did to it — a suspension, a 2FA reset, a profile edit.
This needed a new
audit_log.target_user_id, because it could not be built correctly on the old data:user_idis the actor, so for an admin action it held the admin, and filtering by a target's id returned their own logins while silently omitting every action taken about them. The subject existed only as free text inreason, in three different formats.A row where an admin acted on someone now appears in both trails — one performed it, the other was subject to it. That is intended, not duplication.
Historical rows are not backfilled, deliberately: recovering subjects by parsing three free-text formats would half-work, and a partially reconstructed trail an operator trusts is worse than a stated gap. The page says so where the gap can show, and the docs repeat it.
Fixed
A deleted user's email address could survive in the audit log. Erasure scrubbed rows matching
user_id— the actor — so admin-action rows were never reached, andadmin.user.updatedwritesUpdated <email>: …intoreason. After the account was erased, the address was still there. Both deletion paths were affected, self-service as much as admin, so this applies to an Art. 17 request an operator believed they had honoured.Rows about a user now lose
reason, and onlyreason. The other columns on those rows —email,ip,user_agent,request_id— belong to the admin who acted; nulling them would destroy a still-existing account's accountability record while removing nothing about the erased user.To clear existing rows, run Anonymize orphaned rows from the admin audit-log page. It now reaches rows whose subject is gone as well as those whose actor is — the row class this bug is about — and is idempotent, so running it twice is safe. Worth doing once after upgrading if your instance has processed any deletions.
Security → erasure now sets out exactly what an erasure removes per row, and what deliberately survives.
v1.4.3 (2026-08-26)
No behaviour changes at all — no token, claim, endpoint or response moves. Both items below make Schleuse say out loud what it was already doing silently, after a conformance run in which seven relying parties across five languages drew the wrong conclusion from the same two silences. Upgrading needs no action.
Fixed
A client that requests the
rolesscope without declaring roles now says so at startup. Listingrolesin a client'sscopeslets it request the scope; the claim is only produced if that client also declares arolesarray. Without one, the authorization succeeded, the token response reportedscope: "… roles", and norolesclaim was ever emitted — from the ID token or from userinfo, with nothing anywhere to explain it. The app API had always refused the same configuration outright (400 This client does not define roles); the OIDC path simply stayed quiet.Startup now names the client and both remedies — add the array, or drop the scope from its
scopes. AroleGroupsibling declaring roles does not cover for a missing declaration: each client's claim is built from its own, so the public-mobile-client-plus-confidential-backend pattern needs both to declare.Deliberately a warning rather than a rejection. Refusing the scope at authorization would be louder, and would turn a missing claim into a failed sign-in for every relying party currently in this state.
Other
Documentation: where claims come from, and how to get a
sid. Both facts were already documented, in places a confused integrator never reaches — so a team concluded that session-scoped back-channel logout "cannot be implemented" and shipped anidToken.rolesmistake in their own README.sidhas always worked: a client that sets bothbackchannelLogoutUriandbackchannelLogoutSessionRequiredgets a matchingsidin its ID tokens and its logout tokens, which is the pair an RP needs to end one session rather than all of them. That is now stated where it is looked for — a new Session-scoped logout section, and the client field row that previously mentioned only the logout token.The discovery section now explains that
claims_supporteddescribes what the provider can supply across the ID token and/oidc/userinfotogether, not what any single token carries — the machine-readable promise that contradicted the prose. Therolesscope row says inline that its claim comes from userinfo.The admin Integration page shows, per client, whether its logout is session-scoped, naming the flag — so "my app gets no
sid" is answerable by looking at the client rather than by reading the spec.
v1.4.2 (2026-08-26)
One fix, for SQLite deployments only. PostgreSQL instances are unaffected and need not upgrade urgently. No config, no migration, no new endpoint — but the JSON type of two response fields changes, so read the note below if you have code reading them.
Fixed
email_verifiedandis_adminare booleans again on SQLite. better-sqlite3 has no boolean type, and Knex passed the driver's1/0straight through, so the integers reached the login response,GET /api/me, the session JWT payload and the OIDCemail_verifiedclaim — which OpenID Connect Core specifies as a boolean, so a strict relying party could reject it. That is how this surfaced: while building a demo relying party against v1.4.0.Both values are now coerced once at the database driver, so every surface sees a real boolean. PostgreSQL always returned booleans and is untouched; both dialects were measured rather than assumed.
If you read these fields from a SQLite instance, a comparison like
emailVerified === 1stops matching. Truthiness checks (if (user.emailVerified)) are unaffected, and so is anything reading them from PostgreSQL. The documented behaviour has always beentrue/false— this makes the code match the docs rather than changing what they promise.
v1.4.1 (2026-08-25)
Three fixes, all found by building a pair of demo applications against v1.4.0 and writing down everything that did not behave as documented. Upgrading needs no action — no config, no migration, no API shape change — but the first one changes what browsers can do, and the third changes what the discovery document says when your instance is reachable under more than one name.
Fixed
A browser-based relying party can call
/oidc/userinfoagain.OPTIONSreturned404on both userinfo and the discovery document. A404is not an ok status, so the browser treated the preflight as a network error and never sent the real request — and sinceGET /oidc/userinfocarries anAuthorizationheader it always preflights.oidc-client-tswithloadUserInfo: true,oauth4webapiandangular-auth-oidc-clientall failed out of the box.The cause was Schleuse's own CORS configuration: oidc-provider registers correct preflight handlers, and two
cors: trueroute rules shadowed them — a Nitro CORS rule appends headers but does not answer the preflight. The rules are gone. Cross-originGETresponses improve in passing, from a blanketaccess-control-allow-origin: *to the provider's echoed origin plusAccess-Control-Expose-Headers: WWW-Authenticate. Which endpoints a browser may call is now documented, which nothing was before — that absence is why the rules were added.Mail no longer goes out with an empty envelope sender. Every deployment that did not set
NUXT_SMTP_FROMsent verification, reset and invite mail withMAIL FROM:<>. A null return-path is legitimate for bounces and unusual for transactional mail; receivers score it and some reject outright, so the failure was silent — the relay accepted the message and it never arrived.nuxt.config.tsdeclares the key as an empty string, which meant the Zod default never fired.An empty or whitespace-only value now falls back to
noreply@localhost, and startup warns that this domain is unroutable, because the fallback keeps mail well-formed without making it deliverable. SetNUXT_SMTP_FROMto an address on a domain you control; anyone who started from.env.examplealready has, which is why this survived unnoticed.The discovery document is now identical whichever hostname served it.
issuercame from configuration while every endpoint URL was built from the incoming request, so an instance reached under a second name advertised endpoints on that name while still claiming the configured issuer. OIDC Discovery 1.0 §4.3 forbids the mismatch, so conformant relying parties rejected the document outright — and had they not, they would have redirected browsers to an origin those browsers may not reach. This bit compose networks, reverse proxies and service meshes.Every URL the provider emits is now pinned to
NUXT_ISSUERbefore the request reaches it. That closes a header-trust gap in passing:X-Forwarded-Hostwas honoured from any caller even whereNUXT_TRUST_PROXY=false, and is now dropped, whileX-Forwarded-Protois overwritten from the issuer's scheme so a hostile value cannot downgradehttpstohttp. Your reverse proxy no longer needs to preserve the originalHostfor OIDC to be correct.
Other
- Six configuration variables were missing from
.env.example— the file an operator copies on day one — including bothNUXT_APP_METADATA_*toggles. A test now keeps it in sync with the configuration catalogue.
v1.4.0 (2026-08-24)
A per-user key–value store apps can write through the API, and a fix to the per-client login gate. The store is off by default and adds a table the auto-migration creates on start, so it needs no action. The gate fix does. It closes a case where access an operator believed they had revoked still worked — and, in one specific misconfiguration, it can lock every user out of a gated app on upgrade. Read the Fixed entry before you pull; the app tells you at startup if you are affected.
Fixed
The per-client login gate honours only the roles a client still declares. It used to read role assignments raw, while every other path — the
rolesclaim,GET /api/apps/roles, the admin panel — filters them against what env declares. Nothing prunes assignments when a declaration shrinks, so removing a role from a client'sroleslooked like revocation everywhere an operator looks (the row even disappears from the admin panel) while the gated app kept admitting the user. It failed open, silently. The declaration is now the single revocation lever.Before upgrading, check any client with
loginRequiresRoleorloginRequiredRoles: every role the gate names must also appear in therolesof some client in that role group.loginRequiredRolesselects roles, it does not declare them. A gate naming a role nobody declares can no longer be satisfied by anyone — Schleuse now logs anerrorat startup naming the client, the group and the missing role, and awarningwhen only some of the named roles are undeclared.Six configuration variables were missing from
.env.example— the file an operator copies on day one, so a variable only in the catalogue is a variable nobody discovers. The threeNUXT_EMAIL_INVITE_*template overrides andNUXT_PUBLIC_PASSWORD_HINTwere among them. A test now keeps the file in sync with the configuration catalogue.
Added
A per-user app metadata store — a small key→value store so an app deployed next to Schleuse can remember an entitlement, an external customer id or a preference without running a database of its own.
GET/PATCH /api/apps/metadataauthenticate with the client's ownclient_secretover HTTP Basic; there is no new API key, because a shared one would let any app read and overwrite every other app's data with no attribution. In aPATCHbody a string upserts the key,nulldeletes it, and unmentioned keys are left alone — add, edit and delete in one call.Off unless
NUXT_APP_METADATA_ENABLED=true, and the check runs before authentication, so a disabled deployment never becomes an oracle for whether a client secret is valid. The namespace is the client'smetaGroup ?? clientId, taken from the credentials and never the request; clients declaring the samemetaGroupshare one flat key space and — unlikeroleGroup— can overwrite each other's keys, so share one only when you mean it.Values are opaque text: an app that wants structure puts JSON in the value and owns its own schema. Caps of 50 keys per user and namespace, 4096 bytes per value and 25 keys per request keep this a key→value store rather than a database behind the IdP.
The store is tamper-proof by the user, not secret from them. No end-user path writes it and it is never emitted as an OIDC claim, so an app can store a flag and know the user cannot forge it — but the values appear in that user's own GDPR export, and an operator can correct them from the user detail page. Admin edits are audited with key names only; application writes deliberately are not, since metadata may be written on every request.
metaGroupon an OIDC client and two variables:NUXT_APP_METADATA_ENABLEDandNUXT_APP_METADATA_REQUIRE_GRANT(restrict the store to users who already have a grant with the calling client, reads included).
Other
- The website covers the store across the OIDC, admin panel, security, configuration and API-reference pages; the security guide carries what a leaked client secret now reaches.
v1.3.1 (2026-08-23)
Website and documentation only — the app itself changes in a single string, the support URL printed by the email-canonicalisation pre-flight check. No config, no API, no OIDC behaviour, no migration. Upgrading needs no action.
Fixed
- The documentation moved to https://schleuse.meowww.dev. Every link to the old GitLab Pages domain was a 404 — five in the README, two in changelog entries, and one inside the error the app prints when the email-canonicalisation migration finds colliding accounts.
- The production checklists render as real checkboxes. VitePress ships no task-list support, so the
- [ ]items in the deployment and security guides reached the page as literal bracket pairs. They are checkboxes now, and tickable while you work through a deploy. - The brand mark no longer disappears into the halo behind it on the website's home page: the halo was the same
#06d6a0as the mark. It is a different hue now, and the mark is sized against it instead of sitting at its intrinsic 64px.
Added
- A sitemap and per-page canonical links for the website, both naming
https://schleuse.meowww.dev— a self-hosted copy of the docs now points search engines back at the canonical site instead of competing with it for the same content. Arobots.txtpoints at the sitemap.
v1.3.0 (2026-08-23)
French and Italian, for the interface and for outbound email. Unlike the last two releases, this one changes what existing users see and receive — three behaviour changes take effect on upgrade without any config change on your side. They are listed under Changed below; read them before you pull. An English-only instance is unaffected: rendered English email is byte-identical to v1.2.0.
Added
- French and Italian, bringing the bundled locales to four. Both cover the full interface and the built-in emails — one file per locale under
app/i18n/, holding that language's UI strings plus anemailsubtree. Tone follows each language rather than being copied from German: French uses formal vous, German and Italian informal du / tu. - Built-in email translations. Previously the outbound templates existed only in English — there was no German set either, just per-locale
NUXT_EMAIL_*_<LOCALE>env overrides. All five emails now ship translated in all four locales, sitting underneath the same override cascade as before:NUXT_EMAIL_*_<LOCALE>→NUXT_EMAIL_*→ built-in. - Shipping a catalogue does not enable it.
NUXT_SUPPORTED_LOCALESstill defaults toen; list a locale to let users store it and receive mail in it. - A
:nextcontainer image on every push tomain, plus an immutable:main-<short-sha>for the same build so a pre-release can be pinned or rolled back.:latestis untouched by these and still means "most recent release". See Deployment → Which tag to pull.
Changed
Three changes to behaviour, all of which apply on upgrade with no config change:
- Users with a German, French or Italian locale now receive translated mail where they previously received English. If you had worked around this with
NUXT_EMAIL_*_<LOCALE>overrides, they still win — nothing to undo. - Users with no stored locale now get
NUXT_DEFAULT_LOCALEinstead of English for the verification resend, the 2FA login code and the new-sign-in alert. Those four send paths bypassed the locale cascade entirely, which also meant a locale-suffixed env override never applied to them.users.localeis empty for accounts created before the column existed, so this can affect a meaningful share of an older instance. - Anonymous visitors now see the login page in
NUXT_DEFAULT_LOCALE. The interface's last-resort fallback was hard-coded English, so aNUXT_DEFAULT_LOCALE=deinstance greeted visitors in English while mailing them in German. If you relied on the English login page, setNUXT_DEFAULT_LOCALE=en.
The new-sign-in alert deliberately ignores the triggering request's Accept-Language, unlike every other send: that request is the suspicious one, and its headers must not choose the language of the warning the account owner has to read.
Fixed
- The logo disappeared once you signed in. The account layout never rendered a brand block, so the mark vanished at the moment a user landed on
/account. - The admin sidebar and mobile header rendered the logo and the app name side by side, which put the bundled mark next to an operator's own brand. Both now render one or the other, as the login and account pages already did.
Internal
- The email copy moved out of a separate server-side module into the shared i18n catalogues, so a locale is one file rather than two. Note the two placeholder syntaxes this creates: UI strings use
{single}braces, email atomsones, matching the operator-facing env-override syntax. - Locale parity is now enforced in CI, not only by the type-checker: every bundled locale must carry exactly the English key set, no empty strings, and the same placeholder tokens per key.
- Added the missing regression test for email escaping — the HTML body must escape
firstName/ip/devicewhile the text body keeps them raw.
v1.2.0 (2026-08-21)
Visual identity: the app finally looks like itself. Nothing to configure and nothing to migrate — an operator who has already set their own name, logo or theme keeps exactly what they had.
Changed
- A stock install now shows a logo instead of the word "Schleuse". The mark — the two gate leaves of a canal lock, which is what Schleuse means — ships with the app and is used on the login pages, in the admin sidebar, and as the browser-tab icon, which previously did not exist at all: the favicon was only registered when a logo URL was configured.
- The UI now ships its own typeface. Geist (and Geist Mono for code, identifiers and audit output), bundled with the app as two variable woff2 files, Latin subset, ~52 KB together. Previously no typeface was set at all, so the UI rendered in whatever the visitor's operating system supplied. Deliberately not loaded from a CDN: the login page makes no third-party request, which keeps it working air-gapped and stops visitors' IP addresses reaching a font host. Both faces are SIL OFL 1.1; the licence ships at
/fonts/OFL.txt. The typeface is not configurable — say so if you need it to be. NUXT_PUBLIC_THEME_LOGO_URLis unchanged and still wins when set. If you setNUXT_PUBLIC_THEME_NAMEto your own brand without a logo, you keep seeing your name as text — the bundled mark is never shown above someone else's branding. Email is unaffected: the mail header still uses only an explicitly configured logo, and still falls back to the app name as text.
v1.1.1 (2026-08-21)
Internal only — nothing here changes how Schleuse behaves. No config, no API, no OIDC behaviour, no migration. Upgrading needs no action.
Fixed
pnpm db:migrateand its:make/:rollback/:down/:statussiblings now run. They never had, since the first release — extensionless relative imports don't resolve under Node ESM, and the sharedhelpers.tssat inside the migrations directory, where Knex loads every.tsas a migration. Development tooling only: the server has always applied migrations itself at startup, which is why this went unnoticed. Requires Node ≥ 22.18 (the published image runs 22.23).
Internal
- Removed dead code: an unused error-logging helper, two unused validation helpers and two unused schemas, and a repository method with no callers.
- Simplified the database write helpers — both carried a SQLite branch that ran the write and then a second SELECT to read the row back, which SQLite has not needed since it gained
RETURNING. Verified to return byte-identical rows on SQLite and PostgreSQL, including booleans and timestamps. - Dropped the
db:seedscripts, which pointed at a directory that never existed.
v1.1.0 (2026-08-21)
Added
- SQLite Coolify template —
docker-compose.coolify-sqlite.yml, the single-container variant: no database service, one volume,NUXT_DATABASE_URL=sqlite:/app/data/schleuse.db. For a small instance with no separate database to run or back up. Note that/app/datathen holds the database as well as the auto-generated keys, so losing it loses users, audit log, TOTP enrolments and passkeys at once — andtar/docker cpof a live WAL database is not a safe backup. The Postgres template keeps its filename; both cross-reference each other.
Changed
- Email addresses are matched case-insensitively. Addresses are stored canonically (trimmed, lowercased) and every lookup canonicalises its input, so
A@example.comanda@example.comare one account. Previously they were two, and a user who registered with one casing and typed the other got a generic "invalid email or password" — indistinguishable from a wrong password. Subaddressing (user+tag@…) and dots are preserved. - The OIDC
emailandpreferred_usernameclaims are now always lowercase. For users whose stored address was mixed-case the claim value changes once.subis unaffected; a relying party keying onsubneeds no migration.
Fixed
- Corrected the public docs URL in the README (five links).
Upgrading
A migration canonicalises existing rows at first start. If two accounts hold addresses that differ only by case or whitespace they cannot both survive — the migration refuses to guess, aborts, and the container will not start until you reconcile them. Run the pre-flight query before upgrading; see Upgrading → duplicate email addresses.
v1.0.0 (2026-08-20)
First release.
A self-hosted identity provider: password and WebAuthn/passkey authentication, email verification, TOTP and emailed two-factor codes, and an OIDC provider (Authorization Code Flow with PKCE) for downstream applications.
Includes an admin panel (user management, audit log, integration guide, env configurator, SMTP and email diagnostics), a bearer-gated Prometheus endpoint, a provisioning API for migrating an existing realm without forcing a password reset, GDPR export and erasure, per-application OIDC roles, back-channel and RP-initiated logout, and a bilingual (en/de) interface.
See docs/implementation/v1.0.md for the design constraints and the decisions behind them.