# Backend API for txxmo.dev

This site has a hosted backend at `https://txxmo.dev/api`. It provides user
accounts (sign-up, login, sessions) and a small JSON document store. There is
no custom server code: the site is static files, and pages call this API with
`fetch`. Do not write a Node/Express server, and do not add a database client;
everything goes through the endpoints below.

- Base URL: `https://txxmo.dev/api`
- Requests and responses are JSON. Send `Content-Type: application/json` on
  every request that has a body.
- Every error is `{"error": "human readable message"}` with a matching HTTP status.
- Health check: `GET /api/health` returns `{"ok": true}`.

## Two ways to be signed in

1. **Cookie (pages served from txxmo.dev).** `register` and `login` set an
   HttpOnly session cookie. Same-origin `fetch('/api/...')` sends it
   automatically. JavaScript cannot read the cookie; call `GET /api/auth/me` to
   find out who is logged in.
2. **Bearer token (localhost, other origins, scripts, mobile).** `register` and
   `login` also return a `token`. Send it as `Authorization: Bearer <token>`.
   Cookies never work cross-origin here, so local development must use the token
   and the full URL `https://txxmo.dev/api/...`.

Sessions last 30 days. CORS is open (`Access-Control-Allow-Origin: *`) for
token requests. Do not use `credentials: 'include'`; it will fail.

## Auth endpoints

### POST /api/auth/register
Body: `{"username": "sam", "password": "at least 8 chars"}`

- `username`: 3-64 characters from letters, numbers and `. _ @ + -`. An email
  address is fine. Unique, case-insensitive. Not verified.
- `password`: 8-200 characters.

`201` with `{"user": {"id": 1, "username": "sam", "created": "2026-10-05T22:50:13+00:00"}, "token": "st_..."}`
and the session cookie.

Errors: `400` invalid username or password, `409` username taken, `403`
sign-ups are closed (the site owner can close them), `429` too many sign-ups
from this address (5 per hour).

### POST /api/auth/login
Body: `{"username": "sam", "password": "..."}`

`200` with the same shape as register. `401` wrong username or password. `429`
after 10 failed attempts from one address (locked for 15 minutes).

### GET /api/auth/me
`200` `{"user": {"id", "username", "created"}}`, or `401` if not logged in.

### POST /api/auth/logout
Ends the current session. `200` `{"ok": true}`.

### POST /api/auth/password
Body: `{"current": "...", "new": "..."}`. Requires login. `200` `{"ok": true}`;
`403` if `current` is wrong. Other devices are signed out.

There is no password reset by email and no email sending. The site owner can
set a user's password from the admin panel.

## Data endpoints

Data lives in **collections** of JSON records. Collections are created by the
site owner in the admin panel (edit.txxmo.dev → Database), not through the
API. Calling a collection that does not exist returns `404`
`{"error": "no such collection"}`; if the app needs a new collection, tell the
owner its name and the rules it needs. Names are lowercase letters, numbers and
`_`, starting with a letter.

A record always looks like this:

```json
{
  "id": "dDX8J5Cx2rTo",
  "owner": 1,
  "created": "2026-10-05T22:50:13+00:00",
  "updated": "2026-10-05T22:50:13+00:00",
  "data": {"title": "hello", "done": false}
}
```

`data` is whatever object you saved. `owner` is the id of the user who created
it, or `null` if it was created anonymously or with the admin key. `id`,
`owner`, `created` and `updated` are set by the server.

### GET /api/data/{collection}
Lists records, newest first. Returns `{"items": [record, ...], "total": 42}`.

Query options:
- `limit` (1-200, default 50) and `offset` for paging
- `order=asc` for oldest first (ordering is always by creation time)
- `mine=1` only the logged-in user's records
- `eq.<field>=<value>` only records whose top-level `data` field equals the
  value, for example `?eq.done=true&eq.category=work`. Numbers, `true`, `false`
  and `null` are matched as those types; anything else as a string. Several
  filters are ANDed. Nested fields, ranges, search and sorting by a field are
  not supported; fetch and do those in the browser.

