Skip to content

Public HTML Safety Contract

Public Capell pages must look like ordinary public HTML to every non-admin visitor.

This applies to anonymous users, signed-in non-admin users, crawlers, cached HTML, static exports, previews served outside the admin, and CDN copies.

A safe public response has these properties:

  • the HTML can be served unchanged to anonymous visitors, signed-in non-admin users, admins, crawlers, static exports, and CDN cache;
  • admin editing controls are added only after page load by an authenticated admin beacon response;
  • public Blade receives prepared data and does not discover editor state on its own;
  • cacheable HTML does not contain anything that would help a visitor infer the editor, schema, package, or admin URL structure.
flowchart LR
Controller["Controller/Action/View component"] --> Data["Hydrated public render data"]
Data --> Blade["Public Blade"]
Blade --> Safety["Safety inspection"]
Safety --> Cache["HTML/static cache/CDN"]
Admin["Authenticated admin"] --> Beacon["Post-load authoring beacon"]
Beacon --> Controls["Admin-only edit controls"]
Controls -. "never enters public cache" .-> Admin

Public HTML must not contain:

  • authoring controls, editor scripts, or admin toolbar markup;
  • model IDs, field paths, permissions, package names, or internal selectors;
  • signed Filament editor URLs;
  • editable markers or data attributes that reveal admin structure;
  • hidden admin-only labels or diagnostics;
  • lazy-loaded database state from public Blade views.

In-page authoring is a post-load admin feature. The public page loads normally, then an authenticated admin beacon may add edit controls for that admin session only.

AreaRule
Public BladeReceive hydrated render data. Do not query models or lazy-load relationships.
Render hooksAdd only safe public markup. Do not output authoring selectors or package internals.
Page cacheRefuse to cache unsafe responses. Treat bypass headers as a bug to fix.
ThemesKeep theme output public. Admin editing controls belong in frontend-authoring.
PackagesTest anonymous and non-admin output when the package renders public HTML.

Safe:

<article>
<h1>{{ $pageTitle }}</h1>
<div>{{ $summary }}</div>
</article>

Unsafe:

<article
data-capell-editor="page"
data-model-id="{{ $page->id }}"
data-field-path="content.hero.title"
>
<a href="{{ $signedEditorUrl }}">Edit</a>
{{ $page->title }}
</article>

The unsafe example leaks model identity, field paths, editor selectors, and a signed admin URL into public HTML. It is unsafe even if the element is visually hidden.

Fetch the page as an anonymous user:

Terminal window
curl -s https://example.test/ > /tmp/capell-public.html
rg "filament|signed|field_path|data-capell|model_id|permission|authoring" /tmp/capell-public.html

Then check cache behavior:

Terminal window
curl -I https://example.test/

If the response bypasses cache because unsafe HTML was detected, fix the render path rather than forcing cache.

For rendering, cache, theme, beacon, or public package changes, test both presence and absence:

  • expected public content is present;
  • authoring controls are absent for anonymous users;
  • admin URLs, model IDs, field paths, permissions, and selectors are absent;
  • cached/static HTML matches the anonymous-safe output;
  • public Blade does not rely on relationship fallback queries.
it('serves anonymous-safe public html', function (): void {
$response = $this->get('/example-page');
$response->assertOk();
expect($response->getContent())
->not->toContain('data-capell-editor')
->not->toContain('field_path')
->not->toContain('filament')
->not->toContain('signed');
});
it('does not lazy load relationships while rendering public pages', function (): void {
Model::preventLazyLoading();
$this->get('/example-page')->assertOk();
});
it('keeps cached html identical to anonymous-safe output', function (): void {
$first = $this->get('/example-page')->getContent();
$second = $this->get('/example-page')->getContent();
expect($second)->toBe($first)
->and($second)->not->toContain('data-capell-editor');
});