{
  "schema": "leumas.docs.page/1",
  "id": "skill:host-a-vibe-coded-app",
  "slug": "skills/host-a-vibe-coded-app",
  "kind": "tools",
  "bucket": "skill",
  "title": "host-a-vibe-coded-app",
  "name": "Host a Vibe Coded App",
  "eyebrow": null,
  "chip": null,
  "summary": "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.",
  "keywords": [
    "host-a-vibe-coded-app",
    "uploading",
    "decision determines everything",
    "upload leave",
    "subdomain",
    "host a vibe coded app architecture",
    "leumas host a vibe coded app",
    "gates"
  ],
  "audience": "both",
  "funnel": {
    "product": null,
    "cta": null
  },
  "body": "\n# Hosting an app you built\n\nSomeone has a working thing — a Vite SPA, a Next export, an Astro site, a folder of HTML, or a server\nthey run themselves — and wants it at `theirname.leumas.tech`. This is the decision procedure.\n\n**Read `shared/services/knowledge/build-knowledge/imperium-hosting.md` for the architecture.** This\nfile is the choices around it. For selling a plugin in the app store instead, that is a different\nproduct with a different lifecycle — see `build-leumas-plugin`.\n\n---\n\n## 0 · The one decision that determines everything else\n\n**What did you build?** `hosting.target.kind` decides *whether and how a page is rendered at all*,\nand picking wrong is the difference between a working site and a blank one.\n\n| You have | `target.kind` | Who may set it |\n|---|---|---|\n| A built folder — `dist/`, `build/`, `out/`, `_site/` | **`bundle`** | member |\n| Something you already run on a port | **`proxy`** | member (private IPs: self-host only) |\n| A folder AND a port, and you want Leumas to run it | **`server`** | operator, self-host only |\n| Nothing yet — you want Leumas to render the page | **`config`** *(the default)* | member |\n| It moved | **`redirect`** | member |\n\n[warning] **`mode` and `target.kind` are different axes with the same words.** `mode: 'server'` iframes\nsomebody's app *inside* an Imperium page; `target.kind: 'server'` **is** the app, served as the whole\ndomain. Both exist. Read `imperium-hosting-targets` before assuming which one you want.\n\n**`bundle` is the answer for a vibe-coded app.** It needs no path, no port and no operator\nprivilege — the bytes live under the site's own storage root and nothing else can reach them.\n\n---\n\n## 1 · The shortest path (Studio)\n\n**Hosting → New Website → \"I already built it\" → name it → Create & upload → drop the folder.**\n\nThe wizard's upload lane skips *Look* and *Content* deliberately: those are an Imperium mode's theme\nand `data` payload, and a build you made elsewhere has neither. It creates the site as a **draft** and\nlands you on its Files tab.\n\nIt does not publish. Publishing an empty bundle puts a *\"No bundle uploaded yet\"* page on the\ninternet; publish after the bytes land, from the editor, where you can see what you are publishing.\n\n---\n\n## 2 · The same thing over the API\n\nThree calls, in this order. **The order is not negotiable** — an upload goes to\n`builds/<id>/` under `siteStorageRoot(site)`, so the site must exist before there is anywhere to put\nthe bytes.\n\n```js\n// 1 — claim a name. This is a VALIDITY CHECK, not a reservation: it answers \"is this free right\n//     now\", and the real allocation happens in step 2. Nothing is held between the two.\nawait api.hosting.claimSubdomain('my-app');\n\n// 2 — create the site. `target` is NOT accepted here (it is an update-lane field), so the site is\n//     born as a `config` site rendering its mode, which is what a visitor sees until step 4.\nconst { site } = await api.hosting.create({\n  appName: 'My App', subdomain: 'my-app', mode: 'default', data: {}, seo: {},\n});\n\n// 3 — upload, one file per request, into a NEW build directory. Never overwrite the live one.\nconst dir = `builds/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;\nfor (const f of files) await api.hosting.files.upload(site.id, `${dir}/${f.path}`, f.blob);\n\n// 4 — ONE write flips the whole site over. This is also how you roll back: point at an older dir.\nawait api.hosting.update(site.id, { target: { kind: 'bundle', dir, spa: true } });\n\n// 5 — go live. Refused with 402 if your plan has no published-site slot left; see §4.\nawait api.hosting.publish(site.id);\n```\n\n**`spa: true` when your app owns its own routes** (React Router, Vue Router, anything with client\npaths). It rewrites unknown paths to `index.html`. Leave it off for a static multi-page site, or every\n404 becomes your home page.\n\n---\n\n## 3 · What to upload, and what to leave out\n\n`useFolderUpload` (`admin/hosting/model/useFolderUpload.js`) already encodes this, and a hand-rolled\nuploader should copy it rather than re-derive it:\n\n- **It re-roots automatically.** Drop the project folder; if it contains `dist`/`build`/`out`/\n  `public`/`_site`/`www`, that becomes the root. Uploading the repo would otherwise serve your\n  `package.json` at `/`.\n- **It skips `node_modules`, `.git`, and `.env*`.** The last one is not tidiness — a bundle directory\n  is served to the public internet, so an uploaded `.env` is a published secret.\n- **Per file: 25 MB.** Per site: the plan's `imperium.siteStorageBytes`.\n- **`.php` / `.jsp` / `.asp` / `.cgi` / `.pl` / `.htaccess` / `web.config` are refused**, on upload\n  *and* on rename. Nothing here executes; a bundle is static bytes.\n\n**Rollback is moving a pointer.** Every previous build stays on disk, so `target.dir` is the only\nthing that changes. Never upload over a live directory: the deploy is two writes for exactly this\nreason, and the live site is untouched for the whole upload — which matters because an upload is N\nrequests, not one.\n\n---\n\n## 4 · Free vs paid — what actually gates\n\nBuilding is free. **Publishing is the line.**\n\n| | Free floor | `imperium` plan |\n|---|---|---|\n| `imperium.hosting` | [done] create, edit, **preview** | [done] |\n| `imperium.maxSites` | 1 | 3 |\n| `imperium.publishedSites` | **0** | 3 |\n| `imperium.siteStorageBytes` | 50 MB | 250 MB |\n| `imperium.customCode` (`dynamic:` / `ssr:` pages) | [no] | [done] |\n\nBoth are declared in `shared/packages/entitlements/src/policy.js` — `BASE_CAPABILITIES` is the floor,\n`DEFAULT_PLAN_POLICIES` is what a plan adds. A plan can only ever *raise* a floor (booleans OR,\nnumbers MAX).\n\n- **Preview a draft without paying:** `POST /api/hosting/sites/:id/preview-token` returns a signed\n  URL good for 30 minutes. **Use the `url` it returns**, not a locally rebuilt one — the server knows\n  the deployment's port and base domain and your bundle does not.\n- **A publish refusal answers 402** with `limit`/`used`/`detail` in the body. Read the body:\n  `e.message` is the status line (`\"POST /… → 402\"`) and its own JSDoc says it is not for operators.\n- **\"Pay to stay up\" is automatic.** `resolve.js` re-checks the owner's entitlement on *every visitor\n  request*; a lapsed membership swaps the site for the suspension page on the next hit. Nothing\n  cleans up, nothing is deleted.\n\n---\n\n## 5 · After it is live\n\n- **A custom domain** is `POST /sites/:id/domains` then a DNS TXT record. Adding one never serves it —\n  only proving ownership does, and only one site can hold a verified host at a time.\n- **Per-page SEO** works for `config` sites via the Pages tab. A `bundle` site owns its own `<head>`;\n  Leumas does not rewrite your HTML.\n- **`sitemap.xml` and `robots.txt`** are served per host. `seo.robots: 'noindex'` now genuinely\n  disallows crawling rather than only writing a meta tag.\n- [warning] **Studio cannot iframe a `bundle` site.** `applySecurity` sets `X-Frame-Options: SAMEORIGIN` on\n  every process serving a hosted site, and Studio is a different origin by design. Preview opens a\n  new window. It would have *worked in dev and failed in production*, because the Vite dev server\n  sets no such header.\n\n---\n\n## 6 · Verify\n\n```\npnpm smoke:site-upload     # upload here, serve there — the real two-write deploy\npnpm smoke:hosting-tier    # free builds, paid publishes; the five entitlement keys\npnpm smoke:hosting         # all six target kinds off one routing table\npnpm smoke:site-pages      # what a visitor actually receives, incl. per-page SEO\n```\n\n**Then look at the real host.** Every one of those runs against a rig. The failure this feature\nactually has is a bundle that serves its own `index.html` and 404s every deep link — which is\n`spa: true` not being set, and no test catches it because the home page is fine.\n",
  "source": {
    "path": ".claude/skills/host-a-vibe-coded-app/SKILL.md",
    "blobSha": "",
    "commit": "",
    "committedAt": "",
    "provenance": "no-git",
    "bytes": 8034,
    "hash": "10b14c1e7223a5be61e684d05abd1ee47797a068"
  },
  "urls": {
    "html": "/p/skills/host-a-vibe-coded-app",
    "json": "/docs/skills/host-a-vibe-coded-app.json",
    "md": "/docs/skills/host-a-vibe-coded-app.md"
  },
  "links": {
    "composes": [],
    "usedBy": [],
    "product": [],
    "howTo": [],
    "skills": []
  }
}
