Skip to content

Releases: ufhy/elvel

Elvel 1.0.0-alpha.26

Elvel 1.0.0-alpha.26 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 09 Sep 08:53

Release 1.0.0-alpha.26

Four fixes that ship: SESSION_ENCRYPT was documented and wired to nothing, a
request inherited the previous one's session and cookie jar when two were handled
from one frame, defer() in a queued job or console command was queued and never
run, and nodemailer moves to 9.1.1 for GHSA-2x7j-588g-ccc2. The first three were
behaviours something already promised.

Elvel 1.0.0-alpha.25

Elvel 1.0.0-alpha.25 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 02 Sep 12:08

Release 1.0.0-alpha.25

The release 1.0.0-alpha.24 was meant to be, and never was: its dry run went red
on Linux over a test of mine that bound fixed ports, so nothing was published
under that version and the tag stands as a release that did not happen.

The fix it carries is the reason to take this one. The auth rate limit was
bypassable with a single header.
better-auth resolves the client from headers
only — it never sees the socket — and with no trusted proxies named its
getIPFromHeader trusts a single-value x-forwarded-for outright. So the limit
added in 1.0.0-alpha.22 was turned off by anyone who cared to send one: thirty
failed sign-ins against one account, a different x-forwarded-for on each,
measured on a scaffolded api kit in production with nothing in front of it,
none of them refused. Thirty with no header had twenty-seven refused.

Naming better-auth's own trustedProxies does not close it: its chain walk
returns the rightmost untrusted hop, and for a single-value header that is still
the value the caller wrote.

The framework already knew the answer and was not passing it on. clientIp in
@elvel/http resolves the socket address, or X-Forwarded-For only when the
socket belongs to a proxy http.trustedProxies names. That result is now written
to a header of the framework's own on every auth request — any inbound copy
deleted first — and better-auth is told to read that instead. The rotating header
goes from 0 refused in 30 to 27, which is all a fresh bucket allows.

It closes the opposite failure too: with no resolvable address better-auth fell
back to one shared bucket per path, where three failed sign-ins from anyone
locked the endpoint for every user. A socket address is always there.

Behind a real proxy forwarded addresses still count — name it in
TRUSTED_PROXIES, and name both loopback forms if that is where it sits, because
127.0.0.1 does not cover ::1. An application that writes its own
advanced.ipAddress.ipAddressHeaders keeps them and the injection stops with it.

Elvel 1.0.0-alpha.23

Elvel 1.0.0-alpha.23 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 01 Sep 14:48

Release 1.0.0-alpha.23

Four things, and three of them came out of profiling a scaffolded application
rather than reading the code.

A cached session cookie is revocable now. session.cookieCache is worth
6,664 to 10,241 req/s on the cookie path, and it was a trap: once the cookie
holds the session nothing reads the store, so a signed-out session kept working
for the whole maxAge — five minutes at better-auth's own default. Turning the
cache on now installs a per-user revocation epoch over it, bumped from
better-auth's own database hooks, and encrypts the cookie with strategy: 'jwe'
rather than leaving the user's row readable as base64. Costs a tenth of the win
and refuses a revoked session on its next request. Still off in all four kits:
putting the user row in a cookie is the application's decision.

The framework stopped parsing the same URL eight times a request. Eight
plugins each wrote new URL(request.url).pathname, two of them parsing it twice
in one expression, and the Vite build guard was an async onRequest that every
request waited on to learn it was not asking for /build/. request.url is
already normalised by the Request constructor, so requestPath reads the
string. URL went from 149 profile samples to 1.

One request context instead of five. The request scope, cookie bag, current
route, auth session and deferred queue each had an AsyncLocalStorage and each
called enterWith per request — 11% of samples between them. They are five
symbol-keyed slots in one context now, opened once by the first synchronous hook.

Together: the scaffolded api kit answers a plain route at 64,449 req/s where it
managed 59,715, and the same twelve-second load costs 1,822 CPU samples instead
of 2,382.

better-auth moves to 1.7.2. No code changes needed; the template pin and the
guard test that holds it to the tested version moved with it.

Elvel 1.0.0-alpha.22

Elvel 1.0.0-alpha.22 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 31 Aug 14:33

Release 1.0.0-alpha.22

Two things the release testing found in the auth kit, both invisible from the
code and both caught by measuring a scaffolded application.

The auth endpoints were not rate limited. better-auth turns its own limit on
from NODE_ENV === 'production', and an Elvel application says what environment
it is in through APP_ENV. So an application deployed the way this framework
documents — APP_ENV=production, nothing said about NODE_ENV — left the twelve
endpoints better-auth mounts wide open: twenty failed sign-ins in a row against
/api/auth/sign-in/email answered 401 twenty times. The throttle:6,1 on the
starter kit's own /api/login never helped, because that is not the route a
better-auth client calls. It follows APP_ENV now, and an explicit
auth.rateLimit still wins.

