# 08 — Published API Docs (`openapi.json` + Swagger UI)

**Added:** 2026-08-18, from the maintainer's proposal — *"what about adding swagger support
to that `/api/*` static folder… an optional parameter on the `sgit publish` command."*

**Position: yes, and it is two artefacts, not one.** They have very different costs, so they
get separate treatment and separate flags. One of them also has a **security interaction
that is not obvious** (§4) and must be designed in rather than discovered.

---

## 1. This refines what we told the SG/API team, and it is worth being precise about why

In the 08/17 round we answered their Q5 — *"where does generation run?"* — with **"your
Pages workflow, not sgit"**, because generating a *filtered copy of the live service's
schema* needs the live service's schema, which the CLI does not have and should not vendor.

That answer stands for **their** artefact. The maintainer is proposing a **different** one:

| Artefact | Describes | Who can generate it honestly |
|---|---|---|
| SG/API's `openapi.json` | the **live service's** read subset | their Pages workflow, from the live schema |
| **sgit's published `openapi.json`** | **this published folder** — the operations and objects that actually exist in it | **only `sgit publish`**, because it just wrote them |

The second one cannot drift, because it is **generated from `manifest.json`** — the record
of what was published. The two are complementary: theirs says "this is the contract the
service implements", ours says "this is what this deployment actually serves".

That also closes their conformance loop from the other end: a static host validated against
**its own published contract**, where the contract was emitted by the tool that created the
host.

## 2. The two artefacts

### 2.1 `api/openapi.json` — tiny, generated, cheap

A few KB. Describes the read surface of *this* folder:

```jsonc
{
  "openapi": "3.1.0",
  "info": {
    "title": "sgit vault ivpijuvg (static projection)",
    "version": "1",
    "description": "A published, read-only vault. Every response is ciphertext; this host holds no key and can decrypt nothing. Generated by sgit v0.15.6 from manifest.json."
  },
  "servers": [{"url": "."}],                       // relative: works on any host, any path
  "paths": {
    "/api/vault/read/{vault_id}/{file_id}": {
      "get": {
        "summary": "Fetch one encrypted object",
        "parameters": [
          {"name": "vault_id", "in": "path", "required": true,
           "schema": {"type": "string", "const": "ivpijuvg"}},
          {"name": "file_id",  "in": "path", "required": true,
           "schema": {"type": "string"},
           "examples": {                            // REAL objects from this publish
             "the head ref":  {"value": "bare/refs/ref-pid-muw-1995ccf51fe8"},
             "the branch index": {"value": "bare/indexes/idx-pid-muw-393a7b9bf7cc"},
             "an object":     {"value": "bare/data/obj-cas-imm-4ccb5bc28ba1"}}}
        ],
        "responses": {
          "200": {"description": "Opaque ciphertext (AES-256-GCM: IV(12)‖ct‖tag(16))",
                  "content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}},
          "404": {"description": "No such object"}
        }
      }
    },
    "/manifest.json": {"get": {"summary": "Published object list, commit list, and the declared plaintext surface"}},
    "/cover.json":    {"get": {"summary": "Public description of this vault"}}
  }
}
```

**Design rules:**

- **`servers: [{"url": "."}]`** — relative, so the same file works on Pages, S3, a local
  folder served by `sgit vault serve`, and any path prefix. Never bake in an absolute URL.
- **Do not enumerate every object as an enum.** A vault with 300 objects would produce a
  300-entry schema that duplicates `manifest.json` and grows without bound. Give **three
  real examples** (ref, index, an object) so "Try it out" works, and point at
  `manifest.json` for the full list.
- **Describe only what this folder actually serves.** No `POST /batch`, no presigned
  endpoint, no write paths — they do not exist here, and advertising them is the drift the
  API team rightly warned about.
- Generated from `manifest.json`, always. Never handwritten, never templated by hand.

### 2.2 `api/docs/` — the Swagger UI, and it dwarfs the vault

Measured against the pinned release (`swagger-ui-dist@5.17.14`, raw bytes on disk):

| File | Bytes | Needed? |
|---|---|---|
| `swagger-ui-bundle.js` | 1,452,753 | yes |
| `swagger-ui.css` | 152,071 | yes |
| `swagger-ui-standalone-preset.js` | 230,293 | **no** — only for the topbar/URL explorer, which a fixed-spec page does not use |
| **minimum viable docs page** | **1,604,824 (1.53 MB)** | |

