# hub — records + files API for agent-built sites

One Amplify Gen 2 backend that hosts agent-built sites and holds their data and files, so the agent
building a site never provisions a host, a database or a bucket. No Vercel: sites is the host. One token per site; everything a
site reads or writes is scoped to that site.

- **API**: the HTTP API URL in `amplify_outputs.json` → `custom.hubApiUrl` (also shown on the hosted page).
- **Public files**: `custom.publicFilesUrl` + `/<siteId>/<path>` (CloudFront, stable URLs, 5-min edge cache by default).
- **Admin token**: generated at deploy, stored in AWS Secrets Manager under `custom.adminSecretName`. Never in the repo.

## Skill

The agent skill lives in `skills/sites/SKILL.md`; `scripts/publish_skill.py` syncs it and the plugin manifests to the PUBLIC repo `rkz211/cookiejar` (install: `claude plugin marketplace add rkz211/cookiejar && claude plugin install sites@cookiejar`). This repo is private; agents fetch the served copy at https://raw.githubusercontent.com/rkz211/cookiejar/main/skills/sites/SKILL.md.

## Auth

Three credentials, three scopes:

| Credential | Shape | Can |
|---|---|---|
| **key** (handed to one person) | `sites_<accountId>_<secret>` | create sites; list/rotate/disable the sites it created (`/key`, `/sites/*`); **with `X-Site: <siteId>`**, everything a site token can do on any site it created |
| **site token** | `hub_<siteId>_<secret>` | that site's records, files and edge (`/me`, `/c/*`, `/files/*`, `/edge`) |
| **admin token** | Secrets Manager | everything under `/admin/*`: mint keys, all sites |

All are stored as sha256 only and shown exactly once (create or rotate). A key file for a person is
written by `scripts/mint_key.py --name "<person>"` → `~/Downloads/sites-key-<person>.txt` (`HUB_URL` + `HUB_KEY`).

## Portal

`https://cookiejar.lol/portal/` — a human view of everything a key owns: sites, deploys + build logs, records, files,
env, edge/domains, rotate/disable. Sign in with the key (pasted whole, or as username = account id + password = secret).
The key never leaves the browser: `POST /login/challenge {accountId}` → nonce; the browser computes
`proof = hex(HMAC_SHA256(hex(SHA256(key)), nonce))`; `POST /login {accountId, nonce, proof}` → a 12-hour stateless session
(`sess_…`) that acts exactly like the key, including `X-Site`. Rotating the key ends its sessions. Source: `src/`, built
with Vite (`base: /portal/`) into `site/portal`, so `scripts/publish_site.py site --name sites-home` publishes pages and portal together (build first).

## Routes

| Method | Path | Does |
|---|---|---|
| GET | `/` | route list, no auth |
| GET | `/me` | this site: `siteId`, `name`, `kind`, `buildId` (the deploy serving now), `modifiedAt` (last time what it serves changed: a publish, or a site-tier file write/delete), usage, `publicFilesUrl` |
| GET | `/c` | list collections |
| GET | `/c/{collection}?prefix=&limit=50&cursor=&order=asc\|desc` | list records (max 100/page) |
| POST | `/c/{collection}` | create a record with a generated time-sortable key; body = JSON data |
| DELETE | `/c/{collection}` | delete every record in the collection |
| GET | `/c/{collection}/{key}` | read a record |
| PUT | `/c/{collection}/{key}` | create or replace; body = any JSON ≤ 350 KB |
| DELETE | `/c/{collection}/{key}` | delete a record |
| POST | `/files/{public\|private\|site}` | `{path, contentType, cacheControl?}` → presigned `PUT` (15 min) |
| GET | `/files/{public\|private\|site}?prefix=&limit=100&cursor=` | list files |
| GET | `/files/{public\|private\|site}/{path}` | info + `url` (public: CDN url; site: edge url; private: 1-hour presigned). `?redirect=1` → 302 |
| DELETE | `/files/{public\|private\|site}/{path}` | delete a file |
| POST | `/edge` | put this site on the shared edge at `https://<siteId>.cookiejar.lol/` (idempotent) |
| GET | `/edge` | edge status: `tenantId`, `status`, `domains[]`, `live` (true once the certificate is issued) |
| POST | `/edge/invalidate` | flush the edge cache for this site (202); needed after a manual `site`-tier publish |
| POST | `/edge/domains` | `{domain}` → 202: your own hostname on this site. DNS is written for you when the zone is in our Porkbun account (else `manual[]` lists the records to create); the tenant, and for a server site the gateway certificate + domain, follow automatically |
| GET | `/edge/domains` | every custom domain with `dns`, `tenant`, `gateway`, `live`, `error`, `manual[]` |
| DELETE | `/edge/domains/{host}` | take the hostname off the site (tenant, gateway domain, DNS record) |
| POST | `/login/challenge` | `{accountId}` → `{nonce}` (portal sign-in step 1) |
| POST | `/login` | `{accountId, nonce, proof}` → `{session}` (step 2) |
| GET | `/key` | this key or session (account) |
| PUT/GET | `/admin/dns/porkbun` | admin: store the Porkbun API pair (`{apikey, secretapikey}`, never echoed) / see `configured` + writable `zones` |
| POST | `/sites` | key: `{name}` → `{siteId, token, siteUrl}` (token shown once) |
| GET | `/sites` | key: the sites this key created |
| GET | `/sites/{siteId}` | key: one of them, with `buildId` + `modifiedAt` (has it changed since you last looked?) |
| POST | `/sites/{siteId}/rotate` | key: new site token |
| DELETE | `/sites/{siteId}` | key: disable immediately; edge removal queued (202, poll GET site) |
| POST | `/admin/accounts` | `{name}` → `{accountId, key}` (shown once) |
| GET | `/admin/accounts` | list keys |
| POST | `/admin/accounts/{accountId}/rotate` | replace a key |
| DELETE | `/admin/accounts/{accountId}` | disable a key |
| POST | `/admin/sites` | `{name}` → `{siteId, token, ...}` |
| GET | `/admin/sites` | list sites |
| GET | `/admin/sites/{siteId}` | one site |
| POST | `/admin/sites/{siteId}/rotate` | new token; old one refused immediately; re-enables a disabled site |
| DELETE | `/admin/sites/{siteId}` | disable immediately; edge removal queued (202, poll GET site) |

