# host-a-vibe-coded-app

Put an app you built — with AI or otherwise — on a Leumas subdomain. Which target kind to pick, claiming a name, uploading a build, and what free vs paid actually gates.


# Hosting an app you built

Someone has a working thing — a Vite SPA, a Next export, an Astro site, a folder of HTML, or a server
they run themselves — and wants it at `theirname.leumas.tech`. This is the decision procedure.

**Read `shared/services/knowledge/build-knowledge/imperium-hosting.md` for the architecture.** This
file is the choices around it. For selling a plugin in the app store instead, that is a different
product with a different lifecycle — see `build-leumas-plugin`.

---

## 0 · The one decision that determines everything else

**What did you build?** `hosting.target.kind` decides *whether and how a page is rendered at all*,
and picking wrong is the difference between a working site and a blank one.

| You have | `target.kind` | Who may set it |
|---|---|---|
| A built folder — `dist/`, `build/`, `out/`, `_site/` | **`bundle`** | member |
| Something you already run on a port | **`proxy`** | member (private IPs: self-host only) |
| A folder AND a port, and you want Leumas to run it | **`server`** | operator, self-host only |
| Nothing yet — you want Leumas to render the page | **`config`** *(the default)* | member |
| It moved | **`redirect`** | member |

[warning] **`mode` and `target.kind` are different axes with the same words.** `mode: 'server'` iframes
somebody's app *inside* an Imperium page; `target.kind: 'server'` **is** the app, served as the whole
domain. Both exist. Read `imperium-hosting-targets` before assuming which one you want.

**`bundle` is the answer for a vibe-coded app.** It needs no path, no port and no operator
privilege — the bytes live under the site's own storage root and nothing else can reach them.

---

## 1 · The shortest path (Studio)

**Hosting → New Website → "I already built it" → name it → Create & upload → drop the folder.**

The wizard's upload lane skips *Look* and *Content* deliberately: those are an Imperium mode's theme
and `data` payload, and a build you made elsewhere has neither. It creates the site as a **draft** and
lands you on its Files tab.

It does not publish. Publishing an empty bundle puts a *"No bundle uploaded yet"* page on the
internet; publish after the bytes land, from the editor, where you can see what you are publishing.

---

## 2 · The same thing over the API

Three calls, in this order. **The order is not negotiable** — an upload goes to
`builds/<id>/` under `siteStorageRoot(site)`, so the site must exist before there is anywhere to put
the bytes.

```js
// 1 — claim a name. This is a VALIDITY CHECK, not a reservation: it answers "is this free right
//     now", and the real allocation happens in step 2. Nothing is held between the two.
await api.hosting.claimSubdomain('my-app');

// 2 — create the site. `target` is NOT accepted here (it is an update-lane field), so the site is
//     born as a `config` site rendering its mode, which is what a visitor sees until step 4.
const { site } = await api.hosting.create({
  appName: 'My App', subdomain: 'my-app', mode: 'default', data: {}, seo: {},
});

// 3 — upload, one file per request, into a NEW build directory. Never overwrite the live one.
const dir = `builds/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
for (const f of files) await api.hosting.files.upload(site.id, `${dir}/${f.path}`, f.blob);

// 4 — ONE write flips the whole site over. This is also how you roll back: point at an older dir.
await api.hosting.update(site.id, { target: { kind: 'bundle', dir, spa: true } });

