Skip to content

fix(redirects): fire for unauthenticated visitors#817

Merged
ascorbic merged 8 commits intoemdash-cms:mainfrom
all3f0r1:fix/808-redirect-public
Apr 30, 2026
Merged

fix(redirects): fire for unauthenticated visitors#817
ascorbic merged 8 commits intoemdash-cms:mainfrom
all3f0r1:fix/808-redirect-public

Conversation

@all3f0r1
Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes the redirect middleware so 301/302 rules from _emdash_redirects actually fire for unauthenticated visitors.

The runtime middleware sets up two different shapes of locals.emdash depending on the caller — the authenticated/edit-mode/preview branch exposes the full handlers + db, while the public-visitor fast path intentionally exposes only collectPageMetadata / collectPageFragments (defense-in-depth, so page renders can't reach into admin-capable handlers). The redirect middleware was reading db from locals.emdash only, so on the public branch it bailed out at if (!emdash?.db) return next() — meaning WordPress migration 301s, manual prefix rewrites and the Auto: slug change rows EmDash inserts on slug edits did nothing for real site traffic. hits and _emdash_404_log also stayed at zero, so the bug was invisible from the admin UI.

The fix keeps the public-render boundary intact and routes the lookup through getDb() from the loader, which already does the right thing: it returns the per-request scoped Kysely from ALS (the anonScoped.db the runtime middleware installs for D1 read-replica sessions) when present, and falls back to the singleton instance otherwise. Same accessor the template helpers use.

Closes #808

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (core package — pre-existing cloudflare typecheck failures are unrelated to this change and reproduce on main)
  • pnpm lint passes (no new diagnostics introduced — diagnostic count unchanged at 25 vs. baseline)
  • pnpm test passes for redirect/middleware tests (5 new + 45 redirect-repo + 7 redirect-handler + middleware-prerender all green; 3 pre-existing auth test failures on main are unrelated)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation and pnpm locale:extract has been run (n/a — server middleware, no UI strings)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion (n/a — bug fix)

AI-generated code disclosure

  • This PR includes AI-generated code

Screenshots / test output

New regression test covers the public-visitor branch and verifies the fallback chain:

✓ tests/unit/astro/middleware-redirect.test.ts (5 tests)
  ✓ fires for an unauthenticated visitor on a public path (no locals.emdash.db)
  ✓ fires pattern matches for unauthenticated visitors
  ✓ still uses locals.emdash.db when present (authenticated/edit-mode/preview path)
  ✓ skips silently when no database is available at all
  ✓ does not intercept /_emdash routes

Reproducer from the issue (curl /old with no cookies) now returns 301 with Location: /new and _emdash_redirects.hits increments — same result as the edit-mode case.

The redirect middleware bailed when `locals.emdash.db` was missing, which is
the intentional state for public visitors -- so 301/302 rules from
`_emdash_redirects` only fired for logged-in admins, edit-mode sessions and
preview tokens. WordPress migration redirects, manual rewrites and the
`Auto: slug change` rows did nothing for real traffic, and `hits` /
`_emdash_404_log` stayed at zero.

Falls back to `getDb()` (ALS-aware -- returns the per-request scoped session
when one is active, the singleton otherwise) when `locals.emdash.db` is
absent. Same accessor the loader and template helpers use, so the public
render boundary stays minimal.

Adds a regression test covering exact and pattern matches on the
public-visitor branch, plus the existing authenticated branch and the
"no db configured" fallback.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 29, 2026

🦋 Changeset detected

Latest commit: 8c45d47

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 29, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@817

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@817

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@817

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@817

emdash

npm i https://pkg.pr.new/emdash@817

create-emdash

npm i https://pkg.pr.new/create-emdash@817

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@817

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@817

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@817

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@817

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@817

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@817

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@817

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@817

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@817

commit: 8c45d47

@github-actions github-actions Bot added size/L query-count changed PR diff modifies query-count snapshot files and removed size/M labels Apr 29, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 29, 2026

Query-count snapshot changes

7 routes changed, total Δ +7 queries.

D1

Route Before After Δ
GET / (cold) 20 21 +1
GET /category/development (cold) 24 25 +1
GET /pages/about (cold) 19 20 +1
GET /posts (cold) 20 21 +1
GET /posts/building-for-the-long-term (cold) 31 32 +1
GET /search (cold) 21 22 +1
GET /tag/webdev (cold) 24 25 +1

Comparing snapshot files between base and head. Updated automatically on each push.

@ascorbic
Copy link
Copy Markdown
Collaborator

Thanks @all3f0r1, the correctness fix is right.

Snapshot shows +1 query on every public request. That's because findExactMatch runs uncached, while the pattern path already uses getCachedPatternRules() / setCachedPatternRules() in redirects/cache.ts, with invalidateRedirectCache() wired into every redirect write site (and the slug-change auto-redirect path in content.ts).

Before merging, can we extend the cache to cover exact matches too? Roughly:

  • Add a Map<string, Redirect> to redirects/cache.ts alongside the pattern array.
  • One repo method that loads WHERE enabled = 1, splits into exact Map + pattern array, populates both caches in one query.
  • invalidateRedirectCache() clears both.
  • Middleware does cachedExactMap.get(pathname) instead of repo.findExactMatch.

Cold isolate: 1 query loads both. Warm: 0 queries. Empty-redirect sites: empty Map, no probe needed.

Happy to take a follow-up look once it's pushed.

One query loads both kinds at cold-start; warm requests issue zero
queries. Exact rules indexed by source path in a Map (O(1) lookup),
pattern rules pre-compiled into an array. Empty-redirect sites cache
an empty Map + array, eliminating the per-request probe.

Addresses ascorbic's review on emdash-cms#817: snapshot was showing +1 query on
every public request because findExactMatch ran uncached while pattern
lookups already went through the module-level cache.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@all3f0r1
Copy link
Copy Markdown
Contributor Author

all3f0r1 commented Apr 29, 2026

Alright, I applied your request the cleanest way I could come up with. LMK if it's up to your standard.

@ascorbic
Copy link
Copy Markdown
Collaborator

/review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix is correct and well-motivated. Public branch has anonScoped.db in ALS via runWithContext (middleware.ts:357), so getDb() resolves to the per-request scoped Kysely on the public render path -- exactly the same accessor the loader and template helpers use. Invalidation set is complete (api/redirects/index.ts, [id].ts, content.ts:650 for slug-change auto-redirects, seed/apply.ts:683). Cache redesign in 572ba2e correctly addresses the +1/route warm-path cost the workflow flagged.

A few low-severity notes inline -- none blocking. Mostly observations on the cold-isolate trade-off and the pre-existing module-scope singleton pattern.

One extra note (couldn't anchor to the relevant line because it falls outside the diff hunks): the outer try { ... } catch { return next(); } at redirect.ts:113-116 swallows every error from findAllEnabled / recordHit / log404 / next() and silently bypasses redirects. The comment justifies this as "redirects table doesn't exist yet (pre-migration)" but in practice it also masks transient DB errors and bugs -- and post-cache-redesign, one bad row that throws inside setCachedRedirects (e.g. a compilePattern regression on a malformed pattern source) will cause every public request to silently bypass redirects until the isolate restarts, since the cache was never populated. Not a regression introduced by this PR -- the old code had the same silent catch -- but the blast radius grew because the cache now batches all rules. Consider narrowing to isMissingTableError(error) (used elsewhere in the codebase per AGENTS.md) and at least console.error("[emdash] redirect middleware error:", error) for the rest. Follow-up rather than blocking.

* null = not yet populated, object = cached.
*/
let cachedPatternRules: CachedRedirectRule[] | null = null;
let cachedRedirects: CachedRedirects | null = null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing pattern, not introduced here, but worth flagging for a follow-up. AGENTS.md ("Performance: caching and query patterns") explicitly calls out that module-scope singletons like let cachedRedirects: ... | null = null are vulnerable to Vite SSR chunk duplication: two chunks inlining this module each get their own cachedRedirects binding, so one chunk's writes (or invalidateRedirectCache() calls) won't be visible to readers in the other chunk. The result is stale-cache-after-write or duplicated cold-start queries.

The canonical fix is the globalThis + Symbol.for pattern from settings/index.ts (SITE_SETTINGS_CACHE_KEY/holder) and request-context.ts. This PR rolled with the existing pattern, which is fine -- but since the cache now holds more state and needs to be coherent across writes (especially the auto-redirect on slug change, which is a hot write path), it's a reasonable next perf/correctness pass.

No change requested in this PR; just noting for the maintainer.

// Map + array, so the next request returns immediately without probing.
let cached = getCachedRedirects();
if (!cached) {
const all = await repo.findAllEnabled();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cold-isolate cost note (no change requested, just confirming the trade-off is intentional):

Before this PR, the cold path was one indexed point lookup on source (findExactMatch, with the existing source index) plus -- on miss -- one scan of pattern rules. After: every cold isolate runs findAllEnabled(), a full scan of all enabled rows, even for paths that would have missed both. Sites with large WordPress-migration redirect sets (10k+ rows) will pay materially more on cold start than they did before, in exchange for zero-query warm requests.

The maintainer requested this shape explicitly in review (one query, both caches), and the warm-path zero-query case dominates in steady state -- so this is the right call. Worth keeping in mind if anyone files a cold-start regression on a large-scale migration site; mitigation would be a paginated load or a count-then-decide-strategy fallback for sites above some threshold.

});

it("skips silently when no database is available at all", async () => {
getDbMock.mockRejectedValueOnce(new Error("EmDash database not configured"));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth adding a test for findAllEnabled() itself rejecting (transient DB error) -- right now the suite only covers getDb() rejecting at acquisition time. The current outer-catch behavior would swallow that and call next(), but there's no test pinning that. Same shape as this test, just throw from inside findAllEnabled via vi.spyOn(RedirectRepository.prototype, "findAllEnabled").mockRejectedValueOnce(...). Optional.

@@ -0,0 +1,179 @@
/**
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: file lives at tests/unit/astro/middleware-redirect.test.ts but the source is at src/astro/middleware/redirect.ts. AGENTS.md says "Test files mirror source structure," which would suggest tests/unit/astro/middleware/redirect.test.ts. Existing files in the repo are inconsistent on this so not strictly required, but worth aligning if you're already touching it. No action needed if you'd rather not.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 30, 2026

Review posted successfully at #817 (review).

Summary: Posted a single COMMENT review with 4 line-anchored comments and 1 inline note in the summary body for a comment that couldn't anchor (line 113 falls outside the diff hunks). Verdict is the fix is correct and ready, with low-severity follow-up observations on the module-scope singleton pattern, cold-isolate query cost trade-off, silent catch blast radius, and a missing test for repo-level rejections.

github run

Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I have a feeling we'll end up needing to optimise this at some point, but it's good for now

@ascorbic ascorbic enabled auto-merge (squash) April 30, 2026 14:15
@ascorbic ascorbic merged commit a9c29ea into emdash-cms:main Apr 30, 2026
39 checks passed
@emdashbot emdashbot Bot mentioned this pull request Apr 30, 2026
@all3f0r1 all3f0r1 deleted the fix/808-redirect-public branch April 30, 2026 19:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core cla: signed query-count changed PR diff modifies query-count snapshot files size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Redirect middleware silently skipped for public visitors (locals.emdash.db missing on the anonymous path)

2 participants