# mHub Browser API

When a web page is opened inside an mHub-compatible browser, the browser
exposes the **mHub Browser API**. `window.mhub` is simply the namespace it
lives in, the way `navigator` is for the web platform. It lets a page do
things a normal browser cannot:

1. **`mhub.fetch`**: fetch any URL without CORS, with request headers the
   browser would never let a page set.
2. **`mhub.storage`**: key/value storage that follows the *site*, not the
   domain it was written on.
3. **`mhub.openStream`**: play media that needs mandatory request headers.
4. **`mhub.setLinks`**: add entries ("addon links") to the browser home screen.
5. **`mhub.setBackHandler`**: own the back press while the page runs its own
   navigation.
6. **`mhub.setSearch`**: receive searches the user types into the host's own
   search UI, so your search page needs no input field.
7. **`mhub.device`** / **`mhub.capabilities`**: read what the page can't
   detect on its own.
8. **Mirrors**: one small file on each of your domains, and your site
   survives a dead one. No API call involved; see
   [Mirrors](#mirrors-survive-a-dead-domain).

Everything here is **progressive enhancement**: outside an mHub-compatible
browser the API simply doesn't exist, so guard every use with feature detection.
A page that ignores the API keeps working as a plain website.

## The core guarantee

If `window.mhub` exists, **every member documented in the Core API below MUST
exist and work**. A conforming host has no partial surface and no members that
silently do nothing. What legitimately varies between hosts are the *optional
powers* (a loopback stream proxy, the mirror system, the task protocol, the
signed identity), and each of those is announced in
[`mhub.capabilities`](#windowmhubcapabilities). Check `window.mhub` to know the
API is there; check a capability to know an optional power is there. So
`if (window.mhub)` is the whole check. Probing a core member
(`window.mhub?.fetch`) says the opposite of this guarantee, and once a page
does it for one member it does it for all of them.

The API is the same on **every platform** the host apps run on: mobile, TV,
desktop, web. A platform difference is always expressed through
`capabilities` (or `device`), never through a missing member.

The API is only present when the app has the page-API feature enabled, and only
in the **top document**, never inside embedded (third-party) iframes.

---

## Readiness: the boot script

`window.mhub` is injected **synchronously at document start**, before your
first script, so normally the whole API is simply there:

```js
if (window.mhub) {
  const res = await window.mhub.fetch("https://example.com/api");
}
```

Two things make raw feature-detection awkward: on some hosts the API attaches
a few milliseconds late, and a page often wants to *tell the user* when it is
not running in an mHub browser at all. The **boot script** handles both; load
it as the first script in `<head>`:

```html
<script src="/mhub.js" data-require></script>
```

It gives the page three things:

1. **The queuing stub, installed synchronously.** `mhub.fetch`,
   `mhub.setLinks`, `mhub.setSearch` and `mhub.setBackHandler` are callable
   from the very first line; on a late-attaching host the calls queue and
   replay in order. (`mhub.storage`, `mhub.openStream`, `mhub.capabilities`
   and `mhub.device` cannot be queued meaningfully; while the stub is active
   they are `undefined`/`null`, so touch them after `mhub.ready`.)
2. **Detection.** `mhub.ready` is a `Promise<boolean>`: `true` once a real
   host has attached, `false` when this is a plain browser (decided shortly
   after the document is complete). After it settles, `mhub.hosted` carries
   the same answer synchronously. On `false`, queued `fetch` calls **reject**
   (`"mhub: no host attached"`) instead of hanging forever.
3. **A warning banner** when there is no host, only with the `data-require`
   attribute (override the text with `data-message`). Leave it off and handle
   `mhub.ready === false` yourself; a page built as progressive enhancement
   needs neither.

```js
const hosted = await mhub.ready;
if (!hosted) showDownloadHint();       // or data-require does it for you
```

With the boot script loaded, `window.mhub` is **always** there, because the
stub puts it there in a plain browser too. `if (window.mhub)` then detects
nothing and `mhub.ready` is the only honest answer. Without the boot script it
is the other way round: the namespace exists only where a host injected it,
and testing it is the whole detection.

`mhub.ready` and `mhub.hosted` are provided by the **boot script, not by
hosts**; never feature-detect the host through them.

Self-hosting or inlining the script is fine and recommended for
availability-critical pages; it has no server-side coupling. Pages that cannot
load it can still hand-roll the stub (see the script source; the stub shape
is the compatibility contract and stays stable).

---

## Events

Live host-state changes arrive through a single **`mhubupdate`** event on
`window`, discriminated by `detail.kind`:

```js
addEventListener("mhubupdate", (e) => {
  switch (e.detail.kind) {
    case "device":     applyLayout(e.detail.device); break;
    case "permission": onPermission(e.detail.granted); break;
    case "identity":   refetchUserState(); break;
  }
});
```

| `detail.kind` | Fired when | `detail` |
|---|---|---|
| `device`     | on load (device known)                        | `{ device: { isTV, platform } }` |
| `permission` | on load, and when a permission changes        | `{ name: "fetch", state: "allow"/"deny"/"ask", granted }` |
| `identity`   | when the signed identity / entitlement changes (only on hosts with the `"sign"` capability, never elsewhere) | `{}` (a trigger only) |

- **`permission`** reports the effective per-site decision, so a page can show a
  "grant access" affordance instead of a dead button. `granted` is
  `state === "allow"`; `ask` means the next `mhub.fetch` will prompt. `name`
  identifies which permission it is, today only `"fetch"` (CORS-free web
  access, also covering proxied `openStream`); branch on it so your code keeps
  working if more permissions are added later.
- **`identity`** carries no data on purpose: the signed identity is never exposed
  to page JavaScript. Read it as *"your entitlement may have changed, re-fetch
  your own endpoint"*; the browser attaches the fresh signature to that request.

A second event, **`mhubback`**, is not a state change but a command: the host
handing the back press to the page. It is documented with
[`mhub.setBackHandler`](#windowmhubsetbackhandlerdepth).

---

## `window.mhub.capabilities`

A **synchronous** array of strings naming the optional host powers. Core
members are never listed; they are always present (see the core guarantee).

```js
if (window.mhub?.capabilities.includes("streamProxy")) {
  // openStream can inject mandatory headers here
}
```

| Capability | Meaning |
|---|---|
| `"streamProxy"` | `openStream` can proxy streams with mandatory request headers |
| `"mirrors"`     | the host runs the [mirror system](#mirrors-survive-a-dead-domain): site-file discovery, page-load failover, `fetch` failover, adaptive order |
| `"tasks"`       | `fetch` transparently resolves MediaHubMX fetch tasks (a client-side request with the user's own IP) |
| `"sign"`        | `fetch` attaches the signed client identity to confirmed site endpoints |
| `"search"`      | the host surfaces a site-search entry point (e.g. the address bar) and delivers queries to [`setSearch`](#windowmhubsetsearchconfig) |
| `"cast"`        | *planned, never announced yet*; see [Casting](#planned-casting) |

Unknown strings may appear as the API grows; ignore what you don't know.
The list is **fixed per document load**: a capability never appears or
disappears while your page is running.

---

## `window.mhub.fetch(url, options?)`

A CORS-free, `fetch`-compatible request that runs on the native side.

```js
const res = await window.mhub.fetch("https://example.com/data.json");
const data = await res.json();
```

### Parameters

| Argument  | Type                | Notes                                              |
| --------- | ------------------- | -------------------------------------------------- |
| `url`     | `string`            | Resolved relative to the current page.             |
| `options` | `object` (optional) | A subset of `fetch`'s `RequestInit`, see below.    |

Supported `options`:

- `method`: e.g. `"GET"`, `"POST"`.
- `headers`: a plain object or a `Headers` instance. Because the request is
  built **natively**, this includes headers a browser reserves for itself
  (`Referer`, `Origin`, `User-Agent`, …); set them like any other.
- `body`: **string only** (JSON, form-encoded, …). Blobs/FormData are not
  transferred.
- `redirect`: best-effort. `"follow"` (the default) works everywhere; hosts
  built on a native fetch may not honor `"manual"`/`"error"` and follow anyway.

Credentials are always omitted; the host never attaches its own cookies.

### Return value

A Promise resolving to a **`Response`-compatible** object. On mobile it is a real
`Response` (so `arrayBuffer()` and `blob()` also work); on desktop it is a
lightweight object with the common members. For portability, rely on:

- `status` (number), `ok` (boolean), `url` (string), `headers`
- `text()` → `Promise<string>`
- `json()` → `Promise<any>`

Binary responses are transferred transparently (base64 under the hood) and
reconstructed for you.

The promise **rejects** on network failure, on denied permission, and on
timeout: a request that neither answers nor fails is aborted by the host
(currently after 20 s), so a hanging upstream can never leave your page waiting
forever.

### What it does beyond a plain fetch

- **No CORS.** The request runs natively, so cross-origin responses are readable
  regardless of the target's CORS headers.
- **Forbidden headers.** A browser silently strips `Referer`, `Origin`,
  `User-Agent` and friends from page-initiated requests; here they go through.
  Many stream and API endpoints are unusable without exactly this.
- With the **`"mirrors"`** capability, requests to the site's own endpoints fail
  over to the next mirror on a network error, a timeout or an HTTP status
  `>= 500`; see [Mirrors](#mirrors-survive-a-dead-domain).
- With the **`"tasks"`** capability, MediaHubMX fetch tasks in the response
  are resolved transparently; see the [binding](#mediahubmx-binding).
- With the **`"sign"`** capability, requests to the site's own confirmed
  endpoints carry the signed client identity; see the
  [binding](#mediahubmx-binding).

### Permission

The **first** `mhub.fetch` for a given site shows a one-time dialog
("Allow web access?") naming the host the page is served from, with a
*Remember for this site* option.

- **Allow** + remember → persisted; no further prompts.
- **Allow** without remember → granted for the session.
- **Block** → the call rejects; a red indicator appears in the address bar and
  tapping it re-opens the dialog. A block is session-only and never persisted.

Several `mhub.fetch` calls made before the user answers share the one dialog and
are all resolved by the single decision. The same permission covers proxied
[`openStream`](#windowmhubopenstreamurl-headers-cors) calls; it does **not**
gate `mhub.storage`.

---

## `window.mhub.storage`

Key/value storage that follows your **site**, not the domain it was written on.
All methods return promises; values are strings, so you decide the format.

```js
await window.mhub.storage.set("watchlist", JSON.stringify(items));
const raw = await window.mhub.storage.get("watchlist");   // string | null
await window.mhub.storage.remove("watchlist");
const keys = await window.mhub.storage.keys();            // string[]
```

Storage needs **no permission** and never prompts: nothing leaves the device,
exactly like `localStorage`.

### Why not `localStorage`

Browsers partition `localStorage` per origin. A site reachable under several
mirror domains therefore gets a separate, empty store on each one, and that
hits exactly when a mirror is used, i.e. when the usual domain is unreachable.
The host is the only side that knows which domains are the same site, so it is
the only side that can bridge this.

### Scope

The site part of the key comes from the **host**, derived from the same verified
identity that governs permission and the signature (see *Site identity*). You
cannot choose it, and no other site can name it to reach your data. Inside your
own scope the keys are yours; namespace them per addon if one origin serves
several (`"ted/watchlist"`, `"nasa/watchlist"`).

A site that has not published a site file still gets storage, keyed by its
origin: same isolation, it just cannot span mirrors, because as far as the host
knows there are none.

### Limits

Per site: **512 KB** in total, **256 KB** per value, **200** keys. `set` rejects
when a limit is hit rather than dropping data silently, so you can react.

The store lives on the device; several tabs of the same site share it, writes
are last-write-wins. There is no cross-device sync; it follows the site's
*identity*, not the user.

### Availability

Outside a host `window.mhub` doesn't exist at all, so fall back to
`localStorage`; the data then simply stays on the current domain:

```js
const store = window.mhub?.storage;
const raw = store
  ? await store.get("watchlist")
  : localStorage.getItem("watchlist");
```

---

## `window.mhub.openStream({url, headers?, cors?})`

**The one way to start playback of an external stream.** Media that needs
mandatory request headers (a `Referer`, a token) cannot be played by handing
the URL to a `<video>` tag or a native player: those fetch without your
headers. `openStream` hands the URL to the host, and the host decides what
comes back: the original URL (direct playback) or a loopback-proxied one that
injects the headers natively. The page always plays whatever it gets and never
needs to know the difference.

```js
const s = await window.mhub.openStream({
  url: "https://cdn.example/master.m3u8",
  headers: { Referer: "https://site.example/" },
});
video.src = s.url;         // hls.js / <video>
// nativePlayer.load(s.entry) for native HLS players (path form)
// … playback …
window.mhub.closeStream(s.url);
```

### Parameters

| Field | Type | Notes |
|---|---|---|
| `url` | `string` | Resolved relative to the current page. |
| `headers` | `object?` | Mandatory request headers the stream needs (`Referer`, tokens, …). |
| `cors` | `boolean?` | Set `true` when the page will **read** the stream with XHR (hls.js and every MSE player do); cross-origin CDNs reject those reads, so the host must proxy even though there are no headers. Leave it off for a plain `<video src>`, which is not a CORS request. |

### Return value

`{ url, entry, proxied }`:

| Field | Meaning |
|---|---|
| `url` | Query-form URL. Every sub-request (playlists, segments) is mapped individually; right for hls.js and `<video>`. |
| `entry` | Path-form URL. Relative playlist entries resolve against it and stay on the proxy; right for native HLS players. |
| `proxied` | `true` when the host routed the stream through its loopback proxy. Diagnostic only. |

For a direct stream, `url === entry === ` the input URL and `proxied` is
`false`.

### Rules

The host proxies when the page **needs** it: `headers`, `cors: true`, or
cleartext `http://` media (the loopback clears mixed content). Everything else
comes back direct: no permission prompt, no detour.

- **Every proxied stream** is gated by the same per-site permission as
  `mhub.fetch`: a loopback URL makes the bytes page-readable, which is exactly
  the power that dialog is about. Direct streams never prompt.
- **`headers` given, host has no `"streamProxy"`** → the promise **rejects**.
  A loud failure beats silently returning a URL that will 403 in the player.
- **`cors`/`http://` without a proxy** degrade to direct instead, the same
  behavior the page would get in a plain browser.

Header hygiene is enforced by the host: a blocklist (including
`mediahubmx-signature`; a page can never make the host send its identity), an
SSRF guard, and per-token origin scoping.

### `window.mhub.closeStream(urlOrEntry)`

Release a proxied stream when playback ends. Accepts the `url`, the `entry` or
the bare token; a no-op for direct URLs. There is no permission gate; a page
can only close a stream whose unguessable token it holds.

The host also releases all of a page's streams **itself** when the document
unloads or the tab closes, so `closeStream` is for ending playback early. A
page that forgets it leaks nothing past its own lifetime.

---

## `window.mhub.setLinks(links)`

Declare the site's entries on the browser home screen (mHub calls these *addon
links*). Returns `true` when accepted.

```js
window.mhub.setLinks([
  { id: "tmdb", name: "TMDB", icon: "/icons/tmdb.png", url: "/tmdb" },
  {
    id: "live",
    name: "Live TV",
    endpoints: ["https://a.mx/live", "https://b.mx/live"],
  },
]);
```

**The list you pass is the site's complete set.** Entries the site declared on
an earlier visit and no longer lists are removed; `setLinks([])` removes them
all. Call it with the full list on every load; it is a declaration, not an
append.

### Entry fields

| Field       | Type       | Notes                                                          |
| ----------- | ---------- | -------------------------------------------------------------- |
| `id`        | `string`   | Stable id for the entry (namespaced per site internally).      |
| `name`      | `string?`  | Label shown on the tile. Falls back to `id`.                   |
| `icon`      | `string?`  | Image URL, resolved relative to the page. Falls back to a monogram. |
| `url`       | `string?`  | The single target the tile opens.                              |
| `endpoints` | `string[]?`| Mirror list for the target; use instead of `url` for HA.       |

Give either `url` **or** `endpoints`. Both are resolved relative to the page, so
relative paths work.

### Behaviour

- Tiles open the target as a normal web page; if the target turns out to be an
  mHub addon, the app switches to addon mode automatically.
- Entries persist between visits (a tile is the way *back* to the site) until
  the site itself replaces them or the user removes the tile. They stay
  attributed to the declaring site.
- A site's set holds at most **8** entries; excess entries are dropped.
- The declared `endpoints` do **not** seed the target site's mirror set: that
  would be one site speaking for another. The target declares its own mirrors
  when it is opened; until then the tile uses `endpoints[0]`, and the remaining
  entries serve as load fallbacks for the tile itself.

---

## `window.mhub.setBackHandler(depth)`

Claim the hardware/browser back press while the page is deeper than its own
entry point. Returns `true` when the call was accepted.

The host's back button walks the **tab history**, a list of URLs. A page that
navigates inside itself is invisible to it, so back would jump straight out of
the site (on some hosts even a URL-carrying `pushState` only gets the previous
URL *reloaded*, losing all page state). Instead, the page reports how many
levels deep it is in its **own** navigation:

```js
mhub.setBackHandler(2);                 // I am 2 levels deep
addEventListener("mhubback", () => {
  popOneLevel();
  mhub.setBackHandler(currentDepth);    // report the new depth
});
```

While the reported depth is `> 0`, a back press is dispatched to the page as a
**`mhubback`** event instead of touching the tab history. The page pops one
level and reports its new depth; once that reaches `0`, back presses fall
through to the host again (tab history → start page → out of the browser).

- There is no acknowledgement round-trip: **the depth is the claim.**
- The depth resets to `0` on every document load; a claim never survives a
  navigation, so a page cannot trap the user in a tab.
- **Host fallback:** if the page does not call `setBackHandler` within a short
  timeout (~300 ms) after `mhubback`, the host assumes the page stopped
  listening, clears the claim and handles back presses itself again. **Any**
  `setBackHandler` call counts as the sign of life, also one reporting the
  same depth (a press may legitimately leave the depth unchanged, e.g. closing
  a modal that replaced a level). Always re-report after handling `mhubback`,
  and keep the listener alive as long as you claim a depth.
- A page that drives real browser history (`pushState` + `popstate`) may not
  need this on hosts whose back maps to the WebView's own navigation, but
  claiming the depth is the only behaviour that works on **every** host.

---

## `window.mhub.setSearch(config)`

Let the user search **your site** through the host's own search UI; on mobile
and desktop that is the address bar. The page declares that it handles search
and what to do with a query; your search page then needs no input field of its
own. Returns `true` when accepted.

```js
window.mhub.setSearch({
  placeholder: "Search movies & shows",
  onQuery: (query) => {
    location.href = "/search?q=" + encodeURIComponent(query);
  },
  onSuggest: async (query) => {
    const res = await window.mhub.fetch(
      "/api/suggest?q=" + encodeURIComponent(query)
    );
    return (await res.json()).map((s) => ({ text: s.title, url: s.href }));
  },
});
```

### Config fields

| Field | Type | Notes |
|---|---|---|
| `onQuery` | `(query: string) => void` | Required. The user submitted a search scoped to your site. What happens next is yours: navigate to your results page, filter in place. |
| `onSuggest` | `(query: string) => Suggestion[] \| Promise<Suggestion[]>` | Optional. Called while the user types (debounced by the host). |
| `placeholder` | `string?` | Hint the host may show in its search field. |

A `Suggestion` is `{ text, url? }`. Picking one **with** `url` opens that page
directly (resolved relative to the current page); one **without** is submitted
as a query via `onQuery(text)`.

### Behaviour

- **Capability `"search"`** announces that the host actually surfaces an entry
  point. `setSearch` is core and accepted everywhere, but on a host without
  the capability nothing will ever call your handlers; check it at render
  time to decide whether the page shows its own search box.
- **The declaration is dynamic.** Each call replaces the previous one, and
  `setSearch(null)` withdraws it entirely: the host stops offering the site
  search. Register and withdraw freely as your UI state changes, e.g. offer
  search only in sections that have one.
- The declaration is also **per document**: your callbacks live in the page's
  JS and die with it, so register on every load. How and where the host
  surfaces the search (an in-site mode of the address bar, a search affordance
  on TV) is host UX; the contract is only *query in, handlers called*.
- **Suggestion budget:** the host debounces while the user types, shows at
  most **8** suggestions, and stops waiting after roughly a second; a slow or
  throwing `onSuggest` is dropped silently and never blocks the host UI.
- **Privacy:** input reaches your page only while the host's search UI is
  visibly in its site-search state (an input labeled with your site, or an
  explicit mode the user entered), and a URL is never forwarded. Input typed
  anywhere else never reaches you.

---

## `window.mhub.device`

A small, **synchronous** object describing the host; read it directly, no
`await`, so you can branch on it at first render:

```js
if (window.mhub?.device.isTV) renderTvLayout();
```

| Field      | Type      | Notes                                        |
|------------|-----------|----------------------------------------------|
| `isTV`     | `boolean` | `true` on a TV / remote-driven device.       |
| `platform` | `string`  | `"android"`, `"ios"`, `"electron"` or `"web"` (`"web"` = the host itself renders as a web app, e.g. a TV web runtime). |
| `canPlay`  | `object?` | Optional: `{ hls?, dash?, drm? }` booleans. An **absent field means unknown**, not unsupported; probe with a source trial then. |

`isTV` and `platform` are independent axes: an Android TV reports
`{ isTV: true, platform: "android" }`, a webOS/Tizen TV
`{ isTV: true, platform: "web" }`. Branch layout on `isTV`, never on
`platform`.

It deliberately carries **only what a page cannot derive from standard web
APIs**. For screen size and pixel density use `window.screen` and
`window.devicePixelRatio`; to check whether an app capability exists, use
[`mhub.capabilities`](#windowmhubcapabilities) rather than branching on a
version number.

---

## Mirrors: survive a dead domain

*Capability: `"mirrors"`.*

Media sites lose domains. On a host with the `"mirrors"` capability your site
doesn't go down with one, and the whole integration is **one small file, no
API call**:

Serve `/mhub-site.json` from **every** domain of your site:

```json
{ "id": "your-site-id", "endpoints": ["https://a.example", "https://b.example"] }
```

- **`id`** names the site. Free-form, no relation to any domain; pick any
  stable string. It is not a global namespace either: two unrelated sites may
  use the same id without ever being mixed up.
- **`endpoints`** lists all your domains, including the one serving the file.
  Sub-path sites (`https://host.example/mysite`) are allowed; serve the file
  under that base.

That's it. There is nothing to call: the **host discovers the file by itself**
the first time your page uses any `mhub.*` member (which is what marks it as
an mHub-aware site; ordinary pages never cost a request). It looks in the
**page's directory** first and at the **origin root**, so sub-path sites work
the same way. From then on:

- **Page load:** if loading the current page fails (network error or HTTP
  `>= 500`), the browser reloads it from the next mirror, keeping the path and
  query. The address bar shows whichever mirror actually served the page.
- **`mhub.fetch`:** requests to your endpoints fail over the same way.
- **Adaptive order:** a mirror that works is remembered and tried first next
  time (persisted per site), so a dead primary is skipped on later visits.
- **Permission, storage, signature follow the site**, not the domain; a user
  who granted access once is not asked again when a mirror takes over.

Publish a fresh file any time; new endpoints are picked up and merged on the
next visit.

### How verification works (you don't need this to use it)

A file is a claim, not proof: a page could otherwise name any origin as its
mirror and hand it the site's permission and signature. So the host asks every
listed endpoint itself, over HTTP, and only groups two of them when **each one
names the other** in its own file. Nobody can arrange that for domains they do
not run.

- **An endpoint that does not answer is not rejected: it is kept** and checked
  again on the next occasion. Losing a mirror that is merely down would defeat
  the point of having mirrors. Only an endpoint that answers with a
  *different* `id` is dropped. Rolling a new mirror out one server at a time
  is therefore safe: it joins once both ends list it.
- Until an endpoint is confirmed it may still serve a page (loading transfers
  no trust), but it does not share the site's permission and receives no
  signature.
- Verification re-runs whenever a page of the site is online, so your other
  endpoints are checked without the user ever visiting them.

> **Caveat: origins vs. sub-paths.** Declaring mirrors as bare **origins**
> (`https://a.mx`, `https://b.mx`) is always safe: on failover the path resolves
> identically on every mirror. Mirrors that host the same site under *different*
> sub-paths work for `mhub.fetch`, but a page loaded from them may break on
> root-relative assets; that is the site's responsibility (use `<base>` or
> relative URLs).

---

## Site identity

A site is the **set of its confirmed endpoints**, grouped under the `id` they
publish in [their site file](#mirrors-survive-a-dead-domain). Permission,
storage scope and cache follow that set, not the endpoint that happens to serve
right now, so a user who grants access once is not asked again when a mirror
takes over.

- A site that publishes nothing is simply its own single endpoint, keyed by the
  page's normalised host (`www.` stripped, lowercased).
- Serve several logical sites from one host by giving each its own `id` and its
  own sub-path endpoints, e.g. `https://mhub.mx/tmdb` vs. `https://mhub.mx/live`.

The permission dialog always shows the **host** the page is served from, never
the `id`: an id is free-form, so it is not something a user could rely on.

---

## Permission & privacy

- The user grants web access **per site**, once, via the permission dialog. One
  grant covers the site across all its mirrors, for `mhub.fetch` and for
  proxied `openStream`. `mhub.storage` needs no permission (nothing leaves the
  device).
- The signed `mediahubmx-signature` header is a bearer credential (a pseudonymous
  user id, subscription status, ~15 min validity). It is scoped hard:
  - only for endpoints that are **confirmed members** of the site, and only on
    requests going to them, never to a foreign origin;
  - it is attached by the app and is **never exposed to page JavaScript**, so a
    page can neither read it nor forward it elsewhere; the `openStream`
    header blocklist keeps a page from making the host send it.
- Requests carry no cookies (`credentials` are omitted).

---

## MediaHubMX binding

Everything above is protocol-agnostic: it works for any page in any
conforming browser. The powers in this section tie a host to the **mHub Addon
Protocol** (v1, "MediaHubMX"); each is announced by a capability, and the
coming **Addon Protocol v2** binding will dock here the same way without
touching the core API.

### Site file fallback: `mediahubmx.json`

For [the mirror system](#mirrors-survive-a-dead-domain), a **MediaHubMX addon
needs no extra file**: the host also accepts the `id` + `endpoints` fields in
the `mediahubmx.json` every addon endpoint already serves. `/mhub-site.json`
wins when both exist; a plain website only ever needs `/mhub-site.json`.

### Task protocol: capability `"tasks"`

If a `mhub.fetch` response is a MediaHubMX `taskRequest` asking for a
**fetch**, the host runs that request from the client itself (with the user's
own IP, which is the point: geo checks, IP-bound tokens, rate limits), POSTs
the `taskResponse` back to the mirror that actually answered, and resolves the
page's promise with the final result. Any other task kind is answered with an
error response.

Without the capability, the page receives the raw `taskRequest` JSON and can
fall back to handling it itself.

### Signed identity: capability `"sign"`

For requests to the site's **own confirmed endpoints**, the host attaches the
signed `mediahubmx-signature` header (the same client identity the mHub addon
client sends), so your backend can authorize the user. See
[Permission & privacy](#permission--privacy) for its hard scoping.

---

## Planned: Casting

> **Planned, not implemented anywhere. Do not build against this section;
> everything in it may change.** Availability will be announced by the
> `"cast"` capability when it ships.

The idea: a phone controls the user's **own TV app** in the same LAN,
YouTube-style. The page on the phone discovers the TV, hands playback over and
becomes the remote. Video control (play/pause/seek) is mandatory; the phone
never tunnels the TV's traffic; the TV plays the stream itself.

---

## Platform notes

- The API exists on **every platform**: mobile (Android, iOS), desktop
  (Electron), and TV (Android TV, webOS, Tizen). What differs is how a page
  gets there: mobile and desktop have a free in-app browser; on TV there is no
  free browsing, pages arrive as the packaged runtime or through their home
  tiles; the API they see is the same.
- On mobile, `mhub.fetch` resolves to a genuine `Response`. Elsewhere it may
  resolve to a lightweight object exposing `status`, `ok`, `url`, `headers`,
  `text()` and `json()`; code that sticks to those members is portable
  everywhere.
- The whole API is gated by a server-side feature flag; treat its presence as
  optional and always feature-detect `window.mhub`.
- A conforming host implements the **full core** (the guarantee above) and
  announces its optional powers in `mhub.capabilities`.

---

## Full example

```html
<script>
// No boot script here, so window.mhub exists only if a host injected it:
// the namespace IS the check, and every core member below is guaranteed.
// With /mhub.js loaded this would be wrong, because the stub installs a
// window.mhub in a plain browser too; there the check is `await mhub.ready`.
// Mirrors need no code at all: serving /mhub-site.json on every domain is
// the whole integration; the host discovers it on our first mhub.* call.
if (window.mhub) (async () => {
  // 1) Declare our home-screen entries (the full set, every load).
  window.mhub.setLinks([
    { id: "tmdb", name: "TMDB", icon: "/icons/tmdb.png", url: "/tmdb" },
    { id: "live", name: "Live TV",
      endpoints: ["https://a.mx/live", "https://b.mx/live"] },
  ]);

  // 2) A CORS-free request to our own backend. With "sign", the app attaches
  //    the client identity header; our backend can trust it.
  const res = await window.mhub.fetch("https://mhub.mx/api/catalog");
  if (res.ok) render(await res.json());

  // 3) Play a stream that needs a Referer; one code path for every host.
  const s = await window.mhub.openStream({
    url: item.streamUrl,
    headers: { Referer: "https://mhub.mx/" },
  });
  video.src = s.url;

  // 4) Own the address-bar search: our search page needs no input field.
  window.mhub.setSearch({
    placeholder: "Search movies & shows",
    onQuery: (q) => (location.href = "/search?q=" + encodeURIComponent(q)),
  });
})();

// React to live host-state changes (device known, permission, identity).
addEventListener("mhubupdate", (e) => { /* … */ });
</script>
```
