# ezshow.app — build & host single-file apps via API

ezshow.app hosts **single, self-contained HTML files** on a per-user board: each app lives
at `https://ezshow.app/<username>/<slug>` (your `<username>` is your email's local-part, e.g.
`sngobi@ezcorp.com` → `sngobi`). `https://ezshow.app/<username>` lists all your projects
(password-protected ones appear too, marked 🔒, and still require the password to open).
One file = one app (a "slug"); slugs only need to be unique within your own board. Updating a
slug keeps the link the same.

**Viewing rules**: by default every page (and board) is **internal** — viewers must be signed
in with EZCORP SSO (they're redirected through sign-in automatically). A page can instead be
shared **externally** (`sharing: "external"`): anyone with the link can view, and a
`viewPassword` is then required.

Two server-side capabilities mean a single-file app can do real work without you running a backend:
- **Proxy** (§4) — call an API (OpenRouter, etc.) without shipping the key; ezshow injects the owner's stored secret server-side.
- **Shared data store** (§5) — an owner-scoped JSON store so apps on the same board share state (e.g. a phone app writes a scanned record, a POS app reads and updates it).

This guide is the complete contract for an agent to **build an app, store its secrets, and
deploy it** end to end. Read it top to bottom.

## 1. Credentials — read this first

Every `POST /api` call needs an **API token**: `Authorization: Bearer ezshow_...`.
Accounts are EZCORP SSO (Entra ID) — **there are no passwords; never ask the user for one.**

- Look for a token in the user's request or the `EZSHOW_TOKEN` environment variable.
  This guide assumes `EZSHOW_TOKEN` is set.
- **No token? Run the SSO device flow** — the user approves in their browser and you
  capture the token:

```bash
# 1) start the flow (no auth needed)
curl -s -X POST https://ezshow.app/auth/device
# → {"data":{"code":"…","verifyUrl":"https://ezshow.app/auth/device/<code>",
#            "pollUrl":"https://ezshow.app/auth/device/<code>/token","expiresIn":600,"interval":3}}

# 2) tell the user:
#    > Open <verifyUrl>, sign in with your EZCORP account, and click Approve.

# 3) poll pollUrl every ~3s until approved — capture the token from that response
#    (repeat polls return the same token for ~60s, then the code expires):
curl -s "https://ezshow.app/auth/device/<code>/token"
# pending  → {"data":{"status":"pending"}}
# approved → {"data":{"status":"approved","token":"ezshow_…"}}
export EZSHOW_TOKEN="ezshow_…"
```

Treat the token like a secret: never echo it, commit it, or put it in the bundle. Suggest
the user save it (e.g. export `EZSHOW_TOKEN` in their shell profile) so future sessions skip
the device flow. Codes expire after 10 minutes — just start over. Tokens can be revoked in
the dashboard (https://ezshow.app → API tokens).

## 2. Build the app as ONE self-contained HTML file

ezshow serves exactly one file per slug at `ezshow.app/<username>/<slug>`. There is no way to serve
extra files next to it. So:

- **Inline everything** — all JS and CSS in the single `.html`. For a Vite/React app, use
  [`vite-plugin-singlefile`](https://github.com/richardtallent/vite-plugin-singlefile) so the
  whole bundle inlines into one `index.html`.
- **No external/CDN `<script src>`** that must be fetched at runtime, and no separate asset
  files (split chunks, fonts, images as files). Inline or base64 them.
- **All API calls use absolute `/api/...` paths** (ezshow serves `/api` at the root, while
  your app runs under `/<username>/<slug>`). A relative `api/...` would resolve wrongly.
- Any logic that used to live in a backend must run **client-side in the bundle**. The two
  things that can't: a secret API key → use the **proxy** (§4); shared/persistent state across
  apps → use the **data store** (§5).

## 3. The API

One RPC endpoint: `POST https://ezshow.app/api` with the Bearer token and a JSON body
`{ "action": "...", "payload": { ... } }`.

| action        | payload                                              | result               |
| ------------- | ---------------------------------------------------- | -------------------- |
| `create`      | `{ html, title?, slug?, sharing?, viewPassword?, allowList?, proxy? }` | `{ slug, url }` |
| `update`      | `{ slug, html?, title?, sharing?, viewPassword?, allowList?, proxy? }` | `{ slug, url }` |
| `delete`      | `{ slug }`                                            | `{ ok: true }`       |
| `list`        | `{}`                                                  | `{ slugs: [...] }`   |
| `setSecret`   | `{ name, value }`                                     | `{ ok: true, name }` |
| `listSecrets` | `{}`                                                  | `{ secrets: [...] }` (names only — values never returned) |
| `deleteSecret`| `{ name }`                                            | `{ ok: true }`       |

- `html` is the full HTML document as a string. Responses are `{ "data": ... }` or
  `{ "error": "..." }` with a 4xx/5xx status.
- `sharing`: `"internal"` (default — viewers sign in with EZCORP SSO) or `"external"`
  (anyone with the link; `viewPassword` becomes **required** — the API rejects external
  pages without one).
- `viewPassword`: optional extra gate for internal pages, required for external ones.
  In `update`, omitted fields keep their value; `viewPassword: ""` clears the password
  (rejected while the page is external).
- `allowList`: an access group for internal pages — emails or usernames of the people who
  may open the app, e.g. `["ana.perez@ezcorp.com", "jdoe"]`. Empty/omitted on `create` means
  any EZCORP SSO session may view (the default). Everyone in the group must be an SSO user;
  the owner is always allowed. People outside it get a 403 on the page, its proxy routes,
  its data store and its comments, and don't see the app in Discover or on the board. In
  `update`, omitting it keeps the group and `allowList: []` clears it; switching the page to
  `sharing: "external"` clears it too (external viewers have no SSO identity to check).
- `slug` on `create` is optional (random 4-char if omitted). Custom slugs: 1–40 chars of
  `a-z`, `0-9`, `-`.

## 4. Secrets & the proxy (for apps that need an API key)

**Never put a provider API key in the bundle.** Instead: store it as a ezshow secret, declare
a proxy route on the slug, and have the app call the proxy.

### 4a. Store the key — interactive paste step

Ask the user to paste the key, store it with `setSecret`. Use this snippet so the key never
lands in argv, shell history, disk, or the bundle:

```bash
read -rs OPENROUTER_KEY && export OPENROUTER_KEY                       # silent paste, no echo
jq -n '{action:"setSecret",payload:{name:"openrouter",value:env.OPENROUTER_KEY}}' \
  | curl -s https://ezshow.app/api -H "Authorization: Bearer $EZSHOW_TOKEN" \
       -H 'Content-Type: application/json' --data-binary @-
unset OPENROUTER_KEY
# → {"data":{"ok":true,"name":"openrouter"}}
```

Never echo the key, write it to a file, or commit it. If the user has no key, ask them for
one — do not invent it.

### 4b. Declare the proxy route when publishing

Add a `proxy` map to `create`/`update`. Each route: a name → `{ upstream, secret }` (plus
optional `public`, `limit`, `header`, `scheme`):

```jsonc
"proxy": {
  "openrouter": { "upstream": "https://openrouter.ai/api/v1", "secret": "openrouter", "limit": 2000 }
}
```

- `secret` is the name you stored in 4a (resolved against the slug owner's secrets).
- `limit` (optional): monthly request cap for this app.
- Defaults inject `Authorization: Bearer <key>`. For an API that uses a different header
  (e.g. Anthropic), set `"header": "x-api-key"` and `"scheme": ""`.
- `headers` (optional): static headers added to every upstream request — for APIs that need
  more than the auth header. Anthropic, for example, requires `anthropic-version`:
  ```jsonc
  "anthropic": { "upstream": "https://api.anthropic.com", "secret": "anthropic-admin-key",
    "header": "x-api-key", "scheme": "", "headers": { "anthropic-version": "2023-06-01" } }
  ```
  Max 20 headers; the injected auth header always wins, so `headers` can't clobber the credential.
- If the page has a `viewPassword`, proxy calls require the viewer to unlock first (unless the
  route sets `"public": true`).

### 4c. Call the proxy from the bundle (no key in the browser)

The app calls `/api/proxy/<username>/<slug>/<name>/<upstream path>`. ezshow injects the key
server-side and forwards to the upstream. Username + slug are read from the URL, so the bundle
stays portable:

```js
const [, USER, SLUG] = location.pathname.split("/"); // ["", "<username>", "<slug>"]
const proxy = (name, path, init) => fetch(`/api/proxy/${USER}/${SLUG}/${name}${path}`, init);

// e.g. replace a direct OpenRouter call:
//   fetch("https://openrouter.ai/api/v1/chat/completions", { headers:{Authorization:`Bearer ${key}`}, ... })
// with:
const res = await proxy("openrouter", "/chat/completions", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(payload)        // no Authorization header — ezshow adds it
});
```

### 4d. Record & replay (mock an upstream with a "tape")

A route can capture and replay upstream responses — useful for previews/QA that must not
depend on a live backend. Set `mode` on the route (default `"live"` = normal forwarding):

```jsonc
"proxy": {
  "lana": { "upstream": "https://lana-dev.example.com", "secret": "lana-key", "mode": "record" }
}
```

- `"record"`: forwards normally AND saves each response to a tape. `"replay"`: serves saved
  responses without calling upstream at all (`secret`/`upstream` are not used — a replay-only
  route can omit `secret`).
- The tape is a data-store collection (§5), default name `tape-<routeName>` (override with
  `"tape": "..."`), so the page must be published with `"dataStore": true`. One doc per request,
  id = sha256 of `"<METHOD> <path><query>"`, body `{request:{method,path,query}, status,
  contentType, body}` — list/read/edit/delete recordings via the normal `/api/data` calls
  (e.g. hand-edit a recording to simulate an edge case, then just refresh the app).
- Matching is by method + path + query only; request/response headers and request bodies are
  never stored. Replay misses return 404 with the missing doc id. Only textual bodies within
  the 512 KB doc cap are recorded.

## 5. Shared data store (let sibling apps share state)

Apps under the same account share an **owner-scoped JSON store**, so one app can write data
another reads — e.g. a phone app writes a scanned session and a POS app reads + updates it.
Data is keyed by your `<username>`, so every app on your board sees the same collections (the
`<slug>` in the path is only used to authorize the caller).

**Opt-in per page:** the store is OFF by default. If your app uses it, publish with
`"dataStore": true` in the create/update payload (§6) — otherwise every `/api/data/...` call
through that page returns 403. (The owner can also toggle it in the page's Settings.)

| Method + path | Does |
| --- | --- |
| `POST /api/data/<username>/<slug>/<collection>` | create a doc (auto id, or send `{"id":"..."}`) → `{data:{id}}`. Add `?own=1` to author-lock it (see below) |
| `GET /api/data/<username>/<slug>/<collection>` | list → `{data:{items:[{id,updatedAt,value}]}}` |
| `GET /api/data/<username>/<slug>/<collection>/<id>` | read one → `{data:<doc>}` |
| `PUT /api/data/<username>/<slug>/<collection>/<id>` | replace → `{data:{id}}` |
| `DELETE /api/data/<username>/<slug>/<collection>/<id>` | delete → `{data:{ok:true}}` |

- Same auth as the proxy: for a password-protected app the viewer must have unlocked it; public apps are open.
- Docs cap at 512 KB; updates are **last-write-wins**. Collection = `[a-z0-9-]` (1–40); id = `[A-Za-z0-9._-]` (≤128) or auto.

Helper for the bundle (username + slug read from the URL, so it stays portable):

```js
const [, USER, SLUG] = location.pathname.split("/");
const base = (c, id = "") => `/api/data/${USER}/${SLUG}/${c}${id ? "/" + id : ""}`;
const store = {
  list:   (c)          => fetch(base(c)).then(r => r.json()).then(r => r.data.items),
  get:    (c, id)      => fetch(base(c, id)).then(r => r.json()).then(r => r.data),
  create: (c, doc)     => fetch(base(c), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(doc) }).then(r => r.json()).then(r => r.data.id),
  update: (c, id, doc) => fetch(base(c, id), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(doc) }).then(r => r.json()),
  remove: (c, id)      => fetch(base(c, id), { method: "DELETE" }).then(r => r.json()),
};
// phone: const id = await store.create("sessions", { cui, fields, status: "open" });
// pos:   const open = await store.list("sessions");          // [{ id, updatedAt, value }]
//        await store.update("sessions", id, { ...doc, status: "pushed" });
```

### Viewer-generated content (comments, feedback, form submissions)

**Viewers can write too** — anyone who can view a page with the data store enabled can create
docs. That makes comments on a hosted doc (PRD feedback, reviews, guestbooks) fully supported.
Two extras make them good:

- **Identity**: pages are served on ezshow.app itself, so on an internal (SSO) page
  `fetch("/auth/me")` → `{data:{email,name,username}}` tells you who the viewer is.
- **Ownership**: `POST …?own=1` locks the doc to its creator — only they or the board owner
  (that's the owner's moderation power) can edit/delete it; `list()` items then carry the
  verified `author` username. Internal pages only (needs SSO identity).

```js
const me = (await (await fetch("/auth/me")).json()).data;
await fetch(`/api/data/${USER}/${SLUG}/comments-${SLUG}?own=1`, {
  method: "POST", headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ author: me.name, text, createdAt: new Date().toISOString() })
});
```

Images (e.g. pasted screenshots) fit too: canvas-downscale to ≤1280px JPEG and embed the data
URL in the doc — well under the 512 KB cap. Docs created **without** `?own=1` stay editable by
any viewer — the right mode for shared state like the sessions example above.

## 6. Publish

```bash
# new app with a proxy route + viewer password
curl -s https://ezshow.app/api -H "Authorization: Bearer $EZSHOW_TOKEN" -H "Content-Type: application/json" \
  -d "$(jq -n --rawfile html dist/index.html \
        '{action:"create",payload:{html:$html,title:"ID Scanner",slug:"gtidscan",
          viewPassword:"stores-only",dataStore:true,
          proxy:{openrouter:{upstream:"https://openrouter.ai/api/v1",secret:"openrouter"}}}}')"
# → {"data":{"slug":"gtidscan","username":"sngobi","url":"https://ezshow.app/sngobi/gtidscan"}}
```

(`jq --rawfile` safely embeds the file as a JSON string. No `jq`? Build the JSON any reliable
way — just JSON-escape the HTML.)

To iterate later, `update` the same slug (the link stays the same):

```bash
curl -s https://ezshow.app/api -H "Authorization: Bearer $EZSHOW_TOKEN" -H "Content-Type: application/json" \
  -d "$(jq -n --rawfile html dist/index.html '{action:"update",payload:{slug:"gtidscan",html:$html}}')"
```

## 7. After publishing — always do this

1. `curl -sI https://ezshow.app/<username>/<slug>` → expect `303` for internal pages
   (anonymous viewers are redirected to SSO — signed-in EZCORP users see the page) or
   `401` for external ones (the password gate). Anonymous `200` means something's wrong.
2. If the app uses a proxy, do one real call to confirm it works.
3. Print the link clearly:

   > Published! Share this link: **https://ezshow.app/sngobi/gtidscan**
   > (viewers need the password `stores-only` — only mention this if you set one)

## 8. Errors

| status | meaning                                          | what to do                                 |
| ------ | ------------------------------------------------ | ------------------------------------------ |
| 401    | missing/revoked token; or viewer not signed in with SSO / page not unlocked (proxy & data) | re-run the device flow (§1) / sign in / unlock the page |
| 404    | slug or proxy route doesn't exist                | `list`; check the `proxy` map name         |
| 409    | custom slug already taken                        | pick another slug or omit it               |
| 429    | proxy monthly `limit` reached                    | raise the limit or wait                    |
| 502    | the owner hasn't set the route's `secret`        | run `setSecret` (§4a)                       |
| 400    | malformed payload                                | fix it using the error message             |

Humans can manage pages, secrets **and API tokens** in the dashboard (EZCORP SSO): https://ezshow.app
