# ruosenzhao.com — Agent Guide

A small self-hosted service suite for one person. Everything runs on a single
always-on **ThinkPad E480** (Ubuntu 24.04) at home and is published to the
internet through **one Cloudflare Tunnel**. No third-party SaaS holds the data.
Every page is in **English**. Each hostname below is a separate service behind
the same tunnel; the tunnel connector runs in the host network namespace, so
each service listens only on loopback (`127.0.0.1`) and is reachable from the
public only through the tunnel. If you are an agent with no prior context, read
this file top to bottom — it is meant to be enough on its own.

## Services

| Host | Role | Stack | Auth | Main entry |
|---|---|---|---|---|
| `ruosenzhao.com` (also `www.`) | Navigation home page (static) | nginx container, static HTML + CSS tokens | none (public) | https://ruosenzhao.com/ |
| `storage.ruosenzhao.com` | Self-built file/cloud drive | Rust single binary (axum) in one container; data root `/data/storage` | browser session login **or** per-device credential (HTTP Basic) | https://storage.ruosenzhao.com/ |
| `paste.ruosenzhao.com` | Clipboard / quick transfer | Python stdlib single-file `http.server` | browser sign-in **or** HTTP Basic (user `admin`) | https://paste.ruosenzhao.com/ |
| `mail.ruosenzhao.com` | Read-only webmail over the local IMAP archive, plus a feed where agents leave the owner written reports | Python stdlib single-file `http.server` (unit `mailweb.service`); UI is a static asset tree under `site/` | browser session login **or** bearer token **or** HTTP Basic | https://mail.ruosenzhao.com/ |
| `rest.ruosenzhao.com` | "Rest area" scenes | static site (python `http.server` unit) | none (public) | https://rest.ruosenzhao.com/campfire/ |
| `terminal.ruosenzhao.com` | Browser terminal ("doorbell") | web terminal; a session must be approved in chat first | owner approval | https://terminal.ruosenzhao.com/ |

## Interfaces you can call

Credentials are provided by the site owner. Do not guess, mine or brute-force
them. Below, `...` stands for the owner-supplied secret.

### paste — https://paste.ruosenzhao.com

**A browser** lands on a sign-in card at `/`; signing in sets a `paste_session`
cookie (30 days, HttpOnly; `/logout` clears it). The design assets under
`/assets/*` are public so that card can render.

**A script** should ignore all of that and just use HTTP Basic — every path
below takes it:

- `GET /health` — no auth, returns `200` and the body `ok`. Use it to check liveness.
- `POST /new` — form body `text=<text>&name=<name>&ttl=1h|1d|7d|30d|never`.
  Creates a text paste and redirects to `/p/<id>`.
  `curl -sS -u admin:... -d 'text=hello' https://paste.ruosenzhao.com/new`
- `POST /upload` (or `PUT /upload`) — raw body upload, streamed straight to disk.
  Headers: `X-Filename: <url-encoded name>` names the file; `X-Paste: <id>`
  appends another item to an existing paste; `X-Ttl: 1h|1d|7d|30d|never`.
  `curl -sS -u admin:... -T ./shot.png -H 'X-Filename: shot.png' https://paste.ruosenzhao.com/upload`
  (`curl -T` speaks PUT; it is accepted as an alias for POST.)
- `GET /p/<id>` — HTML page for a paste.
- `GET /raw/<id>` — raw bytes of the first item; `GET /raw/<id>/<n>` — the
  `n`-th item of a multi-file paste.
- `POST /p/<id>/delete` — delete a paste.
- Anything without credentials returns `401` with `WWW-Authenticate: Basic` —
  on every path including `/`, for anything that does not look like a browser.
  Only a browser document request (an HTML `Accept`, or `Sec-Fetch-Mode:
  navigate`) gets the sign-in card instead of that `401`.

### mail — https://mail.ruosenzhao.com

A **read-only** webmail. It does not fetch mail, send mail, or delete anything:
it renders the five mailboxes already archived locally under `/data/mail-archive`
by the fetch timers. **If you want to leave the owner a written report — that is
this service's other half, see `POST /api/reports` below.**

Three credentials work on every path:

1. **Browser session** — `POST /api/login` with JSON `{"user":"admin","password":"..."}`
   sets a `mail_session` cookie (30-day TTL). Use `-c`/`-b` with curl. The cookie
   is `exp.nonce.HMAC(exp.nonce)`: the nonce makes every login unique, and
   `POST /api/logout` really revokes it (the server keeps the thrown-away
   signatures in a bounded in-memory list, so a restart forgets them).
2. **Bearer token** — `Authorization: Bearer <MAIL_REPORT_TOKEN>`, intended for
   agents and scripts. It is **deliberately narrow**: it may `POST /api/reports`
   and nothing else — every mail endpoint answers `403` to it, and it may not
   delete reports either. The value lives in `/home/mike/docker/mail/.env`.
