# Serving resources

The node's serving registry is the single list of what it exposes. Register a
file or directory deliberately, then copy its URL from `spt serve list`.
Removing an entry stops serving it; it does not delete its source bytes.

If node-prefixed docs return 404 or serve controls are unavailable after an
in-place update, check `spt node status` before restarting anything. A resident
network layer older than 0.68.0 cannot supply these routes even when the
installed release can. See [the update diagnosis](../self-update/overview.md#one-command-spt-update)
for the full-restart requirement and its cost to hosted sessions.

<!-- [doc->REQ-WEB-URL-NODE-PREFIX] -->
## Node-prefixed URLs

Every served resource uses the owning node's name:

```text
http://localhost:5474/hfenduleam/f/report.md
http://localhost:5474/hfenduleam/docs/
http://localhost:5474/hfenduleam/a/my-harness/
```

The server listens on loopback only. The default port is `5474`; `SPT_DOCS_PORT`
overrides `daemon.json`'s `docs_port`. A request to bare `/` redirects with HTTP
302 to `/<local-node>/`. Existing bare docs paths, including `/llms-full.txt`,
`/manifest.schema.json`, and raw `.md` pages, remain compatibility aliases.

<!-- [doc->REQ-WEB-URL-BOUND-PORT] -->
Generated serving, attachment, and docs URLs use the running daemon's actual
bound listener port. Changing a client's environment or config does not change
that port. Configuration is used to predict a URL only when no daemon runs;
an unavailable or unreported listener on a running daemon is an error, not a
guessed link. `spt fetch` resolves the receiving node's local listener after
startup, including when a pasted URL names another node or another port.

Node prefixes take precedence over compatibility aliases. If a known subnet
peer is named `cli`, `/cli/...` addresses that peer, not the local docs section.
The alias is shadowed only while that peer is known. Use the canonical twin,
`/<local-node>/docs/cli/...`, for a local docs link that cannot change meaning.
A single-leaf URL naming a docs root file (`llms.txt`, `llms-full.txt`,
`manifest.schema.json`, or a root `.md` page) serves that file before peer
lookup. Dotted hostnames remain valid: a peer named `llms.txt` loses only the
leaf `/llms.txt`, not its node-prefixed `/llms.txt/...` paths. A bare known node
prefix redirects to its trailing-slash form.
The local node still wins over the root-file exception: if the local hostname
is `llms.txt`, `/llms.txt` redirects to `/llms.txt/`; its docs file remains at
`/llms.txt/docs/llms.txt`.

The node root `/<node>/` redirects with HTTP 302 to `/<node>/docs/`, for both
local and remote nodes. Query parameters are preserved. There is no browser
registry index or `?json` index: use `spt serve list` or `spt serve list --json`
to audit exposed paths and URLs.

The path beneath the node prefix identifies the facet:

| Path | Resource |
|---|---|
| `f/<served-name>` | A registered file or directory |
| `docs/` | The installed docs bundle |
| `a/<adapter>/` | The adapter's core-owned served root |
| `<short-alias>/` | An adapter's optional short path, pointing at the same entry |
| `m/<short-id>` | One message, rendered; `?json` for its machine twin |
| `bin/`, `install` | Reserved facets; currently answer 404 naming the facet |

A request naming a **known subnet peer** is answered by that peer through the
local daemon; see [Cross-node serving](cross-node.md) for what comes back and
what each status means. Other first segments retain the docs compatibility
behavior, including the docs server's own 404 for nonexistent pages. An unknown
local facet returns 404 naming that facet.

Use a trailing slash for directory roots: `/<node>/docs/`,
`/<node>/f/<directory>/`, `/<node>/a/<adapter>/`, and adapter aliases. Requests
without that slash redirect before serving the root `index.html`, so browser
relative links resolve inside the correct subtree. Redirects preserve query
parameters. This does not add directory listings or implicit nested indexes;
the docs compatibility surface retains its existing bytes and status behavior.

The facet tokens `docs`, `f`, `a`, `m`, `bin`, and `install` are reserved in
the URL grammar. A node with one of those OS hostnames is reported loudly at
daemon startup and by a peer learning it at join, but may still boot and join.
The router never treats such a token as a top-level node, so that node cannot
be addressed by prefix until renamed at the OS. W0 introduces no hostname
admission policy or SPT node-renaming verb.

<!-- [doc->REQ-WEB-SERVING-REGISTRY] -->
## Register, inspect, remove

```sh
spt serve add report.md
spt serve add ./reports --as review
spt serve list
spt serve list --json
spt serve rm report.md
```

`add` records an absolute reference to the file or directory, not a copy.
Later edits are visible at the same URL; deletion of the source returns 404.
A directory exposes only its own subtree: traversal and symlinks escaping the
root are refused. `rm` accepts the served name or the entry id printed by the
listing. It removes only the registry entry.

Served names remain stable for an entry's lifetime:

- The first `report.md` keeps that name.
- Another path requesting `report.md` receives `report~1.md`, then
  `report~2.md`, with the suffix before the final extension.
- Removing the first entry reserves `report.md` for its prior absolute path
  and kind. Re-adding that same file reclaims `report.md`; a different third
  file still receives `report~2.md`. A stale URL never opens a different resource.
- Re-registering the same absolute path returns its existing entry, even if
  the caller supplies a different `--as` name.
- Explicit `--as` names use the same allocator. A name assigned explicitly
  or by suffixing can be reclaimed only by its prior path with the same kind.
- Allocation history is persisted per node. Another node has its own names;
  the node prefix keeps their URLs distinct.

The broker and brain are separate processes that write
`$SPT_HOME/serve/registry`; CLI clients do not write it directly. The store includes
entry ids, kinds (`file`, `dir`, `attachment`), paths, assigned names,
registration timestamps, optional origin endpoint ids, and allocation history.
Optional lifetime (`ttl_ms`) and receiving-endpoint `audience` metadata apply
to every entry kind, including live file and directory references. Explicit
registrations leave both unset; helper registrations carry a lifetime and
audience. Do not edit or remove this
file to reclaim a name: that would destroy the history which prevents stale
URLs changing meaning.

Writer coordination requires both processes to hold the
same `serve/registry.lock` guard across reading and updating the registry.
Atomic snapshot replacement alone does not prevent one writer from losing
another's update. Both running writer implementations must be updated:
a new brain with an old broker does not fully activate protection. HTTP readers remain
lock-free; [attachment expiry and source protection](attachments.md#lifetime)
are unchanged.

<!-- [doc->REQ-WEB-SERVING-REGISTRY] -->
## Adapter served roots

Core creates `$SPT_HOME/adapters/<adapter>/web/` at activation and registers it
as one directory entry. The adapter writes only the output it wants exposed
into this directory. Its install tree, manifest, and other auxiliary files
are **never** served on its behalf.

The entry answers at `/<node>/a/<adapter>/`. An optional
`[adapter].web_short_path` manifest field requests an additional short alias for
the **same** entry and directory. A requested alias such as `cspt` produces
`/<node>/cspt/`; if that name has already been assigned, the ordinary stable
suffix rule applies.
Use the listing to discover the assigned alias instead of guessing it.

The names `f`, `docs`, `a`, `m`, `bin`, and `install` are reserved. Requesting
one as `web_short_path` refuses activation with a field-naming diagnostic.
Without a short alias the adapter facet still serves normally.

Deactivation removes the registry entry but retains the directory and its
files. Reactivation with the same declaration reclaims the prior alias, not a
new suffix. Reactivation or an adapter update must not erase those output bytes.

<!-- [doc->REQ-WEB-ADAPTER-DOCS-FACET] -->
## Adapter docs

An adapter that ships its own documentation points at it with an optional
`[adapter].docs_dir` manifest field, and it is served at the **reserved `docs`
segment** of the adapter facet:

```text
http://<node>:5474/<node>/a/<adapter>/docs/
```

`docs_dir` is **adapter-relative** — a path inside the adapter's own installed
directory, never an absolute path and never a parent-directory escape. Both
refusals are named `MANIFEST_DOCS_DIR_OUTSIDE:<adapter>`, and the check runs
twice: once when the manifest is validated, and again on every request, so a
directory replaced by a link pointing elsewhere after installation is refused
at the fetch rather than served from wherever the link now leads. The adapter's
own directory root is refused for the same reason its install tree is never
served: it holds the adapter's record and state.

Without the field, `/<node>/a/<adapter>/docs/` answers `404` naming the facet.
It never falls through to a `docs/` folder inside the core-owned `web/` root —
the segment is reserved, so the URL means the same thing on a node whose
adapter declares `docs_dir` and on one whose adapter does not.
Reads also refuse replacement of the core-owned root with a symlink to an
install tree or another directory.
The same containment rules apply through the adapter facet and its alias.

<!-- [doc->REQ-WEB-ENTRY-AUDIENCE] -->
## Entry fields: lifetime, audience, origin

Beside its path and served name, an entry may carry three fields. They are
per-entry and independent of kind, and `spt serve list` renders each one it has
— the registry's job is answering what this node exposes **and to whom**, and a
narrowing that did not render would make that answer a half-truth.

| Field | Meaning |
|---|---|
| `ttl` | A lifetime from registration. Absent means the entry lives until removed. Attachments carry 30 days by default; entries the `FILE_ACCESS_HELPER` registers carry 24 hours. |
| `audience` | The set of endpoints allowed to fetch it. Absent means anyone the `WEB` surface admits; an empty set admits no remote node. |
| `origin` | Who or what registered it — for a message helper, the message short-ID; for an input-report helper, `user-input:<receipt-id>`. |

An **audience** is a cross-node narrowing. The owner serves the entry only to
nodes hosting an admitted endpoint and answers everyone else `403` naming the surface —
deliberately the same refusal a `WEB` deny rule produces, so a narrower audience
cannot be distinguished from a denial by probing and the registry does not
become an oracle for what exists.

It is enforced where the fetch origin is **proven**: the cross-node stream's
handshake identity. That handshake proves a *node*, while an audience names
*endpoints*, so the check the owner can honestly make is whether the proven node
hosts at least one of them — it stops another machine, not another agent on an
admitted endpoint's machine. An endpoint this node cannot place grants no access.

<!-- [doc->REQ-HELPER-SECOND-AUDIENCE] -->
When the seated user hands the same live file to another receiving endpoint,
that endpoint joins the existing input reference's audience. `spt serve list`
shows both recipients; the URL and original 24-hour deadline stay unchanged.
This reuse does not widen an unrelated explicit registration or attachment.

**Loopback remains the trusted machine.** A local browser presents no endpoint
identity, so a request arriving on the loopback listener is served regardless of
audience. An operator who needs secrecy from other users of the same machine has
file permissions, not this field.

An entry whose lifetime has elapsed stops serving immediately — it answers the
ordinary not-found — whether or not the daemon's reaper has run yet.

<!-- [doc->REQ-WEB-ACCESS-SURFACE] -->
## Access control

`WEB` is a rule-addressable control surface, on by default within a subnet
because registration is already deliberate exposure. A blanket closed posture
does not close it; a rule or per-surface mode naming `WEB` can. It is currently
non-attributable: no sender-endpoint stamp is proven for web reads.

The owning node runs the `WEB` check for every request that arrives from
another subnet member, under that member's handshake-proven identity; a refusal
answers `403` with a body naming `WEB` ([Cross-node serving](cross-node.md)).
Local serving remains loopback-only.

<!-- [doc->REQ-XFER-RETIRED] -->
### `XFER` is retired

`WEB` did not rename `XFER` — it replaced what `XFER` was for, and `XFER`
itself is **retired**. The file-transfer machinery it gated has left the tree
with it: the pull-model attachment supersedes it, and cross-node reads of
served content are governed by `WEB`. Retiring rather than repurposing the id
is deliberate — reusing `XFER` for web reads would silently change the meaning
of every stored rule that already names it.

The id is gone from the vocabulary, so nothing new can be granted on it, and
`spt endpoint access` no longer offers it. **A rule you already stored that
names `XFER` stays exactly where you put it.** It governs nothing — the same
standing any unminted id has always had under this open vocabulary — and every
load of the store that contains it prints one line naming it:

```text
ACCESS_SURFACE_RETIRED XFER — a stored rule or mode entry names this surface,
which no longer exists: ... The entry is KEPT and governs nothing (a retired id
is legal exactly as an unminted one is). Remove it when you are ready
(`spt endpoint access remove ...`).
```

One line per retired id, however many rows name it. Nothing is rewritten or
dropped on your behalf: a vocabulary that shrank is not licence to edit a
decision you made, and the report is the only record that the surface was ever
used at all. Remove the row when you are ready to.

<!-- [doc->REQ-WEB-LAN-BOOTSTRAP-FIREWALL] -->
## Bootstrap LAN firewall admission

`spt serve lan --bootstrap` retains the signed-binary admission gate and reports
the listener's actual port and binder executable. Firewall repair is separate:
a denied elevation prompt, unavailable manager, or failed repair leaves a valid
listener running and reports `LAN_FIREWALL_UNVERIFIED`. This is not proof that
the listener is unreachable. Repeat bootstrap to retry; status only inspects.

`LAN_FIREWALL_REPAIR_REQUESTED` means elevation was requested, not that a rule
was installed. The elevated helper queries the original broker's current state;
it never replays bootstrap or starts a stopped listener. Its observed result is
reported separately. `LAN_FIREWALL_RECONCILED` means the owned rule's scope was
observed, **not** that another machine successfully reached the listener.

On both Windows and Linux, admission is **port-scoped**: binder identity is
ownership metadata, not executable enforcement. Another process using that
port may be admitted. Windows writes two rules, not one -- a tailnet half and
a LAN half -- because a single rule cannot cover both without also admitting
the local subnet of a public network.
Linux selects UFW before firewalld before nft; an opaque manager does not
authorize falling through to a different backend.

Supported Linux layouts are active dual-stack UFW, an already-provisioned
owned firewalld policy, or one unrestricted existing inet/filter nft input
chain. IPv6-disabled UFW, multiple or family-specific nft input chains, and
unknown rule scope remain unverified rather than being rewritten.

Fresh firewalld hosts need an operator-provisioned runtime policy named
`spt-bootstrap-tcp`, with description `spt-core bootstrap TCP admission v1`,
target `CONTINUE`, priority `-32768`, ingress `ANY`, egress `HOST`, and no
unrelated allowances. Its permanent identity must also exist. Bootstrap does
not create a silent permanent-only policy or reload firewalld: a reload can
discard unrelated runtime configuration. Missing runtime policy leaves the
listener up, reports the provisioning prerequisite, and remains unverified.

`spt serve lan --stop` stops the listener before attempting owned-rule cleanup.
Cleanup never removes an unowned rule or the installation's UDP rule.
`LAN_FIREWALL_CLEANUP_UNVERIFIED` prints residual-resource guidance; inactive
managers may require operator inspection. Firewalld runtime policy definitions
cannot be deleted without a separately authorized reload, so an inert policy
can remain after its TCP allowances and permanent definition are removed.
After this cleanup, a later firewalld bootstrap needs operator provisioning
again: retaining an inert runtime policy does not retain its permanent identity.
Concurrent SPT repair helpers are refused using a host-wide lock. This does not
serialize independent administrator commands; avoid editing firewall policy
while repair runs, especially UFW rules addressed by mutable rule numbers.

`SPT_INSTALL_NO_FIREWALL` suppresses firewall mutations and elevation, not
listener startup or read-only reporting. Elevated CLI fixtures must set it
unless they explicitly own an isolated firewall environment.