### POST /api/data/{collection}
The body is the record's data object, e.g. `{"title": "hello", "done": false}`.
`201` with the full record. The body must be a JSON object, at most 32 KB.

### GET /api/data/{collection}/{id}
One record, or `404`.

### PUT /api/data/{collection}/{id}
Replaces `data` with the body.

### PATCH /api/data/{collection}/{id}
Shallow-merges the body into `data` (top-level keys only).

### DELETE /api/data/{collection}/{id}
`200` `{"ok": true}`.

## Access rules

Each collection has three rules, chosen by the owner in the admin panel:

| Rule | Options |
|---|---|
| read | `public` anyone · `users` any logged-in user · `owner` only the record's creator · `none` |
| create | `public` anyone · `users` any logged-in user · `none` |
| edit/delete | `owner` only the record's creator · `users` any logged-in user · `none` |

- With read = `owner`, a list returns only the caller's own records.
- `401` means "log in first". `403` means the rule forbids it, or the record
  belongs to someone else.
- `none` means only the admin key can do it.
- The default for a new collection is read `users`, create `users`,
  edit/delete `owner`.

Typical setups: per-user private data → `owner` / `users` / `owner`. A public
feed only the owner's scripts write → `public` / `none` / `none`. A public
contact or waitlist form → `none` / `public` / `none`.

## Admin key

The owner can create a key starting with `sk_` in the admin panel. Sent as
`Authorization: Bearer sk_...`, it works on all `/api/data` endpoints and skips
every rule. It is for server-side scripts and tools only. Never put it in page
JavaScript, HTML or the repository: the site is public and anyone could read it.

## Limits

- 32 KB per record, 256 KB per request
- 5,000 records per collection, 20 collections, 500 users, 64 MB in total
- 240 requests per minute per address (`429` beyond that)

`507` means a limit was reached.

## What this backend does not do

No file or image uploads, no custom server-side logic, no payments, no email,
no realtime/websockets, no roles or admin users inside the app, no extra fields
on the user object. To store a profile, use a collection with the `owner`
rules and one record per user. Anything secret or privileged cannot be done
from page code.

## Example: login form and a protected page

```html
<form id="login">
  <input name="username" autocomplete="username" required>
  <input name="password" type="password" autocomplete="current-password" required>
  <button>Log in</button>
  <p id="msg"></p>
</form>
<script>
async function api(path, method = 'GET', body) {
  const res = await fetch('/api' + path, {
    method,
    headers: body ? { 'Content-Type': 'application/json' } : {},
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw Object.assign(new Error(data.error || 'request failed'), { status: res.status });
  return data;
}

document.getElementById('login').addEventListener('submit', async (e) => {
  e.preventDefault();
  const f = new FormData(e.target);
  try {
    // use '/auth/register' with the same body to sign up
    await api('/auth/login', 'POST', { username: f.get('username'), password: f.get('password') });
    location.href = '/dashboard.html';
  } catch (err) {
    document.getElementById('msg').textContent = err.message;
  }
});
</script>
```

On a page that needs login:

```js
let user;
try { ({ user } = await api('/auth/me')); }
catch { location.href = '/login.html'; }

// the user's own records in a collection called "notes"
const { items } = await api('/data/notes?mine=1');
const note = await api('/data/notes', 'POST', { title: 'hello', done: false });
await api('/data/notes/' + note.id, 'PATCH', { done: true });
await api('/data/notes/' + note.id, 'DELETE');
await api('/auth/logout', 'POST');
```

Hiding a page with JavaScript is cosmetic: the HTML is public. What actually
protects data is the collection rules, so keep anything private in a
collection, not in the page.

## Local development

Pages on `localhost` are a different origin, so use the token:

