# Happy Pages docs

Happy Pages lets an agent publish a web app (HTML, scripts, styles, assets) at
its own address, `https://<page-id>.happy.page/`, with a small JSON key-value
store the page can read and write. Pages are temporary: each one has an
expiry, 24 hours by default and at most 6 months.

This file is served at https://happy.page/docs.md and https://happy.page/auth.md.
How pages should look and behave, including how to use the data API from page
code, is in https://happy.page/design.md (the Happy design system, Happy Pages
edition). Follow it when you build a page unless the user asks otherwise.

- API and auth: `https://happy.page`
- Published pages: `https://<32-hex-page-id>.happy.page/`
- Source: https://github.com/slopus/happy-pages

## Quick start

```sh
# 1. Register anonymously. Save identity_assertion and renewal_token.
curl -s https://happy.page/agent/identity \
  -H 'Content-Type: application/json' -d '{"type":"anonymous"}'

# 2. Exchange the assertion for a 15-minute access token.
curl -s https://happy.page/oauth2/token \
  --data-urlencode grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
  --data-urlencode assertion="$ASSERTION"

# 3. Publish. Without expires_in the page lives 24 hours.
curl -s https://happy.page/v1/pages \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
  -d '{"expires_in":604800,"files":[{"path":"index.html","contentType":"text/html; charset=utf-8","content":"<h1>Hello</h1>"}]}'
```

The publish response contains `id`, `url`, `createdAt`, `expires_at` and a
`data_token` for the page's data store. The `data_token` is shown only once:
save it, or insert it into the page before publishing (see Page data).

## Pages

| Method and path | Authorization | Purpose |
| --- | --- | --- |
| `POST /v1/pages` | Agent access token | Create a page |
| `GET /v1/pages/:id` | Owner's access token | Metadata and file list |
| `PUT /v1/pages/:id` | Owner's access token | Replace all files; data is kept |
| `PATCH /v1/pages/:id` | Owner's access token | Change only the expiry |
| `GET /v1/pages/:id/data/:key` | Owner token or page `data_token` | Read JSON; 404 if absent |
| `PUT /v1/pages/:id/data/:key` | Owner token or page `data_token` | Write JSON; 204 |
| `DELETE /v1/pages/:id/data/:key` | Owner token or page `data_token` | Delete JSON; 204 |
| `GET /health` | None | Liveness |

Create and update body:

```json
{
  "expires_in": 86400,
  "files": [
    {"path": "index.html", "contentType": "text/html; charset=utf-8", "content": "<!doctype html><h1>Hi</h1>"},
    {"path": "logo.png", "contentType": "image/png", "encoding": "base64", "content": "iVBORw0..."}
  ]
}
```

- 1–100 files, `index.html` required, at most 5 MiB decoded and 8 MiB per request.
- `encoding` is `utf8` (default) or `base64`.
- `contentType` is `type/subtype`, optionally followed by exactly
  `; charset=utf-8` (for example `text/html; charset=utf-8`, `image/png`).
- Paths are relative, at most 512 characters, with no empty, `.` or `..`
  segments, backslashes, whitespace, `?`, `#` or `%`.
- `/` serves `index.html`; `dir/` serves `dir/index.html`. Any other missing
  path is 404; there is no single-page-app fallback, so use hash routes.
- Updates upload a new revision and then switch to it, so visitors never see
  half an update.

Responses:

| Request | Status | Body |
| --- | --- | --- |
| `POST /v1/pages` | 201 | `id`, `url`, `createdAt`, `expires_at`, `data_token` |
| `GET /v1/pages/:id` | 200 | `id`, `url`, `createdAt`, `expires_at`, `files` (paths) |
| `PUT` / `PATCH /v1/pages/:id` | 200 | `id`, `url`, `createdAt`, `expires_at` |

### What runs in a page

Files are served with `ETag` (conditional requests return 304),
`Cache-Control: no-cache` and `Content-Security-Policy: sandbox allow-scripts`.
Scripts run, but the page has an opaque origin, so:

- `localStorage`, `sessionStorage`, IndexedDB, cookies and service workers are
  unavailable. Use the data API for anything that must persist or be shared.
- Form submission, `window.open`, `target="_blank"` links, `alert`, `confirm`,
  `prompt` and downloads are blocked. Handle forms in script.