The session was resolved on every request, whether or not one could exist.
AuthServiceProvider resolves it in onRequest, so a health check with no cookie
at all paid for a lookup that could only answer null. Measured by stacking the
providers one at a time: mounting the auth endpoints took an application from
4.4µs to 14.8µs per request — more than the whole framework's request pipeline
costs at 3.9µs, and the largest single per-request cost in the stack.

It is 5.4µs now. On the same scaffolded application, /health went from
28,127 to 54,496 requests per second, which is where the bare kit (56,192)
and Express (55,789) already were. Requests that do carry a session are
unchanged, as they should be.

The guard asks precisely rather than cheaply: the cookie names come from
better-auth's own context rather than being guessed, and the Authorization
header counts too, because the starter kit enables the bearer plugin and a
cookie-only check would have silenced token authentication without a test going
red. When better-auth will not say what its cookies are called, every request
carrying a cookie is passed through — slower, never wrong.

Neither of these was in the performance audit, and the reason is worth recording:
every measurement in it used playground/, which does not register
AuthServiceProvider. The whole auth request path had never been on a scale.

Elvel 1.0.0-alpha.21

Elvel 1.0.0-alpha.21 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 31 Aug 13:11

Release 1.0.0-alpha.21

Everything alpha.20 was, plus the test that stopped it shipping.

alpha.20 never reached npm. Its dry run failed on one test — a rate limit
asserted its own counter one round trip after setting it, under a one-second
window, which a loaded Postgres runner can outlast. The assertion was redundant
with the test above it and is gone. Nothing was published, so nothing about
alpha.20 needs undoing; its tag stays where it is because tags here are immutable,
and this is the version that ships.

What follows is the alpha.20 changelog.

A performance pass driven by measurement, which found three ways the framework
was losing data.

The measuring came first and it changed what got built. Every finding in an audit
of the packages was timed before it was touched; five were refused on their
numbers, and three of the audit's own numbers were wrong. What the timing kept
turning up was not slowness but silence.

sync() destroyed the pivot's created_at on every call. It deleted every
pivot row and reinserted the lot, so adding one tag to an article rewrote "when
did this article get that tag" for every tag it already had. Nothing failed and
there was nothing to restore from. It diffs now, writes only what changed, and
returns { attached, detached }.

append() deleted the file it was appending to when the process could write
it but not read it. get() answers null for anything unreadable, the empty string
that became was written over the top, and the contents were gone. It appends
without reading now — which also makes repeated appends linear rather than
quadratic: 4,000 lines went from 1,392ms to 131ms.

defer() shared one queue across every request. Two requests deferring the
same key at the same moment ran it once and one of them lost its work; whichever
finished first flushed the other's callbacks, before it had finished, through the
wrong exception handler. Each request has its own queue now.

Three more that threw or hung rather than lying: static touches naming a
to-many relation threw a TypeError at the first save, two models that touched
each other never stopped, and with('posts.comments', 'posts.likes') loaded
posts twice and discarded the comments it had just attached.

What the performance work came to, all measured on real Postgres, MySQL and Redis
rather than reasoned about:

  • the request pipeline, 37.3µs to 30.3µs
  • importing config/app.ts, 259ms to 75ms — elvel key:generate was loading a
    CSS inliner, an SMTP client and an authentication framework
  • tags(…).many(20) against Redis, 1,267µs to 89µs
  • a throttled request, five cache round trips to two
  • distinct over 200 values, 18.6ms to 0.21ms
  • walking 400,000 rows, 4.06s to 0.70s
  • an idle worker's dequeue latency, 1,741ms to 2ms with blockFor set
  • Collection.min no longer throws at a million rows

Four new options, all off or conservative by default: cache.memory,
queue.migrateEvery, queue.blockFor, and mail.theme. Two new extension
points: Store.incrementWithin (optional — a third-party store needs no change)
and Grammar.compileClaim (required, so a new dialect has to think about
claiming a row rather than inherit an answer that may be wrong for it).

The docs page on walking a large table was wrong in both directions and is fixed.

Elvel 1.0.0-alpha.19

Elvel 1.0.0-alpha.19 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 29 Aug 17:09

Release 1.0.0-alpha.19

The second half of the bundle fix, and the guard that should have caught both.

alpha.18 replaced the mail inliner so a bundle could be built at all. It uncovered
a second failure underneath: the Vue kit's bundle still could not boot, now
because elvel.ts loaded the reflect polyfill through a dynamic import with the
specifier in a variable. A specifier the bundler cannot see stays a runtime
import(), which then has to resolve from dist/ — and in a kit whose
workspaces entry makes Bun install in the isolated layout it does not. The
catch swallowed it, so the failure surfaced as a message about tsyringe.

The entry now imports ./bootstrap/polyfill.ts with a literal specifier. The
template's copy is empty; the auth layer replaces it with the one import the
passkey chain needs, which is the layering the scaffolder already does.

And verify-published.ts now builds each scaffolded application and boots it from
its own bundle. That check belongs there rather than in the smoke: the smoke
scaffolds inside the workspace, where everything is hoisted to the monorepo root
and the broken shape boots — measured. What breaks it is an application installed
on its own, which is the only kind that script makes.

