Skip to content

Bring your own front-end (v1)

v1 has no build step: upload a built dist/ as a zip.

MeroFoundry hosts that immutable browser bundle on the application’s own host, beside its same-origin /api/ surface. Source upload, a build container, and git push deployment are not v1 features. Build locally, zip the contents of dist/ (a single enclosing dist/ directory is also accepted), upload the archive, activate the resulting version, and then enable the site.

/_app/* is reserved for the uploaded bundle. On a site-enabled host, the serving hook applies this precedence order before the page renderer:

  1. /api/*
  2. /mcp
  3. edge-owned /.well-known/*
  4. static asset bindings (root files)
  5. the bundle

The bundle never falls through to the page renderer. In particular, its /_app/* entries are served before the platform’s asset routing; root static bindings such as /robots.txt still win when they name the same path. A path absent from the manifest resolves in two steps: a path whose last segment has no extension is a route and serves its own document when the bundle has one, <path>/index.html first and <path>.html second; an unresolved route then receives the bundle’s root 404.html with status 404 when the bundle has one, otherwise the entry document (normally index.html) with status 200, so both prerendered multi-page builds and client-side routers work. The root path always serves the entry document, and /404.html itself is an ordinary page served with status 200. The 404 document keeps its own inline script hashes, encoding negotiation, ETag and If-None-Match handling, and is always served no-cache. A path whose last segment has an extension is a missing file and is a 404 without any document.

The upload is a ZIP with at most 5,000 entries and 100 MB uncompressed content. The platform retains at most 10 site versions per application, rejects SVG entries, and rejects unsafe paths, duplicate paths, invalid UTF-8 text entries, unknown merofoundry.json keys, and archives without their entry document. Uploading does not activate or serve a version; those are separate explicit actions.

Optionally place merofoundry.json at the archive root. It is configuration, not a served file:

{
"entry": "index.html",
"immutable": ["assets/*"],
"mutable": ["assets/runtime.js"]
}

The only keys are entry (a path string; default index.html), immutable (an array of path globs), and mutable (an array of path globs). immutable forces matching entries to be content-addressed; mutable forces matching entries to be named. The entry document and every other HTML document are always named, even if an immutable glob matches them. For Vite builds, "immutable": ["assets/*"] is the recommended default because it explicitly marks every generated asset immutable.

An entry is otherwise content-addressed when its basename’s final .- or --delimited segment immediately before the extension has at least eight alphanumeric, _, or - characters, contains at least one letter, and contains either a digit or both uppercase and lowercase letters (for example app.3f2a9c1e.css, index-DkN3v_5c.js, or Vite’s index-ChfyFJlq.js). This remains a heuristic: all-lowercase and all-uppercase segments, such as index-abcdefgh.js, are not auto-detected. Classification is computed at publish time, so existing versions must be re-published to pick up this rule change. Content-addressed entries use Cache-Control: public, max-age=31536000, immutable. Named entries and the entry document use Cache-Control: no-cache with a strong ETag derived from the manifest hash. If-None-Match is honored for every entry with a bodyless 304. Audio, video, and application/octet-stream entries honor a single-byte Range request. Text entries have stored br and gzip variants selected from Accept-Encoding; those responses retain the cache headers, include Vary: Accept-Encoding, and count their stored compressed bytes against the storage entitlement. The site-serving hook passes these headers through unchanged.

Every site response uses the application’s complete site CSP. The policy is the application’s siteCsp setting (directive-keyed HTTPS origin lists), separate from the page builder’s trustedImageOrigins; enabling a site seeds siteCsp.img from trustedImageOrigins when siteCsp was never set. Set it through PATCH /api/v1/applications/{id} or update_application (body.siteCsp). Inline scripts in every HTML document of the bundle are hashed at publish time and each document is served with its own hashes; scripts never receive 'unsafe-inline'. A bundle that compiles WebAssembly it ships (a client-side search index, a media codec) needs siteCsp.script_wasm: true, which adds 'wasm-unsafe-eval' to script-src and nothing else; a bundle with style attributes needs siteCsp.style_unsafe_inline: true. frame-ancestors is an HTTPS-origin allowlist: its default is 'none', * and non-HTTPS origins are rejected, and the platform host plus *.merofoundry.app are never accepted as another tenant’s frame ancestor.

Three gates must all be true, in order, before a bundle serves: SITE_HOSTING_ENABLED, the application site-enable flag, and an active-version pointer. The platform switch is off by default. While off, every site lifecycle endpoint answers 404 and no host lookup occurs; an agent that sees 404 from the site tools on a platform where other tools work should report the switch to an operator rather than retry. Switching it on does not activate any application. Switching it off is the kill switch: uploaded versions and active pointers remain for recovery. The front-end caches a host’s domain resolution for five minutes but re-reads the site flags every 30 seconds, so an enable, a disable, or the platform switch reaches visitors within 30 seconds.

The lifecycle is exposed twice with the same semantics: privately at /api/v1/applications/{id}/site/* for the admin UI (session), and on the Public API at /api/v1/public/applications/{id}/site/* for application API keys and hosted-MCP OAuth grants (permission applications:update). The MCP tools list_site_versions, upload_site_version, set_preview_site_version, activate_site_version, delete_site_version, enable_site, disable_site, and list_site_releases call the Public API routes; create_site_upload_ticket authorizes a direct upload to the same upload route. Every credential sends the ZIP as exactly one multipart field named file and no other fields, and an archive may be at most 104,857,600 bytes (100 MiB).

An application host can serve the files that let a native iOS or Android app claim its links: https://<application host>/.well-known/apple-app-site-association and https://<application host>/.well-known/assetlinks.json, where the host is the custom domain or the platform subdomain. The platform generates both from identitySettings.mobile_apps, a list whose entries take one of two shapes, one per platform:

{
"mobile_apps": [
{"platform": "ios", "team_id": "ABCDE12345", "bundle_id": "com.example.app", "paths": ["/*"]},
{"platform": "android", "package_name": "com.example.app", "sha256_cert_fingerprints": ["14:6D:…:E5"]}
]
}

team_id is ten uppercase letters or digits; bundle_id and package_name are reverse-DNS names; each fingerprint is 32 colon-separated uppercase hex byte pairs. paths is optional and defaults to ["/*"]; narrow it to the paths the app should open. The Apple file lists every iOS entry under applinks and webcredentials, and the Android file has one delegate_permission/common.handle_all_urls statement per Android entry. Both answer application/json with Cache-Control: public, max-age=3600 and never redirect.

Both files, and any change to mobile_apps, need the mobile_distribution capability to be enabled for the application. While it is hidden, optional or disabled, both paths answer the same plain 404 as an application with no mobile_apps, even when entries are stored, and a request that changes mobile_apps fails with mobile_distribution capability is not enabled for this application. Where the deployment offers the capability as optional, one request can opt in and configure: send capabilities: {"mobile_distribution": "enabled"} together with identitySettings.mobile_apps.

mobile_apps is an identity setting, so the existing rule applies: identity settings need an identity model (identityModelId). PATCH /api/v1/applications/{id} replaces the other identitySettings keys whole, so send every identity key you want to keep. mobile_apps is the exception: when a request’s identitySettings omits it, the stored entries are kept. Resending the stored entries unchanged is not a change, and it is accepted whatever the capability state. To add, change or remove an app, send the complete mobile_apps list. A request that omits identitySettings leaves them unchanged. The admin Settings page shows the configured entries read-only and links both URLs. Edit them through the API or update_application.

A generated file always takes precedence over the application’s own copy. When the requested platform has no entry (for example, an empty mobile_apps, or an application configured only for Android that is asked for the Apple file), the platform does not generate that file, and the application’s own file at the same path answers instead: a file bound to the path through static asset bindings first, then the active site bundle’s .well-known/ entry. That file keeps its own content type and cache policy. When neither exists, the path is a plain 404, and the bundle’s entry document is never served there. Other /.well-known/ paths are unchanged. The edge still owns the paths it owns, other paths are never sent to the bundle, and a file bound to another /.well-known/ path through static asset bindings still answers.

When the ZIP is on the machine where the agent runs its shell, use create_site_upload_ticket instead of upload_site_version. The archive then travels in a direct HTTPS upload rather than a model-generated base64 tool argument, which is not a reliable transport at any size. upload_site_version remains available for callers that already use it.

  1. Build, zip, and calculate the two values the ticket binds:
Terminal window
(cd dist && zip -qr ../site.zip .)
sha256sum site.zip # macOS: shasum -a 256 site.zip
wc -c < site.zip
  1. Call create_site_upload_ticket(application_id, archive_sha256, archive_size_bytes) with the lowercase hex digest and the exact byte count. The result contains ticketId, uploadUrl, method (POST), multipartField (file), uploadToken, expiresAt, archiveSha256, archiveSizeBytes, and maxArchiveBytes.
  2. Set UPLOAD_TOKEN to uploadToken and UPLOAD_URL to uploadUrl without printing the token, then upload before expiresAt:
Terminal window
curl --fail-with-body \
-H "Authorization: Bearer $UPLOAD_TOKEN" \
-F "file=@site.zip;type=application/zip" \
"$UPLOAD_URL"
  1. Activate and serve the new version as separate steps with activate_site_version and enable_site. The ticket cannot activate, enable, list, or delete anything.

A ticket is a short-lived bearer derived from the connected OAuth grant:

  • It expires after at most 10 minutes, and never later than the OAuth access token or grant it came from. It cannot be refreshed or extended; request a new ticket instead.
  • It uploads only the declared archive to that application’s upload route. Any other route, tool, or application rejects it, and an expired or unknown ticket answers 401 site_upload_ticket_invalid.
  • Only an application OAuth grant of a platform user can create one; application API keys, bootstrap and consumer grants, and tickets cannot. The grant, current role permissions, and the application, workspace, and tenant are rechecked before the upload is received and again before it is committed, so revoking access stops an unfinished upload.
  • Different bytes answer 422 site_upload_size_mismatch or site_upload_digest_mismatch without storing anything; the ticket still accepts the original archive until it expires. A body larger than the declared size plus 64 KiB of multipart framing answers 413.
  • The first successful upload answers 201 with the inactive version. Re-sending the same archive with the same ticket, for example after a lost response, answers 200 with the same version and creates nothing new. While another request is uploading with the ticket the answer is 409 site_upload_in_progress with Retry-After; if the created version was later deleted the answer is 410 site_upload_result_gone and the version is not recreated.

A GitHub repository can release the site and its custom functions: GitHub builds, and the platform fetches the result. The platform never clones the repository, never runs a build, and never puts a secret of its own into the workflow. This needs the platform’s GitHub App; where it is not configured, the console offers no GitHub controls and the repository and release routes answer 404.

  1. Open the application’s Site versions page in the console and choose Connect GitHub repository. GitHub’s install page asks which repositories the App may see; it requests only metadata and Actions read access, never the repository contents.
  2. Back on the site page, pick the repository and set the artifact name (default merofoundry), then save. Connecting is a console action because it goes through GitHub’s install page; no MCP tool or API key can start it.
  3. End the workflow with one upload step. The console shows this workflow with your artifact name:
name: MeroFoundry release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci && npm run build
# Both directories are optional: site/ is the built site, functions/ holds one
# <name>.ts file per custom function.
- run: |
mkdir -p merofoundry-release
cp -r dist merofoundry-release/site
if [ -d functions ]; then cp -r functions merofoundry-release/functions; fi
- uses: actions/upload-artifact@v4
with:
name: merofoundry
path: merofoundry-release
include-hidden-files: true

The artifact is the release. Keep include-hidden-files: true on the upload step: actions/upload-artifact@v4 drops hidden paths by default, so a site that ships .well-known/security.txt or any other dot-directory loses it silently and the release’s notes do not mention it, because the file never left the runner. Layout of merofoundry-release/:

Path Meaning
site/ The built site, as it would be zipped for uploadSiteVersion. Optional.
functions/<name>.ts One custom function per file; the file stem is the function name and must satisfy the function name rule. Optional.

Anything else in the artifact is ignored and listed in the release’s notes. A release with neither directory fails with empty_release; a run without an artifact of the connected name fails with artifact_not_found, and the console names the artifact it expected. One repository can feed several applications by uploading differently named artifacts from the same run.

Every successful push, workflow_dispatch, release or schedule run of the connected repository becomes a release. It publishes only from a branch in the connection’s allowlist, which starts as the repository’s default branch and can be changed in the console; runs on other branches are listed without fetching their artifact. A published release becomes the preview: one preview host per application, <label>--preview.<app_subdomain_parent>, serves it. Production never moves on its own. In the console’s release list, or through the tools, you can:

  • set any retained version as the preview (setPreviewSiteVersion, MCP set_preview_site_version);
  • activate a version to put it in production (activateSiteVersion, MCP activate_site_version). When its release carries function sources, activation compiles and publishes them first, and a compile diagnostic fails the activation with nothing moved;
  • list releases with their state (queued, fetching, published, failed), failure reason, function snapshot and notes (listSiteReleases, MCP list_site_releases). A functions-only release has no site version to preview or activate.

The preview requires a platform sign-in by an account with access to the application unless the application setting sitePreviewPublic is true (console: Public preview; API: PATCH /api/v1/applications/{id}). The preview host runs against the production custom functions, carries X-Robots-Tag: noindex, and exists only under the platform wildcard: custom domains always serve production.

Discover the application by host, then fetch GET /api/v1/applications/public/slug/{slug}. Use its id, identityModelId, and identitySettings to locate the three identity service/model identifiers:

  • registration: identityModelId;
  • login: identitySettings.login_submission_model_id;
  • logout: the application model whose settings declare logout_submission: true.

Each identity action is a records post, not a new auth endpoint:

POST /api/v1/models/{identityModelId}/records?application_id={applicationId} # register
POST /api/v1/models/{loginSubmissionModelId}/records?application_id={applicationId} # login
POST /api/v1/models/{logoutSubmissionModelId}/records?application_id={applicationId} # logout
POST /api/v1/applications/{applicationId}/auth/refresh # refresh

Registration and login post {"data":{"email":"…","password":"…"}}; logout posts to its submission model with the application id. Browser registration and login set the host-scoped, HttpOnly, SameSite=Lax consumer session cookies and return the session object; JavaScript must not try to read the tokens. A successful registration does not always sign the user in. Under the default registration_email_verification: required, and for any email that already has an account (outside off), registration answers 202 with {"registered": true, "emailVerification": "pending", "message": "…"}, sets no cookie and returns no session: show the message (“check your email”) and keep the user signed out. Under optional the session response carries "emailVerification": "sent": the user is signed in and a confirmation link was mailed. Under off the session response is unchanged, and a duplicate email answers 409 without the email in the body. Branch on emailVerification and on the presence of session, never on the status alone. The verification link lands on the application’s verification_redirect_path with ?email_verification=verified or ?email_verification=invalid; present that and offer sign-in, because the link issues no session. Refresh uses the HttpOnly refresh cookie. In production-like environments these posts must be made from the application’s own host. Cookie-authenticated non-GET calls, including refresh, must send X-Requested-With: XMLHttpRequest; cross-site cookie requests are rejected. The signed-in me operation is a public saved query on the identity model with current_user_id as a server-bound parameter. Do not accept that identity id from the browser; an anonymous query’s missing-parameter response is the signed-out signal.

Password recovery and session control are dedicated routes under the same prefix as refresh: POST …/auth/forgot-password, POST …/auth/reset-password (from your /reset-password page, which the mailed link opens with ?token=…), POST …/auth/change-password and POST …/auth/sign-out-everywhere. Their bodies, answers and limits, the identity settings to choose, and what never to hand-roll are in Secure authentication for your app (secure-authentication). Render the pages yourself; never build tokens, limits or revocation in the front-end. During a per-account login backoff the login answer stays the ordinary 401 “Invalid email or password”, never a 429; show it as a wrong password.

Use this helper for browser calls. It deliberately uses same-origin URLs, sends cookies, and adds the consumer CSRF header only for non-GET methods:

export async function apiFetch(path, options = {}) {
const method = (options.method || 'GET').toUpperCase();
const headers = new Headers(options.headers || {});
if (method !== 'GET') headers.set('X-Requested-With', 'XMLHttpRequest');
return fetch(path, { ...options, method, headers, credentials: 'include' });
}

Closed applications, services, and realtime

Section titled “Closed applications, services, and realtime”

Site-enabled implies an application is closed. A closed application returns 404 to anonymous and consumer callers on generic record, stats, public action, public saved-query, and model-filter realtime routes. There is no consumer per-model opt-in. Identity posts, refresh, inbound services, and the application MCP surface remain available.

Application API keys are also denied generic model routes by default while closed. The sole opt-in is model setting api_key_access: "on"; it is for a server-side integration, never for a browser. consumer_writes remains stored but is treated as off while closed. A model-filter realtime ticket follows the same rule: consumers receive 404, while an application API key needs api_key_access: "on". Stream keys are separate: consumers may use server-derived user:<identity-record-id>: or public: namespaces; no live queries exist in v1.

Before enabling a production site, make services the application’s API for every browser-facing model. Call scaffold_model_services(application_id, model_id, ownership_query_id?, owner_field?, public_read=false) once per model. Supply an existing ownership_query_id, or supply owner_field and the scaffold creates the ownership query; do not supply both. It produces ordinary editable <model>.list, .get, .create, .update, and .delete inbound services. The browser calls these services rather than generic consumer record routes.

For example, this is the complete response captured by the MCP passthrough unit run (the ids are the created ordinary service and saved-query records):

{
"services": {
"list": "11111111-1111-4111-8111-111111111111",
"get": "22222222-2222-4222-8222-222222222222",
"create": "33333333-3333-4333-8333-333333333333",
"update": "44444444-4444-4444-8444-444444444444",
"delete": "55555555-5555-4555-8555-555555555555"
},
"savedQueries": {
"list": "66666666-6666-4666-8666-666666666666",
"get": "77777777-7777-4777-8777-777777777777",
"ownership": "88888888-8888-4888-8888-888888888888"
},
"created": ["list", "get", "create", "update", "delete"],
"existing": [],
"publicRead": false,
"createOwnership": "owner stamped from actor.id"
}

Call an inbound service at POST /api/v1/services/{service-id}/inbound; cookie-authenticated browser calls include X-Requested-With: XMLHttpRequest.

Terminal window
curl -X POST /api/v1/services/11111111-1111-4111-8111-111111111111/inbound -b 'consumer_session=…' -H 'X-Requested-With: XMLHttpRequest' -H 'Content-Type: application/json' --data '{"page":1,"per_page":25}' # list
curl -X POST /api/v1/services/22222222-2222-4222-8222-222222222222/inbound -b 'consumer_session=…' -H 'X-Requested-With: XMLHttpRequest' -H 'Content-Type: application/json' --data '{"id":"record-id"}' # get
curl -X POST /api/v1/services/33333333-3333-4333-8333-333333333333/inbound -b 'consumer_session=…' -H 'X-Requested-With: XMLHttpRequest' -H 'Content-Type: application/json' --data '{"title":"New record"}' # create
curl -X POST /api/v1/services/44444444-4444-4444-8444-444444444444/inbound -b 'consumer_session=…' -H 'X-Requested-With: XMLHttpRequest' -H 'Content-Type: application/json' --data '{"id":"record-id","title":"Changed"}' # update
curl -X POST /api/v1/services/55555555-5555-4555-8555-555555555555/inbound -b 'consumer_session=…' -H 'X-Requested-With: XMLHttpRequest' -H 'Content-Type: application/json' --data '{"id":"record-id"}' # delete

The list body accepts page (default 1), per_page (1..100, default 25), sort (a scaffold-derived field allowlist), order (asc or desc), and filter[field][op]. Filters permit only eq and contains, and fields are server allowlisted. public_read=true marks the generated list and get services public; it does not make writes public. Generated services, rules, and saved queries are normal authoring records: edit them with the service, rule, and saved-query authoring tools, or delete them when replacing the API.

For a custom anonymous catalog service, use the same ordinary authoring records. Three objects give an anonymous visitor a catalog listing:

  1. A public saved query on the model (isPublic: true, fromModel is the model name).
  2. An inbound service with serviceType: "webhook_in", authType: "none", and settings.public: true.
  3. Two application rules on trigger webhook_received, conditioned on the service name ({"==": [{"var": "webhook.name"}, "<service name>"]}): a post_save rule with require_query naming the saved query, and a response rule with patch_response {"op": "replace", "path": "/body", "value": {"items": {"var": "query_rows"}}}.

The browser then POSTs /api/v1/services/{service-id}/inbound with {} (plus the X-Requested-With header the helper above adds). A public saved query answers an anonymous caller; a private one still requires a consumer session.

See the API reference for lifecycle endpoints and the MCP reference for which authoring actions are exposed through MCP. Human operators use the ordered site-hosting rollout runbook for the production flip.