# Hosting an app on a Lathe box

The apps engine runs your own container image on the machine that already runs your databases: the engines' URLs
arrive in its environment, the box serves it over https on its own name and on your domains, and a deploy moves
traffic only once the new container answers. This document is the whole contract, for an agent writing the code.
The same material for a person to read is on the docs site, at https://docs.lathe.live/tutorials.

Every step below is available three ways with the same rules and the same refusals: the portal's Apps tab, the REST
API (https://app.lathe.live/api/v1) and the MCP server (https://mcp.lathe.live). Each step names the MCP tool first and
the REST route second. Both take the same API key, made on the portal's Access page.

## 1. Turn the apps engine on

    configure_engines(instance_id, engines={"postgres": {"memory_mb": 2048}, "apps": {"memory_mb": 512}})
    PUT https://app.lathe.live/api/v1/instances/{id}/engines

At least 256 MB, out of the plan's memory budget; every app on the box takes its share of that one
figure. Name every engine you want running, not only `apps`: an engine left out of the call is stopped, its data
kept. This queues a job; poll get_job until it is done.

## 2. Build an image that keeps three rules

Any language, any base image, built for linux/amd64. On an Apple Silicon machine pass
`--platform linux/amd64` to `docker build`.

1. Listen on the port in `PORT`. The box sets it, so do not hard-code one. Loopback or all interfaces both work.
2. Answer the health path with a 2xx once the app is ready to serve. A new version takes traffic only after this
   answers, and one that never answers is left out with the running version still serving.
3. Keep state under `/data`. It is a directory of the box's disk mounted into the container, kept across deploys
   and restarts and left in place when the app is removed. Everything else in the container is gone at the next
   deploy, so write nothing you want to keep anywhere else.

    FROM node:22-slim
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci --omit=dev
    COPY . .
    ENV NODE_ENV=production
    CMD ["node", "server.js"]        # server.js: app.listen(process.env.PORT)

## 3. Add the app

    set_app(instance_id, name="web", image="ghcr.io/you/app:main", port=8080,
            health_path="/healthz", public=true, memory_mb=512)
    PUT https://app.lathe.live/api/v1/instances/{id}/apps/{name}

| field | what it takes |
| --- | --- |
| name | Lowercase letters, digits and hyphens. Other apps on the box reach it as `<NAME>_URL` once they link it. |
| image | The tag to pull, e.g. `ghcr.io/you/app:main`. Point it at a tag your CI overwrites and a deploy needs nothing else changed. |
| port | What the app listens on. It owns this port and the two above it, which are the slots a new version comes up on, so leave three between two apps: 8080, 8083, 8086. A few ports belong to the box itself (its engines, the pooler, the SQL console); one of those is refused with a sentence saying what holds it. |
| health_path | The path from rule two. `/` by default, which is the wrong answer for anything that renders a page on demand: `/` is then the most expensive route on the site and usually touches the database, the box waits on it before moving traffic, and every deploy pays that render. Point it at an endpoint that touches nothing and answers in well under a second. |
| public | true: served by the box's front door on a name of its own, `<instance>-<name>.<the machine's zone>` (list_apps carries the URL), and on the domains below. false: loopback only, for the box's other apps. |
| default | true: the machine's own name goes to this app as well. One app per box; marking one unmarks the other. Unmarked everywhere, the first public app answers there. |
| cpu_limit | CPU cap in vCPUs, including 0.5 or 1.5. 0 means uncapped; omit to keep. |
| cpu_weight | Relative CPU priority under contention, 1 to 10000, default 100. Spare CPU stays available. |
| memory_mb | This app's share of the engine's budget. |
| domains | Hostnames of yours that already point at the machine. The box gets a certificate for each. A wildcard one level deep (`*.tenants.example.com`) needs a `domain_check` (section 5). |
| links | Other apps on this box this one talks to. Each becomes `<NAME>_URL` in its environment. |
| singleton | true for an app that must never run twice (it holds a lock or its own files): restarted in place, so a deploy has a short gap. |
| read_only | true runs it on a read-only root filesystem, `/data` and `/tmp` still writable. Turn it on once the image tolerates it. |
| upload_mb | the largest request body the front door passes to the app, in MB; 32 unless set, 0 for no limit. Raise it for an app that takes uploads. |
| capabilities | The Linux capabilities the image needs, e.g. `CAP_NET_BIND_SERVICE`. None by default. |
| domain_check | A path of the app's, like `/domain-check`, that says yes to the names it serves (section 5). With a wildcard in `domains` it gates the names under that wildcard; without one, the app answers every name pointing at the machine that no other app claims (one such app per box). Public apps only; `""` turns it off. |

A field you leave out keeps the value the app already has, and takes its default above only on an app being created,
so no call of yours moves a setting it did not name. To change one thing on an app that exists, say so:

    update_app(instance_id, name="web", memory_mb=640)                 # the port, the health path and public are untouched
    PATCH https://app.lathe.live/api/v1/instances/{id}/apps/{name}

Memory that moves from one app to another goes in one call, because the engine's budget is usually spoken for: lowering
the first app is a job, and raising the second is a second job that finds the box busy. An app you do not name keeps
its share, and nothing restarts:

    set_app_memory(instance_id, memory={"web": 512, "worker": 384})
    PATCH https://app.lathe.live/api/v1/instances/{id}/apps   {"memory": {"web": 512, "worker": 384}}

The box runs the image you name, so name one you trust. A private image needs credentials, once per box:

    set_pull_registry(instance_id, registry="ghcr.io", pull_user="you", pull_token="…")
    PUT https://app.lathe.live/api/v1/instances/{id}/registry

For GitHub's registry that is a GitHub username and a personal access token with the `read:packages` scope.
GitHub packages are private by default, and a workflow's own GITHUB_TOKEN lives only while the workflow runs,
so the box needs a token of its own to pull with. This holds for an imported or connected repository
too (section 7): GitHub's registry takes a personal access token, not the Lathe app's own credential, so a private
image always needs this one.
## 4. Give it its environment

    set_app_env(instance_id, name, env={"SESSION_SECRET": "…"})        # replaces the whole environment
    update_app_env(instance_id, name, set={"SESSION_SECRET": "…"}, unset=["OLD"])   # changes some, keeps the rest
    PUT https://app.lathe.live/api/v1/instances/{id}/apps/{name}/env   |   PATCH the same path

Where Lathe builds the repository, these variables are given to the build as well as to the running app - one list for both,
which is what a front-end bundle needs, since it reads its settings while it is being built and never again, and what an
install from a private package registry needs. A change reaches the next build, not the build that already ran.

A build reads them two ways. Each is a build argument, so an ordinary `ARG NAME` in a Dockerfile picks it up - and its value
is then recorded in the image, where anyone who can pull the image can read it. Each is also a named secret, so
`RUN --mount=type=secret,id=NAME cat /run/secrets/NAME` reads it and nothing is written into the image at all. Use the
second for a token, the first for a setting. A repository built without a Dockerfile uses the second on its own.

Use update_app_env unless you hold the whole environment: nothing reads the values back. The box injects `PORT` and,
for **every engine on the machine**, a connection string and its parts - so a library that takes a URL and one that
reads `PGHOST` both work with nothing to configure. They are written before your variables and yours win; override a
URL and its parts together, or a library that reads the parts will still find the box. Turn an engine on later and
its variables appear at that deploy; turn it off and they stop being set.

- Postgres 17: DATABASE_URL, DATABASE_POOLED_URL, PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE, PGSSLMODE, PGSSLROOTCERT, DATABASE_READONLY_URL, DATABASE_POOLED_READONLY_URL
- Redis 8: REDIS_URL, REDIS_HOST, REDIS_PORT, REDIS_USER, REDIS_PASSWORD, REDIS_TLS
- CouchDB 3: COUCHDB_URL, COUCHDB_HOST, COUCHDB_PORT, COUCHDB_USER, COUCHDB_PASSWORD
- NATS 2: NATS_URL, NATS_HOST, NATS_PORT, NATS_USER, NATS_PASSWORD
- SeaweedFS 4: S3_ENDPOINT, S3_REGION, S3_FORCE_PATH_STYLE, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, AWS_ENDPOINT_URL_S3, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
- `<NAME>_URL` for each app named in `links`, on loopback, unchanged while that app's containers come and go.
- Auth, for an app whose `links` name it (it is not given to an app that does not ask): AUTH_URL, AUTH_PUBLIC_URL, AUTH_ANON_KEY, AUTH_SERVICE_KEY, AUTH_JWKS_URL

`list_apps` reports these per app as `injected`, so an agent can read what an app is given rather than guess.

`DATABASE_URL` is the owner role: it can write, and it can create databases. `DATABASE_READONLY_URL` is the same
data through a role that cannot change any of it - it holds `pg_read_all_data` and nothing else, and every
transaction it opens is read-only, so the server refuses a write rather than trusting the code not to try one. Where
you know a query is a read, send it there: a bug or an injection on that path then cannot write, whatever it asks
for. Tables you create later need no new grant. Both come with a pooled sibling on 6432.

Every one of them asks for `sslmode=verify-full` and names an `sslrootcert`: the box mounts its own CA bundle into
your container at `/etc/lathe/ca.crt`, read-only, and the URLs point at that file. libpq does not use the system
trust store, so `verify-full` on its own would send psql, psycopg, Ruby `pg`, `pdo_pgsql` and pgx looking for
`~/.postgresql/root.crt` and refusing to connect; naming a real path works on every client. If you build the URL
yourself rather than using ours, carry the same two parameters.

At most 96 variables of yours, each value under 4 kB. Values are kept with the machine's secrets and are
never returned; list_apps reports the names only. Saving restarts the app with the new environment.

## 5. Put it on a domain of your own

Point an A record at the machine's IPv4 (get_instance carries it), then add the hostname to `domains` on the app.
The box gets a certificate for it within a minute of the record resolving. The app keeps answering on its own name
as well, which is where a public app answers before any domain is added.

**Names you do not know in advance.** An app that serves its own customers, each on a name of theirs or on one you
give them under your domain, cannot list those names ahead of time. Give it a `domain_check` instead:

    update_app(instance_id, name="web", domain_check="/domain-check")
    PATCH https://app.lathe.live/api/v1/instances/{id}/apps/{name}   {"domain_check": "/domain-check"}

Every name pointing at the machine that no other app claims then goes to this app. The first time a name is asked
for, the box calls `http://127.0.0.1:<port>/domain-check?domain=<name>` on the app and gets a certificate for that name
only when the answer is a 2xx; any other answer and the name gets none, so a stranger pointing a name at your machine,
or a scanner trying made-up ones, gets nothing issued. Answer quickly, from what the app already knows (its own table
of names), and say yes only to names you serve. Point the names at the machine as in the paragraph above: one record
per name, or a wildcard record (`*.customers.example.com`) for a whole level. One app per box can do this, and it has
to be public.

To keep it to the names under one level of yours, and to let several apps on one machine do the same, put the
wildcard in the app's `domains` beside the check:

    update_app(instance_id, name="web", domains=["*.customers.example.com"], domain_check="/domain-check")

The app then answers the names one level under it (`shop.customers.example.com`, not `a.shop.customers.example.com`),
each with a certificate once the check has said yes, and nothing else. Every app on the box can have wildcards of its
own; two apps cannot share one.

## 6. Ship a new version

    deploy_app(instance_id, name)
    POST https://app.lathe.live/api/v1/instances/{id}/apps/{name}/actions/deploy

Pulls the image again, tag unchanged or not: what to call after pushing a new build to the tag the app carries. The
box starts the new container in the app's spare slot, waits for the health path, moves the front door to it and only
then stops the old one, so requests keep being served. A `singleton` app is restarted in place instead. An image that
never answers its health path fails the job and leaves the old container serving, so a broken build cannot take the
app down. Read what it printed with app_logs (GET the same path plus /logs); roll back by setting `image` to the
older tag.

    restart_app(instance_id, name)
    POST https://app.lathe.live/api/v1/instances/{id}/apps/{name}/actions/restart

The other tool, for when the image is not what wants changing: the container it is already running is stopped and
started again, in its own slot, in a few seconds. Nothing is pulled and no other app or engine on the box is
touched. For a process that has wedged, a connection pool that will not recover, or state only a fresh start
clears - not for a new build, which is the deploy above.

## 7. Deploy from GitHub

**Let Lathe build it.** The shortest way, and the one to reach for: a repository connected this way needs no
workflow, no image name and no registry credentials of the customer's, and nothing of ours is written into it.

    import_repo(instance_id, repo="you/app", source="lathe")             # creates the app and starts the first build
    connect_app_repo(instance_id, name, repo="you/app", source="lathe")  # an app that already exists

    POST https://app.lathe.live/api/v1/instances/{id}/apps/import    {"repo": "you/app", "source": "lathe"}
    PUT  https://app.lathe.live/api/v1/instances/{id}/apps/{name}/github    {"repo": "you/app", "branch": "main", "source": "lathe"}

Every push to the branch is built on a machine of Lathe's and deployed to the box when it is green. A repository
with a Dockerfile is built from it; one without is built from what its files at the root say it is, which covers
Node, Python, Go, PHP, Ruby, Java, Rust and static sites. `source` defaults to `"lathe"` wherever this deployment
can build, so it can be left out.

An import binds the app with its whole spec and queues the first build at once: the app waits (`pending` in
list_apps, with `build_job_id` in the answer) and the build that goes green is the one the app is created from.
After that every green build deploys, pinned by digest, and `roll_back_app` goes back to any of them.

A build that fails is not tried again on its own, since the same commit would fail the same way:
`rebuild_app(instance_id, name, run)` builds that commit again for a failure that was not the code's, and
`rebuild_app(..., no_cache=True)` builds every step over rather than taking what the builder already has. Reach
for that second one when a build succeeded and produced the wrong thing: the builder keeps one layer cache, so a
step whose output depends on the internet rather than on your repository - an `apt-get update`, a `latest` tag -
is as old as the first build that ran it. It is as slow as your first build was. When the
one-line error does not say why, `build_log(instance_id, name, run)` has the whole of what the build printed - the
step it died on, what was installed, what Railpack decided the repository was - and `list_builds` is every build
including the failed ones, which `app_repo`'s `history` (only the ones that deployed) is not.

Across every app at once rather than one at a time:

    build_activity()
    GET https://app.lathe.live/api/v1/builds

The newest builds of every app of yours that Lathe builds, each with its app, commit, outcome, how long it took
and whether it deployed; how many are running and waiting now and how many may run at once; and how the last day
went. `can_build` is false while builds cannot start at all - nothing of yours is lost when it is, a build asked
for meanwhile is taken once it is put right, and the operator is told without you reporting it. Ask this first
when a push seems not to have arrived: it answers whether the build is queued, running, failed or never asked
for, which per-app calls answer only once you know which app to ask about. The app's environment (section 4) is given to the build as well as to the running
app, which is what a front-end bundle and a private package registry need, as a build argument and as a BuildKit
secret of the same name - `ARG NAME` reads the first and records it in the image, `RUN --mount=type=secret,id=NAME`
reads the second and records nothing. `LATHE_COMMIT` and `LATHE_BRANCH` arrive the same two ways and say what is
being built.

A build stops at the **last stage** in the Dockerfile. Pass `target` when that is not the stage that should run -
a file whose last stage adds test tooling to the image it just built deploys the test image otherwise, and says
nothing, because that image starts and answers:

    connect_app_repo(instance_id, name, repo="you/app", source="lathe", target="runtime")

A build deploys when it is green. Pass `check_command` and it deploys only when that command exits 0 inside the
image the build made - `npm test`, `pytest -q`, `tsc --noEmit`, several joined with `&&`. It runs with the app's
variables and no access to the machine. A build whose check fails is a run that made an image and did not deploy,
which `build_log` shows in full:

    connect_app_repo(instance_id, name, repo="you/app", source="lathe", check_command="npm test")

Two more for a suite that needs more than the shipped image has: `check_target` names a Dockerfile stage the check
runs in instead (a `test` stage that adds the test tooling; built from the same commit, never pushed), and
`check_env` is a dict given to the check and nothing else - the URL of a database to run the tests against:

    connect_app_repo(instance_id, name, repo="you/app", source="lathe", target="runtime", check_target="test",
                     check_command="pytest -q", check_env={"TEST_DATABASE_URL": "postgresql://..."})

A check has no services of its own. A suite that needs a database wants one that already exists, and on Lathe
that is an instance of theirs - give the check the connection string under the name the tests read. Point it at a
database whose contents nobody minds losing, and not the one the app serves from: a suite that truncates tables
will truncate those, and some suites dump or drop whatever database the URL names.

    connect_app_repo(instance_id, name, repo="you/app", source="lathe", check_command="npm test",
                     check_env={"TEST_DATABASE_URL": "postgres://...:5432/postgres?sslmode=require"})

`paths` is space-separated globs a push must touch for this app to be built at all - `apps/web/** packages/ui/**` -
which is what a monorepo needs so one app's push does not rebuild the other three. Empty builds every push, and a
commit whose files cannot be compared is built rather than skipped.

A build of ours also opens a check run on the commit, so its result shows on GitHub where the person pushing
already is, linking back to the build page. It needs `checks: write` on the installation; without it the build is
simply not annotated - and `app_repo`'s `missing_permissions`, and `list_repos`'s `connections`, say so instead of
leaving it to be noticed. Nothing is failing when they are set; somebody who administers that GitHub account
accepts the permission on GitHub, and it takes effect at once.

`app_repo` and `build_log` say how a build was made, which is what an app built this way has instead of an image
reference: `method` is `"dockerfile"` when the repository's own Dockerfile was built, and `"detected"` when there
was none and the files at the root were read instead, where `runtime` names what they were read as. The image, its
registry and its digest are ours, are not the customer's to pull, and are not what to quote at them.

**Moving between the two.** `connect_app_repo` on an app that is already connected changes the connection, in
either direction: `source="lathe"` hands the build to us, `source="actions"` hands it back to their workflow.
Every field is taken from that call, so send the whole of what the connection should be; an `image` sent with
`source="lathe"` is ignored, since a build of ours goes to a registry of ours. Nothing is rebuilt or redeployed by
the change itself - the next push is built the new way, or `deploy_latest_build` / `rebuild_app` acts now.

Pass `build_env=False` where the app's variables are credentials the build has no use for: they are written onto the
machine that runs the build, and a name the Dockerfile declares as an `ARG` is recorded in the image. An app that
reads its configuration at runtime rather than at build time loses nothing by it.

A build's caches are the app's own. A `RUN --mount=type=cache` in the Dockerfile, and the caches a build without a
Dockerfile gets by itself (`.next/cache`, `node_modules/.cache` and their kind), persist from one build of the app
to the next and are never read by a build of another app, on this instance or anyone else's. A build without a
Dockerfile runs a step again when the value of a variable it reads has changed. In a Dockerfile, a step that reads
a variable as an `ARG` does the same, and one that reads it only through `RUN --mount=type=secret` does not: a
secret is kept out of the step's cache on purpose, which is the usual Docker rule, so a step that must see a new
value reads it as an `ARG` as well.

**Or keep your own workflow.** The rest of this section, for a repository whose CI already builds and pushes an
image.


**Import the repository, built by its own workflow** (`source="actions"`), one call:

    import_repo(instance_id, repo="you/app")
    POST https://app.lathe.live/api/v1/instances/{id}/apps/import    {"repo": "you/app"}

Lathe reads the repository (`inspect_repo`, `GET /github/repos/{owner}/{name}?instance_id=…` shows what it
finds), takes the workflow that already builds and pushes an image and the reference it pushes, or opens a pull
request adding `.github/workflows/lathe.yml` (and a Dockerfile, when the repository has none and is Node, Python,
Go or a static site), creates the app with the port and health path the Dockerfile says, and deploys the newest
green build. With no build yet the app waits (`pending` in list_apps) and the first green run of the workflow on
the branch puts it on the box; every green run after that deploys it, pinned by digest, and `roll_back_app` goes
back to any of them. Every field of set_app can be given to override what the repository suggests; `env` is
saved before the first deploy. A private image needs the registry credentials of section 3 - ghcr.io authenticates a
personal access token with `read:packages`, and refuses the Lathe app's own credential. The answer's `image_state`
says whether the registry will actually let the box pull the image, asked rather than guessed: `denied` is the only
case that needs a token (`missing` is a tag the first build has not pushed yet), and it carries the URL of the page
that makes one. The repository must be one the person installed the Lathe app on, from
the Integrations page of the portal (`list_repos` says which).

**Connect the repository** (Integrations page, then Connect GitHub; then the app's card, then Connect this app to a
repository). You pick the workflow that already builds and pushes your image and the branch that counts, and from
then on a green run of it deploys the app. No secret of ours goes into your repository and no step of ours goes into
your workflow, so nothing can fall out of sync with it. The app's card then shows every run of that workflow, names
the commit that is serving, and keeps the builds it deployed so you can put one back: a deploy pins the image by
digest, so going back means the same bytes even though the tag has moved. The workflow below still describes what to
build and push; with the repository connected, leave its last step out.

**Or call the deploy endpoint yourself**, which is what the rest of this section describes and what a workflow that
is not on GitHub, or a repository you would rather not connect, still does.

Either way the box pulls the image with the registry credentials you saved for it, so a private image still needs
those; connecting a repository changes who asks for the deploy, not who the machine logs in to the registry as.

The workflow below is the one Lathe deploys itself with (the service answering this call is an app on a box):
build on every push to `main`, push `:main`
and `:<commit>` to the registry, then ask the box to deploy. Save it as `.github/workflows/deploy.yml`.

Two repository secrets (Settings, then Secrets and variables, then Actions):

| secret | value |
| --- | --- |
| LATHE_DEPLOY_KEY | An API key from the portal's Access page, `lathe_…`. It acts as the customer. |
| LATHE_DEPLOY_INSTANCE | The instance id. |

Without them the deploy step is skipped, which is what a pull request from a fork gets.

```yaml
name: deploy
on:
  push:
    branches: [main]
permissions:
  contents: read
  packages: write
concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: false
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
      - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
        with:
          context: .
          platforms: linux/amd64
          push: true
          tags: |
            ghcr.io/you/app:main
            ghcr.io/you/app:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
      - name: Deploy on Lathe
        env:
          KEY: ${{ secrets.LATHE_DEPLOY_KEY }}
          INSTANCE: ${{ secrets.LATHE_DEPLOY_INSTANCE }}
          BASE: https://app.lathe.live/api/v1
          APP: web
        if: env.KEY != '' && env.INSTANCE != ''
        run: |
          for try in 1 2 3 4 5; do
            code=$(curl -sS -o /tmp/deploy.json -w '%{http_code}' -X POST \
              -H "Authorization: Bearer $KEY" \
              "$BASE/instances/$INSTANCE/apps/$APP/actions/deploy") || code=000
            cat /tmp/deploy.json; echo
            case "$code" in
              2*)  echo "deploy queued"; exit 0 ;;
              409) echo "the instance is busy; trying again in 30s ($try/5)"; sleep 30 ;;
              *)   echo "the deploy was refused with $code"; exit 1 ;;
            esac
          done
          echo "still busy after five tries"; exit 1
```
The box runs one job at a time, so a deploy that arrives while another job is running is refused with 409; the loop
above is why. The endpoint answers 202 with a job id, which get_job follows, as does a webhook if one is set.

## What an app gets, and the rules it runs under

| item | detail |
| --- | --- |
| Network | The engines are reached on the machine's own name over TLS, like any other client. Apps on the same box reach each other on loopback through `<NAME>_URL`. |
| Disk | `/data` is kept and stays after a removal. `/tmp` is private and emptied at every start. |
| Memory | The app's figure is its ceiling. Automatic reallocation moves memory between the box's engines and apps from what each holds, restarting nothing. |
| Deploys | Two slots per app, traffic moved after the health path answers. A box without the memory for two containers at once restarts in place and says so in the log. |
| Hardening | No privileges beyond the capabilities named, no privilege gain, a ceiling on processes. |
| Public apps | Served on 443, each on a name of its own (`<instance>-<name>.<the machine's zone>`) with a certificate of its own, as custom domains are. The machine's own name, with the machine's certificate, goes to the one app marked default. Where the account has it, `subdomain` gives an app a shorter name of your choosing under our zone, whose record we write; one per app, held for you for 30 days after you let it go. |
| Jobs | One job per instance at a time; a busy instance refuses with 409 and a sentence. Slow steps return a job id for get_job. |

## Sign-in for the app: the auth engine

Auth, run by the box beside the app: email links and codes, OAuth (GitHub, Google), sessions and refresh tokens,
with the users in the `auth` schema of the instance's own Postgres, readable by your role. It is built on the
open-source Supabase Auth server, so supabase-js and the Supabase auth clients work against it unchanged. One call
turns it on, nothing to fill in:

    configure_engines(instance_id, engines={"postgres": {}, "apps": {}, "auth": {}})
    PUT https://app.lathe.live/api/v1/instances/{id}/engines

It needs Postgres and the apps engine on the same box (it runs behind their front door) and takes 128 MB by default.
When the job is done, `get_connection_urls` carries an `auth` entry:

| field | what it is |
| --- | --- |
| `url` | the server's public address, `/auth/v1` under a name of its own rather than your app's (Supabase's layout, so their clients work) |
| `anon_key` | the key a browser or a mobile app presents; safe to ship in a page |
| `service_key` | opens the admin API (`/admin/users`, invitations, deletions); a server secret, like the database password |
| `jwks_url` | the public keys, to verify a user's access token without asking the server |
| `callback_url` | the redirect URI to register with GitHub or Google, ready to paste. It follows `auth_domain`, so register both names if you plan to move to one of yours |
| `loopback_url` | `http://127.0.0.1:9999`: the address an app on the box uses, admin routes included |

**An app on the box** declares `links: ["auth"]` in `set_app` and finds `AUTH_URL` (the loopback address),
`AUTH_PUBLIC_URL`, `AUTH_ANON_KEY`, `AUTH_SERVICE_KEY` and `AUTH_JWKS_URL` in its environment. Nothing else to configure.

**Your app's own address has to be on the engine's list.** A sign-in returns to the address the app asked it to
return to, and the engine accepts that address only when it is the `site_url` or one of the `redirect_urls`. An
address on neither is not refused: the sign-in completes and lands on `site_url` instead, so it reads as your app
having been skipped rather than as a setting you have not made. Set `site_url` to your app's address, and add every
other address that finishes a sign-in - a second app on the box, a staging deployment, `http://localhost:3000` while
you develop - to `redirect_urls`:

    update_engine_settings(instance_id, "auth", add={"redirect_urls": ["https://app.example.com/auth/callback"]})
    PATCH https://app.lathe.live/api/v1/instances/{id}/engines/auth/settings   {"add": {"redirect_urls": ["https://app.example.com/auth/callback"]}}

`redirect_urls` is a comma-separated list, and `add` appends to it without your having to send (or know) the rest of
it; `remove` drops an entry the same way. `**` matches any path, so `https://app.example.com/**` covers a whole app.
This is a different list from the OAuth `callback_url` above: that one is registered with GitHub or Google and is
the engine's own address, this one is your app's.

**Watch the one difference between those two URLs.** `AUTH_PUBLIC_URL` ends in `/auth/v1`, because the box's front
door serves the engine under that prefix. `AUTH_URL`, the loopback address, is the server itself, which has no
prefix: its routes are at the root (`${AUTH_URL}/token?grant_type=password`, `${AUTH_URL}/otp`). They sit next to
each other and read as interchangeable, and they are not: `${AUTH_URL}/auth/v1/...` answers 404, and the 404 looks
like a misconfigured engine rather than a prefix that does not belong.

**Server-side only**, if you would rather nothing auth-related reached the browser: call the engine over `AUTH_URL`
from your own routes, keep the session in your own `httpOnly` cookie, and ship no key and no client in the page.
`/token?grant_type=password`, `/otp`, `/verify` and `/user` (on `AUTH_URL`, with no prefix) are the four the REST API needs, with
`apikey: ${AUTH_ANON_KEY}` on each; the refresh token stays in your cookie and your route swaps it. This is the
natural shape for anything server-rendered, and the anon key never has to be baked into a build. The browser
section below is the alternative rather than the default.

**A browser** uses supabase-js unchanged: `createClient(url_without_the_path, anon_key)` - the client appends
`/auth/v1` itself - then `signInWithOtp`, `signInWithOAuth`, `getSession` as documented by Supabase. Or
`@supabase/auth-js` with the `url` above. Tokens are ES256 JWTs; verify them against `jwks_url` in your own code.

**A server anywhere** calls the same URL with `apikey: <service_key>` and `Authorization: Bearer <service_key>` for the
admin routes, once the setting `admin_api` is `public` (it is `loopback` until then: refused with 404 from outside the box).

**Settings** are grouped, and each group saves on its own: `update_engine_settings(instance_id, "auth", {...})`
(`PATCH …/engines/auth/settings`) changes the keys you give and leaves every other one alone, which is what you
want. `configure_engine_settings` (`PUT`) replaces the lot, so a key you leave out goes back to its default. An
empty value puts a key back to its default, which is how you clear a provider or a custom domain. Every key, its
bounds, its default and its group are under `engines.auth.settings` in `list_plans`.

**Reading them back**: `get_engine_settings(instance_id, "auth")` (`GET …/engines/auth/settings`) answers what every
key is set to, with the defaults filled in, the secrets masked, `lists` naming the comma-separated keys `add` and
`remove` work on, and `set` naming the keys you have changed. Read before you replace anything, and never rebuild a
list you have not read. A settings change is a job, so a read while `pending_job` is not null answers the values
from before it; the write's own answer carries the settings as they will be.

| group | what is in it |
| --- | --- |
| `general` | `site_url`, `redirect_urls`, `signups`, `email_signin`, `magic_link`, `authorized_addresses` to let only some addresses in, `otp_expiry_s`, `otp_length`, `admin_api`, `auth_domain` |
| `providers` | `<name>_client_id` and `<name>_secret` for github, google, apple, azure, bitbucket, discord, facebook, figma, fly, gitlab, kakao, keycloak, linkedin_oidc, notion, slack_oidc, spotify, twitch, workos, x and zoom, plus `<name>_url` for azure, gitlab, keycloak and workos. Both halves or neither: one alone is refused, since the server would ignore it |
| `mail` | `smtp_host/port/user/pass/from`, `emails_per_hour`, `mail_min_interval_s` (what a Resend button runs into), `mail_template` and `mail_subject` for the sign-in mail, a `_template` and a `_subject` each for `invite`, `recovery`, `email_change` and `reauthentication`, `secure_email_change`, and the `notify_*` mails for a changed password or address and a linked or unlinked sign-in method |
| `security` | `password_min_length`, `password_characters`, `password_breach_check` against Have I Been Pwned, `captcha` (hcaptcha or turnstile) and `captcha_secret`, `refresh_token_rotation` and `refresh_token_reuse_s`, `reauth_for_password_change`, `current_password_to_change`, `manual_linking` for `linkIdentity`, and the per-address limits `verify_per_5min`, `otp_requests_per_5min`, `token_refresh_per_5min`, `mfa_attempts_per_min` |
| `sessions` | `jwt_expiry_s`, `session_timebox_h`, `session_inactivity_h`, `single_session` |
| `mfa` | `totp` (on by default), `passkeys`, `webauthn`, `recovery_codes`, `max_factors`. Passkeys and security keys are bound to your app's own host, so `site_url` must be set first |

An on/off setting takes `on` or `off`. A secret reads back as a mask; the mask sent back keeps it, and it never
appears in your activity feed. Anything this schema does not carry - phone and SMS, SAML, the auth hooks - goes on
the apps engine's environment for the app named `auth`: `update_app_env(instance_id, "auth", set={...})`, layered
over what the settings render.

`rotate_password(engine="auth")` turns the engine's database password and keeps the keys, which every session hangs
on. `reset_engine("auth")` drops the schema and starts empty. `app_logs(name="auth")` reads the server's log.