Skip to content

Experience surfaces

A surface is a client the server serves at a fixed one-segment path: a UI, an agent front-end, anything that talks to the HTTP API on behalf of a signed-in person. The server hosts the client’s files; the client calls the same versioned API every other consumer calls.

Two kinds of surface exist:

  • Embedded surfaces are compiled into the server binary: /login/, /app/, and /studio/. Changing one means building a new binary.
  • Module surfaces are declared in a module’s manifest, and their built files are placed under the directory named by the surfaces.directory setting. Adding or replacing one needs no rebuild and no restart.

Both kinds are served by the same code path, with the same cache policy and the same single-page-app fallback: a request for a missing page answers with the surface’s entry page, so client-side routes work, while a request for a missing file under assets/ answers 404. One difference: a directory surface refuses any path segment that begins with ., which answers 404 instead of falling back, because those files are on disk.

A module surface has two halves, and each fact lives in exactly one of them.

  • The manifest holds everything that goes through a human publish: the mount path, the actions the surface may perform, its navigation entry, and the API contract version the module targets. A module that declares a surface must also pin that contract version.
  • The directory holds only built files, at <directory>/<moduleKey>/<assets>/. It carries no separate package descriptor or manifest file.

A surface is mounted when it is declared in the applied metadata and its entry file exists in the directory. The server evaluates that rule on every request, so applying a manifest and copying files can happen in either order, and the surface appears as soon as both halves are in place. A generated build descriptor that travels with the files is future packaging work.

A surface is identified by its name: the module’s qualified key (vendor.moduleKey, or moduleKey when the module has no vendor), a colon, and the key of the surface’s entry in the manifest’s surfaces map. Module acme.crm declaring a surface keyed app gives acme.crm:app; module crm declaring the same key gives crm:app. These are two different surfaces. The platform’s own surfaces are henceforth:app and henceforth:studio. The sign-in page has no name and is never a client of the API.

The mount path is where a web surface is served. It is validated and unique on the server, but it is not an identity: changing it does not change the surface’s name. Anything that refers to the path, such as bookmarks and links, must be updated. Renaming the surface key or the module changes the name, which is a breaking change for anything that uses the old one.

The words henceforth and hf are reserved for first-party modules. No module can use either as its key, and only built-in modules can use either as a vendor key, so a module can never declare a surface with a platform name.

GET /v1/surfaces lists a module surface only when the caller holds at least one of the actions it declares that an administrator can grant, at any scope. Because there would be nothing to hold otherwise, a module surface must declare at least one grantable action: plan and apply refuse a module surface whose actions is empty, or lists only actions that cannot be granted (record and flux actions), with 422 APPLY_SURFACE_NEEDS_GRANTABLE_ACTION naming the surface. A module cannot put its surface in front of every user by declaring nothing, and no mounted surface is listed to nobody, the administrator included. A surface the platform ships (henceforth:app, henceforth:studio) is exempt, and with no grantable action is listed for everyone. An embedded surface is always a candidate; a module surface is a candidate only while it is mounted.

People who can read a module’s metadata also see why a surface that module declares is not mounted: the response carries an unmounted list naming the surface, the path where its entry file was expected, and the reason. Everyone else sees neither the surface nor the diagnostic. They see each listed surface’s declared actions on the same terms.

Once the instance is claimed, a mounted surface’s files are public: anyone who can reach the server can request them, signed in or not, whatever the surface’s actions say. The list filters what GET /v1/surfaces offers a person, not who can fetch the bytes. A surface that needs a secret must not ship it in its built files: keep secrets out of built files, and fetch them from the API after sign-in, where the platform checks the caller’s permissions on each request.

The surface hub is the list of surfaces a person may use, whatever client asks for it. It is the response of GET /v1/surfaces, and any client can read it: the sign-in page does after sign-in, and hf surface list prints it in a terminal, with no browser needed. The web is one kind of surface; the list names each entry’s kind so a client that cannot open a kind can still show it. An older hf prints a kind it does not know rather than failing.

The sign-in page acts on the list as follows.

  • A first-party surface is opened at once: henceforth:studio, else henceforth:app. A module surface is never chosen as the landing, even when it is the only one listed and whatever its key is called, because installing a module must not be able to take over where people land. A next on the sign-in link that names a listed third-party surface by its mount path is not followed either: the page shows “Continue to
  • Otherwise the page shows the hub: each listed surface with its label, its canonical name, the module that provides it and, for a module’s surface, a “Third-party” marker. Nothing is opened until the person chooses. The label is module-authored text; the name and marker are the server’s, so a module that calls its surface “Studio” is still shown as acme.crm:studio from acme.crm.
  • When nothing is listed, the page says that no surfaces are assigned and to ask an administrator. It does not fall back to /app/.
  • When the list cannot be loaded, the page says so and offers Retry. It opens nothing without a list.

hf surface list prints each surface’s name, kind, the module it belongs to and its label, with the mount path of a web surface under it; --json prints the server’s response unchanged. An empty list prints a notice and exits 0.

hf surface open (or its shortcut, hf open) acts on the same list, but it never chooses for the person. It opens the surface you name by its canonical name, or the default you set for the target with hf surface default <name>. With neither, it prints the hub and fails: a terminal command has no page to wait on while the person chooses, and guessing would decide which surface receives the person’s session. A name the list does not hold is refused before any sign-in code is minted, with the surfaces you can use and a suggestion when you typed a key or a mount path instead of a name.

