From 41b19eff297e438615573b5dae6c1e4e6c1106d9 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 13 Feb 2026 14:36:04 -0600 Subject: [PATCH] docs: add user-facing documentation --- docs/README.md | 30 +++++ docs/admin-api.md | 247 ++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 170 +++++++++++++++++++++++++++++ docs/backfill.md | 78 +++++++++++++ docs/configuration.md | 31 ++++++ docs/deployment.md | 61 +++++++++++ docs/lexicons.md | 115 ++++++++++++++++++++ docs/quickstart.md | 72 ++++++++++++ docs/xrpc-api.md | 171 +++++++++++++++++++++++++++++ 9 files changed, 975 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/admin-api.md create mode 100644 docs/architecture.md create mode 100644 docs/backfill.md create mode 100644 docs/configuration.md create mode 100644 docs/deployment.md create mode 100644 docs/lexicons.md create mode 100644 docs/quickstart.md create mode 100644 docs/xrpc-api.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..11b955c --- /dev/null +++ b/docs/README.md @@ -0,0 +1,30 @@ +# HappyView + +HappyView is a lexicon-driven ATProto AppView. Upload lexicon definitions at runtime and HappyView dynamically generates XRPC query and procedure endpoints, indexes records from the network via Jetstream, and proxies writes to users' PDSes --- no restart required. + +## How it fits together + +``` +Jetstream ──> HappyView ──> PostgreSQL + │ + ┌───────────┼───────────┐ + │ │ │ + Clients AIP PDSes + (OAuth) (user repos) +``` + +- **Jetstream** pushes real-time record events. HappyView subscribes to the collections defined by uploaded lexicons and indexes records into Postgres. +- **AIP** (ATProto Identity Provider) handles OAuth 2.1 with PKCE. HappyView validates tokens by calling AIP's `/oauth/userinfo` endpoint. +- **PDSes** store user data. HappyView proxies writes and blob uploads to each user's PDS using DPoP-authenticated requests. +- **Clients** talk to HappyView's XRPC and admin APIs. Any ATProto-compatible client can connect. + +## Docs + +- [Quickstart](quickstart.md) - get a local instance running +- [Configuration](configuration.md) - environment variables reference +- [Deployment](deployment.md) - Docker, production, TLS +- [Lexicons](lexicons.md) - uploading and managing lexicon definitions +- [Backfill](backfill.md) - bulk-indexing historical records +- [XRPC API](xrpc-api.md) - query and procedure endpoints +- [Admin API](admin-api.md) - manage lexicons, backfills, and admins +- [Architecture](architecture.md) - internals for contributors diff --git a/docs/admin-api.md b/docs/admin-api.md new file mode 100644 index 0000000..a136806 --- /dev/null +++ b/docs/admin-api.md @@ -0,0 +1,247 @@ +# Admin API + +All admin endpoints live under `/admin` and require an AIP-issued Bearer token from a DID that exists in the `admins` table. + +## Auth + +Admin auth works the same as user auth — the Bearer token is validated against AIP's `/oauth/userinfo` endpoint to retrieve the caller's DID. That DID is then checked against the `admins` table. + +**Auto-bootstrap**: If the `admins` table is empty, the first authenticated request automatically inserts the caller as the initial admin. + +Non-admin DIDs receive a `403 Forbidden` response. + +```sh +# All examples assume $TOKEN is an AIP-issued access token for an admin DID +AUTH="Authorization: Bearer $TOKEN" +``` + +--- + +## Lexicons + +### Upload / upsert a lexicon + +``` +POST /admin/lexicons +``` + +```sh +curl -X POST http://localhost:3000/admin/lexicons \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ + "lexicon_json": { "lexicon": 1, "id": "example.record", "defs": { "main": { "type": "record", "key": "tid", "record": { "type": "object", "properties": { "title": { "type": "string" } } } } } }, + "backfill": true, + "target_collection": null + }' +``` + +| Field | Type | Required | Description | +| ------------------- | ------- | -------- | ------------------------------------------------------------------- | +| `lexicon_json` | object | yes | Raw lexicon JSON (must have `lexicon: 1` and `id`) | +| `backfill` | boolean | no | Whether uploading triggers historical backfill (default `true`) | +| `target_collection` | string | no | For query/procedure lexicons, the record collection they operate on | + +**Response**: `201 Created` (new) or `200 OK` (upsert) + +```json +{ + "id": "example.record", + "revision": 1 +} +``` + +### List lexicons + +``` +GET /admin/lexicons +``` + +```sh +curl http://localhost:3000/admin/lexicons -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "id": "example.record", + "revision": 1, + "lexicon_type": "record", + "backfill": true, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + } +] +``` + +### Get a lexicon + +``` +GET /admin/lexicons/{id} +``` + +```sh +curl http://localhost:3000/admin/lexicons/example.record -H "$AUTH" +``` + +**Response**: `200 OK` with full lexicon details including raw JSON. + +### Delete a lexicon + +``` +DELETE /admin/lexicons/{id} +``` + +```sh +curl -X DELETE http://localhost:3000/admin/lexicons/example.record -H "$AUTH" +``` + +**Response**: `204 No Content` + +--- + +## Stats + +### Record counts + +``` +GET /admin/stats +``` + +```sh +curl http://localhost:3000/admin/stats -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +{ + "total_records": 12345, + "collections": [{ "collection": "example.record", "count": 500 }] +} +``` + +--- + +## Backfill + +### Create a backfill job + +``` +POST /admin/backfill +``` + +```sh +curl -X POST http://localhost:3000/admin/backfill \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "collection": "example.record" }' +``` + +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ---------------------------------------------------------- | +| `collection` | string | no | Limit to a single collection (backfills all if omitted) | +| `did` | string | no | Limit to a single DID (discovers all via relay if omitted) | + +**Response**: `201 Created` + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "status": "pending" +} +``` + +### List backfill jobs + +``` +GET /admin/backfill/status +``` + +```sh +curl http://localhost:3000/admin/backfill/status -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "collection": "example.record", + "did": null, + "status": "completed", + "total_repos": 42, + "processed_repos": 42, + "total_records": 1000, + "error": null, + "started_at": "2025-01-01T00:01:00Z", + "completed_at": "2025-01-01T00:05:00Z", + "created_at": "2025-01-01T00:00:00Z" + } +] +``` + +--- + +## Admin management + +### Add an admin + +``` +POST /admin/admins +``` + +```sh +curl -X POST http://localhost:3000/admin/admins \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "did": "did:plc:newadmin" }' +``` + +**Response**: `201 Created` + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "did": "did:plc:newadmin" +} +``` + +### List admins + +``` +GET /admin/admins +``` + +```sh +curl http://localhost:3000/admin/admins -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "did": "did:plc:admin", + "created_at": "2025-01-01T00:00:00Z", + "last_used_at": "2025-01-02T12:00:00Z" + } +] +``` + +### Remove an admin + +``` +DELETE /admin/admins/{id} +``` + +```sh +curl -X DELETE http://localhost:3000/admin/admins/550e8400-e29b-41d4-a716-446655440000 \ + -H "$AUTH" +``` + +**Response**: `204 No Content` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..934c91d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,170 @@ +# Architecture + +Guide for contributors working on HappyView itself. + +## Module overview + +``` +src/ + main.rs Startup: config, DB, migrations, spawn workers, start server + lib.rs AppState struct, module declarations + config.rs Environment variable loading + error.rs AppError enum (Auth, BadRequest, Forbidden, Internal, NotFound, PdsError) + server.rs Axum router: fixed routes + admin nest + XRPC catch-all + lexicon.rs ParsedLexicon, LexiconRegistry (Arc>) + profile.rs DID document resolution, PDS discovery, profile fetching + jetstream.rs Jetstream WebSocket listener, record indexing + backfill.rs Background worker for bulk record ingestion + auth/ + middleware.rs Claims extractor (validates Bearer token via AIP /oauth/userinfo) + admin/ + mod.rs Admin route definitions + auth.rs AdminAuth extractor (Claims + DID lookup + auto-bootstrap) + admins.rs Admin CRUD handlers + lexicons.rs Lexicon CRUD handlers + stats.rs Record count stats + backfill.rs Backfill job creation and status + types.rs Request/response structs for admin endpoints + repo/ + mod.rs Re-exports + dpop.rs DPoP JWT proof generation (ES256/P-256) + pds.rs PDS proxy helpers (JSON POST, blob POST, response forwarding) + session.rs ATP session fetching from AIP + upload_blob.rs Blob upload handler + media.rs Media blob URL enrichment + at_uri.rs AT URI parsing + xrpc/ + mod.rs Re-exports + query.rs Dynamic GET handler (single record + list with pagination) + procedure.rs Dynamic POST handler (create vs put auto-detection) +``` + +## Request flow + +### Reads (queries) + +``` +Client GET /xrpc/{method}?params + -> xrpc::xrpc_get() + -> LexiconRegistry lookup (must be Query type) + -> SQL query on records table (collection from target_collection) + -> Media blob URL enrichment + -> JSON response +``` + +### Writes (procedures) + +``` +Client POST /xrpc/{method} + Bearer token + -> Claims extractor validates token via AIP /oauth/userinfo + -> xrpc::xrpc_post() + -> LexiconRegistry lookup (must be Procedure type) + -> Fetch ATP session from AIP /api/atprotocol/session + -> Generate DPoP proof (ES256) + -> Proxy to user's PDS (createRecord or putRecord) + -> Upsert record locally + -> Forward PDS response +``` + +### Admin endpoints + +``` +Client request + Bearer token + -> AdminAuth extractor: + 1. Claims validation via AIP + 2. DID lookup in admins table (auto-bootstrap if empty) + 3. 403 if not admin + -> Admin handler + -> JSON response +``` + +## Data flow + +### Real-time indexing + +``` +Jetstream WebSocket + -> Filter by wantedCollections (from record-type lexicons) + -> commit events: + create/update -> UPSERT into records table + delete -> DELETE from records table + -> Cursor tracked in AtomicI64 (rewinds 5s on reconnect) +``` + +### Backfill + +``` +backfill_jobs table (status = pending) + -> Worker picks up job + -> Relay listReposByCollection -> list of DIDs + -> For each DID (8 concurrent): + PLC directory -> PDS endpoint + PDS listRecords -> UPSERT into records table + -> Update job status and counters +``` + +## Database schema + +### `records` + +| Column | Type | Description | +|--------|------|-------------| +| `uri` | text (PK) | AT URI (`at://did/collection/rkey`) | +| `did` | text | Author DID | +| `collection` | text | Lexicon NSID | +| `rkey` | text | Record key | +| `record` | jsonb | Record value | +| `cid` | text | Content identifier | +| `indexed_at` | timestamptz | When HappyView indexed this record | + +### `lexicons` + +| Column | Type | Description | +|--------|------|-------------| +| `id` | text (PK) | Lexicon NSID | +| `revision` | integer | Incremented on upsert | +| `lexicon_json` | jsonb | Raw lexicon definition | +| `lexicon_type` | text | record, query, procedure, definitions | +| `backfill` | boolean | Whether to backfill on upload | +| `target_collection` | text | For queries/procedures: which record collection | +| `created_at` | timestamptz | | +| `updated_at` | timestamptz | | + +### `admins` + +| Column | Type | Description | +|--------|------|-------------| +| `id` | uuid (PK) | | +| `did` | text (unique) | Admin's ATProto DID | +| `created_at` | timestamptz | | +| `last_used_at` | timestamptz | Updated on each authenticated request | + +### `backfill_jobs` + +| Column | Type | Description | +|--------|------|-------------| +| `id` | uuid (PK) | | +| `collection` | text | Target collection (null = all) | +| `did` | text | Target DID (null = all) | +| `status` | text | pending, running, completed, failed | +| `total_repos` | integer | | +| `processed_repos` | integer | | +| `total_records` | integer | | +| `error` | text | Error message if failed | +| `started_at` | timestamptz | | +| `completed_at` | timestamptz | | +| `created_at` | timestamptz | | + +## Testing + +```sh +# Unit tests (no database needed) +cargo test --lib + +# All tests including e2e (requires Postgres) +docker compose -f docker-compose.test.yml up -d +TEST_DATABASE_URL=postgres://happyview:happyview@localhost:5433/happyview_test cargo test +docker compose -f docker-compose.test.yml down +``` + +E2e tests use `wiremock` to mock external services (AIP, PLC directory, PDSes) and a real Postgres database for full integration coverage. diff --git a/docs/backfill.md b/docs/backfill.md new file mode 100644 index 0000000..4a28b5a --- /dev/null +++ b/docs/backfill.md @@ -0,0 +1,78 @@ +# Backfill + +Backfill bulk-indexes existing records from the ATProto network into HappyView's database. It discovers repos via the relay and fetches records directly from each user's PDS. + +## When backfill runs + +- **Automatically** when a record-type lexicon is uploaded with `backfill: true` (the default) +- **Manually** via `POST /admin/backfill` + +## How it works + +1. **Determine target collections**: uses the specified collection, or all record lexicons with `backfill: true` +2. **Discover DIDs**: calls the relay's `com.atproto.sync.listReposByCollection` to find repos that contain records for each collection (paginated, 1000 per page) +3. **Fetch records**: for each DID, resolves the PDS endpoint via PLC directory, then calls `com.atproto.repo.listRecords` on the PDS (paginated, 100 per page) +4. **Upsert**: inserts or updates records in Postgres + +## Creating a backfill job + +### Backfill all collections + +```sh +curl -X POST http://localhost:3000/admin/backfill \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +### Backfill a specific collection + +```sh +curl -X POST http://localhost:3000/admin/backfill \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "collection": "games.gamesgamesgamesgames.game" }' +``` + +### Backfill a specific DID + +```sh +curl -X POST http://localhost:3000/admin/backfill \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "collection": "games.gamesgamesgamesgames.game", + "did": "did:plc:abc123" + }' +``` + +## Monitoring progress + +```sh +curl http://localhost:3000/admin/backfill/status \ + -H "Authorization: Bearer $TOKEN" +``` + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "collection": "games.gamesgamesgamesgames.game", + "did": null, + "status": "running", + "total_repos": 42, + "processed_repos": 15, + "total_records": 350, + "error": null, + "started_at": "2025-01-01T00:01:00Z", + "completed_at": null, + "created_at": "2025-01-01T00:00:00Z" + } +] +``` + +Job statuses: `pending` -> `running` -> `completed` or `failed`. + +## Concurrency + +The backfill worker processes one job at a time, polling for pending jobs every 5 seconds. Within a job, up to 8 PDSes are fetched concurrently. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..08d31e1 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,31 @@ +# Configuration + +HappyView is configured via environment variables. A `.env` file in the project root is loaded automatically on startup. + +## Environment variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `DATABASE_URL` | yes | --- | Postgres connection string | +| `AIP_URL` | yes | --- | AIP instance URL for OAuth token validation | +| `HOST` | no | `0.0.0.0` | Bind host | +| `PORT` | no | `3000` | Bind port | +| `JETSTREAM_URL` | no | `wss://jetstream2.us-west.bsky.network/subscribe` | Jetstream WebSocket URL | +| `RELAY_URL` | no | `https://bsky.network` | Relay URL for backfill repo discovery | +| `PLC_URL` | no | `https://plc.directory` | PLC directory URL for DID resolution | +| `RUST_LOG` | no | `happyview=debug,tower_http=debug` | Log filter (uses `tracing_subscriber::EnvFilter`) | + +## Example `.env` + +```sh +DATABASE_URL=postgres://happyview:happyview@localhost/happyview +AIP_URL=http://localhost:8080 + +# Optional overrides +# HOST=0.0.0.0 +# PORT=3000 +# JETSTREAM_URL=wss://jetstream2.us-west.bsky.network/subscribe +# RELAY_URL=https://bsky.network +# PLC_URL=https://plc.directory +# RUST_LOG=happyview=debug,tower_http=debug +``` diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..24fb399 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,61 @@ +# Deployment + +## Docker + +Build the image: + +```sh +docker build -t happyview . +``` + +### Local development with Docker Compose + +```sh +docker compose up +``` + +This starts Postgres, AIP, and HappyView together. See `docker-compose.yml` for the full configuration. + +### Production Compose example + +```yaml +services: + postgres: + image: postgres:17 + environment: + POSTGRES_USER: happyview + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + POSTGRES_DB: happyview + volumes: + - pgdata:/var/lib/postgresql/data + + happyview: + image: happyview:latest + ports: + - "3000:3000" + environment: + DATABASE_URL: "postgres://happyview:${POSTGRES_PASSWORD}@postgres/happyview" + AIP_URL: "https://aip.example.com" + depends_on: + postgres: + condition: service_healthy + +volumes: + pgdata: +``` + +## Railway / Fly.io / generic + +1. Provision a Postgres database +2. Set `DATABASE_URL` and `AIP_URL` environment variables +3. Deploy the Docker image or build from source +4. HappyView listens on `PORT` (default `3000`) +5. Health check: `GET /health` returns `ok` + +## Database + +Migrations run automatically on startup via `sqlx::migrate!()`. No manual migration step is needed. + +## TLS + +HappyView does not terminate TLS. Put it behind a reverse proxy (nginx, Caddy, Cloudflare Tunnel, etc.) for HTTPS. diff --git a/docs/lexicons.md b/docs/lexicons.md new file mode 100644 index 0000000..81f74ba --- /dev/null +++ b/docs/lexicons.md @@ -0,0 +1,115 @@ +# Lexicons + +Lexicons are ATProto schema definitions that tell HappyView which records to index and what XRPC endpoints to serve. HappyView supports uploading lexicons at runtime via the admin API. + +## Supported lexicon types + +| Type | Effect | +|------|--------| +| `record` | Subscribes to Jetstream for that collection and indexes records into Postgres | +| `query` | Registers a `GET /xrpc/{nsid}` endpoint that queries indexed records | +| `procedure` | Registers a `POST /xrpc/{nsid}` endpoint that proxies writes to the user's PDS | +| `definitions` | Stored but does not generate routes or subscriptions | + +## Uploading a lexicon + +```sh +curl -X POST http://localhost:3000/admin/lexicons \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lexicon_json": { + "lexicon": 1, + "id": "games.gamesgamesgamesgames.game", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "properties": { + "title": { "type": "string" } + } + } + } + } + }, + "backfill": true + }' +``` + +Re-uploading the same lexicon ID increments its revision number. + +## The `target_collection` field + +Query and procedure lexicons need to know which record collection they operate on. Set `target_collection` to the NSID of the record lexicon: + +```sh +curl -X POST http://localhost:3000/admin/lexicons \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lexicon_json": { + "lexicon": 1, + "id": "games.gamesgamesgamesgames.listGames", + "defs": { + "main": { + "type": "query", + "parameters": { + "type": "params", + "properties": { + "limit": { "type": "integer" } + } + }, + "output": { "encoding": "application/json" } + } + } + }, + "target_collection": "games.gamesgamesgamesgames.game" + }' +``` + +Without `target_collection`, queries and procedures won't know which DB records to read from. + +## The `backfill` flag + +When `backfill` is `true` (the default), uploading a record-type lexicon triggers a backfill job that discovers existing repos via the relay and fetches historical records from their PDSes. + +Set `backfill: false` if you only want to index new records going forward. + +## Jetstream collection filters + +When record-type lexicons change (uploaded or deleted), HappyView automatically reconnects to Jetstream with an updated collection filter. If no record lexicons exist, the Jetstream listener idles without connecting. + +## Example: full setup + +Upload the record lexicon, then a query and a procedure that target it: + +```sh +# 1. Record lexicon (triggers Jetstream subscription + backfill) +curl -X POST http://localhost:3000/admin/lexicons \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lexicon_json": { "lexicon": 1, "id": "games.gamesgamesgamesgames.game", "defs": { "main": { "type": "record", "key": "tid", "record": { "type": "object", "properties": { "title": { "type": "string" } } } } } }, + "backfill": true + }' + +# 2. Query lexicon +curl -X POST http://localhost:3000/admin/lexicons \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lexicon_json": { "lexicon": 1, "id": "games.gamesgamesgamesgames.listGames", "defs": { "main": { "type": "query", "output": { "encoding": "application/json" } } } }, + "target_collection": "games.gamesgamesgamesgames.game" + }' + +# 3. Procedure lexicon +curl -X POST http://localhost:3000/admin/lexicons \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lexicon_json": { "lexicon": 1, "id": "games.gamesgamesgamesgames.createGame", "defs": { "main": { "type": "procedure", "input": { "encoding": "application/json" }, "output": { "encoding": "application/json" } } } }, + "target_collection": "games.gamesgamesgamesgames.game" + }' +``` diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..e4579d6 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,72 @@ +# Quickstart + +## Prerequisites + +- Rust (stable) +- PostgreSQL 17+ +- A running [AIP](https://github.com/graze-social/aip) instance + +## 1. Clone and configure + +```sh +git clone https://github.com/graze-social/happyview.git +cd happyview +cp .env.example .env +``` + +Edit `.env`: + +```sh +DATABASE_URL=postgres://happyview:happyview@localhost/happyview +AIP_URL=http://localhost:8080 +``` + +See [Configuration](configuration.md) for all available variables. + +## 2. Start Postgres and run migrations + +```sh +docker compose up -d postgres +cargo run +``` + +Migrations run automatically on startup. + +## 3. Upload a lexicon + +The first authenticated request to an admin endpoint auto-creates you as the initial admin. Authenticate with an AIP-issued Bearer token: + +```sh +curl -X POST http://localhost:3000/admin/lexicons \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "lexicon_json": { + "lexicon": 1, + "id": "games.gamesgamesgamesgames.game", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "properties": { + "title": { "type": "string" } + } + } + } + } + }, + "backfill": true + }' +``` + +HappyView now subscribes to `games.gamesgamesgamesgames.game` on Jetstream and starts indexing records. + +## 4. Query records + +```sh +curl http://localhost:3000/xrpc/games.gamesgamesgamesgames.listGames?limit=10 +``` + +See [XRPC API](xrpc-api.md) for query and procedure details. diff --git a/docs/xrpc-api.md b/docs/xrpc-api.md new file mode 100644 index 0000000..01cc229 --- /dev/null +++ b/docs/xrpc-api.md @@ -0,0 +1,171 @@ +# XRPC API + +HappyView dynamically registers XRPC endpoints based on uploaded lexicons. Query lexicons become `GET /xrpc/{nsid}` routes, procedure lexicons become `POST /xrpc/{nsid}` routes. + +## Auth + +- **Queries** (`GET /xrpc/{method}`): unauthenticated +- **Procedures** (`POST /xrpc/{method}`): require an AIP-issued `Authorization: Bearer ` header +- **getProfile**: requires auth +- **uploadBlob**: requires auth + +--- + +## Fixed endpoints + +### Health check + +``` +GET /health +``` + +```sh +curl http://localhost:3000/health +``` + +**Response**: `200 OK` with body `ok` + +### Get profile + +``` +GET /xrpc/app.bsky.actor.getProfile +``` + +Returns the authenticated user's profile, resolved from their PDS via PLC directory lookup. + +```sh +curl http://localhost:3000/xrpc/app.bsky.actor.getProfile \ + -H "Authorization: Bearer $TOKEN" +``` + +**Response**: `200 OK` + +```json +{ + "did": "did:plc:abc123", + "handle": "user.bsky.social", + "displayName": "User Name", + "description": "Bio text", + "avatarURL": "https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did:plc:abc123&cid=bafyabc" +} +``` + +### Upload blob + +``` +POST /xrpc/com.atproto.repo.uploadBlob +``` + +Proxies a blob upload to the authenticated user's PDS. Maximum size: 50MB. + +```sh +curl -X POST http://localhost:3000/xrpc/com.atproto.repo.uploadBlob \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: image/png" \ + --data-binary @image.png +``` + +**Response**: proxied from the user's PDS. + +--- + +## Dynamic query endpoints + +Query endpoints are generated from lexicons with `type: "query"`. They support two modes depending on whether a `uri` parameter is provided. + +### Single record + +``` +GET /xrpc/{method}?uri={at-uri} +``` + +```sh +curl "http://localhost:3000/xrpc/games.gamesgamesgamesgames.listGames?uri=at%3A%2F%2Fdid%3Aplc%3Aabc%2Fgames.gamesgamesgamesgames.game%2Fabc123" +``` + +**Response**: `200 OK` + +```json +{ + "record": { + "uri": "at://did:plc:abc/games.gamesgamesgamesgames.game/abc123", + "$type": "games.gamesgamesgamesgames.game", + "title": "My Game" + } +} +``` + +Media blobs are automatically enriched with a `url` field pointing to the user's PDS. + +### List records + +``` +GET /xrpc/{method}?limit=20&cursor=0&did=optional +``` + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `limit` | integer | 20 | Max records to return (max 100) | +| `cursor` | string | `0` | Pagination cursor (opaque, pass from previous response) | +| `did` | string | --- | Filter records by DID | + +```sh +curl "http://localhost:3000/xrpc/games.gamesgamesgamesgames.listGames?limit=10&did=did:plc:abc" +``` + +**Response**: `200 OK` + +```json +{ + "records": [ + { + "uri": "at://did:plc:abc/games.gamesgamesgamesgames.game/abc123", + "title": "My Game" + } + ], + "cursor": "10" +} +``` + +The `cursor` field is present only when more records exist. + +--- + +## Dynamic procedure endpoints + +Procedure endpoints are generated from lexicons with `type: "procedure"`. HappyView auto-detects create vs update based on whether the request body contains a `uri` field. + +### Create a record + +``` +POST /xrpc/{method} +``` + +When the body does **not** contain a `uri` field, a new record is created. + +```sh +curl -X POST http://localhost:3000/xrpc/games.gamesgamesgamesgames.createGame \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "title": "My Game" }' +``` + +HappyView proxies this to the user's PDS as `com.atproto.repo.createRecord`, then indexes the created record locally. + +### Update a record + +When the body **contains** a `uri` field, the existing record is updated. + +```sh +curl -X POST http://localhost:3000/xrpc/games.gamesgamesgamesgames.createGame \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "uri": "at://did:plc:abc/games.gamesgamesgamesgames.game/abc123", + "title": "Updated Title" + }' +``` + +HappyView proxies this to the user's PDS as `com.atproto.repo.putRecord`, then upserts the record locally. + +**Response** for both: proxied from the user's PDS. -- 2.54.0