```js
const API = 'https://txxmo.dev/api';
const { token } = await fetch(API + '/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ username, password }),
}).then(r => r.json());
localStorage.setItem('token', token);

const res = await fetch(API + '/data/notes?mine=1', {
  headers: { Authorization: 'Bearer ' + localStorage.getItem('token') },
});
```

A helper that works in both places: use relative `/api` and the cookie when
`location.hostname` is `txxmo.dev`, otherwise the full URL and the stored token.

## Forms (contact, quote, sign-up…)

`POST /api/forms/{name}` stores a message the site owner reads in their panel.
`name` is lowercase letters, numbers, `_` or `-` (e.g. `contact`, `quote`).
Send either a normal HTML form post or JSON; any field names work and become
the labels the owner sees. Special fields: `_gotcha` (a hidden honeypot: leave
it empty), `_next` (a path on this site to go to afterwards). Plain form posts
are redirected back with `?sent=1`; JSON requests get `201 {"ok": true}`.
Limit: 10 messages per hour per visitor. No login needed. There is no way to
read messages through the API. A plain form post can carry up to three image
files (`<input type="file">`): they're stored with the message and the owner
sees them in the panel.

```html
<form method="post" action="/api/forms/contact">
  <input name="name" required> <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <input name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">
  <button>send</button>
</form>
```

## Site content for custom sites

When the site is built by you (GitHub) but the owner edits words, hours, a
notice and photos in the panel, the panel publishes that content here so the
site can show it. Switch it on under Settings → "Content on the API" (developer
panels). Nothing to log in for.

- `GET /api/site` → `{"site": {"domain", "name"}, "generated": "…", "content": {…}}`.
  `content` has one object per editor section: `business` (name, tagline,
  phone, email, address, map_link, area, badge, hours, special_hours, logo,
  social), `home` (banner, banner_from, banner_until, hero_title, hero_text,
  hero_image, highlights, steps, intro, testimonials, review_link), `services`
  (intro, list), `about`, `gallery` (photos, pairs), `contact`, `quote`,
  `booking`, `pay`, `seo`, `look`. Photos are `/api/media/<name>` on this
  domain. Cached for a minute.
  When the owner has connected their Google listing (Settings → Google
  reviews), `content.google` is there too: `{"name", "rating", "count",
  "maps_url", "write_url", "reviews": [{"who", "photo", "stars", "text",
  "when", "time", "url"}], "fetched", "show"}` — up to five of Google's most
  relevant reviews, refreshed daily. `show` is the owner's choice about the
  site displaying them; `write_url` is the "leave us a review" link.
- `GET /api/site.js` → a 4 KB script that fills a page. Include it and mark
  elements:

```html
<script src="/api/site.js" defer></script>
<a data-sp="business.phone" data-sp-href="tel:business.phone">541-000-0000</a>
<p data-sp-lines="business.address">1 Main St<br>Town</p>
<div data-sp-show="home.banner" hidden></div>               <!-- the notice, only while it's on and in its dates -->
<span data-sp-open-now hidden></span>                        <!-- "Open now · until 5pm", from business.hours -->
<ul data-sp-list="business.hours"><template><li><span data-sp="days"></span> <b data-sp="time"></b></li></template></ul>
<img data-sp-src="home.hero_image" src="/fallback.jpg" alt="">
<p data-sp-show="google.rating" hidden><b data-sp="google.rating"></b> on Google from <span data-sp="google.count"></span> reviews</p>
<ul data-sp-list="google.reviews"><template><li><span data-sp="stars"></span>★ <q data-sp="text"></q> — <span data-sp="who"></span></li></template></ul>
```

The page keeps what it already shows until the content arrives, so write the
real values into the HTML as a fallback. After filling, `sitepanel:ready`
fires on `document` with the payload, and `window.SitePanel.content` holds it;
`SitePanel.openNow(hours)` gives `{open, text}` for any hours list.

## Software licensing

