# Badge network protocol

The contract between three pieces that are built and deployed separately:

| | |
| --- | --- |
| **broker** (`broker/`) | Bun HTTP service. Holds the badge registry and the offer queue, and is the only thing that talks to GitHub. |
| **the App Store page** (`app/src/routes/store/`) | Browser. Scans a GitHub repo through the broker and addresses an offer to a badge ID. |
| **Solana OS** (`firmware/solana-os/src/net/broker_client.*`) | The badge. Registers once, then polls for offers and asks the wearer before installing anything. |

This file is the authoritative wire format. Change it here first.

---

## Identity

Every badge has an **Ed25519 keypair**, generated once on first boot and held in
the SE050 secure element when the part answers, in NVS when it does not
(`firmware/solana-os/src/identity/identity.h`).

- **public key** — 32 bytes, shown and transmitted as base58 (Bitcoin alphabet,
  the same encoding Solana uses for addresses). 43–44 characters.
- **badge ID** — the **first 8 characters of the base58 public key**, verbatim,
  case-sensitive. This is what a human types into the App Store page. Base58
  omits `0`, `O`, `I` and `l`, so it survives being read off a 320×240 screen.

The badge ID is derived, not assigned: the broker recomputes it from the public
key at registration and rejects a mismatch. If two different public keys ever
produce the same 8 characters, the broker refuses the second registration
rather than silently reassigning — a collision must never let one badge receive
another's scripts.

### Proving ownership

Registration is a challenge/response so that knowing a badge ID is not enough
to impersonate the badge. The signed message is ASCII, no trailing newline:

```
solana-badge-register:<pubkeyBase58>:<nonce>
```

Signature is Ed25519 over those bytes, transmitted **base64** (standard
alphabet, with padding). The broker verifies before issuing a token.

Nonces are single-use and expire after 120 s.

### Session token

Registration returns an opaque bearer token, 32 bytes of randomness as base64url
(43 chars). The badge stores it in NVS and sends it on every subsequent call:

```
Authorization: Bearer <token>
```

A badge that has lost its token re-runs challenge → register with the same
keypair; the broker replaces the token and keeps the badge ID, the name and any
pending offers.

---

## Base URLs

Broker listens on `:8787` by default (`PORT`). Every route below is under
`/api/v1`. `/health` sits outside the versioned prefix.

All `/api/v1/*` responses carry permissive CORS
(`Access-Control-Allow-Origin: *`, `Allow-Headers: Authorization, Content-Type`)
because the App Store page is a static site served from a different origin, and
`OPTIONS` is answered for every route.

Errors are always `{ "error": "<machine-readable-code>", "message": "<prose>" }`
with a 4xx/5xx status. Codes used: `bad_request`, `bad_signature`,
`unknown_nonce`, `unauthorized`, `not_found`, `id_collision`, `too_many`,
`rate_limited`, `upstream`, `too_large`.

---

## Badge-facing routes

### `POST /api/v1/badge/challenge`

```json
→ { "pubkey": "<base58>" }
← { "nonce": "<32 hex chars>", "expiresAt": 1770000000 }
```

### `POST /api/v1/badge/register`

```json
→ { "pubkey": "<base58>", "name": "badge-3F2A", "firmware": "0.1.0",
    "nonce": "<from challenge>", "signature": "<base64 Ed25519>" }
← { "badgeId": "7Qk2M9xA", "token": "<base64url>", "name": "badge-3F2A" }
```

### `POST /api/v1/badge/heartbeat` — auth

Cheap liveness ping; also the fast path for "is there anything waiting".

```json
← { "ok": true, "pending": 0, "serverTime": 1770000000 }
```

### `GET /api/v1/badge/inbox` — auth

Pending offers, oldest first, **capped at 5**. Marks the badge online.

```json
← { "offers": [ Offer, ... ], "serverTime": 1770000000 }
```

An `Offer`:

```json
{
  "id": "of_8fa31c05",
  "repo": "solana-foundation/badge-scripts",
  "ref": "main",
  "url": "https://github.com/solana-foundation/badge-scripts",
  "sender": "web",
  "createdAt": 1770000000,
  "expiresAt": 1770003600,
  "scripts": [
    { "name": "blinky", "file": "blinky.lua", "bytes": 812,
      "sha256": "<64 hex>", "description": "Chases the two RGB LEDs" }
  ]
}
```

`name` is already sanitised by the broker to `[a-z0-9._-]{1,24}` — it is used
directly as the app id on the badge, i.e. as a directory name on LittleFS. It
must additionally **not begin with a dot**, which is what stops `.` and `..`
from ever being emitted; the character class alone permits both. The badge
enforces the same rule independently in `app_store::isValidId()` and rejects
anything that fails it, so a broker that got this wrong could not traverse out
of `/apps` — but a conforming broker must not make it try.

At most **8 scripts per offer**.

