# 202. Sites

**For:** everyone. How this documentation is served.

## Declaring a site

Add a `[site]` block to a configuration and the application becomes a site: every id the build emits is prefixed with its name and every route sits under its path.

```toml
[site]
name = "docs"
at = "/fsr/docs"
shell = "../../app/generated/shell.json"
```

Nothing in the TypeScript changes. Routes are still `routes/[slug]/page.tsx` and a link is still a literal path. Only the plan and the bundle spell the prefix, as `docs:routes/[slug]/page.tsx#default`.

A site runs alone under `fsr dev` with its own layout as the page, so it can be developed without the application that will mount it.

## Mounting a site in a shell

The shell is the application that owns the document: the root layout, the sign-in and the vendor tree. Its configuration names what it mounts.

```toml
[sites.docs]
artifact = "sites/docs"
```

That row and the site's own `[site]` block are two halves of one link, so `fsr` writes both at once rather than leaving you to keep two files in step:

```
fsr sites link www www/sites/docs --at /fsr/docs
fsr sites unlink www docs
```

`link` refuses a shell that is itself a site, a site that mounts sites, a name the table already holds and a site whose `[site]` names somewhere else. `unlink` takes both halves back out. With `--keep-site` it leaves the site's own block alone.

`fsr sites list www` reports the resolved table: each row with the prefix its artifact claims, the version, the hash and a note when it does not hold, followed by every version the cache holds. Given `--host <url>` it compares instead. It puts each instance's mounted rows beside the table, marks them `ok`, `lags` or `absent` and exits non-zero when any of them disagrees.

An artifact path resolves against the shell's config root, so a site kept inside the shell resolves identically in development and in a release. At boot the host reads each artifact and checks it is the site it claims to be. It refuses one carrying engine-owned rows or a leaked server module, then grafts its routes into the shell's root layout.

```diagram
site-graft
```

The nesting is exactly one level. Only the shell's root layout wraps a site; a deeper layout in the shell does not.

## What the shell and site share

The shell and the site exchange typed information in both directions at build time. There are no runtime calls between them.

The shell's build writes `generated/shell.json`: every store key its loaders seed, with the type the browser reads, its import map and the exact version of each framework it vendors. A site names that file and its build writes `generated/shell.d.ts` in return, so the site reads the shell's store with the right type and imports React from the shell's URL rather than shipping a second copy.

The framework versions are there so a site does not have to track the shell's by hand. A mounted site vendors no framework at all: the shell's import map overrides its own on every shared specifier, so a second copy is bytes the browser never loads. Its build reads the version it renders under out of the contract instead. Vendoring one anyway at a different version is a build error naming both copies, as is pointing a shared specifier at the site's own URL or pinning a version the shell does not serve.

```ts
import { key } from "@snapfire/fsr-client/store";
import type { ShellStore } from "@generated/shell";

export const theme = key<ShellStore["site/theme"]>("site/theme");
```

The other direction is the site's own contract. Its clients, cache tags and types are prefixed and merged into the shell's registry without collision.

## What the shell keeps

The shell owns the document, the session and navigation. A click from the shell into the site is a payload navigation that keeps the header's island alive and imports the site's entry on the way.

That entry calls `enableNavigation` too, as every entry does. A second call on a document the navigator already wired leaves the first call's wiring in place. The next click after moving into the site also patches in place. Moving between two mounted sites keeps the header throughout.

The shell wins on every collision. Its import map overrides the site's on any shared specifier and the site's `[session]`, `[auth]`, `[cache]` and `not-found.tsx` are dropped. A static root outside the site's own prefix is dropped too, so a site cannot claim `/static/js/vendor` and shadow the shell's. The roots the host infers already sit under the prefix, so a package the site vendors for itself is served at `/<site>/static/js/vendor`, its stylesheets and icons the same way. All of it is listed under `ignored` in the boot report.

Store keys are the exception: nothing namespaces them. A site must prefix its own by hand.

## Artifacts and versions

An artifact is a deploy tree: the files the host reads, laid out by what each file is rather than by where it sat in the project. The configuration goes under `config/`, the plan and the contracts under `app/` and every static root under `serve/` at the route it answers. Routes, sources, types and markdown are build inputs and stay out.

Destinations are derived instead of copied. A site whose static root points at a shared build outside its own directory still packs, because the tree does not record where that directory sat. Laying a tree out again also yields the same tree, so a hash taken in a working tree is the hash of what a release copied out of it.

```
fsr sites hash www/sites/docs
fsr sites pack www/sites/docs --version 1.4.0
```

`hash` prints the hash, the parts it covers and, with `--files`, every file and its digest. `pack` writes the whole thing as a gzipped tar with a manifest at its root, listing each file with its size and sha256. Packing the same tree twice writes the same bytes, so two builders can be compared.

The hash is over that listing rather than the bytes, which means a manifest alone yields it: a pin can be checked before anything is downloaded.

## Deploying a version

`[sites] root` is a cache: `<root>/<name>/<version>` per installed version, which is where `artifact = "docs@1.4.0"` already resolves.

```
fsr sites install www docs-1.4.0.tar.gz --keep 3
```

An install unpacks into a dot-prefixed staging directory beside its destination. It verifies every file against the manifest and the whole listing against the hash. Only then does it rename the directory into place. If a fetch dies, no mount sees any of it. If the archive arrives wrong, the running version keeps serving and the install says which file disagreed. `--keep` sweeps older versions, never the one in use, so a rollback needs no network.

