# Embedding Forge Studio

Every Forge Studio editor is a plain HTML page, so embedding one is just an
`<iframe>`. There is no SDK to install, no build step, no API key, and no
account. You control the editor from your own page with URL parameters and a
small `postMessage` protocol.

- [Environments](#environments--which-url-to-embed) — prod / QA / demo URLs
- [Quick start](#quick-start)
- [What you can embed](#what-you-can-embed) — support matrix
- [Saving to your server](#saving-to-your-server-docforge) — savepath, the host
  endpoint contract, capability tickets, encryption
- [URL parameters](#url-parameters)
- [The postMessage API](#the-postmessage-api)
- [Host-side helpers](#host-side-helpers) — copy-paste base64 + a promise wrapper
- [Worked examples](#worked-examples)
- [Security](#security) — **read this before going to production**
- [Self-hosting](#self-hosting) — **including the converter fallback**
- [Sizing and mobile](#sizing-and-mobile)
- [Offline behaviour](#offline-behaviour)
- [Troubleshooting](#troubleshooting)

A live, working version of everything here is in
[`embed-demo.html`](embed-demo.html) — a mock intranet page with three real
embeds. Open it and view source; it's the fastest way to copy a pattern.

## Environments — which URL to embed

| | URL | Use it for |
|---|---|---|
| **Production** | `https://editors.jnc360.tech` | What your integration should point at. Only ever updated by promoting a tested QA build |
| **QA** | `https://editors-qa.jnc360.tech` | The next version — often ahead of prod. Point your *staging* here to test upcoming editor releases against your integration |
| **Cross-origin demo** | `https://embed-demo.jnc360.tech` | A separate-origin host site embedding the editors, with a real store endpoint behind it — the live version of this document's save contract |

Make the editor origin a **config value** in your app, not a hardcoded string —
then production uses prod, staging can flip to QA, and a future self-hosted
copy is a config change. This document is served on both editor origins
(`/EMBEDDING.md`), so it always matches the version you're embedding.

---

## Quick start

A read-only spreadsheet viewer, complete:

```html
<iframe src="https://editors.jnc360.tech/sheet.html?embed=1&view=1&src=reports/q4.csv"
        style="width:100%; height:420px; border:0"></iframe>
```

An editable document your page can drive:

```html
<iframe id="doc" src="https://editors.jnc360.tech/word.html?embed=1"
        style="width:100%; height:560px; border:0"></iframe>

<script>
  // Attach the listener BEFORE the iframe finishes loading — see the note
  // under forge:ready. Otherwise you can miss the handshake.
  window.addEventListener('message', e => {
    if (e.origin !== 'https://editors.jnc360.tech') return;   // always check
    if (e.data?.type === 'forge:ready') {
      document.getElementById('doc').contentWindow.postMessage({
        type: 'forge:load', format: 'html', data: '<h1>Hello</h1><p>Edit me.</p>',
      }, 'https://editors.jnc360.tech');
    }
    if (e.data?.type === 'forge:document') {
      console.log('got', e.data.name, e.data.format);        // data is base64
    }
  });
</script>
```

Swap the host for your own if you self-host — see [Self-hosting](#self-hosting).

---

## What you can embed

| Editor | Page | `?embed` | `?view` | `?src` | postMessage | Loads | Exports |
|---|---|:--:|:--:|:--:|:--:|---|---|
| **DocForge** (word processor) | `word.html` | ✅ | ✅ | ✅ | ✅ | `docx`, `html`, `txt` | `docx` |
| **SheetForge** (spreadsheet) | `sheet.html` | ✅ | ✅ | ✅ | ✅ | `xlsx`, `csv` | `xlsx` |
| **PDFForge** (PDF viewer/editor) | `pdf.html` | ✅ | ✅ | ✅ | ✅ | `pdf` | `pdf` |
| **GraphicForge** (image editor) | `photo.html` | ✅ | ✅ | ❌ | ❌ | — | — |
| **MotionForge** (video editor) | `video.html` | ✅ | ✅ | ❌ | ❌ | — | — |

GraphicForge and MotionForge honour the chrome parameters, so they embed and
display fine — they just have no programmatic file API. To get media in or out
of those two, the user drags files in and exports normally.

---

## URL parameters

Combine freely: `word.html?embed=1&view=1&src=/files/report.docx`.

| Parameter | Effect |
|---|---|
| `?embed=1` | Hides the Forge Studio app bar, so the editor looks like part of your page rather than a separate app. Use this for every embed. |
| `?view=1` | **Read-only.** Hides toolbars, panels, layer lists and side bars, and disables editing interactions. Combine with `embed=1` for a pure viewer. |
| `?src=URL` | Auto-opens a file on load. Relative (`samples/x.csv`) or absolute (`https://…/x.csv`). DocForge, SheetForge and PDFForge only. |
| `?savepath=URL` | **DocForge.** Persist to your server: GET on load, POST on save. Must be an **absolute URL** when your server is a different origin than the editor — see [Saving to your server](#saving-to-your-server-docforge). |
| `?encrypt=1` | **DocForge, with `savepath`.** Seal the bytes client-side (AES-256-GCM) before upload; your server stores ciphertext. Passphrase via prompt or `forge:config` — never in the URL. |

`?src` is fetched by the **editor's** browser context, so the URL must be
same-origin with the editor page or served with permissive CORS headers
(`Access-Control-Allow-Origin`). A file behind a session cookie on your own
domain will not be readable by an editor hosted elsewhere.

`?view=1` is presentation-level, not a security boundary. It hides the editing
UI; it does not make the underlying file unreachable to someone using devtools.
Never treat it as access control — if a user must not have the bytes, don't send
the bytes.

---

## The postMessage API

Supported by **DocForge, SheetForge and PDFForge**. Two core messages you send
and four you receive; DocForge adds a save/open family (marked ✎) that only
does anything when a `savepath` is configured.

### Host → editor

| Message | Payload |
|---|---|
| `forge:load` | `{ type, format, data, name? }` — put a document into the editor |
| `forge:export` | `{ type }` — ask for the current document back |
| ✎ `forge:save` | `{ type }` — save to the savepath now (same as the user pressing Save) |
| ✎ `forge:saveas` | `{ type }` — save under a new name (prompts the user) |
| ✎ `forge:config` | `{ type, savepath?, encrypt?, passphrase? }` — set or change save settings after load; the only safe way to deliver a passphrase |
| ✎ `forge:list` | `{ type, prefix? }` — list files beside the savepath (defaults to its directory) |
| ✎ `forge:open` | `{ type, path }` — open a stored file; it becomes the new save target |

`data` is a **base64 string for binary formats** (`docx`, `xlsx`, `pdf`) and a
**plain string for text formats** (`html`, `txt`, `csv`). `name` is optional and
only used for the filename.

### Editor → host

| Message | Payload | When |
|---|---|---|
| `forge:ready` | `{ type }` | Once, at startup — only when running inside an iframe |
| `forge:loaded` | `{ type }` | A `forge:load` (or ✎ `forge:open`) finished successfully |
| `forge:document` | `{ type, format, name, data }` | Reply to `forge:export`; `data` is **always base64** |
| `forge:error` | `{ type, message }` | A load, export or open threw |
| ✎ `forge:saved` | `{ type, ok, status?, error? }` | After any save to the savepath — user-initiated or `forge:save` |
| ✎ `forge:files` | `{ type, files: [{path, size, at}] }` | Reply to `forge:list`; `path` is an absolute URL |

### Sequence

```
editor  ──► forge:ready
host    ──► forge:load     { format:'html', data:'<h1>Hi</h1>' }
editor  ──► forge:loaded
             … user edits …
host    ──► forge:export
editor  ──► forge:document { format:'docx', name:'doc.docx', data:'UEsDBBQ…' }
```

### ⚠️ Don't miss `forge:ready`

`forge:ready` fires once, as soon as the editor's scripts run. If you attach
your `message` listener after the iframe has already loaded, **the handshake is
gone and you'll wait forever.** Attach the listener first — before you insert
the iframe or set its `src`.

You don't strictly need `forge:ready` at all: the editor keeps listening for the
lifetime of the page, so a `forge:load` sent later always works. `forge:ready`
just tells you the earliest safe moment. If you'd rather not race it, wait for
the iframe's own `load` event instead:

```js
frame.addEventListener('load', () => {
  frame.contentWindow.postMessage({ type: 'forge:load', /* … */ }, TARGET);
});
```

### Message filtering

The editor ignores anything that isn't an object with a `type` string starting
`forge:`, so your own app's postMessage traffic won't confuse it. Do the same on
your side — the snippets below all check `type` before acting.

---

## Saving to your server (DocForge)

DocForge embeds can persist the document to **your** backend — identity lives
in the path you hand it (e.g. your logged-in user's id), and your server
enforces who may write there:

```html
<iframe src="https://editors.jnc360.tech/word.html?embed=1&savepath=/api/docs/user42/report.docx&encrypt=1"></iframe>
```

- `savepath=URL` — the editor **GETs** it on load (round-trip: an existing doc
  opens right back up; 404 starts blank) and **POSTs** the `.docx` bytes to it
  on save (`application/octet-stream`, cookies included — allow CORS with
  credentials if cross-origin). ⚠️ **Pass an absolute URL** (`https://your-site.com/api/docs/…`):
  a relative path resolves inside the iframe, i.e. against the *editor's*
  origin, not your site's. There is **one Save button** (the toolbar 💾,
  also **Ctrl/Cmd+S**); a `Save: server / this device` selector next to it
  chooses whether it POSTs to the savepath or downloads locally — it only
  appears when a savepath is configured. The host can also command a save and
  observe the result:
  ```js
  frame.postMessage({ type: 'forge:save' }, ORIGIN);
  // → { type: 'forge:saved', ok: true, status: 200 }
  frame.postMessage({ type: 'forge:config', savepath: '/api/docs/u42/x.docx',
                      encrypt: true, passphrase: 'from-your-key-store' }, ORIGIN);
  ```
- **Naming**: a *new* document (blank, template, or opened from the device)
  prompts for a file name on its first save — and warns before overwriting an
  existing file. Documents opened from the store save straight back to their
  own path. `forge:saveas` (or Ctrl+Shift+S / the toolbar 💾… button) forces the
  naming flow; saved files reopen through the flowing native importer so they
  stay properly editable.
- **Encryption at rest is your server's job — and needs nothing from the
  editor.** If you simply want stored documents encrypted, encrypt/decrypt
  server-side around the GET/POST with your own key: the editor sends and
  receives plain `.docx` over TLS and never knows. No parameter, no
  passphrase, no key in the browser. This is the right model for shared /
  multi-user documents and is what most integrations should do.
- `encrypt=1` — the **zero-knowledge** option, only for "the server must
  never be able to read it": the document is sealed **client-side** before
  upload (PBKDF2-SHA256 · 200k iterations → AES-256-GCM, `FORGEENC1`
  container), so your server stores ciphertext it cannot read. The passphrase
  is prompted once per session, or supply it via `forge:config` (never put it
  in the URL). A wrong passphrase fails decryption outright on reopen — and
  nobody without the passphrase, including you, can recover the document.
  Don't use this when server-side at-rest encryption is all you need.

### The host endpoint contract — implement exactly this

Everything the editor will ever send to your server, given
`savepath=https://your-site.com/docs/<userId>/report.docx`. Your endpoint is
free-form — the editor only cares about these behaviours:

| Request | You must respond | Used for |
|---|---|---|
| `GET <savepath>` | `200` + raw bytes, or `404` if nothing stored | Round-trip on load; also the **existence probe** during the naming flow (a `200` triggers the overwrite warning) |
| `POST <file url>` — body is the raw bytes, `Content-Type: application/octet-stream` | any `2xx` | Saving. The URL is the savepath, or sibling `<dir>/<chosen-name>.docx` after the naming/Save-As flow |
| `GET` on the directory URL (trailing slash), sent with `Accept: application/json` | `200` + JSON array `[{"path": "...", "size": 1234, "at": "2026-07-27T22:04:05Z"}]` | The Open picker and `forge:list`. `path` may be site-rooted (`/docs/u42/x.docx`) — the editor resolves it against the listing URL. Return `[]`, not an error, for an empty directory |
| `OPTIONS` (preflight) | `204` + the CORS headers below | Browsers preflight the cross-origin POST |

The bytes are opaque to you: a `.docx` file, or a `FORGEENC1` ciphertext
container when encryption is on. Store and return them unmodified.

**CORS — the editor calls with `credentials: 'include'`, which changes the rules:**

```
Access-Control-Allow-Origin: https://editors.jnc360.tech   ← echo the exact Origin; a wildcard * is REJECTED with credentials
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, OPTIONS           ← on the preflight
Access-Control-Allow-Headers: Content-Type, Accept         ← on the preflight
Vary: Origin
```

Allow both `https://editors.jnc360.tech` and `https://editors-qa.jnc360.tech`
if you test against QA (or your own origin if self-hosting).

**Auth is yours.** The user's cookies for your domain ride along on every
request, so authenticate the session server-side and check it may touch the
`<userId>` in the path. The editor never sees or handles your credentials.

**Token-auth hosts (SPA / Bearer / no cookies): use capability tickets.**
If your app authenticates with an `Authorization` header from
localStorage rather than cookies, nothing rides along on the editor's
fetches — it cannot attach your Bearer token. Bridge it with a short-lived
capability URL:

1. Your page (which *has* the token) calls your API to mint a **ticket** — a
   short-TTL (~15 min) signed value scoped to one document or folder and one
   permission level, e.g. HMAC over `{user, folder, scope, exp}`.
2. The ticket goes **in the savepath**:
   `savepath=https://your-site.com/api/docs/<ticket>/report.docx` — identity
   in the path, exactly as designed. Your endpoint validates the signature,
   expiry and (per request) the user's current membership, so revocation is
   immediate.
3. **Deliver the savepath via `forge:config` after the iframe loads**, not in
   the iframe URL — keeps tickets out of browser history and referrers. Push a
   fresh savepath the same way before the ticket expires.
4. CORS becomes the easy mode: echo the editor origin **without**
   `Access-Control-Allow-Credentials` — the ticket is the credential, and with
   no ambient cookies these endpoints have no CSRF surface at all.

**Reference implementation:** [`demo-site/store-server.py`](demo-site/store-server.py)
— ~150 lines of dependency-free Python implementing this whole table (plus
optional `DELETE`), running live behind
[embed-demo.jnc360.tech](https://embed-demo.jnc360.tech)'s `/demo-store/*`.
Fetchable copy: <https://embed-demo.jnc360.tech/store-server.py>. This
document itself is served at <https://editors.jnc360.tech/EMBEDDING.md>.

### Set-up checklist (for humans and AI agents)

1. Implement the four behaviours above at some path on **your** origin, e.g.
   `/api/docs/`. Enforce your own auth on every method.
2. Embed with an **absolute** savepath:
   `word.html?embed=1&savepath=https://your-site.com/api/docs/<userId>/report.docx`
   (a relative savepath resolves against the editor's origin — the classic
   mistake; your network tab will show saves going to the editor's domain).
3. Verify from a terminal before blaming the editor — these four must pass:
   ```bash
   # preflight → 204 with the CORS headers echoed
   curl -si -X OPTIONS -H 'Origin: https://editors.jnc360.tech' \
     -H 'Access-Control-Request-Method: POST' https://your-site.com/api/docs/u1/t.docx | head -8
   # save → 2xx
   printf test | curl -si -X POST -H 'Origin: https://editors.jnc360.tech' \
     -H 'Content-Type: application/octet-stream' --data-binary @- \
     https://your-site.com/api/docs/u1/t.docx | head -1
   # load → the same bytes back
   curl -s -H 'Origin: https://editors.jnc360.tech' https://your-site.com/api/docs/u1/t.docx
   # list → JSON array including t.docx
   curl -s -H 'Origin: https://editors.jnc360.tech' -H 'Accept: application/json' \
     https://your-site.com/api/docs/u1/
   ```
4. In the page, listen origin-checked for `forge:saved` / `forge:files` /
   `forge:loaded` / `forge:error` (see [the wrapper below](#a-promise-wrapper))
   and you have the full save / list / open loop.
5. Encryption (optional): add `&encrypt=1` and deliver the passphrase with
   `forge:config` after the iframe loads. Verify the stored file now starts
   with the bytes `FORGEENC1` instead of `PK`.

## Host-side helpers

Inside the iframe the editor exposes `window.forgeB64`, but that's the editor's
context, not yours. Your page needs its own conversion. These two are all you
need:

```js
// ArrayBuffer/Uint8Array → base64 (chunked, so it survives large files)
function toBase64(buf) {
  const u8 = new Uint8Array(buf);
  let s = '';
  const CH = 0x8000;                                  // avoid arg-limit blowups
  for (let i = 0; i < u8.length; i += CH) {
    s += String.fromCharCode.apply(null, u8.subarray(i, i + CH));
  }
  return btoa(s);
}

// base64 → Uint8Array
const fromBase64 = b64 => Uint8Array.from(atob(b64), c => c.charCodeAt(0));
```

`String.fromCharCode(...bigArray)` throws on large inputs — that's why the
encoder chunks. Don't simplify it away.

### A promise wrapper

Turns the message ping-pong into `await`:

```js
function forgeClient(frame, origin) {
  const send = (msg, waitFor) => new Promise((resolve, reject) => {
    const onMsg = e => {
      if (e.source !== frame.contentWindow) return;
      if (origin !== '*' && e.origin !== origin) return;
      const d = e.data;
      if (!d || typeof d.type !== 'string') return;
      if (d.type === waitFor) { cleanup(); resolve(d); }
      else if (d.type === 'forge:error') { cleanup(); reject(new Error(d.message)); }
    };
    const cleanup = () => window.removeEventListener('message', onMsg);
    window.addEventListener('message', onMsg);
    frame.contentWindow.postMessage(msg, origin);
  });

  return {
    load: (format, data, name) =>
      send({ type: 'forge:load', format, data, name }, 'forge:loaded'),
    export: () => send({ type: 'forge:export' }, 'forge:document'),
  };
}

// usage
const forge = forgeClient(document.getElementById('doc'), 'https://editors.jnc360.tech');
await forge.load('html', '<h1>Invoice</h1>');
const doc = await forge.export();          // { format, name, data:base64 }
```

---

## Worked examples

### 1 · Load a file from your server into the editor

```js
const res  = await fetch('/api/documents/42.docx');
const b64  = toBase64(await res.arrayBuffer());
await forge.load('docx', b64, 'contract.docx');
```

### 2 · Save the edited document back to your server

```js
const doc   = await forge.export();                     // base64
const bytes = fromBase64(doc.data);

await fetch('/api/documents/42', {
  method: 'PUT',
  headers: { 'Content-Type': 'application/octet-stream' },
  body: bytes,
});
```

Post the base64 string directly instead if your backend prefers JSON — just
remember it's ~33% larger than the bytes.

### 3 · Offer the result as a download

```js
const doc  = await forge.export();
const MIME = {
  docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  pdf:  'application/pdf',
};
const url = URL.createObjectURL(new Blob([fromBase64(doc.data)], { type: MIME[doc.format] }));
Object.assign(document.createElement('a'), { href: url, download: doc.name }).click();
URL.revokeObjectURL(url);
```

### 4 · A viewer with no JavaScript at all

```html
<iframe src="https://editors.jnc360.tech/pdf.html?embed=1&view=1&src=/manuals/setup.pdf"
        style="width:100%; height:600px; border:0"
        title="Setup manual"></iframe>
```

Drop `view=1` and visitors can annotate and fill the PDF (their changes stay in
their browser unless you pull them out with `forge:export`).

---

## Security

**The editor does not verify who is messaging it.** `js/embed.js` accepts any
`forge:*` message from any origin and sends its replies with a `'*'` target
origin. That is deliberate — it keeps embedding trivial — but it moves the
security burden onto your page. Three consequences:

1. **Always check `e.origin` in your own listener.** Otherwise any other frame
   or window on the page could feed you a fake `forge:document`.
   ```js
   if (e.origin !== 'https://editors.jnc360.tech') return;
   ```
2. **Prefer an explicit target origin when sending**, rather than `'*'`:
   ```js
   frame.contentWindow.postMessage(msg, 'https://editors.jnc360.tech');
   ```
   With `'*'`, a document you push is readable by whatever happens to occupy
   that frame — which matters if the frame could be navigated elsewhere.
3. **Don't put secrets in `?src`.** It's a URL: it lands in browser history,
   referrer headers and server logs. Use a short-lived signed URL if the file
   is sensitive.

### Sandboxing

If you want defence in depth, the editors work under `sandbox` provided scripts
and same-origin access survive:

```html
<iframe src="…/word.html?embed=1"
        sandbox="allow-scripts allow-same-origin allow-downloads"></iframe>
```

- `allow-scripts` — required; the editors are JavaScript.
- `allow-same-origin` — required for IndexedDB, the service worker and `?src`
  fetches. Note that combining it with `allow-scripts` for a **same-origin**
  frame lets the frame remove its own sandbox, so it only buys you real
  isolation when the editor is on a different origin.
- `allow-downloads` — only if users will use the editor's own Save/Export
  buttons. Not needed when your page handles the file via `forge:export`.
- `allow-modals` — only if you want the editor's `alert`/`confirm` prompts.

### Content Security Policy

If your page sets a CSP, allow the editor's origin to be framed:

```
Content-Security-Policy: frame-src https://editors.jnc360.tech;
```

And if you self-host, make sure the editor's own responses don't carry an
`X-Frame-Options: DENY`/`SAMEORIGIN` header or a `frame-ancestors` directive
that excludes your site — either one blocks framing outright. Forge Studio's
own Caddy vhosts set neither, so the default hosted build frames fine.

### Privacy

Everything runs in the visitor's browser and nothing is uploaded — **with one
exception**, covered next.

---

## Self-hosting

Forge Studio is static files. Copy the folder onto any web server, or clone the
repo and serve it:

```bash
./start.sh          # python3 -m http.server 8642
```

Then embed from your own origin. Requirements are minimal: correct MIME types
(especially `.wasm` as `application/wasm` for MotionForge), and no
`X-Frame-Options` header blocking frames.

### ⚠️ The one thing that isn't client-side

DocForge's **PDF ↔ DOCX conversion** calls a server. It tries a relative
`api/convert/…` path first, and if that isn't there it falls back to a
**hard-coded public endpoint**:

```js
// js/word.js
let resp = await fetch('api/convert/pdf2docx', { … });
if (!resp || !resp.ok) {
  resp = await fetch('https://editors.jnc360.tech/api/convert/pdf2docx', { … });
}
```

The same fallback exists for `docx2pdf`. **So on a self-hosted copy without its
own converter, opening a PDF in DocForge (or exporting a laid-out PDF) uploads
that document to `editors.jnc360.tech`.** No other feature does this — every
other editor and format is genuinely local — but if you embed DocForge for
confidential documents you must handle it. Options:

- **Run your own converter** and expose it at `api/convert/pdf2docx` and
  `api/convert/docx2pdf` relative to the editor. The relative call is tried
  first, so the fallback never fires. This is what the hosted deployment does —
  its Caddy vhost proxies `/api/convert/*` to a local `pdf2docx` service.
- **Block the fallback** with CSP (`connect-src` excluding that host) or by
  patching `js/word.js` to drop the second `fetch`. Conversion then fails
  cleanly instead of leaving your network — DocForge falls back to its built-in
  canvas renderer for PDFs, and to its own PDF writer for export.
- **Avoid the path** — the fallback only triggers for PDF import/export in
  DocForge. `docx`/`html`/`txt` round-trips, all of SheetForge, and PDFForge's
  own viewing/annotation never touch it.

Everything else — every format conversion, render and export listed in the
[support matrix](#what-you-can-embed) — happens entirely in the browser.

---

## Sizing and mobile

The editors fill whatever box you give them, so **the iframe needs an explicit
height**; an iframe's default is 150px and there is no content-based
auto-resize.

```html
<iframe class="forge" src="…" style="width:100%; height:70vh; border:0"></iframe>
```

Rules of thumb: viewers ~400–600px; editable DocForge/SheetForge ≥520px so the
toolbars and a useful amount of document both fit; GraphicForge and MotionForge
want ≥600px and a wide frame — their panel layouts get cramped below ~700px
wide.

The editors are responsive and have a touch layout, so a narrow frame degrades
gracefully rather than breaking. On phones, prefer `view=1` for anything you
aren't asking people to actually edit.

Forge Studio ships no `postMessage` resize protocol; if you want the frame to
track content height, drive it from your own side.

---

## Offline behaviour

Each editor page registers a service worker that precaches the whole suite, so
an embedded editor keeps working offline after the first load. Two implications
for embedders:

- The service worker is scoped to **the editor's origin**, not your page's. It
  won't interfere with your site's own service worker.
- After you deploy a new Forge Studio build, returning visitors get the new
  version because the app fetches network-first and the cache name changes each
  release. If you pin a copy, remember to bump `CACHE` in `sw.js` when you
  change any file, or your users keep the old build.

`?src` fetches still need the network unless the target is itself cached.

---

## Troubleshooting

| Symptom | Cause / fix |
|---|---|
| Nothing happens after `forge:load` | You missed `forge:ready` — attach the listener before the iframe loads, or send on the iframe's `load` event. |
| Frame is blank / refused to connect | `X-Frame-Options` or CSP `frame-ancestors` on the editor's origin. Check response headers. |
| `?src` file never opens | Cross-origin without CORS, a 404, or a cookie-protected URL the editor can't authenticate to. Check the network tab **inside** the frame. |
| `forge:error: Unsupported load format` | Format not in that editor's list — see the [support matrix](#what-you-can-embed). DocForge can't take `xlsx`, etc. |
| Export returns garbage / fails to open | You forgot to base64-decode. `forge:document.data` is **always** base64, including for text-ish formats. |
| `btoa` throws on a big file | Use the chunked `toBase64` above, not `String.fromCharCode(...u8)`. |
| Editor loads but is uneditable | `view=1` is set. Remove it. |
| No postMessage response at all from `photo.html` / `video.html` | Those two have no postMessage API by design. |
| PDF import in DocForge hits the network | Expected — see [the converter fallback](#-the-one-thing-that-isnt-client-side). |
| Saves show up on the **editor's** domain in the network tab | Your `savepath` is relative, so it resolved against the editor origin. Pass an absolute URL on your own origin. |
| Save fails only in the browser (curl works) | CORS with credentials: you must echo the exact `Origin` and send `Access-Control-Allow-Credentials: true` — a wildcard `*` is rejected. Answer the `OPTIONS` preflight too. |
| Open picker says "No files saved here yet" | The directory GET didn't return a JSON array — check it honours `Accept: application/json` and returns `[]` (not 404) when empty. |

---

## Reference

- Live demo: [`embed-demo.html`](embed-demo.html)
- Live **cross-origin** demo with a real host-side store:
  <https://embed-demo.jnc360.tech> (source: [`demo-site/`](demo-site/))
- Host endpoint reference implementation:
  [`demo-site/store-server.py`](demo-site/store-server.py)
- In-app docs: [`docs.html`](docs.html)
- Implementation: [`js/embed.js`](js/embed.js) — the whole protocol is ~60 lines
- Per-editor handlers: `js/word.js`, `js/sheet.js`, `js/pdf.js` (search
  `forgeEmbedInit`)
