Experience surfaces
What a surface is
Section titled “What a surface is”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.
Embedded and module surfaces
Section titled “Embedded and module surfaces”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.directorysetting. 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.
Declaration and files
Section titled “Declaration and files”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.
Identity
Section titled “Identity”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.
Who sees a surface
Section titled “Who sees a surface”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
Section titled “The surface hub”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, elsehenceforth: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. Anexton 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:studiofromacme.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.
What a surface may do
Section titled “What a surface may do”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.
PUTon a unique field creates a record or changes an existing one depending on whether the key exists, so a surface must declareruntime.data.record.createandruntime.data.record.updateto call it. With only one of them the answer is403 SURFACE_SCOPE_EXCEEDEDwhether or not the key exists. A surface that only creates usesPOST. - 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.applyor anadmin.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.
In the browser
Section titled “In the browser”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 openor 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_runtimecookie, 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.readlets 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
actionsapplies to tokens already issued. --no-auth. A server started without authentication has no tokens and so no ceiling.
Trust today
Section titled “Trust today”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.
Reference
Section titled “Reference”- Module manifest – the
surfacesandplatformproperties. - Settings –
surfaces.directory. - HTTP API reference –
GET /v1/surfaces.