It then pins what it placed, writing the hash into the mount naming that version, because install is when the hash is computed and when you intend to ship that version. `--no-pin` leaves the table alone. A mount still pointing at the previous version is untouched, since moving the pointer is a separate decision. `fsr sites pin www docs` makes that decision on its own. Only a `name@version` mount can be pinned: a path is a linked working tree that changes on every build, so a pin there would be stale after the next build.

The pin lives in the shell instead of beside the artifact. A pin inside the artifact would be replaced by whoever replaced the artifact, so it is only useful as a statement the shell makes about the site.

The source of the archive is pluggable. A directory of archives and a single archive ship with the framework. An object store, a registry or a company artifact service needs one method, `fetch(package, version, into)`. The install path around it does not change.

Then move the row and reload. The host rebuilds its tables whole and swaps them; a request in flight finishes on the old ones. A pinned `hash` refuses bytes that do not match it. `GET /__fsr/sites` lists what is actually mounted, with version and hash, so a monitor can compare the fleet against the table.

## Reloading the sites without the shell

`SIGHUP` reloads everything, the shell's own configuration and plan included, which is the wrong operation for a site deploy: one team's signal would ship whatever state the shell's files happen to be in.

A sites-only reload reads the artifacts again and rebuilds against the shell exactly as the process booted it. The shell's configuration, plan and contracts are held values, so an edited configuration or a half-written plan on disk cannot reach the tables through it. A shell change needs a restart.

For an operator who cannot signal the process, such as in a container with no exec or on a platform with no restart hook, there is a route:

```
curl -X POST /__fsr/sites/reload
```

It answers with the mounted rows. When the candidate is refused it answers `409` with the reason, which a signal cannot tell you.

The route exists only when the dependency has the feature and the application has installed the mounter. A host missing either has no route and no reload. The dependency carries the feature:

```toml
snapfire_fsr_sites = { version = "0", features = ["reload_route"] }
```

The application installs the mounter, which tells the host how to mount the sites a second time:

```rust
let builder = snapfire_fsr_sites::mountable(builder);
snapfire_fsr_sites::mount_all(builder)?
```

`mount_all` on its own mounts the sites at boot and leaves the process with no way to do it again.

`fsr sites reload www --host <url>` posts it. It does the same thing from a laptop instead of a shell on the box. `--host` repeats for a fleet and the instances are asked one at a time, stopping at the first refusal, since a `409` says what was published is bad and carrying on would ship it to the rest. `--all` continues anyway, for when one instance is already known to be broken. With no `--host` it reads the shell's own `server.listen`.

## Protecting the administrative routes

`/__fsr/` is the host's administrative surface and **the framework does not guard it**. `GET /__fsr/sites` reports every mounted site with its version and hash. The reload route above changes what the process serves. Neither authenticates. Restricting them is the deployment's job, done at whatever sits in front of the host. A host published straight to the internet with no proxy exposes both. `fsr sites list --host` and `fsr sites reload` forward credentials to it with `--header "Name: Value"`, which repeats. `$FSR_SITES_HEADER` carries one that should stay out of shell history. They create no credentials of their own, because there is nothing here to authenticate against: the header is for whatever you put in front.

Add a rule wherever your traffic already passes that denies the prefix except from the network your operators are on.

**nginx.** `^~` matters: a plain `location /__fsr/` loses to any regex location. This one has to win against the catch-all that proxies everything else.

```nginx
location ^~ /__fsr/ {
  allow 10.0.0.0/8;
  deny all;
  proxy_pass http://127.0.0.1:8080;
}
```

**Apache.** Place it before the `ProxyPass` for `/`, since the first match wins.

```apache
<LocationMatch "^/__fsr/">
  Require ip 10.0.0.0/8
</LocationMatch>
```

**Caddy.**

```caddy
@fsr path /__fsr/*
handle @fsr {
  @denied not remote_ip 10.0.0.0/8
  respond @denied 404
  reverse_proxy 127.0.0.1:8080
}
```

**HAProxy.**

```haproxy
acl fsr_path path_beg /__fsr/
acl operators src 10.0.0.0/8
http-request deny deny_status 404 if fsr_path !operators
```

**Envoy.** Match the prefix `/__fsr/` on its own route and give it a `direct_response` of 404. You can also send it to a cluster only the operators' listener reaches. An RBAC filter with a `url_path` prefix permission is the equivalent where the mesh already runs one.

**Traefik.** Put an `ipAllowList` middleware on a router whose rule is ``PathPrefix(`/__fsr/`)``, declared before the catch-all router so it matches first.

**Kubernetes ingress-nginx.** Use a `server-snippet` annotation carrying the nginx block above. A separate ingress for the prefix with `whitelist-source-range` also works.

Answering 404 rather than 403 is the better default, since it does not confirm the surface is there.

A proxy rule does not cover everything. A host bound to `0.0.0.0` is reachable around the proxy, so bind it to loopback or to the interface the proxy is on. A sidecar or another pod on the same network is not the public internet, but it is not an operator either. That is why the rule allows only the operators' network instead of only denying the public one.

## Try it

Run `curl /__fsr/sites` on this site. The `docs` row is these pages, mounted into the main application at `/fsr/docs` from `sites/docs` inside it.