// 5 — go live. Refused with 402 if your plan has no published-site slot left; see §4.
await api.hosting.publish(site.id);
```

**`spa: true` when your app owns its own routes** (React Router, Vue Router, anything with client
paths). It rewrites unknown paths to `index.html`. Leave it off for a static multi-page site, or every
404 becomes your home page.

---

## 3 · What to upload, and what to leave out

`useFolderUpload` (`admin/hosting/model/useFolderUpload.js`) already encodes this, and a hand-rolled
uploader should copy it rather than re-derive it:

- **It re-roots automatically.** Drop the project folder; if it contains `dist`/`build`/`out`/
  `public`/`_site`/`www`, that becomes the root. Uploading the repo would otherwise serve your
  `package.json` at `/`.
- **It skips `node_modules`, `.git`, and `.env*`.** The last one is not tidiness — a bundle directory
  is served to the public internet, so an uploaded `.env` is a published secret.
- **Per file: 25 MB.** Per site: the plan's `imperium.siteStorageBytes`.
- **`.php` / `.jsp` / `.asp` / `.cgi` / `.pl` / `.htaccess` / `web.config` are refused**, on upload
  *and* on rename. Nothing here executes; a bundle is static bytes.

**Rollback is moving a pointer.** Every previous build stays on disk, so `target.dir` is the only
thing that changes. Never upload over a live directory: the deploy is two writes for exactly this
reason, and the live site is untouched for the whole upload — which matters because an upload is N
requests, not one.

---

## 4 · Free vs paid — what actually gates

Building is free. **Publishing is the line.**

| | Free floor | `imperium` plan |
|---|---|---|
| `imperium.hosting` | [done] create, edit, **preview** | [done] |
| `imperium.maxSites` | 1 | 3 |
| `imperium.publishedSites` | **0** | 3 |
| `imperium.siteStorageBytes` | 50 MB | 250 MB |
| `imperium.customCode` (`dynamic:` / `ssr:` pages) | [no] | [done] |

Both are declared in `shared/packages/entitlements/src/policy.js` — `BASE_CAPABILITIES` is the floor,
`DEFAULT_PLAN_POLICIES` is what a plan adds. A plan can only ever *raise* a floor (booleans OR,
numbers MAX).

- **Preview a draft without paying:** `POST /api/hosting/sites/:id/preview-token` returns a signed
  URL good for 30 minutes. **Use the `url` it returns**, not a locally rebuilt one — the server knows
  the deployment's port and base domain and your bundle does not.
- **A publish refusal answers 402** with `limit`/`used`/`detail` in the body. Read the body:
  `e.message` is the status line (`"POST /… → 402"`) and its own JSDoc says it is not for operators.
- **"Pay to stay up" is automatic.** `resolve.js` re-checks the owner's entitlement on *every visitor
  request*; a lapsed membership swaps the site for the suspension page on the next hit. Nothing
  cleans up, nothing is deleted.

---

## 5 · After it is live

- **A custom domain** is `POST /sites/:id/domains` then a DNS TXT record. Adding one never serves it —
  only proving ownership does, and only one site can hold a verified host at a time.
- **Per-page SEO** works for `config` sites via the Pages tab. A `bundle` site owns its own `<head>`;
  Leumas does not rewrite your HTML.
- **`sitemap.xml` and `robots.txt`** are served per host. `seo.robots: 'noindex'` now genuinely
  disallows crawling rather than only writing a meta tag.
- [warning] **Studio cannot iframe a `bundle` site.** `applySecurity` sets `X-Frame-Options: SAMEORIGIN` on
  every process serving a hosted site, and Studio is a different origin by design. Preview opens a
  new window. It would have *worked in dev and failed in production*, because the Vite dev server
  sets no such header.

---

## 6 · Verify

```
pnpm smoke:site-upload     # upload here, serve there — the real two-write deploy
pnpm smoke:hosting-tier    # free builds, paid publishes; the five entitlement keys
pnpm smoke:hosting         # all six target kinds off one routing table
pnpm smoke:site-pages      # what a visitor actually receives, incl. per-page SEO
```

**Then look at the real host.** Every one of those runs against a rig. The failure this feature
actually has is a bundle that serves its own `index.html` and 404s every deep link — which is
`spa: true` not being set, and no test catches it because the home page is fine.


---
Source: .claude/skills/host-a-vibe-coded-app/SKILL.md
Canonical: https://docs.leumas.tech/p/skills/host-a-vibe-coded-app