License keys for desktop or mobile software, locked to a device. The site owner
makes keys in the panel (Licenses tab): product, how many devices at once, an
expiry, and any extra fields the app should receive (`meta`, e.g. a plan name).
The app sends the key and a **HWID**: a stable id for the machine it's running
on (hash a machine GUID, a disk serial, or the like; 1–128 characters, no spaces).
No login needed; 120 calls an hour per address.

- `POST /api/license/activate` `{"key", "hwid", "name"?, "version"?}` — binds
  this device to the key. The same device again is fine. `403` when the key is
  suspended, revoked or expired; `409` when every device slot is taken; `404`
  for an unknown key. The body is the same shape as validate's.
- `POST /api/license/validate` `{"key", "hwid", "version"?}` — the regular
  check (on start, then every few hours). Always `200`, with `valid` true or
  false and `reason` ("paused", "suspended", "expired on 2027-01-31", "this
  device isn't activated"…). "paused" means the owner stopped the clock, for
  maintenance or a dispute; the days left are kept and the key comes back as
  it was.
- `POST /api/license/deactivate` `{"key", "hwid"}` — frees this device's slot.
- `GET /api/license/public-key` — the site's Ed25519 public key.

Every activate/validate answer looks like:

```json
{"valid": true, "reason": "", "key": "…", "hwid": "…", "action": "validate", "ts": 1790000000,
 "product": "MyApp Pro", "status": "active", "expires": "2027-01-31", "days_left": 120, "max_devices": 2, "devices": 1,
 "meta": {"plan": "pro"}, "name": "Pat Jones", "email": "pat@example.com",
 "product_info": {"version": "1.4.0", "notes": "…", "download": "https://…/api/license/download/…"},
 "message": "{\"action\":\"validate\",…}", "signature": "base64…"}
```

`message` is the exact string that was signed: the same facts as canonical
JSON. **Verify `signature` over the bytes of `message`, then read the facts
out of `message`**, not out of the top-level copies. That way nothing has to be
re-serialised in your language. Ed25519; the public key is in the panel.
If the check fails, or `ts` is more than a few minutes from the device's clock,
treat the answer as invalid. Cache the last good answer so the app keeps
working offline for whatever grace you choose.

`product_info` appears when the panel has a build set for the product
(Licenses → Products): the current version, release notes, and a download
link that only works with a valid license and for 15 minutes. The app compares
`version` with its own, and if newer fetches `download`. The link is the same
whether you uploaded the build or gave a URL (a URL is redirected to).

```python
# Python: pip install cryptography
import base64, json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
PUB = Ed25519PublicKey.from_public_bytes(base64.b64decode("…public key from the panel…"))
def subscription(answer):
    """The verified facts, or None if the answer can't be trusted."""
    try: PUB.verify(base64.b64decode(answer["signature"]), answer["message"].encode())
    except Exception: return None
    return json.loads(answer["message"])
```

```js
// Node, no dependencies
const crypto = require('crypto');
const PUB = crypto.createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from('…public key…', 'base64')]), format: 'der', type: 'spki' });
function subscription(a) { return crypto.verify(null, Buffer.from(a.message, 'utf8'), PUB, Buffer.from(a.signature, 'base64')) ? JSON.parse(a.message) : null; }
```

**C++** (a loader, a desktop app): a single header does the device id, the
calls, the signature check and the download. Get `sitepanel_license.hpp`, an
example `main.cpp` and a `CMakeLists.txt` from the panel's Licenses page. It
needs libcurl, OpenSSL and nlohmann-json (`vcpkg install curl openssl nlohmann-json`).

```cpp
sitepanel::License lic("https://txxmo.dev", "…public key…");
auto sub = lic.activate(key, "Pat's PC", "1.4.0");       // or lic.validate(key, "1.4.0") on later runs
if (!sub.valid) { std::cout << "No access: " << sub.reason << "\n"; return 1; }
std::cout << sub.product << " until " << sub.expires.value_or("forever") << " (" << sub.days_left << " days)\n";
if (sub.update_available("1.4.0")) lic.download(sub, "update.bin");
```

The data webhook (Tools) gets `license.activated` and `license.deactivated`.

## Files (uploads)

Logged-in users (or the admin key) can upload files and get back a link to use
in a document, a profile or a page. Images are fixed up (rotated the right way,
shrunk to 2000px) on the way in; PDFs and plain text (`txt`, `csv`, `json`)
are kept as they are. 15 MB per file, 500 MB per site.

- `POST /api/files` — multipart form data with one field, `file`. Returns
  `201 {"id", "url", "name", "size", "type", "owner", "created"}`.
- `GET /api/files` — your own uploads (every upload with the admin key).
- `GET /api/files/{id}` — the file itself. Anyone with the link can open it;
  the id is long and random, so only share it where you mean to. Images and
  PDFs show inline, text downloads.
- `DELETE /api/files/{id}` — yours only (anything with the admin key).

```js
const fd = new FormData(); fd.append('file', input.files[0]);
const r = await fetch('/api/files', { method: 'POST', body: fd });   // no Content-Type header: the browser sets it
const { url } = await r.json();                                      // e.g. "/api/files/f_…", put it in a document
```

## Shop

If the owner has turned the shop on, product pages exist at `/shop/` and
`/shop/{slug}/`, and `GET /shop/products.json` lists products:
`{"currency": "usd", "products": [{slug, title, price (minor units, e.g. cents),
price_text, kind ("physical"|"virtual"), mode ("view"|"buy"|"link"), stock,
sold_out, image, images, url, description, tags, buy_url}]}`. `/shop/shop.js`
is a ready-made cart: include it and use buttons with `data-add="slug"`; it
renders into an element with `id="cart"`.

Buying (only when `mode` is `buy` and the owner has set up card payments):

- `POST /api/shop/checkout` body `{"items": [{"slug": "...", "qty": 1}]}` →
  `{"url": "https://checkout.stripe.com/...", "order": "ord_..."}`. Send the
  visitor to `url`; Stripe takes the card and returns them to
  `/shop/thanks/?session_id=...`. If the visitor is logged in (cookie or
  bearer token), the order is tied to their account and their email is
  prefilled. Errors: `404` not buyable, `409` not enough stock, `503` payments
  not set up.
- `GET /api/shop/order?session_id=cs_...` → the order for the thanks page:
  `{id, status, email, amount, currency, items, shipping, downloads: [{title,
  url, left}]}` or `{"status": "pending"}` while payment is still confirming.
- `GET /api/shop/orders` (login required) → `{"orders": [...]}`, the
  customer's own paid orders with any still-valid download links. Use this
  for an account page or "my purchases".
- `GET /api/shop/download/{token}` → the file for a virtual product (links
  work 10 times within 30 days).

## Status codes

`200` ok · `201` created · `400` bad input · `401` not logged in or session
expired · `403` not allowed · `404` no such collection or record · `409`
username taken · `413` too big · `415` body was not sent as JSON · `429` too
many requests · `507` a storage limit was reached · `500` server error


## Counting things without a backend: `/_e/<name>`

Any request to `https://<domain>/_e/<name>` answers `204 No Content` and is counted once in the site's
stats (Stats page → "What people did", and the monthly report). No cookies, nothing about the visitor is
stored. `name` is 1–32 characters: lowercase letters, digits, `-` or `_`. At most 40 different names a day.

```js
navigator.sendBeacon('/_e/signup');          // from a click or submit handler
```

Names with a built-in label: `call`, `directions`, `email`, `contact`, `quote`, `review`, `social`.
Counts are an estimate: known bots are left out, and anyone can call the address.

## Deploy hook (from the panel's Tools page)

```
curl -X POST https://edit.<domain>/hooks/deploy/<token>     # rebuild the newest commit; one a minute
curl https://edit.<domain>/hooks/status/<token>             # JSON: live deploy id + the last 10 deploys
```

Build variables set on the Tools page are available to the install and build commands as environment variables.