Put that next to the thing it documents: the measured full store for a real vault is
**576 KB / 292 objects** (`06` §2.6). **The UI is ~2.7× the entire published vault.**

| Target | Cost of vendoring |
|---|---|
| S3 / CDN | negligible — cached once, served forever |
| **A git repo (Pages)** | 1.53 MB in history. Git is content-addressed, so republishing *identical* bytes adds nothing — the cost is **once per pinned version**, not per publish |
| Every **copy** of the folder | zip, `mirror`, USB, S3 sync: 1.53 MB each time, and a custody mirror carries a JS bundle it has no use for |
| A local folder | fine |

So the UI is opt-in, and — reversing this file's first recommendation — **CDN is the better
default of the two delivery modes** (§4).

## 3. The flags

```
sgit publish [--api-spec] [--api-docs[=cdn|bundled]]
```

| Flag | Emits | Default |
|---|---|---|
| `--api-spec` | `api/openapi.json` (a few KB) | **on** when `--api-docs` is used; otherwise off |
| `--api-docs` | `api/docs/index.html` referencing the pinned CDN with SRI | off; **bare `--api-docs` means `=cdn`** |
| `--api-docs=cdn` | as above, ~4 KB added to the folder | |
| `--api-docs=bundled` | UI vendored into the output, +1.53 MB, **no external origin** | opt-in: offline, air-gapped, LAN-only, or no-third-parties policy |

**`--api-docs` implies `--api-spec`** — a UI with no spec to render is a broken page.

**Where the bundled bytes come from.** sgit should **not** ship 1.53 MB in its wheel for an
optional flag. `--api-docs=bundled` fetches the pinned files once into
`~/.sgit/assets/swagger-ui/<version>/`, **verifies them against the same SRI hashes the CDN
mode pins**, and copies from there on every later publish. One pinned version, one set of
hashes, two delivery modes — and the vendored copy's integrity is guaranteed by the pin
rather than by trusting whatever was on disk. First use offline fails with a message naming
`--api-docs=cdn`.

The fetch order is **`static.sgit.ai` first, jsdelivr as fallback** — a first-party mirror is
safe *here*, at publish time, because the hash decides and a substituted byte fails closed.
It is not safe as a read-time origin, and that distinction is the subject of
[`09__asset-origin.md`](09__asset-origin.md).

## 4. Delivery mode: the security interaction, and why CDN wins on it anyway

**Swagger UI is same-origin with the loader**, and the loader may store keys. The loader
design offers *"remember this key on this device"*; a key in `localStorage` is scoped to the
**origin**, not the path, so **any script on that origin can read it** — including one on the
docs page. No key passes *through* the docs page, which is what makes this easy to miss.

The mitigation is not "avoid the CDN", it is **SRI**: with a pinned hash the browser refuses
to execute bytes that do not match, so a compromised or substituted CDN file does not run at
all. That closes the exfiltration path directly, which is why the CDN mode is acceptable —
and, given §2.2's numbers, preferable.

| Mode | Compromised-CDN risk | Residual |
|---|---|---|
| `=cdn` **with SRI + pinned version** | **closed** — mismatched bytes do not execute | availability (CDN down → docs page degrades), reader IP seen by the CDN, no offline |
| `=bundled` | no external origin at all | +1.53 MB everywhere; the pin still has to be maintained |
| CDN **without** SRI, or a floating tag (`@5`, `latest`) | **open** | **not acceptable — do not offer it** |

### Required attributes for `=cdn`

```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui.css"
      integrity="sha384-wxLW6kwyHktdDGr6Pv1zgm/VGJh99lfUbzSn6HNHBENZlCN7W602k9VkGdxuFvPn"
      crossorigin="anonymous" referrerpolicy="no-referrer">
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-bundle.js"
        integrity="sha384-wmyclcVGX/WhUkdkATwhaK1X1JtiNrr2EoYJ+diV3vj4v6OC5yCeSu+yW13SYJep"
        crossorigin="anonymous" referrerpolicy="no-referrer"></script>
```

(Hashes computed 2026-08-18 from `swagger-ui-dist@5.17.14`; regenerate on every version
bump — `openssl dgst -sha384 -binary <file> | openssl base64 -A`. Recompute, never copy from
a web page.)

1. **Exact version pin.** Never `@5`, never `latest` — a floating tag makes the SRI hash a
   guaranteed future breakage *or* a guaranteed future omission.
