Skip to content

Mount a surface from a directory

Serve a built single-page app at a path of its own, such as /crm/, from a module’s manifest and a directory of files, without rebuilding or restarting the server.

  • A running Henceforth server that you started with --metadata-path, and its URL.
  • A built surface: a directory with an entry file (for example index.html) and its hashed files under assets/.
  • admin.metadata.apply to apply the manifest, and admin.metadata.module.read on the module to read the diagnostics in step 7.
  • The hf binary, logged in to the server. See Manage metadata with the hf CLI.

Run your surface’s own build. The platform does not build it: it serves the output as is. The steps use a module crm, a build output directory dist, and the entry file index.html.

2. Place the files under the surfaces directory

Section titled “2. Place the files under the surfaces directory”

Put the build output at <directory>/<moduleKey>/<assets>/, where moduleKey is the module’s qualified key (crm, or acme.crm for a module with a vendor) and assets is the directory name you will declare in step 4:

metadata/
surfaces/
crm/
dist/
index.html
assets/
app-3f9a1c.js

In settings.json5, in the metadata directory, name the directory from step 2 with a relative path:

{
surfaces: { directory: "surfaces" },
}

A relative path resolves against the metadata directory, so the same value works under hf dev and in production. The server reads this setting at startup: restart it once after adding or changing it. The server refuses to start if the directory does not exist.

4. Declare the surface and pin the contract

Section titled “4. Declare the surface and pin the contract”

In the module’s manifest, crm_module.json5, add a surfaces entry and a platform block. A module that declares a surface must pin the API contract version it targets:

{
moduleKey: "crm",
label: "CRM",
manifestVersion: 1,
surfaces: {
crm: {
mountPath: "/crm/",
assets: "dist",
entry: "index.html",
actions: [
"runtime.data.record.read",
"runtime.data.record.query",
"admin.metadata.module.read",
],
},
},
platform: { apiVersion: "v1" },
}

actions lists what the surface may do on behalf of the people who use it, and it decides who sees the surface listed. Declare what your surface calls and no more: here it may read and query records and read module metadata. A token issued to the surface is refused everything else. Each entry must be a known action. The server refuses admin.metadata.apply and every admin.iam.* action on a module surface. See Experience surfaces.

Terminal window
hf validate ./metadata

hf validate checks the manifest and the platform pin, and warns for each surface whose entry file is not where the server will look. It does not check that the actions entries are known actions; the apply in the next step does. If surfaces.directory is not a directory, it reports one warning saying the server will refuse to start, instead of a line per surface. A warning does not change the exit code.

Terminal window
hf apply

An unknown actions entry fails the plan and the apply with APPLY_UNKNOWN_SURFACE_ACTIONS, naming each one. An entry the server will not let a module surface declare fails with APPLY_SURFACE_ACTIONS_NEED_APPROVAL, also naming each one. Files and manifest can arrive in either order: the surface is mounted as soon as it is declared in the applied metadata and its entry file exists.

Terminal window
curl https://your-server/v1/surfaces \
-H "authorization: Bearer $TOKEN"

The surfaces list contains an entry with "name": "crm:crm" and "mountPath": "/crm/". The name is the module’s qualified key, a colon, then the surface key, here module crm and surface key crm; the mount path may change while the name stays. If the surface is declared but not mounted, it is absent from surfaces, and a caller who can read the module’s metadata sees an unmounted list instead:

{
"unmounted": [
{
"name": "crm:crm",
"key": "crm",
"module": "crm",
"mountPath": "/crm/",
"reason": "entryMissing",
"expectedPath": "/srv/metadata/surfaces/crm/dist/index.html"
}
]
}

Place a file at expectedPath and request the list again; no restart is needed. The server also logs a warning for each such surface after every apply.

Terminal window
hf open crm:crm