### `GET /api/v1/badge/offers/:offerId/scripts/:name` — auth

The Lua source, `Content-Type: text/plain; charset=utf-8`. The bytes hash to the
`sha256` in the offer; the badge verifies before writing to flash.

### `POST /api/v1/badge/offers/:offerId/accept` — auth

Sent after the install finishes, so the web page can report what actually
landed.

```json
→ { "installed": ["blinky"], "failed": [ { "name": "radar", "reason": "sha256 mismatch" } ] }
← { "ok": true, "status": "accepted" }
```

### `POST /api/v1/badge/offers/:offerId/decline` — auth

```json
← { "ok": true, "status": "declined" }
```

---

## Sender-facing routes

No auth — the App Store is open to anyone, exactly as asked. What protects a
badge is that **nothing installs without the wearer pressing A**, plus the rate
limits below.

### `POST /api/v1/repos/scan`

Reads the Lua scripts out of a GitHub repository. The broker does this rather
than the browser because GitHub sends no CORS headers for raw content and
because a server-side `GITHUB_TOKEN` lifts the 60 req/h anonymous rate limit.

```json
→ { "url": "https://github.com/owner/repo" }
← { "repo": { "owner": "owner", "name": "repo", "ref": "main",
              "dir": "scripts", "url": "https://github.com/owner/repo",
              "description": "…", "stars": 12 },
    "scripts": [ { "name": "blinky", "file": "blinky.lua", "path": "scripts/blinky.lua",
                   "bytes": 812, "sha256": "<64 hex>",
                   "description": "Chases the two RGB LEDs",
                   "preview": "<first 40 lines>" } ] }
```

Accepted `url` forms:

```
https://github.com/owner/repo
https://github.com/owner/repo/tree/<ref>
https://github.com/owner/repo/tree/<ref>/<dir>
git@github.com:owner/repo.git
owner/repo
```

Rules: default directory `scripts/`, default ref the repository's default
branch, non-recursive, `*.lua` only, **32 files max**, **96 KB per file max**
(`PUSH_MAX_FILE_BYTES` in the firmware — a bigger file could not be installed
anyway). `description` is the first `--` comment line of the file, if any.

### `GET /api/v1/badges/:badgeId`

Lets the page confirm a typed ID before sending. Never exposes the token.

```json
← { "badgeId": "7Qk2M9xA", "name": "badge-3F2A", "firmware": "0.1.0",
    "online": true, "lastSeenAt": 1770000000, "pubkey": "<base58>" }
```

`online` means seen within 30 s.

### `POST /api/v1/offers`

```json
→ { "badgeId": "7Qk2M9xA", "repoUrl": "https://github.com/owner/repo",
    "scripts": ["blinky", "radar"], "sender": "web" }
← { "offerId": "of_8fa31c05", "status": "pending", "expiresAt": 1770003600 }
```

The broker re-scans the repo (from cache when fresh) and snapshots the script
bodies into the offer, so the badge downloads exactly what the sender saw even
if the repo changes in between.

### `GET /api/v1/offers/:offerId`

Polled by the page to show "delivered" / "installed".

```json
← { "offerId": "of_8fa31c05", "badgeId": "7Qk2M9xA", "status": "pending",
    "repo": "owner/repo", "scripts": [ … ], "createdAt": …, "deliveredAt": null,
    "resolvedAt": null, "installed": [], "failed": [] }
```

`status` ∈ `pending` (queued) · `delivered` (badge has seen it) · `accepted` ·
`declined` · `expired`.

---

## Limits

| | |
| --- | --- |
| Offer lifetime | 1 hour, then `expired` |
| Pending offers per badge | 5 — a sixth is rejected with `too_many` |
| Offers per sender IP | 20 / 5 min |
| Repo scans per IP | 30 / min, plus a 60 s cache per `owner/repo@ref/dir` |
| Scripts per offer | 8 |
| Bytes per script | 96 KB |

## What this deliberately does not do

Anyone who learns a badge ID can queue an offer to it. That is the design — it
is an app *store*, and the badge is the thing that decides. The protections are
that an offer is inert until the wearer accepts it on the device, that the
scripts run inside the existing Lua sandbox with its memory and callback-time
limits, and that the rate limits above keep the prompt from being used as a
nuisance. Nothing here should be read as authenticating the *sender*.

---

## Appendix: broker implementation notes

Clarifications only. Nothing below changes a shape defined above; it pins down
what the spec left open, so that the three components agree on the details
without anyone having to read `broker/src/`. Added by the broker implementation.

### Status codes

Every success is **200**, including offer creation — a badge with a minimal HTTP
stack checks `status == 200` and nothing else. Failures map one-to-one from the
error code:

| Code | Status | When |
| --- | --- | --- |
| `bad_request` | 400 | malformed body, bad field, unparseable repo URL |
| `unknown_nonce` | 400 | nonce unknown, already used, expired, or issued to a different key |
| `bad_signature` | 401 | signature does not verify |
| `unauthorized` | 401 | missing, malformed or unrecognised bearer token |
| `not_found` | 404 | no such badge, offer, script, repository or directory |
| `id_collision` | 409 | badge ID already belongs to another public key |
| `too_large` | 413 | request body, script, script count or file count over a limit |
| `too_many` | 429 | the badge already has 5 unanswered offers |
| `rate_limited` | 429 | per-IP window exhausted; carries `Retry-After` in seconds |
| `upstream` | 502 | GitHub unreachable, rate-limited or unhelpful |

An unexpected internal failure answers **500** with code `upstream` — clients
already have to handle that code, and it is the only server-side one defined.

`OPTIONS` answers **204** with the CORS headers, on every route including paths
that do not exist (otherwise a browser reports a CORS failure where there is
really a 404).

### Registration

- The nonce is consumed on **any** register attempt, successful or not, so one
  challenge buys exactly one try.
- Only one nonce is live per public key: a second `challenge` invalidates the
  first. Nothing needs more than one in flight, and it bounds the nonce table by
  the number of badges.
- A nonce presented with a *different* public key than it was issued to fails as
  `unknown_nonce`, not `bad_signature` — the signature may well be valid, it is
  the nonce that does not belong to that key.
- `name` and `firmware` are optional. Omitted, they default to
  `badge-<first 4 of the badge ID>` and `unknown`.
- Re-registration **updates** `name` and `firmware` from the request and keeps
  the badge ID, the public key and the queue. The badge is authoritative for its
  own name, and it sends one every time; "keeps the name" above means the
  identity survives, not that a rename is ignored.
- The token is base64url **without padding** — 43 characters for 32 bytes.

### Offers

- `id` is `of_` followed by 8 lowercase hex characters.
- **The inbox returns unresolved offers, `pending` and `delivered` alike**, until
  they are accepted, declined or expired. Dropping an offer from the inbox the
  moment it was first seen would lose it if the badge rebooted between reading
  it and the wearer answering. The per-badge cap of 5 counts the same set, so
  the inbox cap and the queue cap always agree.
- `accept` and `decline` are **idempotent**: the first resolution wins, and a
  repeat returns `{ ok: true, status: <the first one> }`. A badge that installs
  successfully and then loses its reply to a flaky network will retry, and that
  must not read as a failure.
- Resolving an offer that has already expired is allowed, and moves it to
  `accepted`/`declined`. A badge reporting in slightly late has still done the
  work. Downloading a script from an expired offer is **not** allowed — 404.
- An offer belonging to another badge is `not_found`, not `unauthorized`: a badge
  holding a valid token has no business learning that another badge's offer ID
  exists.
- `accept` takes `installed` and `failed`; both are optional and default to
  empty. Entries are truncated to the script-name and reason lengths the badge
  can produce.
- The bodies are snapshotted into the offer at creation. Expired and resolved
  offers are deleted 24 hours after their expiry, along with those bodies.

### Scanning

- A directory holding more than 32 `.lua` files, or any single file over 96 KB,
  fails the whole scan with `too_large` naming the offender. Quietly omitting
  the file would leave the sender wondering where their script went; the badge
  could not install it either way.
- Files are listed in case-insensitive filename order, and that order decides
  which of two names that sanitise identically keeps the plain form.
- Sanitisation, in order: drop a trailing `.lua`, lowercase, replace anything
  outside `[a-z0-9._-]` with `-`, collapse runs of `-`, trim leading and
  trailing `-` and `.`, truncate to 24, and fall back to `script` if nothing
  survives. Collisions get `-2`, `-3`, … with the stem truncated so the result
  is still at most 24 characters. Trimming the dots matters: the bare name is
  used as a directory on the badge, and `[a-z0-9._-]{1,24}` on its own would
  admit `.` and `..`.
- `description` is the first `--` comment line, capped at 160 characters. A
  `--[[` block comment is not one, and the search stops at the first line that
  is not a comment.
- `preview` is the first 40 lines.
- In the `/tree/<ref>/<dir>` form, a branch name containing a slash cannot be
  told from `<ref>/<dir>` without asking GitHub which branches exist, so the
  first segment after `/tree/` is the ref and everything after it is the
  directory. `https://github.com/o/r/tree/feature/thing` means ref `feature`,
  directory `thing`.
- The 60 s cache is keyed on the *requested* ref, so `owner/repo` and
  `owner/repo/tree/main` are separate entries even when `main` is the default
  branch.

### Two routes outside the contract

Neither is part of the protocol; both exist because someone will open the broker
URL in a browser to find out whether it is up.

| | |
| --- | --- |
| `GET /` | HTML status page: version, badge count, offer count, uptime |
| `GET /PROTOCOL.md` | this file, as `text/markdown` |