Record shape: `{collection, key, data, createdAt, updatedAt}`. Lists return `{items, cursor}`; pass `cursor` back for the next page; `cursor: null` = end.

Identifiers: `collection` = `[A-Za-z0-9_-]{1,64}`; `key` = `[A-Za-z0-9_.:@~-]{1,256}`; file `path` = slash-separated segments of `[A-Za-z0-9._()!*'-]`, no `.`/`..`, ≤ 512 chars.

Errors are `{error: "..."}` with the right status: 400 bad input, 401 no/invalid token, 403 disabled site, 404 missing, 405 wrong method, 413 record too big.

## Upload flow

1. `POST /files/public` with `{path, contentType}` → `{uploadUrl, headers, url}`.
2. `PUT` the bytes to `uploadUrl` sending **exactly** the returned `headers` (the signature covers them).
3. Public: the file is at `url` right away. Private: `GET /files/private/{path}` for a fresh 1-hour url.

## File tiers

| Tier | Where it's served | Use |
|---|---|---|
| `public` | `https://<files cdn>/<siteId>/<path>`, stable, cached at the edge | images, downloads a page links to |
| `private` | 1-hour presigned url per read | anything only the owner should see |
| `site` | `https://<siteId>.cookiejar.lol/<path>` | the site's own web root: a static build |

## The shared edge (sites)

One CloudFront **multi-tenant** distribution serves every site. A site's tenant is created by `POST /edge`
(never by CloudFormation): its `siteId` parameter fills the origin path `/<siteId>/site`, its domain is
`<siteId>.cookiejar.lol`, and CloudFront issues and renews the certificate itself (HTTP-validated).
DNS is a single wildcard CNAME `*.cookiejar.lol → <connection group routing endpoint>` in Cloudflare (DNS-only,
set by hand 2026-09-22; `scripts/dns_wildcard.py` is the Porkbun equivalent); nothing per site touches DNS. Directory urls resolve to `index.html`
(`/about` and `/about/` → `/about/index.html`); a missing file returns the site's `/404.html` if present, else a bare 403 (ship a 404.html).

Static site fast path: upload the build into the `site` tier, `POST /edge` once, wait for `live: true`. This manual upload path remains available for sites without a managed static deployment. Once a managed release is active, site-file reads follow that release and direct writes/deletes return 409; create a new deployment to edit it.

## Deploys (the Vercel part)

sites builds and hosts the site from its source; the agent never runs a build tool of its own.

| Method | Path | Does |
|---|---|---|
| POST | `/deploy` | `{kind: "server"\|"static", outputDir?}` → `{deployId, source.uploadUrl}`. Zip the project (package.json at the top) and `PUT` it there |
| POST | `/deploy/{deployId}/start` | build it |
| GET | `/deploy/{deployId}` | `status` created → starting → building → placing → live / failed / superseded, the CodeBuild phase, the last 40 log lines, `url` |
| GET | `/deploys` | recent deploys |
| POST | `/deploy/{deployId}/activate` | activate a retained server version or static release (rollback) |
| GET / PUT | `/env` | the site's environment variables, applied to its server at the next deploy (≤ 3.5 KB) |

Build workers have no storage or deployment IAM permissions. The trusted API provides a version-pinned source download URL and a single-write artifact upload URL, each scoped to one object and valid for one hour. Source archives stay private. Project/dependency scripts cannot list or change another site's bucket objects.