hf opens the surface by the name from step 7, signed in as you. When hf asks whether to open the surface from module crm, answer y (see Experience surfaces for why it asks). In a browser that is not already signed in as you, the page asks “Sign in as (your name)?”; click Continue. A request for /crm redirects to /crm/, and a path that matches no file serves the entry page so client-side routes work. A path segment that begins with . is the exception: it answers 404 under a directory surface.

Terminal window
curl -I https://your-server/crm/

answers 200 with content-type: text/html; charset=utf-8. A hashed file such as /crm/assets/app-3f9a1c.js answers 200 with an immutable cache header, and /crm/assets/missing.js answers 404.

  • /crm/ answers 404 NOT_FOUND: the surface is declared but not mounted, or not declared. Read unmounted in step 7. directoryNotConfigured means settings.json5 has no surfaces.directory, or the server has not been restarted since you added it.
  • The server exits with surfaces.directory ... is not a directory: the configured path does not exist. Create it, or correct the setting.
  • The apply fails with APPLY_UNKNOWN_SURFACE_ACTIONS: an actions entry does not name a known action. The error names the surface and the entry.
  • The apply fails with APPLY_SURFACE_ACTIONS_NEED_APPROVAL: the surface lists admin.metadata.apply or an admin.iam.* action. Remove it from actions; module surfaces cannot declare these yet.
  • The surface opens the login form: the browser session expired. Run hf open crm:crm again to sign in.
  • hf open crm:crm says the surface is not mounted or not available to you: it is not in your list. Read unmounted in step 7, or check that you hold one of the surface’s grantable actions.
  • The login page says the surface “is no longer available”: the surface key changed, the module was uninstalled, or the surface is not listed for you (you hold none of its grantable actions). A mountPath change never causes this; the browser signs in again silently at the new path.
  • A call from the surface answers 403 SURFACE_SCOPE_EXCEEDED: the route needs an action the surface does not declare. The message names the surface and the action. Add the action to actions and apply the manifest; no new sign-in is needed, because the server reads the list on every request. The person must also hold the action: declaring it grants nothing. A route that needs several actions needs all of them, and the message names the first one missing: POST /v1/plan needs admin.metadata.plan and the module, object and field read actions.
  • A metadata write to main answers 403 SURFACE_SCOPE_EXCEEDED naming admin.metadata.apply, although the surface declares the write action itself. A write to main also needs admin.metadata.apply, and a module surface cannot declare it, so adding it to actions fails the apply with APPLY_SURFACE_ACTIONS_NEED_APPROVAL. A module surface cannot write metadata to main, and cannot publish a branch either. It can write to a cart branch (/v1/metadata@{branchId}/...) with the write action alone; a person with admin.metadata.apply publishes the branch outside the surface, for example with hf.
  • A call from the surface answers 401 UNKNOWN_SURFACE: the surface key changed or the module was uninstalled. A mountPath change never causes it. The person signs in again.
  • hf validate warns declares no actions: the surface can call no action-guarded route. Add the actions it uses. Apply goes further and refuses a module surface with no grantable action (next item), static content included.
  • Apply fails with APPLY_SURFACE_NEEDS_GRANTABLE_ACTION: the surface’s actions is empty, or lists only record or flux actions, which cannot be granted. A surface is listed to a person who holds one of its grantable actions, so add at least one. Choose the narrowest grantable action the surface needs, for example runtime.screen.render for a surface that only renders screens: every action you declare widens what a token issued to the surface may do, and admin.metadata.module.read in particular lets it read compiled-module metadata.
  • Compilation fails with declares a surface but no platform.apiVersion: add the platform block from step 4.
  • Sign-in shows a list of surfaces instead of opening one, or lands on a surface you did not expect: the sign-in page opens henceforth:studio when the person can see it, otherwise henceforth:app, matched by name and only for those two platform surfaces, so a module surface never takes it over, whatever its key or mount path. When neither is listed, the page shows the surfaces the person was listed and opens one only when they choose it; when nothing is listed it says so. Call GET /v1/surfaces with that person’s token, as in step 7, and read surfaces: a surface missing from the list is one that is not mounted, or a module surface none of whose grantable actions the person holds.