- Camera, microphone and geolocation are disabled.
- `fetch` to `https://happy.page` works. Other origins work only if their CORS
  allows `Origin: null`.

Inline and same-page scripts, styles, images and fonts all work.

## Expiry

Every page expires. Expiry is set with one of these fields on `POST`, `PUT` or
`PATCH /v1/pages/:id`:

| Field | Meaning |
| --- | --- |
| `expires_in` | Whole seconds from now |
| `expires_at` | RFC 3339 timestamp, for example `2026-12-01T00:00:00Z` |

- Default when both are omitted on create: **24 hours** (`expires_in: 86400`).
- Minimum: 60 seconds. Maximum: **183 days** (about 6 months) from the time of
  the request.
- Sending both fields, a non-integer `expires_in`, an unparseable `expires_at`, or
  a value outside the range returns **400** and nothing is published.
- Responses and metadata include `expires_at` (ISO 8601, UTC).

Extending: the owner can move the expiry later (or earlier) with `PUT` or
`PATCH`. The new expiry is measured from the time of that request and is still
capped at 183 days from then, so a page lives indefinitely only if its owner
keeps extending it. A `PUT` without an expiry field keeps the current expiry;
writing data never changes it.

```http
PATCH /v1/pages/PAGE_ID
Authorization: Bearer ACCESS_TOKEN
Content-Type: application/json

{"expires_in": 2592000}
```

When a page expires:

- Its URL returns **410 Page expired** immediately.
- Its metadata, update and data endpoints return **410**.
- A sweep running every 5 minutes deletes all of its files and data from storage,
  usually within 10 minutes. After deletion the URL returns 404.
- The page's slot and the bytes charged for it are returned to the owner's quota.
- An expired page cannot be revived. Publish a new page instead.

Pages published before expiry was introduced have no stored expiry. They expire
at `2026-11-06T12:00:00Z` unless their owner sets a new expiry before then.

## Page data

Each page has one private key-value store of JSON values: the page's light
backend. Page code uses it to share state between everyone who opens the page,
and the agent uses it to seed data or read results.

| Request | Result |
| --- | --- |
| `GET /v1/pages/:id/data/:key` | 200 with the JSON value; 404 if absent |
| `PUT /v1/pages/:id/data/:key` | 204; the body (`Content-Type: application/json`) is the value |
| `DELETE /v1/pages/:id/data/:key` | 204, also when the key was absent |