2. **`integrity` + `crossorigin="anonymous"`** — SRI does not apply to a cross-origin
   response without it.
3. **`referrerpolicy="no-referrer"`** — otherwise the CDN learns *which vault* is being
   viewed, from every reader. In a privacy product that is the part worth caring about; the
   CDN should learn no more than "somebody loaded Swagger UI".
4. **A CSP `<meta>` on the docs page** — `script-src 'self' https://cdn.jsdelivr.net;
   connect-src 'self'`. `connect-src 'self'` means that even a script that somehow ran could
   not POST a key anywhere. Belt and braces, and free.
5. **Legible degradation.** If the CDN is unreachable the page must say so and link
   `./openapi.json` — not render blank. The vault still clones; only the docs are missing.

### The root fix belongs in the loader

All of the above manages a risk that exists **only because the loader may persist a key on
the origin**. If the loader keeps keys in memory for the session and never writes them to
`localStorage`, the same-origin objection collapses for both modes — and for every other
script anyone ever adds to a published folder. That is worth raising with the Web team as a
loader design decision, not worked around here.

**A second, smaller note:** publishing a docs page makes the estate's shape more legible —
object count, sizes, update rhythm. `manifest.json` already discloses this (it is required
for custody), and a directory listing would too, so there is **no new disclosure** — but it
is worth one line in the publish output at `--visibility bare`, because a docs page *looks*
like an invitation in a way a manifest does not.

## 5. `sgit vault serve` serves it too

No extra work — it is a static folder. But two touches:

```console
$ sgit vault serve --open
  Serving   .sg_vault/publish/
  URL       http://127.0.0.1:8420/
  Loader    http://127.0.0.1:8420/index.html
  API docs  http://127.0.0.1:8420/api/docs/          ← when present
```

And `--open` should still open the **loader**, not the docs — the loader is the product; the
docs are the proof.

## 6. Why this is worth doing (beyond being neat)

1. **It makes a published vault self-describing to an agent.** An agent that finds a
   published folder gets a machine-readable contract without being told anything — which is
   exactly the "agent tax" the component-registry brief identifies as the real product.
2. **It is a demonstration, not documentation.** A Swagger UI whose every operation returns
   opaque bytes, with no auth scheme in the document at all, makes the claim visible: *this
   is the entire surface, it is all GET, and none of it is readable.* That is the same
   argument as the loader's ciphertext panel, aimed at a different reader.
3. **It closes the conformance loop from the publisher's end** (§1).
4. **It costs a few KB** for the part that matters. The expensive part is optional.

## 7. Acceptance criteria (folds into P2/P4 — see `05__implementation-phases.md`)

- [ ] `api/openapi.json` is **generated from `manifest.json`**, never handwritten.
- [ ] `servers` is relative (`"."`); the file works unchanged on Pages, S3, a folder, and
      under any path prefix.
- [ ] It describes **only** operations this folder actually serves — no batch, no presigned,
      no write paths.
- [ ] It validates as OpenAPI 3.1 (assert in the suite).
- [ ] Examples reference **real** file_ids from this publish, and "Try it out" against a
      `sgit vault serve` instance returns 200 with ciphertext.
- [ ] `--api-docs` implies `--api-spec`; bare `--api-docs` resolves to `=cdn`.
- [ ] `=cdn` emits an **exact version pin**, `integrity`, `crossorigin="anonymous"`,
      `referrerpolicy="no-referrer"` and the CSP meta — assert all five in the generated
      HTML, and assert no floating tag (`@5`, `latest`) appears anywhere in it.
- [ ] `=bundled` adds **no external origin** — assert the generated HTML contains no
      `http://` or `https://` script/link source at all.
- [ ] `=bundled` verifies the fetched assets against the same SRI hashes before caching them,
      and fails closed on a mismatch (test with a deliberately corrupted cache entry).
- [ ] A docs page whose UI fails to load still names `./openapi.json` — assert against the
      page source, since the CDN cannot be made to fail in a unit test.
- [ ] The standalone preset is **not** emitted (it is 230 KB and unused).
- [ ] Both artefacts are added to the **declared plaintext surface** in `manifest.json`,
      with their hashes — they are plaintext, so invariant 5's audit trail must include them.
- [ ] Invariant 1 still holds: the **ciphertext** subtree is byte-identical whether or not
      docs were emitted.