Every surface is in a module: the part of its canonical name before the :. Studio and App are in the henceforth module; crm:crm is in the crm module. Opening a surface hands its module’s code your session, in the same origin as Studio, so hf surface open always names the module, and --json output carries it as "module". hf reads the module from the name, not from the key or the label, and refuses a surface whose listing names a different module than its name does. The first time you open a surface of a module other than henceforth on a given target from a terminal, hf asks before it opens it and remembers a yes. The yes is yours: after someone else signs in to that target, hf asks again. Whenever hf cannot ask (with --json, the mode agents and scripts use, or without a terminal) it refuses a surface that has not been confirmed on that target yet, and mints no sign-in code for it: the absence of a question is not consent, so no caller can open a surface no person has confirmed. Opening it once from a terminal confirms it.

The list is guidance, not authorization: the PDP and the surface’s ceiling decide each request. A token issued to a surface still lists the person’s other surfaces, because the list ignores the ceiling.

A surface’s actions are also its ceiling. A token issued to a surface carries the surface’s name, and the server limits that token to the actions the surface declares, whoever the signed-in person is. An administrator using a surface that declares only runtime.data.record.read can read records through it and nothing else: a delete answers 403 SURFACE_SCOPE_EXCEEDED, and so does a delete of a record that does not exist, because the server checks the ceiling before it looks anything up. The refusal names the surface and the actions it would have to declare. It says nothing about what the person holds.

Four properties are worth knowing:

  • The ceiling never grants. Declaring an action does not give the person the right to perform it. The person’s own permissions and record ownership still decide; the ceiling only removes actions from what they could do.
  • An upsert needs both create and update. PUT on a unique field creates a record or changes an existing one depending on whether the key exists, so a surface must declare runtime.data.record.create and runtime.data.record.update to call it. With only one of them the answer is 403 SURFACE_SCOPE_EXCEEDED whether or not the key exists. A surface that only creates uses POST.
  • No surface mints credentials. A token issued to a surface cannot create a session code, a principal, a password reset or an attached credential, whatever the surface declares, because a credential would outlive the ceiling that asked for it.
  • Some actions need an approval. A module surface cannot declare admin.metadata.apply or an admin.iam.* action: plan and apply refuse it until installs can be approved.

A surface that declares no actions can call no action-guarded route. hf validate warns about it, and apply refuses it for a module surface (see above).

To read a surface’s actions, do not look for a list in these pages: they are published by the server. GET /v1/auth/me reports the surface the calling token was issued to, with its name, mount path and actions, and GET /v1/surfaces carries the actions of each surface the caller may read about.

The identity a token carries is the surface’s name, not its mount path. Moving a surface to another path keeps its tokens valid. Renaming the surface key, or uninstalling the module, makes the server answer 401 UNKNOWN_SURFACE to its tokens, and the person signs in again.

The browser keeps one token per surface, in localStorage under the surface’s mount path, so each surface holds only the token issued to it. A second slot belongs to the login page, which holds an unscoped token with the person’s full authority. That slot is what lets one sign-in open every surface: a surface with no token redirects to the login page, which asks the server for a one-time code with its own token and exchanges it for a token issued to that surface. A deep link survives the round trip.

Three things follow:

  • A hand-off does not extend a session. Every token the exchange issues expires no later than the token that requested the code. Signing in again, with hf open or a password, is what starts a new session.
  • Signing out removes every stored token. Sign-out removes every surface’s token and the login page’s, so nothing signs the person back in. It does not clear the hf_runtime cookie, which only serves a runtime module’s JavaScript and expires with the token it holds.
  • Slots are a cache, not an identity. The mount path only keys where the browser stores a token; the server checks the surface name the token carries. Moving a surface to another path keeps its tokens valid, and the browser quietly signs in again at the new path. A path handed to a different surface discards the old token on its first use.

The login page’s token can do anything the person can. Same-origin script can read every slot, as described under Trust today.

What the ceiling does not protect:

  • Script on the same origin. A hostile script can read every credential the browser holds and is not bound by the ceiling of the token it was issued.
  • Instance-wide scope. Reading records is not scoped per surface: declaring runtime.data.record.read lets the surface read every record its person may read.
  • Manifest edits take effect live. The server reads the ceiling from the declared metadata on every request, so an edit to actions applies to tokens already issued.
  • --no-auth. A server started without authentication has no tokens and so no ceiling.

Every surface is served from the same origin as Studio. Any script a surface runs can read the credentials the browser holds for every other surface on that origin and can act as the signed-in user, an administrator included. Same origin means full trust: mount only surfaces you trust.

The ceiling described above narrows what a well-behaved surface’s token may do. It does not make a hostile surface safe, because a hostile surface does not have to use the token it was issued. Only serving each surface from its own origin would separate them.

Because the files take effect on the next request, bypassing the human publish that guards a manifest, write access to surfaces.directory is equivalent to running script as any signed-in user, an administrator included. Restrict who can write to it as tightly as you restrict who can publish metadata.

The public origin an operator configures with --public-origin binds SSH sign-in to the instance. It does not separate surfaces: they all share that one origin.

The same-origin argument is made in full in the runtime screen trust model.