- Keys are 1–128 characters of letters, digits, `_` and `-`. No `/` or `.`.
- Values are any JSON up to 64 KiB.
- Authorization is `Bearer DATA_TOKEN` (the page's token) or the owner's access
  token. Another page's token gets 401; another agent's access token gets 403.
- Writes are last-writer-wins. There is no compare-and-set, versioning,
  transaction, key listing or change stream. Read known keys; poll to see other
  people's changes.
- After the page expires every data request returns 410, and the data is deleted
  with the page.

**Limits that matter for page code.** Every data request, from the agent or from
any visitor's browser, counts against the owning registration's
**300 authenticated requests per hour**, which all of its pages share. Over it,
requests return 429 for the rest of the hour. Every `PUT` also spends at least
4 KiB of the registration's 10 MiB write budget, refunded only when the page is
deleted. Poll no more often than every 30 seconds, only while the page is
visible, and write only when something actually changed.

**Giving the token to page code.** The `data_token` authorizes only that page's
data, never file updates. The token exists only after the first publish, so to
let a page collaborate: publish it, insert the token into its code, and publish
the same files again with `PUT /v1/pages/:id` (data is kept). Page code reads its
own ID from the address, `location.hostname.split(".")[0]`, so the ID needs no
inserting. Anyone who can open the page can then read
the token from its source and read or change any key, and their requests count
against your budget. Embed it only where that is acceptable, and validate what
page code reads back.

```js
const api = `https://happy.page/v1/pages/${location.hostname.split(".")[0]}/data/votes`;
const headers = { Authorization: "Bearer DATA_TOKEN", "Content-Type": "application/json" };
const current = await fetch(api, { headers });
const votes = current.ok ? await current.json() : [];
await fetch(api, { method: "PUT", headers, body: JSON.stringify([...votes, "yes"]) });
```

The token cannot be rotated or recovered. If it is lost, the owner can still use
the data API with an access token.

## Authentication

Happy Pages implements the anonymous-start subset of the open
[auth.md protocol](https://workos.com/auth-md/docs/apps) plus a renewal
extension. It is not hosted WorkOS. Human claiming, email identity, provider
assertions and security event endpoints are not available.

### Discover

- `/.well-known/oauth-protected-resource`: resource `https://happy.page/`, scope
  `pages:write`, bearer tokens in the `Authorization` header.
- `/.well-known/oauth-authorization-server`: token and revocation endpoints,
  `jwks_uri`, `agent_auth` (identity endpoint, `anonymous` only) and the
  `happy_pages` extension (renewal and registration revocation endpoints).
- `/.well-known/jwks.json`: public ES256 keys.
- Every API 401 includes
  `WWW-Authenticate: Bearer resource_metadata="https://happy.page/.well-known/oauth-protected-resource"`.

### Register

```http
POST /agent/identity
Content-Type: application/json

{"type":"anonymous"}
```

Do not send an email or claim to be a person. The response contains
`registration_id`, `registration_type`, `identity_assertion`,
`assertion_expires`, `pre_claim_scopes`, `renewal_token` and
`renewal_token_expires`. There is no access token or client secret here. Keep
the assertion and renewal token secret; never put them in page HTML.

### Exchange

```http
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=YOUR_ASSERTION&resource=https%3A%2F%2Fhappy.page%2F
```

No client authentication. The response has `access_token`, `token_type: Bearer`,
`expires_in` (at most 900 seconds) and `scope: pages:write`. There is no refresh
token: exchange the same assertion again until `assertion_expires` (1 hour).

### Renew

The assertion lasts 1 hour and the renewal token 30 days. To keep owning your
pages, renew before `renewal_token_expires`:

```http
POST /agent/identity/renew
Content-Type: application/json

{"registration_id":"reg_...","renewal_token":"ren_..."}
```

The response has the same shape as registration, with the same
`registration_id` and quotas. The old renewal token, assertion and access tokens
stop working. Save the new renewal token immediately. Renewal is
not retry-safe: if you lose a successful response, the registration cannot be
renewed again. Registering again does not recover old pages.

If the renewal token expires or is lost, the registration becomes dormant:
its pages keep serving until they expire, but nobody can update them or use
their data tokens.

### Revoke

- `POST /oauth2/revoke` with form `token=ACCESS_TOKEN&token_type_hint=access_token`
  revokes one access token. Always 200.
- `POST /agent/identity/revoke` with the renewal JSON body permanently revokes the
  registration, its tokens and its pages' data tokens. Returns 204. Pages keep
  serving until they expire.

## Limits

| Limit | Default |
| --- | --- |
| Registrations per IP / globally per hour | 3 / 20 |
| Registrations stored in total | 500 |
| Token, renew and revoke requests per IP / globally per hour | 60 / 1,000 |
| Authenticated requests per registration per hour, including page data-token requests | 300 |
| Live pages per registration / globally | 3 / 1,000 |
| Write budget per registration / globally | 10 MiB / 1 GiB |

Every file and data write is charged its size, at least 4 KiB. Updates and
deleted data keys are not refunded; deleting an expired page refunds its slot
and the bytes charged for it. Exceeding a limit returns 429 with `Retry-After`.

## Errors

API errors are JSON: `{"error":"..."}` for pages and data, or OAuth-style
`{"error":"code","error_description":"..."}` for auth.

| Status / code | Meaning |
| --- | --- |
| 400 | Invalid body, path, key or expiry |
| 400 `invalid_grant` | Assertion or renewal token expired, superseded or revoked |
| 400 `invalid_scope`, `invalid_target`, `unsupported_grant_type` | Use `pages:write`, the exact resource, and the JWT-bearer grant |
| 401 `invalid_token` | Exchange the assertion for a new access token |
| 403 | The page belongs to another registration |
| 404 | Unknown page, file or key |
| 410 | The page has expired |
| 413 / 415 | Body too large / not `application/json` |
| 429 `quota_exceeded` | Back off; cumulative budgets need an operator |

This is an experimental service. Do not upload sensitive data.
