Design Doc: Sentin8:Web — Flutter Web Static Host
1. Summary
Sentin8:Web is the single-instance static file server for every Flutter
web app in the platform. One process serves all apps across all verticals:
app locations are discovered at request time from runtime-registered
Valkey keys (captain:app_dir:{slug}, written by vertical enroll), so
the host holds zero compile-time knowledge of any app or vertical. Every
request is PQ-TLS-terminated in-process (Sentin8:TLS), the hostname picks
the app, static assets stream from the app's built build/web directory
with an SPA fallback, and the /v1/app/runtime/context bootstrap call is
proxied to Sentin8:App on the same origin.
2. Motivation
- One process per machine for all app shells. The platform can host dozens of app instances (dash, vertical admin apps, tenant apps). A per-app static server would multiply processes, certificates, and monitoring; a single multi-app host with runtime discovery keeps the fleet small and apps deployable without restarts.
- Zero compile-time vertical knowledge. App directories live in every
workspace (
sentin8/apps/,bank8/apps/,hotel8/apps/, …). Hardcoding those paths would couple the platform to its verticals; the registry-key discovery keeps the host generic — a newly enrolled app is served the moment its key exists. - Same-origin bootstrap. Flutter apps must fetch their runtime context (auth domain, branding, feature flags) before any user signs in — the SDK holds no token yet. Web proxies that call to app on the app's own origin so the TLS-injected instance headers establish the caller identity; a bearer/Shield gate there is impossible pre-login.
- Browser runtime constraints. Flutter WASM with threads needs
cross-origin isolation headers; the service-worker registration shipped
in
flutter_bootstrap.jsmust be stripped for in-app webviews; SPA routes need an index fallback. The host centralizes these once instead of every app rebuilding them.
3. Architecture & High-Level Design
3.1 Two serving modes
- Multi-app mode (default, app-store). The hostname decides the app:
the slug is the first DNS label before
.{env}.{org}.{domain}(e.g.ebank.prod.ibank.local→ebank). Resolution:captain:app_dir:{slug}Valkey key → absolute path of the built web directory. Discovery is on-the-fly — no restart needed for newly built or registered apps. - Single-app mode (dash).
--web-root <dir>(orSENTIN8_WEB_ROOT) serves one fixed directory for all requests — the platform dash. The directory must containindex.html(fail-fast otherwise).
Both modes share the file-serving core, differ only in root resolution.
3.2 Request pipeline
Client ──TLS──▶ Sentin8:Proxy (L4 SNI) ──▶ sentin8:web (in-process PQ-TLS)
│ context middleware
│ (X-Sentin8-Hostname/App-Id)
▼
/v1/health ── liveness JSON
/v1/app/runtime/context ── proxy → sentin8:app
everything else ── static/SPA handler
- The context middleware injects
X-Sentin8-*headers and strips client-supplied copies. Under the L4 proxy (byte-piping, no header injection) the HTTPHostheader is the fallback hostname source — without it every proxied hostname would resolve empty. - The runtime-context proxy forwards only
x-sentin8-*andhostheaders to app'sGET /v1/app/runtime/contextover the platform TLS channel, then relays status/content-type/body unchanged. It is a public, pre-login surface: instance metadata only, same trust basis as the app shell itself. - The traffic counter counts every inbound request except health checks and flushes to Valkey (stat ingest).
3.3 Static file serving invariants
- Path traversal protection: the requested path is resolved
(
canonicalize) and must stay under the resolved web root; anything else 404s. Encoded sequences (..%2F) are decoded before the check, so canonicalization is the enforcement point, not string matching. - SPA fallback: extensionless paths serve
index.html(client-side routing); missing files WITH an extension 404. flutter_bootstrap.jspatching: the service-worker settings object is replaced with{}in memory (brace-depth scanner), the file is servedno-store, must-revalidateso a rebuilt app is never shadowed by a cached bootstrap.- Cross-origin isolation:
COOP: same-originandCOEP: require-corpare set on the bootstrap script,main.dart.js, and.wasmfiles — required for multi-threaded WASM.
3.4 Policy posture
The Shield engine boots (refuse-to-start on invalid policy text) and
hot-reloads like every service — uniform enforced posture — but web has no
token-bearing handler to gate: static assets are the app shell and the
runtime-context proxy is public pre-login. The former bearer gate on the
proxy was removed (it 401'd every app bootstrap; the caller identity is
the app instance, not a user). Registration declares only the
sentin8:web-policy M2M workload (sentin8:bus:consume) for policy
hot-reload consumption.
4. Key Traits and Data Structures
MultiAppState: optional Valkey connection manager forcaptain:app_dir:{slug}lookups. A missing connection degrades to 404s for app-hostname requests (health and dash mode are unaffected).SingleAppState: the fixed web root (dash mode).Sentin8Tls: in-process TLS termination, on-demand SNI cert resolution, context middleware — identical to every other Rust service.strip_service_worker_settings: a brace-depth scanner that replaces theserviceWorkerSettings: {…}object with{}, string-safe (a literal}inside a string does not end the object).
5. Rationale, Trade-offs, and Alternatives
- Valkey keys instead of filesystem scanning or sys API calls. The
registry key is written once at
vertical enrolland updated by deploys; lookups are O(1), require no per-request HTTP call, and keep web independent of sys's schema. Alternative (querying sys per request) added a hop on every asset; filesystem scanning reintroduced hardcoded vertical paths. - Slug = first hostname label. The uniform hierarchy
{app}.{env}.{org}.{domain}makes the first label the app slug by construction; no lookup table is needed. App slugs therefore must not contain dots (enforced at registration). - In-memory bootstrap patching instead of a build step. Flutter's
generated bootstrap carries service-worker settings that break in-app
webviews; patching at serve time keeps app builds stock and the patch
logic in one place. Cache-control
no-storeguarantees clients always get the patched body. - Public runtime-context proxy instead of shielding it behind auth. The endpoint's response is instance metadata (auth domain, branding, feature flags) — nothing user-scoped — and the transport + TLS-injected instance headers already establish the caller. Gating it would break every first paint.
6. Internal Implementation & Invariants (Private Design)
serve_from_web_rootis the single file path for both modes: decode → canonicalize → under-root check → file? stream : SPA fallback → 404. The unit tests pin traversal protection, SPA fallback, MIME handling, and the bootstrap patch.- Streaming bodies (
tokio_util::io::ReaderStream) for non-bootstrap files — large WASM bundles are never fully buffered; content-length comes from the file metadata. - Bootstrap responses are read fully and re-encoded (they are small); COOP/COEP headers are attached after the body is built.
- Health endpoint (
/v1/health) returns status + build hash — captain monitors it; it bypasses the traffic counter. - Hostname resolution falls back from
X-Sentin8-HostnametoHost(port stripped) because the L4 proxy does not inject headers. - No tables, no migrations, no outbox — own DB identity — the policy
boot pool connects read-only via
SENTIN8_WEB_DATABASE_URL(sentin8_web_servicerole, declared in the registration and seeded byplatform.sql); the shared dev DSN is only the unset-variable fallback for local harnesses. - Graceful shutdown rides
Sentin8Tls::serve(same shutdown name as every service); in-flight requests drain before exit.
7. Unresolved Questions / Future Designs
- Long-lived immutable assets (
main.dart.js,*.wasm, fonts — Flutter fingerprints them) serveCache-Control: public, max-age=31536000, immutable; the bootstrap staysno-store(a pure header change plus test). - ETag/If-None-Match support computes
ETag: "{size}-{mtime}"(cheap and change-accurate) on first stream and honorsIf-None-Match→ 304 — the same handler change as the immutable-cache header.
Design attribution: sentin8:web and its underlying concepts were designed by Hamid Jahanzad Hamid@Jahanzad.com.