**static**: CodeBuild runs `npm ci` + `npm run build` and zips `outputDir` (default `out`). The trusted deployer validates the archive and stages it at `/<siteId>/releases/<deployId>/` before switching the tenant's origin path and invalidating its cache. Previous releases remain available for rollback; existing live folders are never synced or deleted during a build. Limits: 50 MiB compressed, 100 MiB extracted, 20 MiB per file, 5,000 entries, no symlinks/traversal/duplicate paths, and a root `index.html` is required. Static files use `max-age=0, must-revalidate`; CloudFront propagation still takes time.

**server**: CodeBuild runs the OpenNext build (dummy cache/queue overrides: one Lambda, no S3 cache, no
queue, no ISR) and packages `{index.mjs wrapper, server/, assets/}`; the deployer creates or updates the site's Lambda
`hub-site-<siteId>` (nodejs20, shared logging role, gateway targets the `live` alias, env = your `/env` vars + `HUB_URL` +
`HUB_TOKEN` = a per-deploy *server token* registered before traffic changes; retained versions remain authorized for rollback and in-flight requests), adds the site's routes
(`ANY /<siteId>` and `ANY /<siteId>/{proxy+}` → the live alias) on the **sites gateway** (one HTTP API behind the wildcard
custom domain `*.cookiejar.lol`), and cuts its tenant on the **server edge**, a second multi-tenant distribution
whose one origin is the gateway with origin path `/{{siteId}}`. Why a gateway: a tenant-only distribution sends the
tenant hostname as the origin Host regardless of policy, and only a domain that *owns* that hostname accepts it. The
wrapper strips the `/<siteId>` prefix, serves `/assets` itself and refuses requests without the edge's `x-hub-origin`
header. Next.js on the server edge: use `images: { unoptimized: true }`. Server sites answer only on their default
host for now (a customer domain on a server site needs a router; static sites take any domain).

Build starts are claimed under a per-site lease, with the exact CodeBuild request persisted for safe retries. Retries stop before AWS's five-minute idempotency window expires; the worker then reconciles the existing build instead of launching another. The latest accepted start or activation request controls publication. Completion events are checked against AWS's current build record and the recorded request ID, so duplicate, late and superseded results cannot replace the selected release. Interrupted placement reuses its saved Lambda version and retries on the minute schedule.

Disabling a site immediately refuses all its tokens and cancels pending publication. DELETE returns 202 with `edgeRemoval: pending`; GET the site with its account key or admin credential until `edgeRemoval: complete`. The worker disables then deletes the tenant in separate bounded steps. Certificate activation honors disablement. Rotating the site token re-enables the site, but does not resume previously cancelled builds. Server-token hashes are retained for deployed versions; disabling the site refuses all of them.

Upgrade note: deployments started by the older unrestricted build worker should be restarted as new deployments after this backend update. Existing hosted content and manual upload roots are preserved; old static deploys need one rebuild before they support release rollback. Older server versions register their saved credential when explicitly activated.

## Architecture

```
site build / agent --Bearer site token--> API Gateway HTTP API --> Lambda hub-api
                                                               ├── DynamoDB  HubTable (single table, on-demand, PITR, RETAIN)
                                                               ├── S3 HubPublicFiles  <-- CloudFront (OAC)  = public urls
                                                               ├── S3 HubPrivateFiles (presigned only)
                                                               ├── S3 HubSites <-- CloudFront multi-tenant edge (OAC) = <siteId>.cookiejar.lol
                                                               │     └── one DistributionTenant per site, cut by POST /edge
                                                               ├── API Gateway HubSitesGateway (*.cookiejar.lol) <-- CloudFront server edge
                                                               │     └── /<siteId>/* -> Lambda hub-site-<siteId>:live (OpenNext + wrapper)
                                                               ├── CodeBuild HubBuild (static: npm run build; server: OpenNext) -> EventBridge -> hub-deployer
                                                               └── Secrets Manager: admin token
```

`amplify/functions/hub-api/`: `handler.ts` (routes) · `auth.ts` (sites, tokens) · `records.ts` · `files.ts` · `http.ts` (event/response shapes). `amplify/backend.ts` wires it. The template Cognito `auth` is still defined but unused by the API.

## Cost shape

Usage comes from CodeBuild, Lambda, API Gateway, DynamoDB, S3 and CloudFront. There are also retained artifacts, secrets and scheduled recovery operations; this is not a zero-idle-cost service. Monitor actual usage before increasing the number of accounts.

## Develop / deploy

Push to `main` → Amplify runs lint, backend type checks and regression tests before `ampx pipeline-deploy`, then builds the Vite front page.
Local checks: `npm test`, `npm run lint`, `npm run typecheck:backend`, and `npm run build` (needs a temporary `{}` in `amplify_outputs.json`). Tests use an in-memory DynamoDB-compatible server for conditional-write behavior and mocked external AWS clients; the build-role test synthesizes its CloudFormation permissions. Run isolated live-site checks after deployment to verify AWS integration and edge propagation.
