# 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**: every page (and board) is **internal** — viewers must be signed in with
EZCORP SSO (they're redirected through sign-in automatically). External sharing is disabled.
Use an `allowList` to restrict a page to specific SSO users and an optional `viewPassword`
as an additional gate.

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.**
Tokens are valid for **30 days**, so once you have one, reuse it — never make the user
re-approve the device flow just because a new session started.

**Find an existing token before asking for one.** Check, in order:

1. the user's request itself (they may have pasted one),
2. the `EZSHOW_TOKEN` environment variable,
3. the token file at `~/.ezshow/token` (macOS/Linux) or `%USERPROFILE%\.ezshow\token`
   (Windows) — a plain-text file containing just the token,
4. any other durable per-user storage your environment gives you (agent memory, session
   secrets, project env config) where a previous session may have saved it.

Found one? Verify it with a cheap call before using it —
`{"action":"list","payload":{}}` returning 401 means it's expired or revoked; anything
else means it works. Only when no stored token exists (or the stored one gets 401) run
the device flow below — and after capturing the new token, **persist it immediately**
so the next session finds it in step 2–4:

- **CLI on macOS/Linux**: `mkdir -p ~/.ezshow && printf '%s' "$EZSHOW_TOKEN" > ~/.ezshow/token && chmod 600 ~/.ezshow/token`
- **CLI on Windows**: write it to `%USERPROFILE%\.ezshow\token`
  (PowerShell: `New-Item -Force -ItemType Directory "$env:USERPROFILE\.ezshow" | Out-Null; Set-Content -NoNewline "$env:USERPROFILE\.ezshow\token" $env:EZSHOW_TOKEN`)
- **Web / sandboxed / ephemeral environment** (no home directory that survives the
  session): save it to whatever durable per-user store you have — agent memory, the
  environment's secrets/env configuration — and if truly nothing persists, ask the user
  to add it as an `EZSHOW_TOKEN` environment secret so you find it next time.

Never write the token inside the user's project or anything git-tracked, and never put
it in the bundle. If a stored token gets 401, delete/overwrite it after re-running the
device flow.

- **No token anywhere? 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://app.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. Persist
it as described above 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?, personal?, teamSlug? }` | `{ slug, url, ownerUrl, team, teamSlug, sharing, allowList }` |
| `update`      | `{ slug, html?, title?, sharing?, viewPassword?, allowList?, proxy?, note?, username?, team?, teamSlug?, personal? }` | `{ slug, url, ownerUrl, team, teamSlug, version, sharing, allowList }` |
| `delete`      | `{ slug, username? }`                                 | `{ ok: true }`       |
| `purgeVersions` | `{ slug, version? }`                                | `{ ok, purged }`     |
| `versions`    | `{ slug }`                                            | `{ current, versions }` |
| `rollback`    | `{ slug, version }`                                   | `{ slug, url, version, rolledBackTo, changed }` |
| `list`        | `{}`                                                  | `{ slugs: [...] }`   |
| `setSecret`   | `{ name, value }`                                     | `{ ok: true, name }` |
| `listSecrets` | `{}`                                                  | `{ secrets: [...] }` (names only — values never returned) |
| `deleteSecret`| `{ name }`                                            | `{ ok: true }`       |

- **Teams (default):** if the user is in a team, `create` publishes to that team: the app
  gets a team link `https://ezshow.app/<team>/<teamSlug>` (returned as `url` — print THAT
  link) and is visible to the team only (`allowList: ["@<team>"]`). The app still lives
  under its author (`ownerUrl`, `/api/proxy/<username>/<slug>/…` and `/api/data/<username>/<slug>/…`
  are unchanged). `teamSlug` sets a branded team link (default = the slug, `-2` added if
  taken). Pass `personal: true` only when the user says "personal" / "just for me" /
  "not for my team". To open a team app to everyone use `allowList: []`; to share with
  another team add `"@<other-team>"`.
- `username` on `update`/`versions`/`rollback`/`delete`: the board of an app someone handed
  you (maintainer). Omit it for your own apps.
- `html` is the full HTML document as a string. Responses are `{ "data": ... }` or
  `{ "error": "..." }` with a 4xx/5xx status.
- `sharing`: only `"internal"` is supported (and is the default). `"external"` receives a
  400 error explaining that external sharing is disabled and that `allowList` controls access.
- `viewPassword`: optional extra gate on top of EZCORP SSO. In `update`, omitted fields keep
  their value; `viewPassword: ""` clears the password.
- `allowList`: an access group for internal pages — emails, usernames or `"@team"` entries
  for the people who may open the app, e.g. `["ana.perez@ezcorp.com", "jdoe", "@audit"]`.
  `[]` means any EZCORP SSO session may view. Omitted on `create`: `["@<your-team>"]` for
  team apps, `[]` for personal apps; a list containing only the owner's username
  makes the app owner-only. Owner-only apps are omitted from Discover entirely, including
  for the owner; the owner still manages them from the dashboard and can open their direct
  links. The owner is always allowed. People outside the group 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. Entries are normalized to SSO usernames but are not verified
  against the company directory when saved, so use the exact identity; the response echoes
  the applied list. Malformed values receive a 400 with corrective guidance. In `update`,
  omitting the field keeps the group and `allowList: []` clears it.
- **Agent rule — “make it private”:** when the user asks to make an app, project, or repo
  private, use `sharing: "internal"` and `allowList: ["<owner-username>"]` on `create` or
  `update`. Here, private means owner-only. Never use `allowList: []` for this request — `[]`
  means every EZCORP SSO user. Get the owner username from their SSO email's local-part or
  the existing project's `username`; if it is unknown, ask before publishing rather than
  briefly creating a company-wide app. Verify that the response echoes exactly that one
  owner username in `allowList`.
- `slug` on `create` is optional (random 4-char if omitted). Custom slugs: 1–40 chars of
  `a-z`, `0-9`, `-`.
- **Versions**: an `update` whose `html` actually differs bumps the page's version
  (v1 → v2 → …, same link) and keeps the outgoing HTML viewable read-only at
  `<url>?v=<n>` (newest 250 kept). Viewer comments are grouped by the version they were
  written against. The optional one-line `note` ("what changed") shows on the new
  version's divider in the comment panel — include it when iterating on feedback.
  Metadata-only updates (title, password, toggles) never bump. If an old version must
  disappear (it shipped a secret, say), `purgeVersions` with `version: n` permanently
  removes that snapshot; omit `version` to wipe the page's whole history. Comments and
  the version counter are untouched. `versions` lists the history (`{n, at, note,
  hasSnapshot}`), and `rollback` republishes an old version's HTML as a NEW version with
  an auto-note ("Rolled back to vN") — the counter never rewinds and only content
  changes; every other setting stays as it was.

## 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`: anonymous viewers are
   redirected to SSO. 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