3. **HTTP Basic** — `-u admin:<password>` also works.

The server never sends `WWW-Authenticate`, so a browser never pops its native
dialog; the sign-in card is part of the page.

- `GET /api/health` — no auth, `200`.
- `GET /api/session` — whether the current caller is authenticated.
- `GET /api/overview` — the five mailboxes, per-folder counts, unread, last fetch.
- `GET /api/messages?acct=&folder=&q=&limit=&offset=` — the message list.
  `acct` is one of `gmail-main`, `outlook`, `cuhk`, `gmail-edu`, `126`, or `all`
  to search every mailbox at once (the UI's 「全部信箱」 switch; ~1.3s for 21k
  messages). Each item carries `att` (attachment count) and `preview`.
  `q` searches locally across sender/subject/recipients.
- `GET /api/message?path=<index path>` — one message, headers + text + sanitized HTML.
- `GET /api/attachment?path=&idx=&inline=1` — one attachment; `idx` is its
  position. Only inert types are ever served `inline` (png/jpeg/gif/webp/bmp/avif/pdf);
  anything else downloads even with `inline=1`, and every attachment answer carries
  `default-src 'none'; …; sandbox` plus `nosniff` — an `.svg` attachment opened in a
  tab would otherwise run its script as this origin and could read the whole archive.
- `GET /api/raw?path=` — the original `.eml`.
- `GET /api/reports?limit=&kind=&acct=&before=` — the report feed.
- `POST /api/reports` — **write a report for the owner.** JSON:
  `{"title":"…","body":"…","author":"…","kind":"summary|daily|note|alert|task","acct":"…"}`.
  `body` accepts a little Markdown (`**bold**`, `` `code` ``, `- bullets`).
  `kind` picks the colour strip and the label in the UI.
  ```
  curl -sS -H "Authorization: Bearer $MAIL_REPORT_TOKEN" -H 'Content-Type: application/json' \
       -d '{"title":"Morning digest","body":"- 3 need a reply","author":"andy","kind":"daily"}' \
       https://mail.ruosenzhao.com/api/reports
  ```
- `GET /api/reports/<id>`, `DELETE /api/reports/<id>` (session/Basic only, not the token).

Only paths that appear in the archive index can be opened; the server never
joins a caller-supplied path onto the archive root.

The list is built to be read without opening anything: `/data/mail-archive/index-<acct>.jsonl`
(metadata), `preview-<acct>.jsonl` (first line of the body, from
`tools/preview-backfill.py`) and `atts-<acct>.jsonl` (attachment count, from
`tools/att-backfill.py`). All three are regenerated by those scripts, not by hand.

### storage — https://storage.ruosenzhao.com

Two kinds of credential:

1. **Browser session** — `POST /api/login` with JSON `{"user":"admin","password":"..."}`
   returns a session cookie (90-day TTL). Keep it in a cookie jar (`-c`/`-b`).
2. **Per-device credential** — while logged in, `POST /api/devices` with JSON
   `{"name":"my-cli","read_only":false,"scope":"/notes"}` returns a secret.
   Use it as HTTP Basic with any username: `-u dev:<secret>`. `scope` binds the
   credential to a virtual root; `read_only:true` makes writes return `403`.

HTTP API (accepts the session cookie or a device credential via Basic):

- `GET /healthz` — returns `ok`.
- `POST /api/login`, `POST /api/logout`, `GET /api/whoami`
- `GET /api/list?path=/dir` — list a directory
- `GET /api/search?q=term` — search
- `GET /api/stats` — usage stats
- `GET /api/devices`, `POST /api/devices`, `PATCH|DELETE /api/devices/{id}` — manage credentials
- `GET /api/trash`, `POST /api/trash/restore` (JSON `{"id":"..."}`), `DELETE /api/trash/{id}`
- `GET /api/shares`, `POST /api/shares`, `PATCH|DELETE /api/shares/{name}` — named persistent
  public shares (admin only; these are what back `/public/<name>/`)
- `POST /api/share` (JSON `{"path":"...","host":"..."}`) — a one-off signed link
- `POST /r2/upload-url`, `POST /r2/commit`, `POST /r2/download-url` — R2 direct path for large files

**WebDAV** — mount the site root with a device credential as HTTP Basic:

```
mount -t davfs https://storage.ruosenzhao.com/ /mnt/netdisk   # davfs2; Finder/Windows also work
curl -u dev:<secret> -X PROPFIND -H 'Depth: 0' https://storage.ruosenzhao.com/   # -> 207
```

A `GET /` serves the web UI; every other method (`PROPFIND`, `PUT`, …) goes to
WebDAV. Unauthenticated requests get `401` with `WWW-Authenticate: Basic`.

**Read-only public area** — `GET`/`HEAD https://storage.ruosenzhao.com/public/<name>/`
and below expose a named share to anyone (no auth). See the constraints below.

