Skip to content

Commit cd42fa6

Browse files
swisskymarcusbellamyshaw-cell
authored andcommitted
feat: toolbar config option with client-side bootstrap mode for shared caches (emdash-cms#1886)
* feat: add toolbar config with client-side bootstrap mode for shared caches Implements the design agreed in Discussion emdash-cms#1742: a `toolbar` option with "server" (default, unchanged), "client" (cache-identical public HTML with a client-rendered Edit pill and an `_edit` query param for fresh editor renders), and `false` (disabled). The toolbar is now also dismissible in the browser. * fix: mark _edit canonical redirect and render as uncacheable Addresses review: the 302 for non-editors now carries Cache-Control: private, no-store, and both the redirect and the editor render opt out of the route cache (a cached redirect would bounce editors back to the canonical URL). * test: extract bootstrap script by index instead of regex CodeQL flags the <script>…</script> regex as a bad HTML-filtering pattern (case-sensitive tags). Use indexOf/lastIndexOf — we are slicing our own generated string, not filtering HTML.
1 parent 2e16c44 commit cd42fa6

11 files changed

Lines changed: 559 additions & 20 deletions

File tree

.changeset/toolbar-client-mode.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"emdash": minor
3+
"@emdash-cms/admin": patch
4+
---
5+
6+
Adds a `toolbar` config option for reliable editor-toolbar delivery behind shared caches. `toolbar: "client"` keeps public HTML identical for every visitor and shows a client-side "Edit" pill for logged-in editors that opens a fresh, uncached editor render via an `_edit` query param; `toolbar: false` disables the toolbar entirely. The toolbar can now also be dismissed in the browser via its × button. The default (`"server"`) is unchanged.

docs/src/content/docs/reference/configuration.mdx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,33 @@ emdash({
367367

368368
Uploads that exceed the configured limit are rejected with a `413 Payload Too Large` response on the direct upload path, or a `400 Validation Error` on the signed-URL path.
369369

370+
### `toolbar`
371+
372+
**Optional.** Controls how the editor toolbar (the floating pill on public pages) is delivered. Defaults to `"server"`.
373+
374+
| Value | Behavior |
375+
| ----- | -------- |
376+
| `"server"` (default) | The toolbar is injected server-side into every HTML response rendered for an authenticated editor. |
377+
| `"client"` | Public HTML is identical for every visitor. A tiny bootstrap script shows an "Edit" pill in browsers that have logged into the admin; clicking it verifies the session and reloads the page with an `_edit` query param, which is always rendered fresh (never cached) with the full toolbar. |
378+
| `false` | Never render the toolbar or the bootstrap script. |
379+
380+
```js
381+
emdash({
382+
toolbar: "client",
383+
})
384+
```
385+
386+
Use `"client"` when your public HTML is served through a shared cache (Cloudflare Cache Everything / [Workers Cache](https://developers.cloudflare.com/workers/cache/), Fastly, Varnish, …). With server-side injection, an editor browsing the public site receives the cached anonymous variant — without the toolbar — whenever an anonymous visitor primed the cache first, so the toolbar appears and disappears with cache state. In client mode nothing session-specific is injected into shareable HTML, so the cache stays fully effective and the toolbar is reliable.
387+
388+
Notes on `"client"` mode:
389+
390+
- Logged-out visitors who open a shared `?_edit` URL are redirected to the canonical URL, so the param can't leak drafts or prime extra cache entries with page content.
391+
- The "logged in" signal is a non-secret `localStorage` flag set by the admin; the pill verifies the real session before entering the edit view.
392+
- The bootstrap is a small inline `<script>`. If your site sends a strict `Content-Security-Policy` without `'unsafe-inline'`, add a hash for it — the same applies to the server-injected toolbar.
393+
- EmDash injects nothing session-specific — but if your own templates branch on `Astro.locals.user` (e.g. an "Admin" nav link for logged-in users), that variance is still in your HTML and still fragments the cache.
394+
395+
In every mode, the toolbar can be dismissed in the browser via its × button (per-browser, until the next time an editor opens the admin). Preview and edit-mode responses always render server-side with `Cache-Control: private, no-store`.
396+
370397
### `experimental`
371398

372399
**Optional.** Opt-in features whose behaviour or wire format may change, or be removed, in a minor release. Each field is independently enabled.

packages/admin/src/components/Header.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ import { ThemeToggle } from "./ThemeToggle";
1212
export type { CurrentUser } from "../lib/api/current-user";
1313

1414
async function handleLogout() {
15+
// Clear the public-site toolbar-bootstrap flag (see Shell.tsx).
16+
try {
17+
localStorage.removeItem("emdash-editor");
18+
} catch {
19+
// ignore — flag is best-effort
20+
}
1521
const res = await apiFetch("/_emdash/api/auth/logout?redirect=/_emdash/admin/login", {
1622
method: "POST",
1723
credentials: "same-origin",

packages/admin/src/components/Shell.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,26 @@ export function Shell({ children, manifest }: ShellProps) {
4747
}
4848
}, [user?.isFirstLogin]);
4949

50+
// Maintain the non-secret "an editor session may exist in this browser"
51+
// localStorage flag consumed by the public-site toolbar bootstrap
52+
// (`toolbar: "client"`, Discussion #1742). Set here — not in the login
53+
// flows — so every auth method (passkey, OAuth, magic link, dev bypass)
54+
// is covered. Opening the admin also un-dismisses the toolbar.
55+
// Key literals are duplicated in emdash core, which the admin can't import.
56+
React.useEffect(() => {
57+
if (!user) return;
58+
try {
59+
if (user.role >= 30) {
60+
localStorage.setItem("emdash-editor", "1");
61+
localStorage.removeItem("emdash-toolbar-dismissed");
62+
} else {
63+
localStorage.removeItem("emdash-editor");
64+
}
65+
} catch {
66+
// localStorage unavailable — the toolbar pill just won't appear
67+
}
68+
}, [user]);
69+
5070
return (
5171
<Sidebar.Provider
5272
defaultOpen

packages/core/src/astro/integration/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
350350
trustedProxyHeaders: resolvedConfig.trustedProxyHeaders,
351351
maxUploadSize: resolvedConfig.maxUploadSize,
352352
admin: resolvedConfig.admin,
353+
toolbar: resolvedConfig.toolbar,
353354
};
354355

355356
// Determine auth mode for route injection

packages/core/src/astro/integration/runtime.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,30 @@ export interface EmDashConfig {
589589
favicon?: string;
590590
};
591591

592+
/**
593+
* Editor toolbar delivery on public pages.
594+
*
595+
* - `"server"` (default): the toolbar is injected server-side into every
596+
* HTML response rendered for an authenticated editor. Simple and
597+
* zero-config, but behind a shared cache (Cloudflare Cache Everything /
598+
* Workers Cache, Fastly, Varnish, …) editors often receive the cached
599+
* anonymous variant — without the toolbar — whenever an anonymous visitor
600+
* primed the cache first, so the toolbar appears and disappears with
601+
* cache state.
602+
* - `"client"`: public HTML is identical for everyone (nothing
603+
* session-specific is injected server-side, so shared caches stay fully
604+
* effective). A tiny bootstrap script shows an "Edit" pill for browsers
605+
* that have logged into the admin (non-secret localStorage flag). Clicking
606+
* it verifies the session and reloads the page with an `_edit` query
607+
* param, which is always rendered fresh (never cached) with the full
608+
* toolbar. Logged-out visitors opening an `_edit` URL are redirected to
609+
* the canonical URL.
610+
* - `false`: never render the toolbar or bootstrap script.
611+
*
612+
* See the visual-editing docs for the cache-behavior details.
613+
*/
614+
toolbar?: "server" | "client" | false;
615+
592616
/**
593617
* Version of Astro the host project is building with. Populated by the
594618
* integration's `astro:config:setup` hook (not authored by the user) and

packages/core/src/astro/middleware/request-context.ts

Lines changed: 127 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,27 @@
88
* - Preview tokens: _preview query param with signed HMAC token
99
* - Edit mode: emdash-edit-mode cookie (for visual editing)
1010
* - Toolbar injection: floating pill for authenticated editors
11+
* - Client toolbar mode (`toolbar: "client"`): cache-identical HTML with a
12+
* client-side bootstrap pill and an `_edit` query param for fresh editor
13+
* renders (Discussion #1742)
1114
*/
1215

1316
import type { APIContext } from "astro";
1417
import { defineMiddleware } from "astro:middleware";
18+
// @ts-ignore - virtual module
19+
import virtualConfig from "virtual:emdash/config";
1520

1621
import { resolveSecretsCached } from "#config/secrets.js";
1722

1823
import { verifyPreviewToken, parseContentId } from "../../preview/tokens.js";
1924
import { getRequestContext, runWithContext } from "../../request-context.js";
25+
import { EDIT_PARAM, renderToolbarBootstrap } from "../../visual-editing/toolbar-bootstrap.js";
2026
import { renderToolbar } from "../../visual-editing/toolbar.js";
2127

28+
type ToolbarMode = "server" | "client" | false;
29+
30+
const toolbarMode: ToolbarMode = virtualConfig?.toolbar ?? "server";
31+
2232
/** Astro's route-cache handle. EmDash requires Astro 6+, so it's always present. */
2333
type RouteCache = APIContext["cache"];
2434

@@ -35,6 +45,34 @@ function optOutOfRouteCache(cache: RouteCache): void {
3545
cache.set(false);
3646
}
3747

48+
/**
49+
* Inject HTML before `</body>` if the response is an HTML page with a body
50+
* end tag. Does not touch cache headers — callers decide whether the result
51+
* is still shareable. `injected` tells the caller whether anything changed.
52+
*/
53+
async function injectBeforeBodyEnd(
54+
response: Response,
55+
htmlToInject: string,
56+
): Promise<{ response: Response; injected: boolean }> {
57+
const contentType = response.headers.get("content-type");
58+
if (!contentType?.includes("text/html")) return { response, injected: false };
59+
60+
const html = await response.text();
61+
if (!html.includes("</body>")) {
62+
// Body already consumed — rebuild the response unchanged.
63+
return { response: new Response(html, response), injected: false };
64+
}
65+
66+
const injected = html.replace("</body>", `${htmlToInject}</body>`);
67+
return {
68+
response: new Response(injected, {
69+
status: response.status,
70+
headers: response.headers,
71+
}),
72+
injected: true,
73+
};
74+
}
75+
3876
/**
3977
* Inject toolbar HTML into a response if it's an HTML page.
4078
* Returns the original response if not HTML.
@@ -44,25 +82,49 @@ async function injectToolbar(
4482
toolbarHtml: string,
4583
routeCache: RouteCache,
4684
): Promise<Response> {
47-
const contentType = response.headers.get("content-type");
48-
if (!contentType?.includes("text/html")) return response;
85+
const result = await injectBeforeBodyEnd(response, toolbarHtml);
86+
if (result.injected) {
87+
// Toolbar-injected HTML is session-specific (its presence reveals an
88+
// active editor session); it must never be stored in a shared CDN cache
89+
// and served to anonymous visitors. Mirrors the preview branch's guard
90+
// (#1398). `Cache-Control` covers browsers/downstream proxies; the
91+
// route-cache opt-out covers the shared edge cache, which ignores
92+
// `Cache-Control`.
93+
result.response.headers.set("Cache-Control", "private, no-store");
94+
optOutOfRouteCache(routeCache);
95+
}
96+
return result.response;
97+
}
4998

50-
const html = await response.text();
51-
if (!html.includes("</body>")) return new Response(html, response);
99+
/**
100+
* Inject the client-toolbar bootstrap script. Identical for every visitor, so
101+
* cache headers and route-cache options are left untouched and the response
102+
* stays fully shareable.
103+
*/
104+
async function injectBootstrap(response: Response): Promise<Response> {
105+
const result = await injectBeforeBodyEnd(response, renderToolbarBootstrap());
106+
return result.response;
107+
}
52108

53-
const injected = html.replace("</body>", `${toolbarHtml}</body>`);
54-
const result = new Response(injected, {
55-
status: response.status,
56-
headers: response.headers,
109+
/**
110+
* Redirect an `_edit` URL to its canonical form (same URL without the param).
111+
* Applied when the requester is not an authenticated editor, so a shared
112+
* `?_edit` link degrades gracefully for everyone else (Discussion #1742).
113+
*/
114+
function redirectToCanonical(url: URL): Response {
115+
const canonical = new URL(url);
116+
canonical.searchParams.delete(EDIT_PARAM);
117+
return new Response(null, {
118+
status: 302,
119+
headers: {
120+
Location: canonical.pathname + canonical.search + canonical.hash,
121+
// Header-following caches (Fastly, Varnish, browsers) must not store
122+
// the redirect — a cached 302 would bounce editors back to the
123+
// canonical URL. The route-cache opt-out at the call site covers the
124+
// Workers Cache, which ignores Cache-Control.
125+
"Cache-Control": "private, no-store",
126+
},
57127
});
58-
// Toolbar-injected HTML is session-specific (its presence reveals an active
59-
// editor session); it must never be stored in a shared CDN cache and served
60-
// to anonymous visitors. Mirrors the preview branch's guard (#1398).
61-
// `Cache-Control` covers browsers/downstream proxies; the route-cache
62-
// opt-out covers the shared edge cache, which ignores `Cache-Control`.
63-
result.headers.set("Cache-Control", "private, no-store");
64-
optOutOfRouteCache(routeCache);
65-
return result;
66128
}
67129

68130
export const onRequest = defineMiddleware(async (context, next) => {
@@ -97,9 +159,30 @@ export const onRequest = defineMiddleware(async (context, next) => {
97159
// Fast path: check for CMS signals before doing any work
98160
const hasEditCookie = cookies.get("emdash-edit-mode")?.value === "true";
99161
const hasPreviewToken = url.searchParams.has("_preview");
162+
// `_edit` requests a fresh (never cached) editor render; only meaningful in
163+
// client toolbar mode where public HTML is otherwise identical for everyone.
164+
const hasEditParam = toolbarMode === "client" && url.searchParams.has(EDIT_PARAM);
165+
166+
if (hasEditParam) {
167+
// `_edit` URLs are their own cache key. Never store them in the route
168+
// cache — a cached anonymous redirect would bounce editors back to the
169+
// canonical URL, and a cached editor render must never be shared.
170+
optOutOfRouteCache(context.cache);
171+
172+
// A non-editor (anonymous, logged-out, or insufficient role) opening an
173+
// `_edit` URL is sent to the canonical URL — shared `?_edit` links
174+
// degrade gracefully and never prime a cache entry with page content.
175+
if (!isEditor) {
176+
return redirectToCanonical(url);
177+
}
178+
}
100179

101-
// No CMS signals and not an editor → skip everything (zero overhead)
180+
// No CMS signals and not an editor → skip everything (zero overhead in
181+
// server mode; client mode injects the identical-for-everyone bootstrap)
102182
if (!hasEditCookie && !hasPreviewToken && !isEditor) {
183+
if (toolbarMode === "client") {
184+
return injectBootstrap(await next());
185+
}
103186
return next();
104187
}
105188

@@ -162,21 +245,45 @@ export const onRequest = defineMiddleware(async (context, next) => {
162245
response.headers.set("Cache-Control", "private, no-store");
163246
}
164247

165-
// Inject toolbar for authenticated editors
166-
if (isEditor) {
248+
// Inject toolbar for authenticated editors. Preview and edit-mode
249+
// responses are session-specific (`private, no-store` + route-cache
250+
// opt-out) in every toolbar mode, so the server toolbar is safe to
251+
// inject here even in client mode.
252+
if (isEditor && toolbarMode !== false) {
167253
const toolbarHtml = renderToolbar({
168254
editMode,
169255
isPreview: !!preview,
170256
});
171257
return injectToolbar(response, toolbarHtml, routeCache);
172258
}
173259

260+
// Stale edit cookie without a session (client mode): still serve the
261+
// shareable bootstrap variant.
262+
if (toolbarMode === "client" && !isEditor && !preview) {
263+
return injectBootstrap(response);
264+
}
265+
174266
return response;
175267
});
176268
}
177269

178-
// Editor without CMS signals — no ALS needed, but inject toolbar
270+
// Editor without preview/edit-mode signals.
179271
if (isEditor) {
272+
if (toolbarMode === false) {
273+
return next();
274+
}
275+
276+
// Client mode: without the `_edit` param the response must stay
277+
// byte-identical to the anonymous variant (plus the same bootstrap
278+
// script), so shared caches serve one entry for everyone. The bootstrap
279+
// pill is the editor's entry point into the fresh `_edit` render.
280+
if (toolbarMode === "client" && !hasEditParam) {
281+
return injectBootstrap(await next());
282+
}
283+
284+
// Server mode, or an `_edit` request in client mode: inject the full
285+
// toolbar (response becomes `private, no-store` and route-cache
286+
// opted out).
180287
const response = await next();
181288
const toolbarHtml = renderToolbar({
182289
editMode: false,

packages/core/src/virtual-modules.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ declare module "virtual:emdash/config" {
2020
auth?: AuthDescriptor;
2121
authProviders?: AuthProviderDescriptor[];
2222
i18n?: I18nConfig | null;
23+
toolbar?: "server" | "client" | false;
2324
}
2425

2526
const config: VirtualConfig;

0 commit comments

Comments
 (0)