Elvel 1.0.0-alpha.18

Elvel 1.0.0-alpha.18 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 29 Aug 14:41

Release 1.0.0-alpha.18

A fix release for one bug that alpha.17 shipped and nothing in CI could see.

app:build produced a bundle that could not boot in the Vue kit, and because the
CLI hands over to a newer bundle, elvel.ts serve stopped working with it too —
the application was bricked until dist/ was deleted or ELVEL_BUNDLE=0 was set.
It was found by scaffolding the released version from npm and running it the way
the deployment page recommends, which is the only place it shows: the smoke test
scaffolds inside the workspace, where the package resolves.

The cause was the mail theme's inliner. css-inline is WASM whose wasm-bindgen
glue assigns its own module.exports to itself and then reads its .wasm through
__dirname, so it cannot be bundled; left external, it resolved in most kits and
not in the Vue kit, which declares workspaces and so installs in Bun's isolated
layout. juice is ordinary JavaScript, bundles, and matches whole CSS selectors
rather than the few a hand-written inliner could be trusted with.

Also in it: three CodeQL findings in the router and the Vite tag harvester, a
cache test that asked for a guarantee second-resolution expiry cannot give, and
mail:theme listed where the other publishing commands are.

Elvel 1.0.0-alpha.17

Elvel 1.0.0-alpha.17 Pre-release
Pre-release

Choose a tag to compare

@ufhy ufhy released this 29 Aug 13:27

Release 1.0.0-alpha.17

The first release that carries @elvel/client, which is why its name had to be
created on npm by hand first — OIDC can publish a version, not invent a package.
It is the browser half of what @elvel/spa used to be, and this release is where
the split becomes real for anyone scaffolding rather than only in the repository.

Also in it: the mail theme became a stylesheet inlined at render rather than a
token object interpolated into style attributes, notifications learned the rest of
Laravel's contract — a string via(), per-channel queues and middleware, and a
sending event a listener can call off — and three CodeQL findings in the router
and the Vite harvester were fixed, none reachable by a visitor and all of them
bugs anyway.

Elvel 1.0.0-alpha.13

Elvel 1.0.0-alpha.13 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 22 Aug 04:29

1.0.0-alpha.13

Elvel 1.0.0-alpha.11

Elvel 1.0.0-alpha.11 Pre-release
Pre-release

Choose a tag to compare

@ufhy ufhy released this 21 Aug 06:08

Five bugs, all in paths nothing had ever executed.

An audit went looking for the shape that produced the last two releases' bugs — a
code path that is rendered, string-matched or stubbed, but never actually run. It
found five more. Two of them meant a feature did not work at all.

auth:schema could not run on MySQL. session.token is unique and not
sortable, so the generator emitted table.text('token').unique() — and MySQL
answers BLOB/TEXT column 'token' used in key specification without a key length.
The migration failed on the second table, for every application, core tables
included. A keyed string is varchar now whether or not better-auth calls it
sortable.

It hid because the adapter's dialect suite built its tables by hand, so the
migration the command actually writes had never been executed against MySQL —
only rendered and string-matched.

auth:schema --diff pointed a foreign key at a table that does not exist.
The diff renders only the tables that are missing, and references were resolved
against that subset — so twoFactor.userId → user found nothing, fell back to the
raw schema key, and emitted .on('user') in an application whose table is named
something else. --diff had no test at all, while being the documented way to add
a plugin to a running application.

model:prune did nothing. {--model=*} is Laravel's spelling for a
repeatable option and the signature parser read the star as a default value of
"*", so every model was filtered out and the command reported
No model defines prunable() against an application whose model defined one.
Both spellings work now.

model:prune --pretend under-reported. It returned after the first batch, so
--chunk=2 against four expired rows said "2 row(s) would be pruned" — and
running it deleted four. It counts instead of walking now. Under-reporting on the
flag whose only purpose is deciding whether to run the real thing is the wrong
direction to be wrong in.

Every prompt hung where no terminal was attached. migrate in production asks
for confirmation, and from a CI job or a cron entry it rendered the question and
waited for ever — ten minutes before the process was killed by hand. A deploy that
holds its migration lock and never fails is worse than one that fails: nothing
reports it and nobody is told that --force is the answer.

Prompts take their default now and say so, which is also the safe direction:
confirmInProduction defaults to false, so a non-interactive production run
refuses. secret() throws instead, since a blank password is not an answer.

better-auth plugin schemas are tested against real servers, by generating the
migration and running it — up() and down(), on SQLite, Postgres and MySQL.
That is what found the first two bugs above. Adding a plugin is two lines in
config/auth.ts; the documentation
now covers it.

model:prune has tests, in playground/test, because it reads prunable()
off models on disk and an application is the only place it can be exercised.

Every fix is inside the framework. Two things worth knowing anyway:

  • If you generated auth migrations with an earlier alpha and use MySQL, they
    never ran, so there is nothing to undo — regenerate with auth:schema.
  • If a pipeline of yours was hanging on migrate, it will now refuse instead.
    Pass --force, which is what it always needed.