### rest — https://rest.ruosenzhao.com

Static scene pages organised by path: `https://rest.ruosenzhao.com/campfire/`
(scenes `/snow`, `/forest`, `/river` are planned). Keep the trailing slash on
subdirectories. The old `campfire.ruosenzhao.com` host 301-redirects here.

### terminal — https://terminal.ruosenzhao.com

Open it in a browser. A terminal session is only created after the owner
approves it in chat — creating a session is not scriptable and exposes no API.
The one endpoint that answers without approval is a plain liveness check:

- `GET /health` — no auth, returns `200` with the body `door ok`. Use it to
  confirm the door is reachable; it reveals nothing and grants no session.

## Hard constraints & invariants — do not try to work around these

1. **100 MB per request through the tunnel.** Cloudflare's free plan caps a
   single request that passes through the tunnel at 100 MB. storage routes
   files over 90 MiB (`R2_DIRECT_THRESHOLD`) through R2 instead, for exactly
   this reason. Don't try to push more than 100 MB through an ordinary request;
   it will fail.
2. **R2 is only a cache/relay, never the master copy.** The authoritative data
   always lives on the E480; R2 is a staging/pass-through used for large
   uploads/downloads. R2's free tier is 10 GiB and evicts by LRU once exceeded —
   brief overflow is tolerated, but it must not be left over. Never make R2 the
   only home of a file.
3. **`/public/<name>/` is strictly read-only.** Any method other than `GET`/`HEAD`
   gets `405` with `Allow: GET, HEAD`. There are no writes, no deletes, and no
   listing beyond what the share exposes; the mapping from `<name>` to a real
   path exists only on the server. It is rate-limited to **10 Mbit/s per IP and
   30 Mbit/s globally**; over the limit returns `429` with `Retry-After`. Don't
   scrape it hard or hammer it.
4. **Not built yet: a "drop inbox" and a CLI called `ruo`.** An inbox (where
   other people upload files *to* the owner) and the `ruo` command-line tool are
   both planned but **do not exist**. Don't script against them.
5. **Authentication.** storage uses a browser session login plus per-device
   credentials (a token can bind a virtual root/scope and be read-only). paste
   takes HTTP Basic (`admin`) for scripts and a browser sign-in card for people.
   Credentials are provided by the site owner — never guess, mine, or brute-force
   them.
6. **One design language, two page languages.** Colours, spacing, radii, font
   sizes and motion are centralised as CSS variables in a single `tokens.css`,
   so one file re-skins the whole set. Site assets live at
   `/assets/{tokens.css,base.css,theme.js}`. Prefer the tokens over hard-coded
   values, and don't edit the design package unless asked.
   **Every public page is in English — `mail` is the deliberate exception.** The
   mail reader is a private daily-use tool whose content (the mail itself, and
   the agents' reports) is mostly Chinese, so its UI is Chinese on purpose. It
   still uses the same tokens and assets.
7. **paste has a total quota, not just a per-file cap.** A single upload is
   capped at 4 GB as a safety limit, but the real constraint is the total
   **20 GB** quota (`PASTE_QUOTA_GB`). Going over returns `507` with an English
   explanation — free space first rather than retrying.
8. **The Cloudflare Tunnel is remotely managed — the local `config.yml` ingress
   is NOT authoritative.** `GET /accounts/<id>/cfd_tunnel/<id>` reports
   `config_src: "cloudflare"`, and the routing table lives at the edge. Editing
   `work/cloudflared/config.yml`, restarting the connector, even
   `--force-recreate` changes nothing. Use
   `/home/mike/docker/cf-lab/work/tunnel-ingress.py` (`list` / `set` / `del` /
   `pull`) or the dashboard. Nothing is published to the public internet except
   what appears in `tunnel-ingress.py list`.

## Planned / does not exist

- **Delivery inbox** (uploads from other people *to* the owner): planned, not implemented.
- **`ruo` command-line tool**: planned, not implemented.
- One-off links from `POST /api/share` may expire; the persistent public areas
  are the named shares behind `/public/<name>/`.

## How to learn more

- Everything under this host is static and public read-only — you can fetch the
  pages and `/assets/*`. A copy of **this document** is *meant* to live at
  `https://storage.ruosenzhao.com/public/docs/AGENTS.md`, but the owner has
  **not published that share yet** — the path currently returns `404`. Do not
  rely on it; fetch this file from `https://ruosenzhao.com/AGENTS.md` instead.
- Source code, systemd units, and build/deploy scripts for every service live on
  the host: `/home/mike/docker/{storage,paste,rest,cf-lab,mail}`. The mail site's
  own notes are in `/home/mike/docker/mail/` (`app.py` is the whole backend;
  `site/` is the whole front end). Ask the owner for access.
- If anything here disagrees with a live response, **trust the live response** —
  the running interfaces are authoritative. Where a detail is uncertain, the
  actual endpoint wins.
