BoxProbe
Public lab Fixed upstream · merged Medusa 2.14.0

An intermittent 404 the admin SPA sees, that the DB doesn't

Running scout against a clean Medusa 2.14.0 cold-boot, 8 of 15 admin scenarios started failing seconds after login. The trigger turned out to be a 3/5 (60%) intermittent 404 on GET /admin/users/me — with the same cookie, the same actor_id, and the same SQL hitting postgres and returning the row. Filed upstream; Medusa maintainer confirmed in 25 minutes and identified a buffer-aliasing race in the mikro-orm entity serializer. The fix — PR #15806, from a community contributor — was merged on 2026-07-14, and the issue closed as completed.

3/5 (60%)
Reproduces on 2.14.0
0/5
Reproduces on 2.13.6
25 min
To maintainer confirm
4
Evidence layers in report
Fixed upstream — merged Filed 2026-06-04 Merged 2026-07-14

Confirmed as a regression in the mikroOrm entity serializer and routed to the team for a fix with regression coverage. The fix landed in PR #15806, merged on 2026-07-14; the issue was closed as completed.

How the loop closed

Forty days after we filed the report, the fix we pointed at shipped. A community contributor picked up the confirmed root cause, wrote the patch plus a regression test, and we validated on the thread that it matched what we had reproduced from the outside.

  1. The maintainer confirmed the root cause in the mikroOrm serializer 25 minutes after filing and routed it to the team for a fix.
  2. When the stale-bot moved to auto-close the issue, we commented to keep it open until a fix was linked.
  3. Community contributor dosacha opened PR #15806 with the exact fix direction — snapshot the collected keys in getKeys() so a nested serialize() can never overwrite the parent frame's key buffer — plus regression coverage for the nested-serialization path.
  4. We reviewed the patch on the thread, confirmed it matched the root cause we had reproduced from the outside, and offered to re-run our original reproduction against the build.
  5. PR #15806 was merged on 2026-07-14 (commit 4564616) and issue #15594 was closed as completed.

The fix: getKeys() now returns a stable snapshot of the collected keys instead of exposing the shared keyCollectionBuffer by reference — the precise aliasing our black-box evidence isolated to the postgres → handler boundary.

Issue #15594 (closed) Fix PR #15806 (merged)
Headline regression · auth race

Admin /admin/users/me intermittently 404s within seconds of login (~60% reproducible)

On Medusa 2.14.0, after a successful login, the admin SPA's first call to GET /admin/users/me during navigation to any sub-route returns 404 in about 60% of attempts. The SPA reacts by redirecting the user back to /login. The same flow is rock-solid on 2.13.6 (0/5 across many trials).

confirmed upstream bug — routed for fix

Same request, same cookie session, same actor_id — but the response flips between 200 (with user) and 404 ("User with id: X was not found") within a 2-second window. Postgres receives the exact ORM-generated query and returns the row; the row is dropped somewhere between the DB driver and the route handler.

How we found it: Running scout's UI-driven regression scenarios against a clean 2.14.0 cold-boot caused 8 of 15 admin scenarios to fail at the first post-login navigation. Drilling in past concurrency, DB state, and pg-driver hypotheses isolated a 4-line Playwright repro hitting the same intermittent 404.
Confirmed Medusa maintainer reply on issue thread, 25 minutes after filing

This is a regression in the entity serialization layer, not in the auth/session or DB path. The row is dropped in mikroOrmSerializer, which every module read passes through. getKeys() returns the shared keyCollectionBuffer by reference (not a copy) when the buffer is exactly full; a nested serialize() call during relation traversal then mutates that buffer while the parent loop is still iterating it, intermittently overwriting the parent's remaining keys (potentially including id). The previous implementation (retained as mikro-orm-serializer-old.ts) allocated a fresh Array.from(keys) per serialize() call, so nested calls could never corrupt a parent frame — which is why 2.13.6 does not reproduce.

Root-cause file: packages/core/utils/src/dal/mikro-orm/mikro-orm-serializer.ts

Scope: This is a core, hot serialization path that can intermittently drop rows from any query result. Admin login is just one visible symptom.

The whole point of the report we filed was that we couldn't pinpoint the in-Medusa cause from outside — we just stacked enough evidence that the maintainer could. Four observable layers, in order:

  1. 1

    Symptom (user-visible)

    Login → navigate to /collections → 2s later the page bounces back to /login. Sometimes. Roughly 3 in 5 attempts on 2.14.0.

  2. 2

    Same request, different response

    The 200 (right after login) and the 404 (2s later) carry the exact same connect.sid cookie. The 404 body interpolates the real, current admin user_id — so req.auth_context.actor_id resolved correctly. Handler reached the if (!user) NOT_FOUND branch.

  3. 3

    DB returns the row, handler sees nothing

    Postgres log_statement = 'all' shows the exact ORM-emitted SELECT against the user table reaches the DB during the 404. Running that same SQL by hand 30 times in a row returns 1 row, 30/30. So the row leaves postgres but is gone by the time const [user] = await remoteQuery(query) runs.

  4. 4

    Source code on this path is bit-identical

    md5-comparing every .js file in the request chain — @medusajs/user, modules-sdk, orchestration, framework/http, authenticate-middleware, /admin/users/me/route, /auth/session/route, medusa-config — between 2.13.6 and 2.14.0 produces zero diffs.

Each hypothesis we tested ourselves before filing — so the maintainer didn't have to ask “did you try X?”

Twenty lines. Runs against an unmodified dtc-starter at the v2.14.0 tag.

# pip install playwright && playwright install chromium
import asyncio
from playwright.async_api import async_playwright

async def attempt(p, base_url):
    browser = await p.chromium.launch(headless=True)
    ctx = await browser.new_context()
    page = await ctx.new_page()
    await page.goto(f"{base_url}/login", wait_until="networkidle")
    await page.locator("input").nth(0).fill("[email protected]")
    await page.locator("input").nth(1).fill("supersecret")
    await page.locator("button").filter(has_text="Continue with Email").click()
    await asyncio.sleep(2)
    await page.goto(f"{base_url}/collections", wait_until="networkidle")
    await asyncio.sleep(2)
    kicked = "login" in page.url
    await browser.close()
    return kicked

async def main():
    async with async_playwright() as p:
        kicks = sum(await attempt(p, "http://localhost:9000/app") for _ in range(5))
        print(f"kicked: {kicks}/5")

asyncio.run(main())
dtc-starter @ v2.14.0 Related case: 2.13.6 → 2.14.0 contract drift

Want this kind of audit for your application?

Send a URL and a user scenario. We'll run scout against your baseline and target and share the analysis.

Get a Free Sample Report