# Backend API for apexautomotivellc.com

This site has a hosted backend at `https://apexautomotivellc.com/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://apexautomotivellc.com/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 apexautomotivellc.com).** `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://apexautomotivellc.com/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.apexautomotivellc.com → 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://apexautomotivellc.com/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 `apexautomotivellc.com`, 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.

```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>
```

## 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
