···2233HappyView is the best way to build an [AppView](https://atproto.com/guides/glossary#app-view) for the [AT Protocol](https://atproto.com). Upload your [lexicon](reference/glossary.md#at-protocol-terms) schemas and get a fully functional AppView, complete with [XRPC](reference/glossary.md#at-protocol-terms) endpoints, OAuth, real-time network sync, and historical [backfill](guides/backfill.md), without writing a single line of server code.
4455-Building an AppView from scratch means wiring up firehose connections, record storage, XRPC routing, OAuth flows, and PDS write proxying before you can even think about your application. HappyView handles all of that. Define your data model with lexicons, add custom logic with Lua scripts when you need it, and ship your app.
55+Building an AppView from scratch means wiring up real-time event streams, record storage, XRPC routing, OAuth flows, and PDS write proxying before you can even think about your application. HappyView handles all of that. Define your data model with lexicons, add custom logic with Lua scripts when you need it, and ship your app.
6677## Features
88···16161717- **Schema-first**: Your Lexicons are the source of truth. Upload a schema and HappyView derives endpoints, indexing rules, and network sync from it. You describe _what_ your data looks like; HappyView figures out the rest.
18181919-- **Zero boilerplate**: HappyView handles AppView infrastructure (firehose, backfill, OAuth, PDS proxying) for you. You should be writing application logic from minute one, not plumbing.
1919+- **Zero boilerplate**: HappyView handles AppView infrastructure (Jetstream, backfill, OAuth, PDS proxying) for you. You should be writing application logic from minute one, not plumbing.
20202121- **Runtime-configurable**: Lexicons can be added, updated, and removed without restarting the server. New endpoints and sync rules take effect immediately, so you can iterate on your data model in real time.
2222
···33HappyView has two distinct authentication surfaces:
4455- **XRPC** (`/xrpc/*`) — client-level identification via an **API client key** on every request, plus optional user-level AT Protocol OAuth for endpoints that need a specific user's identity (e.g. procedures that write to a PDS).
66-- **Admin API** (`/admin/*`) — user-level authentication via session cookies, admin API keys, or service auth JWTs, gated by [permissions](../guides/permissions.md).
66+- **Admin API** (`/admin/*`) — user-level authentication via admin API keys or service auth JWTs, gated by [permissions](../guides/permissions.md).
7788## Which endpoints require what?
991010-| Endpoint type | Client identification | User authentication |
1111-| ----------------------------------- | ------------------------ | ------------------------------------------------------------------------------------ |
1212-| Queries (`GET /xrpc/{method}`) | `X-Client-Key` required | Optional — DPoP auth if the query needs to know who the user is |
1313-| Procedures (`POST /xrpc/{method}`) | `X-Client-Key` required | Required — DPoP auth so HappyView can proxy writes to the user's PDS |
1414-| Admin API (`/admin/*`) | — | Required — session cookie, admin API key, or service auth JWT with the right [permissions](../guides/permissions.md) |
1515-| Health check (`GET /health`) | — | — |
1010+| Endpoint type | Client identification | User authentication |
1111+| ---------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------ |
1212+| Queries (`GET /xrpc/{method}`) | `X-Client-Key` required | Optional — DPoP auth if the query needs to know who the user is |
1313+| Procedures (`POST /xrpc/{method}`) | `X-Client-Key` required | Required — DPoP auth so HappyView can proxy writes to the user's PDS |
1414+| Admin API (`/admin/*`) | — | Required — admin API key or service auth JWT with the right [permissions](../guides/permissions.md) |
1515+| Health check (`GET /health`) | — | — |
16161717## XRPC: API client identification
1818···22222323HappyView resolves the client key from the first of:
24242525-1. The session cookie, if the user logged in through this client's OAuth flow (the cookie carries the `client_key` that minted it).
2626-2. The `X-Client-Key` request header.
2727-3. A `client_key` query-string parameter.
2525+1. The `X-Client-Key` request header.
2626+2. A `client_key` query-string parameter.
28272928On top of the client key, HappyView does best-effort validation that the caller actually controls the client:
3029···52515352Queries that don't care who is calling need nothing more than the client key. Procedures — and queries whose Lua scripts read the caller's DID — need a real AT Protocol OAuth session.
54535555-XRPC routes only accept **DPoP auth** (`Authorization: DPoP <token>` + `DPoP` proof header + `X-Client-Key`). Bearer tokens, service auth JWTs, and session cookies are not accepted on XRPC endpoints.
5454+XRPC routes only accept **DPoP auth** (`Authorization: DPoP <token>` + `DPoP` proof header + `X-Client-Key`). Bearer tokens and service auth JWTs are not accepted on XRPC endpoints.
56555756Third-party apps authenticate users through the [DPoP key provisioning](#dpop-key-provisioning-for-third-party-apps) flow: your app gets a DPoP keypair from HappyView, runs a standard OAuth flow with the user's PDS using that keypair, then registers the resulting tokens back with HappyView.
5857···86858786For procedures, HappyView proxies the write to the user's PDS using the stored OAuth session (see [Proxying procedures](#proxying-procedures-to-the-users-pds) below).
88878989-:::note
9090-The HappyView dashboard uses a separate cookie-based OAuth flow where HappyView itself acts as the OAuth server. This is only for the dashboard — third-party apps always use DPoP key provisioning.
9191-:::
9292-9388## Admin API: user authentication
94899595-Admin endpoints don't use API clients. They require a real HappyView user, identified by one of three methods:
9696-9797-### Session cookie (dashboard)
9898-9999-When you log in to the dashboard via AT Protocol OAuth, HappyView sets a signed, HttpOnly session cookie containing your DID. That cookie is honored on admin endpoints as long as the DID is a HappyView user with the required permission for the call.
9090+Admin endpoints don't use API clients. They require a real HappyView user, identified by one of two methods:
1009110192### Admin API key
10293···125116126117### Admin access and the first user
127118128128-On a fresh deployment, the `users` table is empty. The first authenticated request to any admin endpoint auto-bootstraps that user as the **super user** with all permissions granted — so the first handle to log in owns the instance.
119119+On a fresh deployment, the `users` table is empty. The first authenticated request to any admin endpoint auto-bootstraps that user as the **super user** with all permissions granted. This includes logging in to the dashboard — the dashboard makes admin API calls on your behalf, so the first person to log in becomes the super user.
129120130130-To add more users after that, use `POST /admin/users` or the [dashboard](dashboard.md). You can assign permissions individually or use a template (`viewer`, `operator`, `manager`, `full_access`). See [Admin API](../reference/admin-api.md#user-management) for details.
121121+To add more users after that, use `POST /admin/users` or the [dashboard](dashboard.md). You can assign permissions individually or use a template (`viewer`, `operator`, `manager`, `full_access`). See [Admin API — Users](../reference/admin/users.md) for details.
131122132123## Proxying procedures to the user's PDS
133124134134-When a client calls an XRPC procedure that writes a record, HappyView proxies the write to the user's PDS. There are two auth paths that support this:
125125+When a client calls an XRPC procedure that writes a record, HappyView proxies the write to the user's PDS. This requires a DPoP-authenticated session — the app must have gone through the [DPoP key provisioning](#dpop-key-provisioning-for-third-party-apps) flow and registered tokens for the user. HappyView uses the app's provisioned DPoP key to generate fresh proofs and attach the stored access token to the outbound PDS request.
135126136136-- **Cookie auth (dashboard)** — `atrium-oauth` attaches a DPoP proof and a DPoP-bound access token to the outbound request automatically.
137137-- **DPoP key provisioning (third-party apps)** — HappyView uses the app's provisioned DPoP key to generate fresh proofs and attach the stored access token (see below).
138138-139139-A request that only carries an `X-Client-Key` header (no session cookie or DPoP token) can hit queries but can't proxy writes — there's no user to write as. Service auth JWTs and admin API keys similarly don't carry a user session.
127127+A request that only carries an `X-Client-Key` header (no DPoP token) can hit queries but can't proxy writes — there's no user to write as.
140128141129## DPoP key provisioning for third-party apps
142130143143-Third-party apps that want HappyView to make PDS writes on behalf of their users use the **DPoP key provisioning** flow instead of cookie auth. This avoids browser-based redirects through HappyView's domain, which can be blocked by Firefox's Bounce Tracker Protection.
131131+Third-party apps that want HappyView to make PDS writes on behalf of their users use the **DPoP key provisioning** flow. This avoids browser-based redirects through HappyView's domain, which can be blocked by Firefox's Bounce Tracker Protection.
144132145133The idea: the app gets a DPoP keypair from HappyView, uses that keypair during its own OAuth flow with the user's PDS, then registers the resulting tokens back with HappyView. From that point on, XRPC requests authenticated with `Authorization: DPoP <access_token>` plus a `DPoP` proof header and `X-Client-Key` will have HappyView proxy writes using the stored session.
146134···184172```json
185173{
186174 "provision_id": "hvp_...",
187187- "dpop_key": { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "d": "..." }
175175+ "dpop_key": {
176176+ "kty": "EC",
177177+ "crv": "P-256",
178178+ "x": "...",
179179+ "y": "...",
180180+ "d": "..."
181181+ }
188182}
189183```
190184···223217 "provision_id": "hvp_...",
224218 "pkce_verifier": "...",
225219 "did": "did:plc:user123",
226226- ...
220220+ "access_token": "...",
221221+ "refresh_token": "...",
222222+ "expires_at": "2026-04-17T00:00:00Z",
223223+ "scopes": "atproto transition:generic",
224224+ "pds_url": "https://bsky.social",
225225+ "issuer": "https://bsky.social"
227226}
228227```
229228···283282- [JavaScript SDK](../sdk/overview.md) — authenticate and make XRPC calls from JavaScript
284283- [Permissions](../guides/permissions.md) — full list of permissions and what each one grants
285284- [API Keys](../guides/api-keys.md) — create scoped admin API keys for automation
286286-- [Admin API — API Clients](../reference/admin-api.md#api-clients) — register API clients and configure rate limits
285285+- [Admin API — API Clients](../reference/admin/api-clients.md) — register API clients and configure rate limits
···99| `DATABASE_URL` | yes | --- | Database connection string. SQLite (`sqlite://path/to/db?mode=rwc`) or Postgres (`postgres://user:pass@host/db`) |
1010| `DATABASE_BACKEND` | no | auto-detected | Force `sqlite` or `postgres`. Auto-detected from `DATABASE_URL` scheme if not set |
1111| `PUBLIC_URL` | yes | --- | Public-facing URL for HappyView (used for OAuth callbacks, e.g. `https://happyview.example.com`) |
1212-| `SESSION_SECRET` | no | dev default | Secret key for signing session cookies. **Must be set in production** |
1212+| `SESSION_SECRET` | no | dev default | Secret key for signing session cookies (at least 64 characters). **Must be set in production** |
1313| `HOST` | no | `0.0.0.0` | Bind host |
1414| `PORT` | no | `3000` | Bind port |
1515| `JETSTREAM_URL` | no | `wss://jetstream1.us-east.bsky.network` | Jetstream WebSocket URL for real-time record streaming |
+61-4
packages/docs/docs/getting-started/dashboard.md
···11# Dashboard
2233-HappyView ships with a web dashboard that provides a visual interface for everything the [admin API](../reference/admin-api.md) offers: managing lexicons, viewing indexed records, and monitoring backfill jobs. It runs as a separate Next.js application alongside the Rust backend and authenticates via AT Protocol OAuth.
33+HappyView ships with a web dashboard that provides a visual interface for everything the [admin API](../reference/admin-api.md) offers. It runs as a separate Next.js application alongside the Rust backend and authenticates via AT Protocol OAuth.
4455-On a fresh deployment with no users in the database, the first handle to log in is automatically bootstrapped as the super user with all permissions — so log in with the handle you want to own the instance first.
55+On a fresh deployment with no users in the database, the first person to log in to the dashboard is automatically bootstrapped as the super user with all permissions — so log in with the handle you want to own the instance first.
6677-## Adding a lexicon
77+## Lexicons
8899-Navigate to **Lexicons > Add Lexicon** and choose **Local** or **Network**.
99+Navigate to **Lexicons** to see all uploaded lexicons. Each entry shows the NSID, type (record, query, procedure), and whether a Lua script is attached.
1010+1111+### Adding a lexicon
1212+1313+Click **Add Lexicon** and choose **Local** or **Network**.
10141115**Local** lexicons are defined by you. The editor shows two side-by-side panels (stacked on mobile):
1216···35393640See [Lua Scripting](../guides/scripting.md) for the full runtime reference and examples.
37414242+## Records
4343+4444+Navigate to **Records** to browse all indexed AT Protocol records. Records are grouped by collection and searchable. Each record shows its AT URI, author DID, and the raw record JSON.
4545+4646+## Backfill
4747+4848+Navigate to **Backfill** to view and manage backfill jobs. You can start a new backfill for any record-type lexicon to import historical records from the network. The page shows job status, progress (repos processed / total), and record counts. See [Backfill](../guides/backfill.md) for how the process works.
4949+5050+## Users
5151+5252+Navigate to **Users** to manage who can access the admin API and dashboard. You can add users by DID, assign permissions individually or via a template (`viewer`, `operator`, `manager`, `full_access`), and remove users. The super user is highlighted and has all permissions by default. See [Permissions](../guides/permissions.md) for what each permission grants.
5353+5454+## Events
5555+5656+Navigate to **Events** to view the audit log of admin actions. Events include user creation, lexicon uploads, permission changes, backfill starts, and more. Each entry shows the event type, severity, actor, subject, and timestamp. Events are retained for the number of days configured by `EVENT_LOG_RETENTION_DAYS` (default 30).
5757+5858+## Settings
5959+6060+The **Settings** section contains several sub-pages:
6161+6262+### General
6363+6464+Configure instance-level settings: application name, logo, terms of service URL, and privacy policy URL. These values appear on OAuth authorization screens and can also be set via environment variables — dashboard values take precedence.
6565+6666+### API Clients
6767+6868+Register and manage third-party API clients. Each client gets an `hvc_…` client key and `hvs_…` client secret. You can configure the client type (confidential or public), allowed origins, scopes, and per-client rate limits. See [Authentication — API client identification](authentication.md#xrpc-api-client-identification) for how clients are used.
6969+7070+### API Keys
7171+7272+Create and revoke admin API keys for automation. Each key is scoped to specific permissions and tied to the creating user. See [API Keys](../guides/api-keys.md) for details.
7373+7474+### Users
7575+7676+An alternative path to the top-level Users page for managing user accounts and permissions.
7777+7878+### Plugins
7979+8080+Manage installed plugins and configure plugin secrets. Plugins extend HappyView with additional functionality. Plugin secrets are encrypted at rest when `TOKEN_ENCRYPTION_KEY` is configured. See [Plugins](../guides/plugins.md) for details.
8181+8282+### Labelers
8383+8484+Configure labeler subscriptions for content labeling. See [Labelers](../guides/labelers.md) for details.
8585+8686+### Environment Variables
8787+8888+View the current values of all environment variables that affect HappyView's behavior. This is a read-only view — values are set via your deployment environment, not the dashboard.
8989+9090+### Accounts
9191+9292+Manage connected AT Protocol accounts used by the instance.
9393+3894## Next steps
39954096- [Lexicons](../guides/lexicons.md) — how lexicons drive HappyView's indexing and routing
4197- [Lua Scripting](../guides/scripting.md) — write custom query and procedure logic
4298- [Permissions](../guides/permissions.md) — manage user access to admin features
9999+- [Configuration](configuration.md) — full list of environment variables
···1414cp .env.example .env
1515```
16161717-Edit `.env` and set at least `PUBLIC_URL` (e.g. `http://localhost:3000`) and `SESSION_SECRET`. The defaults work for everything else. See [Configuration](../configuration.md) for the full list of environment variables.
1717+Edit `.env` and set at least `PUBLIC_URL` (e.g. `http://localhost:3000`) and `SESSION_SECRET` (at least 64 characters). The defaults work for everything else. See [Configuration](../configuration.md) for the full list of environment variables.
18181919## 2. Start the stack
2020
···8899After deploying the template, you'll need to configure a few things before the stack works properly:
10101111-1. **Set your session secret.** In the HappyView service variables, set `SESSION_SECRET` to a strong random value. This is used to sign session cookies.
1111+1. **Set your session secret.** In the HappyView service variables, set `SESSION_SECRET` to a random string of at least 64 characters. This is used to sign session cookies.
1212+ ```sh
1313+ openssl rand -base64 48
1414+ ```
121513162. **Assign a public domain.** In the Railway dashboard, add a public domain to the HappyView service. The service needs a publicly accessible URL for OAuth callbacks. Set `PUBLIC_URL` to this domain (e.g. `https://happyview-production.up.railway.app`).
1417 :::note
+3-3
packages/docs/docs/guides/backfill.md
···77- **Automatically** when a record-type lexicon is uploaded with `backfill: true` (the default). See [Lexicons - Backfill flag](lexicons.md#backfill-flag).
88- **Manually** via `POST /admin/backfill` or the [dashboard](../getting-started/dashboard.md). You can scope a manual backfill to a specific collection, a specific DID, or both.
991010-See the [admin API](../reference/admin-api.md#backfill) for endpoint details.
1010+See the [admin API](../reference/admin/backfill.md) for endpoint details.
11111212## How it works
1313···19192020## Job lifecycle
21212222-A backfill job moves through `pending → running → completed` (or `failed`). Unlike earlier versions of HappyView, the job is only marked `completed` once every discovered repo has been processed end-to-end — there is no separate downstream queue. Progress is visible in real time on the dashboard's Backfill page.
2222+A backfill job moves through `pending → running → completed` (or `failed`). Unlike earlier versions of HappyView that relied on Tap, the job is only marked `completed` once every discovered repo has been processed end-to-end — there is no separate downstream queue. Progress is visible in real time on the dashboard's Backfill page.
23232424If a job fails midway, the `error` field contains the failure reason. Re-running the backfill resumes from scratch but is idempotent (records are upserted by URI).
2525···3434## Next steps
35353636- [Lexicons](lexicons.md#backfill-flag): Control whether lexicons trigger backfill on upload
3737-- [Admin API](../reference/admin-api.md#backfill): Full reference for backfill endpoints
3737+- [Admin API — Backfill](../reference/admin/backfill.md): Full reference for backfill endpoints
+1
packages/docs/docs/guides/database-setup.md
···72727373## Next steps
74747575+- [SQLite → Postgres migration](sqlite-to-postgres-migration.md) — switch an existing instance from SQLite to Postgres
7576- [Postgres → SQLite migration](postgres-to-sqlite-migration.md) — switch an existing instance from Postgres to SQLite
7677- [Lua scripting](scripting.md) — write queries that target either backend
7778- [Configuration](../getting-started/configuration.md) — `DATABASE_URL` and related variables
+105
packages/docs/docs/guides/developing-plugins.md
···11+# Developing Plugins
22+33+This guide covers how to build your own HappyView WASM plugins. For installing and configuring plugins, see the [Plugins guide](plugins.md).
44+55+See the [happyview-plugins](https://github.com/gamesgamesgamesgames/happyview-plugins) repository for examples and the plugin SDK.
66+77+## Plugin Manifest
88+99+Each plugin has a `manifest.json` that describes its metadata:
1010+1111+```json
1212+{
1313+ "id": "steam",
1414+ "name": "Steam",
1515+ "version": "1.0.0",
1616+ "api_version": "1",
1717+ "description": "Import your Steam game library and playtime data.",
1818+ "icon_url": "https://example.com/steam-icon.png",
1919+ "auth_type": "openid",
2020+ "wasm_file": "steam.wasm",
2121+ "required_secrets": [
2222+ {
2323+ "key": "PLUGIN_STEAM_API_KEY",
2424+ "name": "Steam Web API Key",
2525+ "description": "Get your API key at steamcommunity.com/dev/apikey"
2626+ }
2727+ ]
2828+}
2929+```
3030+3131+| Field | Description |
3232+| ------------------ | ----------------------------------------------------- |
3333+| `id` | Unique plugin identifier |
3434+| `name` | Display name |
3535+| `version` | Semantic version |
3636+| `api_version` | Plugin API version (currently "1") |
3737+| `description` | Brief description shown during install |
3838+| `icon_url` | Optional icon URL |
3939+| `auth_type` | Authentication type: `oauth2`, `openid`, or `api_key` |
4040+| `wasm_file` | WASM binary filename (default: `plugin.wasm`) |
4141+| `required_secrets` | Array of secrets the plugin needs |
4242+4343+## API Endpoints
4444+4545+### Public Endpoints
4646+4747+| Endpoint | Description |
4848+| --------------------------------------- | ---------------------------------------------- |
4949+| `GET /external-auth/providers` | List available auth providers |
5050+| `GET /external-auth/accounts` | List user's linked accounts |
5151+| `GET /external-auth/{plugin}/authorize` | Start OAuth flow |
5252+| `GET /external-auth/{plugin}/callback` | OAuth callback handler |
5353+| `POST /external-auth/{plugin}/sync` | Sync data from linked account |
5454+| `POST /external-auth/{plugin}/unlink` | Unlink account |
5555+| `POST /external-auth/{plugin}/connect` | Connect with API key (for `api_key` auth type) |
5656+5757+### Admin Endpoints
5858+5959+| Endpoint | Description |
6060+| --------------------------------------- | ------------------------------------------- |
6161+| `GET /admin/plugins` | List installed plugins |
6262+| `POST /admin/plugins` | Install a plugin |
6363+| `POST /admin/plugins/preview` | Preview plugin before installing |
6464+| `GET /admin/plugins/official` | Browse the official plugin registry catalog |
6565+| `DELETE /admin/plugins/{id}` | Remove a plugin |
6666+| `POST /admin/plugins/{id}/reload` | Reload plugin from source |
6767+| `POST /admin/plugins/{id}/check-update` | Check whether a newer version is available |
6868+| `GET /admin/plugins/{id}/secrets` | Get configured secrets (masked) |
6969+| `PUT /admin/plugins/{id}/secrets` | Update plugin secrets |
7070+7171+The dashboard's **Settings > Plugins** page calls `GET /admin/plugins/official` to populate the install browser, and `POST /admin/plugins/{id}/check-update` to display update badges on installed plugins.
7272+7373+## Plugin Exports
7474+7575+Plugins must export these functions:
7676+7777+| Export | Signature | Description |
7878+| ------------------- | ----------------------------- | ---------------------------- |
7979+| `alloc` | `(size: u32) -> u32` | Allocate memory |
8080+| `dealloc` | `(ptr: u32, size: u32)` | Deallocate memory |
8181+| `get_authorize_url` | `(ptr: u32, len: u32) -> i64` | Generate OAuth authorize URL |
8282+| `handle_callback` | `(ptr: u32, len: u32) -> i64` | Handle OAuth callback |
8383+| `refresh_tokens` | `(ptr: u32, len: u32) -> i64` | Refresh expired tokens |
8484+| `get_profile` | `(ptr: u32, len: u32) -> i64` | Get external profile info |
8585+| `sync_account` | `(ptr: u32, len: u32) -> i64` | Sync data and return records |
8686+8787+## Host Functions
8888+8989+Plugins can import these host functions:
9090+9191+| Import | Description |
9292+| ------------------- | ----------------------- |
9393+| `host_http_request` | Make HTTP requests |
9494+| `host_get_secret` | Read configured secrets |
9595+| `host_log` | Write to server logs |
9696+| `host_kv_get` | Read from KV storage |
9797+| `host_kv_set` | Write to KV storage |
9898+| `host_kv_delete` | Delete from KV storage |
9999+100100+## Next steps
101101+102102+- [Official plugins repository](https://github.com/gamesgamesgamesgames/happyview-plugins) — ready-to-use plugins and the plugin SDK
103103+- [Plugins guide](plugins.md) — install and configure plugins
104104+- [API Keys](api-keys.md) — authenticate programmatic access to admin endpoints
105105+- [Permissions](permissions.md) — configure user access to plugin management
+4-4
packages/docs/docs/guides/event-logs.md
···11# Event Logs
2233-HappyView maintains an internal event log that records system activity — lexicon changes, record operations, Lua script executions and errors, user actions, API key events, backfill jobs, and Jetstream connectivity. Events are stored in the database and queryable via the [admin API](../reference/admin-api.md#event-logs).
33+HappyView maintains an internal event log that records system activity — lexicon changes, record operations, Lua script executions and errors, user actions, API key events, backfill jobs, and Jetstream connectivity. Events are stored in the database and queryable via the [admin API](../reference/admin/events.md).
4455## Event types
66···1414| `lexicon.updated` | info | Lexicon NSID | `revision`, `has_script`, `source` |
1515| `lexicon.deleted` | info | Lexicon NSID | — |
16161717-Logged when lexicons are uploaded, updated, or deleted via the [admin API](../reference/admin-api.md#lexicons). The `actor_did` is the user who performed the action.
1717+Logged when lexicons are uploaded, updated, or deleted via the [admin API](../reference/admin/lexicons.md). The `actor_did` is the user who performed the action.
18181919### Record events
2020···118118curl "http://localhost:3000/admin/events?limit=20&cursor=2026-03-01T11:59:00Z" -H "$AUTH"
119119```
120120121121-See the [Admin API reference](../reference/admin-api.md#list-event-logs) for full parameter documentation.
121121+See the [Admin API reference](../reference/admin/events.md#list-event-logs) for full parameter documentation.
122122123123## Retention
124124···130130131131## Next steps
132132133133-- [Admin API — Event Logs](../reference/admin-api.md#event-logs) — full query parameters and response format
133133+- [Admin API — Event Logs](../reference/admin/events.md) — full query parameters and response format
134134- [Permissions](permissions.md) — control which users can read event logs
135135- [Troubleshooting](../reference/troubleshooting.md) — using event logs to diagnose issues
+8-8
packages/docs/docs/guides/index-hooks.md
···2233Index hooks are Lua scripts that run automatically whenever a record in a collection is created, updated, or deleted on the network. They run **before** the record is indexed, giving you the ability to filter out unwanted records, transform record data before storage, or trigger side effects like syncing with external services.
4455-Unlike [query and procedure scripts](scripting.md) that run in response to XRPC requests, index hooks are triggered by the firehose.
55+Unlike [query and procedure scripts](scripting.md) that run in response to XRPC requests, index hooks are triggered by incoming Jetstream events.
6677## Attaching a hook
8899-Each record-type lexicon can have one index hook. You can add it through the [dashboard](../getting-started/dashboard.md) (click "Add Index Hook" on any record lexicon's detail page) or via the [admin API](../reference/admin-api.md#upload--upsert-a-lexicon) by including the `index_hook` field when uploading a lexicon.
99+Each record-type lexicon can have one index hook. You can add it through the [dashboard](../getting-started/dashboard.md) (click "Add Index Hook" on any record lexicon's detail page) or via the [admin API](../reference/admin/lexicons.md#upload--upsert-a-lexicon) by including the `index_hook` field when uploading a lexicon.
10101111## Script structure
1212···5050| `rkey` | string | The record key |
5151| `record` | table? | The full record as a Lua table (nil on delete) |
52525353-Index hooks do **not** have access to `caller_did`, `input`, `params`, `method`, or the `Record` API. They run from the firehose, not from a user request.
5353+Index hooks do **not** have access to `caller_did`, `input`, `params`, `method`, or the `Record` API. They run from the Jetstream event stream, not from a user request.
54545555## Available APIs
56565757Index hooks have access to:
58585959-- **[Database API](scripting.md#database-api)** — `db.query`, `db.get`, `db.search`, `db.backlinks`, `db.count`, `db.raw`
6060-- **[HTTP API](scripting.md#http-api)** — `http.get`, `http.post`, `http.put`, `http.patch`, `http.delete`, `http.head`
6161-- **[JSON API](scripting.md#json-api)** — `json.encode`, `json.decode`
5959+- **[Database API](../reference/lua/database-api.md)** — `db.query`, `db.get`, `db.search`, `db.backlinks`, `db.count`, `db.raw`
6060+- **[HTTP API](../reference/lua/http-api.md)** — `http.get`, `http.post`, `http.put`, `http.patch`, `http.delete`, `http.head`
6161+- **[JSON API](../reference/lua/json-api.md)** — `json.encode`, `json.decode`
6262- **[Utility globals](scripting.md#utility-globals)** — `log()`, `now()`, `TID()`, `toarray()`
63636464## Error handling and retries
···73737474### Performance considerations
75757676-Because hooks run synchronously before indexing, they block the firehose consumer while executing. With retry logic (1s + 2s + 4s backoff), a persistently failing hook could block for ~7 seconds per record. Keep hook scripts fast and ensure external services they depend on are reliable.
7676+Because hooks run synchronously before indexing, they block the Jetstream consumer while executing. With retry logic (1s + 2s + 4s backoff), a persistently failing hook could block for ~7 seconds per record. Keep hook scripts fast and ensure external services they depend on are reliable.
77777878### Dead letter table
7979···218218219219- [Lua Scripting](scripting.md): Full reference for the sandbox, APIs, and debugging
220220- [Lexicons](lexicons.md): Understand how record, query, and procedure lexicons work together
221221-- [Admin API](../reference/admin-api.md#upload--upsert-a-lexicon): Upload lexicons with index hooks via the API
221221+- [Admin API — Lexicons](../reference/admin/lexicons.md#upload--upsert-a-lexicon): Upload lexicons with index hooks via the API
+3-3
packages/docs/docs/guides/labelers.md
···65656666Self-labels (applied by the record author) use an outline badge style to distinguish them from external labels. Hover over a badge to see the source labeler's DID.
67676868-Labels are also available in the records API response and in Lua scripts via the `get_labels` and `get_labels_batch` functions. See the [Scripting guide](scripting.md) for details.
6868+Labels are also available in the records API response and in Lua scripts via the [`atproto.get_labels` and `atproto.get_labels_batch`](../reference/lua/atproto-api.md#atprotoget_labels) functions.
69697070## Permissions
7171···77777878## Next steps
79798080-- [Admin API reference](../reference/admin-api.md#labelers) — full endpoint documentation
8181-- [Scripting](scripting.md) — access labels in Lua scripts with `get_labels` and `get_labels_batch`
8080+- [Admin API — Labelers](../reference/admin/labelers.md) — full endpoint documentation
8181+- [AT Protocol API](../reference/lua/atproto-api.md) — access labels in Lua scripts with `get_labels` and `get_labels_batch`
8282- [Permissions](permissions.md) — manage user access to labeler operations
+10-10
packages/docs/docs/guides/lexicons.md
···2233Lexicons are the core building block of HappyView. They're [AT Protocol schema definitions](https://atproto.com/specs/lexicon) that describe your data model, and HappyView uses them to decide which records to index from the network and what XRPC endpoints to serve.
4455-You don't write route handlers or database queries; you upload a lexicon and HappyView generates the infrastructure from it. There are two ways to add lexicons: uploading them via the [admin API](../reference/admin-api.md#lexicons) or [dashboard](../getting-started/dashboard.md), or fetching them directly from the AT Protocol network via [DNS authority resolution](#network-lexicons).
55+You don't write route handlers or database queries; you upload a lexicon and HappyView generates the infrastructure from it. There are two ways to add lexicons: uploading them via the [admin API](../reference/admin/lexicons.md) or [dashboard](../getting-started/dashboard.md), or fetching them directly from the AT Protocol network via [DNS authority resolution](#network-lexicons).
6677## Supported lexicon types
88···21212222For example, a query lexicon `xyz.statusphere.listStatuses` would set `target_collection` to `xyz.statusphere.status` to read from that record collection.
23232424-See the [admin API](../reference/admin-api.md#upload--upsert-a-lexicon) for how to set `target_collection` when uploading.
2424+See the [admin API](../reference/admin/lexicons.md#upload--upsert-a-lexicon) for how to set `target_collection` when uploading.
25252626:::note
2727The `target_collection` is available in Lua scripts as the `collection` global, but it is not required if your endpoint uses a Lua script.
···35353636When record-type lexicons change (uploaded or deleted), HappyView reconnects to Jetstream with an updated collection filter. HappyView always includes `com.atproto.lexicon.schema` in the filter to track network lexicon updates.
37373838-Deleting a lexicon stops live indexing for that collection but does **not** remove previously indexed records from the database. To fully reset a collection's state, delete the lexicon and the associated records, re-add the lexicon, and run a [backfill](backfill.md).
3838+Deleting a lexicon stops live indexing for that collection but does **not** remove previously indexed records from the database. If you want to start fresh, you'll need to delete the records separately (e.g. via the admin API or directly in the database) before re-adding the lexicon and running a [backfill](backfill.md).
39394040## Network lexicons
4141···67676868### Live updates via Jetstream
69697070-The Jetstream subscription always includes `com.atproto.lexicon.schema` alongside the dynamic record collections. When a record event arrives:
7070+HappyView's Jetstream subscription always includes the `com.atproto.lexicon.schema` collection, so it receives real-time events whenever a lexicon schema record is created, updated, or deleted on the network. When an event arrives, HappyView checks whether the record's DID and rkey (the NSID) match any tracked network lexicon:
71717272-- **create/update**: If the event's DID and rkey match a tracked network lexicon (`authority_did` and `nsid`), the lexicon is parsed, upserted into the `lexicons` table and in-memory registry, and collection filters are updated if it's a record type.
7373-- **delete**: The lexicon is removed from the `lexicons` table and registry.
7272+- **create/update**: The new schema is parsed and upserted into the `lexicons` table and the in-memory registry. If it's a record-type lexicon, Jetstream collection filters are updated to include the new collection.
7373+- **delete**: The lexicon is removed from the `lexicons` table and registry, and collection filters are updated accordingly.
74747575### Startup re-fetch
7676···78787979## XRPC routing for unknown methods
80808181-When a client calls `/xrpc/{method}` and HappyView has a local lexicon (with a handler or Lua script) for that NSID, the request is served locally. Otherwise, HappyView proxies the request to the method's **home authority** using the same DNS-based authority resolution described above:
8181+When a client calls `/xrpc/{method}` and HappyView has a local lexicon for that NSID, the request is handled by the lexicon's Lua script (or HappyView's default behavior if no script is attached). Otherwise, HappyView attempts to proxy the request to the method's **home authority** using the same DNS-based authority resolution described above:
828283831. Extract the authority from the NSID (all segments except the last). `com.example.foo.getBar` → authority `com.example.foo`.
84842. Reverse it to form a domain: `foo.example.com`.
···90909191- HappyView does **not** proxy to the reversed hostname directly. `foo.example.com` is only the DNS host for the TXT record — the actual XRPC request goes to whatever PDS endpoint the authority DID resolves to.
9292- Proxying applies equally to queries and procedures. For procedures, HappyView uses the caller's OAuth session to attach a DPoP-bound access token (see [Authentication](../getting-started/authentication.md#proxying-procedures-to-the-users-pds)).
9393-- If authority resolution fails — no TXT record, unresolvable DID, or the target PDS 404s the method — the client gets an error back. HappyView does not fall back to any other routing strategy.
9494-- "Network lexicons" (lexicons you've explicitly tracked via the dashboard) are only about **indexing** record collections and keeping the schema up to date. They don't add handler logic. An unknown query against a tracked network lexicon still proxies out — it doesn't run against your local record table unless you also upload a local query lexicon with a matching `target_collection`.
9393+- If authority resolution fails — no TXT record, unresolvable DID, or the target PDS doesn't support the method — the client gets an error back. HappyView does not fall back to any other routing strategy.
9494+- Tracking a network lexicon does **not** make HappyView handle requests for that NSID locally. Network lexicons are only about indexing record collections and keeping the schema up to date. If a client calls a query NSID that you've tracked as a network lexicon but haven't uploaded a local query lexicon for, HappyView still proxies the request out — it won't query your local record table. To serve a method locally, upload a local query or procedure lexicon with a matching `target_collection`.
95959696-In short: if you want to serve an XRPC method on your instance, you need a local lexicon for it. Otherwise HappyView acts as a pass-through to the method's home PDS.
9696+In short: if you want to serve an XRPC method on your instance, you need a local lexicon for it. Otherwise HappyView attempts to proxy to the method's home authority.
97979898## Next steps
9999
+31-31
packages/docs/docs/guides/permissions.md
···8899### Lexicons
10101111-| Permission | Description |
1212-|---|---|
1111+| Permission | Description |
1212+| ----------------- | ---------------------------------------------- |
1313| `lexicons:create` | Upload and upsert lexicons (local and network) |
1414-| `lexicons:read` | List and view lexicon details |
1515-| `lexicons:delete` | Delete lexicons |
1414+| `lexicons:read` | List and view lexicon details |
1515+| `lexicons:delete` | Delete lexicons |
16161717### Records
18181919-| Permission | Description |
2020-|---|---|
2121-| `records:read` | List and view indexed records |
2222-| `records:delete` | Delete individual records |
1919+| Permission | Description |
2020+| --------------------------- | --------------------------------------- |
2121+| `records:read` | List and view indexed records |
2222+| `records:delete` | Delete individual records |
2323| `records:delete-collection` | Bulk-delete all records in a collection |
24242525### Script Variables
26262727-| Permission | Description |
2828-|---|---|
2929-| `script-variables:create` | Create and update script variables |
3030-| `script-variables:read` | List script variables (values are masked) |
3131-| `script-variables:delete` | Delete script variables |
2727+| Permission | Description |
2828+| ------------------------- | ----------------------------------------- |
2929+| `script-variables:create` | Create and update script variables |
3030+| `script-variables:read` | List script variables (values are masked) |
3131+| `script-variables:delete` | Delete script variables |
32323333### Users
34343535-| Permission | Description |
3636-|---|---|
3737-| `users:create` | Add new users |
3838-| `users:read` | List and view user details |
3939-| `users:update` | Modify user permissions |
4040-| `users:delete` | Remove users |
3535+| Permission | Description |
3636+| -------------- | -------------------------- |
3737+| `users:create` | Add new users |
3838+| `users:read` | List and view user details |
3939+| `users:update` | Modify user permissions |
4040+| `users:delete` | Remove users |
41414242### API Keys
43434444-| Permission | Description |
4545-|---|---|
4444+| Permission | Description |
4545+| ----------------- | ------------------- |
4646| `api-keys:create` | Create new API keys |
4747-| `api-keys:read` | List API keys |
4848-| `api-keys:delete` | Revoke API keys |
4747+| `api-keys:read` | List API keys |
4848+| `api-keys:delete` | Revoke API keys |
49495050### Operations
51515252-| Permission | Description |
5353-|---|---|
5454-| `backfill:create` | Start backfill jobs |
5555-| `backfill:read` | View backfill job status |
5656-| `stats:read` | View record statistics |
5757-| `events:read` | Query the event log |
5252+| Permission | Description |
5353+| ----------------- | ------------------------ |
5454+| `backfill:create` | Start backfill jobs |
5555+| `backfill:read` | View backfill job status |
5656+| `stats:read` | View record statistics |
5757+| `events:read` | Query the event log |
58585959## Permission templates
6060···9191- Cannot be deleted
9292- Cannot have their permissions modified by other users
93939494-There is always exactly one super user. Super status can be transferred to another user via the transfer endpoint.
9494+There is always exactly one super user. Super status can be transferred to another user via the dashboard or transfer endpoint in the Admin API.
95959696## Escalation guards
9797···132132- `PATCH /admin/users/{id}/permissions` — grant or revoke individual permissions
133133- `POST /admin/users/transfer-super` — transfer super user status (super user only)
134134135135-See the [Admin API reference](../reference/admin-api.md#user-management) for full details.
135135+See the [Admin API — Users](../reference/admin/users.md) for full details.
136136137137## Next steps
138138
+4-99
packages/docs/docs/guides/plugins.md
···11# Plugins
2233-HappyView uses WASM plugins to integrate with external platforms. Auth plugins enable users to link their accounts from platforms like Steam, Xbox, itch.io, and others, then sync data (like game libraries) to their AT Protocol identity.
33+HappyView uses WASM plugins to extend its functionality. Plugins can integrate with external platforms, sync data to users' AT Protocol identities, and more. Auth plugins — the first supported plugin type — enable users to link accounts from platforms like Steam, Xbox, itch.io, and others, then sync data like game libraries.
4455Official plugins for Steam, Xbox, itch.io, and other platforms are available in the [happyview-plugins](https://github.com/gamesgamesgamesgames/happyview-plugins) repository.
66···4646**Requires:** `TOKEN_ENCRYPTION_KEY` environment variable (base64-encoded 32-byte key).
47474848Generate one with:
4949+4950```bash
5051openssl rand -base64 32
5152```
···6061PLUGIN_XBOX_CLIENT_SECRET=your-client-secret
6162```
62636363-Dashboard-configured secrets take precedence over environment variables.
6464-6565-## Plugin Manifest
6666-6767-Each plugin has a `manifest.json` that describes its metadata:
6868-6969-```json
7070-{
7171- "id": "steam",
7272- "name": "Steam",
7373- "version": "1.0.0",
7474- "api_version": "1",
7575- "description": "Import your Steam game library and playtime data.",
7676- "icon_url": "https://example.com/steam-icon.png",
7777- "auth_type": "openid",
7878- "wasm_file": "steam.wasm",
7979- "required_secrets": [
8080- {
8181- "key": "PLUGIN_STEAM_API_KEY",
8282- "name": "Steam Web API Key",
8383- "description": "Get your API key at steamcommunity.com/dev/apikey"
8484- }
8585- ]
8686-}
8787-```
8888-8989-| Field | Description |
9090-|-------|-------------|
9191-| `id` | Unique plugin identifier |
9292-| `name` | Display name |
9393-| `version` | Semantic version |
9494-| `api_version` | Plugin API version (currently "1") |
9595-| `description` | Brief description shown during install |
9696-| `icon_url` | Optional icon URL |
9797-| `auth_type` | Authentication type: `oauth2`, `openid`, or `api_key` |
9898-| `wasm_file` | WASM binary filename (default: `plugin.wasm`) |
9999-| `required_secrets` | Array of secrets the plugin needs |
100100-101101-## API Endpoints
102102-103103-### Public Endpoints
104104-105105-| Endpoint | Description |
106106-|----------|-------------|
107107-| `GET /external-auth/providers` | List available auth providers |
108108-| `GET /external-auth/accounts` | List user's linked accounts |
109109-| `GET /external-auth/{plugin}/authorize` | Start OAuth flow |
110110-| `GET /external-auth/{plugin}/callback` | OAuth callback handler |
111111-| `POST /external-auth/{plugin}/sync` | Sync data from linked account |
112112-| `POST /external-auth/{plugin}/unlink` | Unlink account |
113113-| `POST /external-auth/{plugin}/connect` | Connect with API key (for `api_key` auth type) |
114114-115115-### Admin Endpoints
116116-117117-| Endpoint | Description |
118118-|----------|-------------|
119119-| `GET /admin/plugins` | List installed plugins |
120120-| `POST /admin/plugins` | Install a plugin |
121121-| `POST /admin/plugins/preview` | Preview plugin before installing |
122122-| `GET /admin/plugins/official` | Browse the official plugin registry catalog |
123123-| `DELETE /admin/plugins/{id}` | Remove a plugin |
124124-| `POST /admin/plugins/{id}/reload` | Reload plugin from source |
125125-| `POST /admin/plugins/{id}/check-update` | Check whether a newer version is available |
126126-| `GET /admin/plugins/{id}/secrets` | Get configured secrets (masked) |
127127-| `PUT /admin/plugins/{id}/secrets` | Update plugin secrets |
128128-129129-The dashboard's **Settings > Plugins** page calls `GET /admin/plugins/official` to populate the install browser, and `POST /admin/plugins/{id}/check-update` to display update badges on installed plugins.
6464+These are only necessary if you can't configure variables via the dashboard. Dashboard-configured secrets take precedence over environment variables.
1306513166## Security
13267···13671- **Scoped storage**: Plugin KV storage is isolated per-plugin and per-user
13772- **No filesystem access**: Plugins cannot access the host filesystem
13873139139-## Developing Plugins
140140-141141-See the [happyview-plugins](https://github.com/gamesgamesgamesgames/happyview-plugins) repository for examples and the plugin SDK.
142142-143143-### Plugin Exports
144144-145145-Plugins must export these functions:
146146-147147-| Export | Signature | Description |
148148-|--------|-----------|-------------|
149149-| `alloc` | `(size: u32) -> u32` | Allocate memory |
150150-| `dealloc` | `(ptr: u32, size: u32)` | Deallocate memory |
151151-| `get_authorize_url` | `(ptr: u32, len: u32) -> i64` | Generate OAuth authorize URL |
152152-| `handle_callback` | `(ptr: u32, len: u32) -> i64` | Handle OAuth callback |
153153-| `refresh_tokens` | `(ptr: u32, len: u32) -> i64` | Refresh expired tokens |
154154-| `get_profile` | `(ptr: u32, len: u32) -> i64` | Get external profile info |
155155-| `sync_account` | `(ptr: u32, len: u32) -> i64` | Sync data and return records |
156156-157157-### Host Functions
158158-159159-Plugins can import these host functions:
160160-161161-| Import | Description |
162162-|--------|-------------|
163163-| `host_http_request` | Make HTTP requests |
164164-| `host_get_secret` | Read configured secrets |
165165-| `host_log` | Write to server logs |
166166-| `host_kv_get` | Read from KV storage |
167167-| `host_kv_set` | Write to KV storage |
168168-| `host_kv_delete` | Delete from KV storage |
169169-17074## Next steps
171757676+- [Developing Plugins](developing-plugins.md) — create your own plugins with the WASM plugin API
17277- [Official plugins repository](https://github.com/gamesgamesgamesgames/happyview-plugins) — ready-to-use plugins for Steam, Xbox, itch.io, and more
17378- [API Keys](api-keys.md) — authenticate programmatic access to admin endpoints
17479- [Permissions](permissions.md) — configure user access to plugin management
···46464747### What the codemod converts automatically
48484949-- `$1`, `$2`, etc. parameter placeholders to `?` positional parameters
5050-- `jsonb` operators (`->`, `->>`, `@>`, `?`) to SQLite `json_extract()` calls
4949+- `$1`, `$2`, etc. parameter placeholders to `?`
5050+- JSON operators (`->`, `->>`) and `::jsonb` casts to `json_extract()` calls
5151- `ILIKE` to `LIKE` (SQLite `LIKE` is case-insensitive for ASCII by default)
5252- `NOW()` to `datetime('now')`
5353-- `::text`, `::integer`, etc. type casts to SQLite equivalents (`CAST(... AS ...)`)
5454-- `COALESCE` and other standard SQL functions (no change needed)
5353+- `NOW() + INTERVAL '...'` / `NOW() - INTERVAL '...'` to `datetime('now', '...')`
5554- `TRUE`/`FALSE` literals to `1`/`0`
5656-- `RETURNING *` clauses (removed, as SQLite has limited RETURNING support)
57555856### What it flags for manual review
59576058The tool prints warnings for patterns it cannot convert automatically:
61596262-- Complex Postgres-specific functions (`array_agg`, `string_agg`, `generate_series`, etc.)
6363-- Window functions with Postgres-specific syntax
6464-- `ON CONFLICT` clauses with complex conditions
6565-- CTEs (`WITH` queries) that use Postgres-specific features
6666-- Any SQL that the parser cannot confidently transform
6060+- JSONB `?` (contains-key) operator — consider using `json_each()` with an `EXISTS` subquery
6161+- `make_interval()` — Postgres-specific, needs manual conversion
6262+- `SIMILAR TO` — use `LIKE` or `GLOB` instead
6363+- `ANY()` / `ALL()` array operators — no direct SQLite equivalent
6464+- Type casts other than `::jsonb` (e.g., `::text`, `::integer`) — may need manual conversion to `CAST(... AS ...)`
67656866Review the flagged lines and update them manually.
6967···87858886## Next steps
89878888+- [SQLite → Postgres migration](sqlite-to-postgres-migration.md) — migrate in the opposite direction
9089- [Database setup](database-setup.md) — choose between SQLite and Postgres for new instances
9190- [Backfill](backfill.md) — re-index records from the network after switching backends
9291- [Lua scripting](scripting.md) — write SQL that works against either backend
+21-417
packages/docs/docs/guides/scripting.md
···35353636An instruction limit of 1,000,000 prevents infinite loops. Exceeding it terminates the script with an error.
37373838+See the [Standard Libraries](../reference/lua/standard-libraries.md) reference for the full list of available Lua modules and builtins.
3939+3840## Context globals
39414042These globals are set automatically before `handle()` is called.
···85878688The `Record` API is only available in **procedure** scripts. It handles creating, updating, loading, and deleting AT Protocol records. Writes are proxied to the caller's PDS and indexed locally.
87898888-### Constructor
8989-9090-```lua
9191-local r = Record("xyz.statusphere.status", { status = "\ud83d\ude0a", createdAt = now() })
9292-```
9393-9494-Creates a new record instance for the given collection. The optional second argument sets initial field values. The record's `_key_type` is automatically set from the lexicon's `key` definition. Default values from the schema are populated for any missing fields.
9090+See the full [Record API reference](../reference/lua/record-api.md) for constructor, static methods, instance methods, fields, schema validation, and save behavior.
95919696-### Static methods
9292+Quick example:
97939894```lua
9999--- Save multiple records in parallel
100100-Record.save_all({ record1, record2, record3 })
101101-102102--- Load a record from the local database by AT URI
103103-local r = Record.load("at://did:plc:abc/xyz.statusphere.status/abc123")
104104--- Returns nil if not found
105105-106106--- Load multiple records in parallel
107107-local records = Record.load_all({ uri1, uri2 })
108108--- Returns nil entries for URIs not found
9595+function handle()
9696+ local r = Record(collection, input)
9797+ r:save()
9898+ return { uri = r._uri, cid = r._cid }
9999+end
109100```
110101111111-### Instance methods
112112-113113-```lua
114114--- Save (creates or updates depending on whether _uri is set)
115115-r:save()
116116-117117--- Delete from PDS and local database
118118-r:delete()
119119-120120--- Set the record key type (tid, any, nsid, or literal:*)
121121-r:set_key_type("tid")
122122-123123--- Set a specific record key
124124-r:set_rkey("my-key")
125125-126126--- Auto-generate a record key based on _key_type
127127-local key = r:generate_rkey()
128128-```
129129-130130-**Key type behavior for `generate_rkey()`:**
131131-132132-| Key type | Generated rkey |
133133-| --------------- | --------------------------------- |
134134-| `tid` | Sortable timestamp-based ID |
135135-| `any` | Same as `tid` |
136136-| `literal:value` | The literal value after the colon |
137137-| `nsid` | Error — use `set_rkey()` instead |
138138-139139-### Instance fields
140140-141141-These fields are set automatically and are read-only (writes raise an error):
142142-143143-| Field | Type | Description |
144144-| ------------- | ------- | ----------------------------------------------------------- |
145145-| `_uri` | string? | AT URI — set after `save()`, cleared after `delete()` |
146146-| `_cid` | string? | Content hash — set after `save()`, cleared after `delete()` |
147147-| `_key_type` | string? | Record key type from the lexicon definition |
148148-| `_rkey` | string? | Record key — set via `set_rkey()` or `generate_rkey()` |
149149-| `_collection` | string | Collection NSID (always set) |
150150-| `_schema` | table? | Schema definition from the lexicon (used for validation) |
151151-152152-### Schema validation
153153-154154-When a record has a schema (loaded from the lexicon):
155155-156156-- **On save:** required fields are checked, and missing required fields raise an error
157157-- **On construction:** default values from schema properties are auto-populated
158158-- **On save:** only fields defined in the schema's `properties` are sent to the PDS
159159-160160-### Save behavior
161161-162162-`r:save()` auto-detects create vs update:
163163-164164-- If `_uri` is nil → calls `createRecord` on the PDS
165165-- If `_uri` is set → calls `putRecord` on the PDS
166166-167167-After a successful save, `_uri` and `_cid` are updated on the record instance.
168168-169102## Database API
170103171104The `db` table provides access to the database. Available in both queries and procedures.
172105173173-### db.query
106106+See the full [Database API reference](../reference/lua/database-api.md) for `db.query`, `db.get`, `db.search`, `db.backlinks`, `db.count`, and `db.raw`.
174107175175-```lua
176176-local result = db.query({
177177- collection = "xyz.statusphere.status", -- required
178178- did = "did:plc:abc", -- optional: filter by DID
179179- limit = 20, -- optional: max 100, default 20
180180- offset = 0, -- optional: for pagination
181181- sort = "name", -- optional: field to sort by, default "indexed_at"
182182- sortDirection = "asc", -- optional: "asc" or "desc", default "desc"
183183-})
184184-185185--- result.records — array of record tables (each includes a "uri" field)
186186--- result.cursor — present when more records exist
187187-```
188188-189189-The `sort` field can be a top-level column (`indexed_at`, `did`, `uri`) or any field inside the record's `value` object (e.g. `name`, `createdAt`). Field names must contain only alphanumeric characters and underscores.
190190-191191-### db.get
108108+Quick example:
192109193110```lua
194194-local record = db.get("at://did:plc:abc/xyz.statusphere.status/abc123")
195195--- Returns the record table or nil
196196--- The returned table includes a "uri" field
197197-```
198198-199199-### db.search
200200-201201-```lua
202202-local result = db.search({
203203- collection = "xyz.statusphere.status", -- required
204204- field = "displayName", -- required: record field to search
205205- query = "alice", -- required: search term
206206- limit = 10, -- optional: max 100, default 10
207207-})
208208-209209--- result.records — array of matching records, ranked by relevance:
210210--- exact match > prefix match > contains match, then alphabetical
211211-```
212212-213213-### db.backlinks
214214-215215-Find records that reference a given AT URI anywhere in their data. Useful for finding likes on a post, replies to a thread, or any record that links to another.
216216-217217-```lua
218218-local result = db.backlinks({
219219- collection = "xyz.statusphere.status", -- required
220220- uri = "at://did:plc:abc/xyz.statusphere.status/foo", -- required: the URI to find references to
221221- did = "did:plc:abc", -- optional: filter by DID
222222- limit = 20, -- optional: max 100, default 20
223223- offset = 0, -- optional: for pagination
224224-})
225225-226226--- result.records — array of records whose data contains the given URI
227227--- result.cursor — present when more records exist
228228-```
229229-230230-The search checks the full record data, so it works regardless of which field holds the reference (`subject`, `parent`, `reply.root`, etc.).
231231-232232-### db.count
233233-234234-```lua
235235-local n = db.count("xyz.statusphere.status")
236236-local n = db.count("xyz.statusphere.status", "did:plc:abc") -- filter by DID
237237-```
238238-239239-### db.raw
240240-241241-Run a raw SQL query against the database. Supports `SELECT`, `INSERT`, `UPDATE`, `DELETE`, and `CREATE TABLE` statements.
242242-243243-```lua
244244--- Read query
245245-local rows = db.raw(
246246- "SELECT uri, did, record FROM records WHERE collection = $1 AND did = $2 LIMIT $3",
247247- { "xyz.statusphere.status", "did:plc:abc", 10 }
248248-)
249249-250250-for _, row in ipairs(rows) do
251251- -- row.uri, row.did, row.record (JSONB is returned as a Lua table)
111111+function handle()
112112+ local result = db.query({ collection = collection, limit = 20 })
113113+ return { records = result.records, cursor = result.cursor }
252114end
253253-254254--- Write query (returns affected rows, if any)
255255-db.raw("CREATE TABLE IF NOT EXISTS my_table (id TEXT PRIMARY KEY, value TEXT NOT NULL)")
256256-db.raw("INSERT INTO my_table (id, value) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET value = $2",
257257- { "key1", "hello" })
258115```
259116260260-Parameters are passed as an array and bound to `$1`, `$2`, etc. Supported parameter types: strings, integers, numbers, booleans, and nil.
261261-262262-Column types are mapped automatically:
263263-264264-| Postgres type | Lua type |
265265-| ---------------------- | -------- |
266266-| `TEXT`, `VARCHAR` | string |
267267-| `INT4`, `INT8` | integer |
268268-| `FLOAT4`, `FLOAT8` | number |
269269-| `BOOL` | boolean |
270270-| `JSON`, `JSONB` | table |
271271-| `TIMESTAMPTZ` | string (ISO 8601) |
272272-| Other | string (fallback) |
273273-274117## HTTP API
275118276119The `http` table provides async HTTP client functions. Available in both queries and procedures.
277120278278-### Methods
279279-280280-All methods take a URL and an optional options table, and return a [response table](#response).
281281-282282-```lua
283283-http.get(url, opts?)
284284-http.post(url, opts?)
285285-http.put(url, opts?)
286286-http.patch(url, opts?)
287287-http.delete(url, opts?)
288288-http.head(url, opts?)
289289-```
290290-291291-### Options
292292-293293-The optional second argument is a table with:
121121+See the full [HTTP API reference](../reference/lua/http-api.md) for all methods, options, and response format.
294122295295-| Field | Type | Description |
296296-| --------- | ------ | ---------------------------------------------- |
297297-| `headers` | table | Request headers as key-value string pairs |
298298-| `body` | string | Request body (ignored for GET and HEAD) |
299299-300300-### Response
301301-302302-Every method returns a table with:
303303-304304-| Field | Type | Description |
305305-| --------- | ------- | ---------------------------------------------------- |
306306-| `status` | integer | HTTP status code |
307307-| `body` | string | Response body text (empty string for HEAD) |
308308-| `headers` | table | Response headers as key-value pairs (lowercase keys) |
309309-310310-### Examples
123123+Quick example:
311124312125```lua
313313--- Simple GET
314126local resp = http.get("https://api.example.com/data")
315315--- resp.status = 200, resp.body = "...", resp.headers["content-type"] = "application/json"
316316-317317--- GET with custom headers
318318-local resp = http.get("https://api.example.com/data", {
319319- headers = { ["authorization"] = "Bearer token123" }
320320-})
321321-322322--- POST with JSON body
323323-local resp = http.post("https://api.example.com/hook", {
324324- body = '{"key": "value"}',
325325- headers = { ["content-type"] = "application/json" }
326326-})
327327-328328--- PUT, PATCH, DELETE, HEAD follow the same pattern
329329-local resp = http.put(url, { body = data, headers = { ... } })
330330-local resp = http.patch(url, { body = data, headers = { ... } })
331331-local resp = http.delete(url, { headers = { ... } })
332332-local resp = http.head(url)
127127+local data = json.decode(resp.body)
333128```
334129335130## AT Protocol API
336131337337-The `atproto` table provides AT Protocol utility functions. Available in queries, procedures, and [index hooks](index-hooks.md).
338338-339339-### atproto.resolve_service_endpoint
340340-341341-```lua
342342-local endpoint = atproto.resolve_service_endpoint(did)
343343-```
344344-345345-Resolves a DID to its AT Protocol service endpoint URL by fetching the DID document. Supports both `did:plc:*` (via the PLC directory) and `did:web:*` (via `.well-known/did.json`).
346346-347347-| Parameter | Type | Description |
348348-| --------- | ------ | ------------------------ |
349349-| `did` | string | The DID to resolve |
350350-351351-**Returns:** The service endpoint URL as a string, or `nil` if resolution fails (DID not found, no PDS service in document, network error).
352352-353353-### Examples
354354-355355-```lua
356356--- Resolve a did:plc DID
357357-local endpoint = atproto.resolve_service_endpoint("did:plc:abc123")
358358--- endpoint = "https://pds.example.com"
359359-360360--- Resolve a did:web DID
361361-local endpoint = atproto.resolve_service_endpoint("did:web:example.com")
362362--- endpoint = "https://example.com"
363363-364364--- Handle resolution failure
365365-local endpoint = atproto.resolve_service_endpoint("did:plc:unknown")
366366-if not endpoint then
367367- return { error = "Could not resolve DID" }
368368-end
369369-370370--- Use with HTTP API to call a remote XRPC endpoint
371371-local endpoint = atproto.resolve_service_endpoint(did)
372372-if endpoint then
373373- local resp = http.get(endpoint .. "/xrpc/com.example.method")
374374- local data = json.decode(resp.body)
375375-end
376376-```
377377-378378-### atproto.get_labels
379379-380380-```lua
381381-local labels = atproto.get_labels(uri)
382382-```
383383-384384-Returns an array of labels for a single AT URI. Merges external labels (from subscribed labelers) with self-labels (from the record's `labels.values[]` field).
385385-386386-| Parameter | Type | Description |
387387-| --------- | ------ | ------------------------------ |
388388-| `uri` | string | AT URI of the record to query |
132132+The `atproto` table provides AT Protocol utility functions like DID resolution and label queries.
389133390390-Each label in the array is a table with:
391391-392392-| Field | Type | Description |
393393-| ----- | ------ | ---------------------------------------- |
394394-| `src` | string | DID of the labeler (or record author) |
395395-| `uri` | string | AT URI this label applies to |
396396-| `val` | string | Label value (e.g. "nsfw", "!hide") |
397397-| `cts` | string | Timestamp when the label was created |
398398-399399-Expired labels are automatically filtered out. Returns an empty array if no labels exist.
400400-401401-### atproto.get_labels_batch
402402-403403-```lua
404404-local labels_by_uri = atproto.get_labels_batch(uris)
405405-```
406406-407407-Batch version of `get_labels`. Takes an array of AT URIs and returns a table keyed by URI, where each value is an array of labels.
408408-409409-| Parameter | Type | Description |
410410-| --------- | ----- | ------------------------ |
411411-| `uris` | table | Array of AT URI strings |
412412-413413-**Returns:** A table keyed by URI. Each value is an array of label tables (same shape as `get_labels`). URIs with no labels have an empty array.
414414-415415-### Label Examples
416416-417417-```lua
418418--- Get labels for a single game
419419-local labels = atproto.get_labels("at://did:plc:abc/games.gamesgamesgamesgames.game/rkey1")
420420-for _, label in ipairs(labels) do
421421- if label.val == "!hide" then
422422- -- skip this game in feed results
423423- end
424424-end
425425-426426--- Batch fetch labels for multiple games (efficient for feed hydration)
427427-local uris = {}
428428-for _, item in ipairs(skeleton) do
429429- uris[#uris + 1] = item.game
430430-end
431431-432432-local labels_by_uri = atproto.get_labels_batch(uris)
433433-for _, uri in ipairs(uris) do
434434- local labels = labels_by_uri[uri]
435435- for _, label in ipairs(labels) do
436436- if label.val == "!hide" then
437437- -- filter out this game
438438- end
439439- end
440440-end
441441-```
134134+See the full [AT Protocol API reference](../reference/lua/atproto-api.md) for `atproto.resolve_service_endpoint`, `atproto.get_labels`, and `atproto.get_labels_batch`.
442135443136## JSON API
444137445445-The `json` global provides JSON serialization and deserialization. Available in queries, procedures, and [index hooks](index-hooks.md).
446446-447447-### json.encode
448448-449449-```lua
450450-local str = json.encode({ key = "value", items = { 1, 2, 3 } })
451451--- '{"key":"value","items":[1,2,3]}'
452452-```
138138+The `json` global provides JSON serialization and deserialization.
453139454454-Converts a Lua table to a JSON string.
455455-456456-### json.decode
457457-458458-```lua
459459-local tbl = json.decode('{"key": "value"}')
460460--- tbl.key == "value"
461461-```
462462-463463-Parses a JSON string into a Lua table. Returns an error if the input is not valid JSON.
464464-465465-## Standard libraries
466466-467467-The following Lua 5.4 standard library modules are available:
468468-469469-<details>
470470-<summary>
471471-`string`
472472-</summary>
473473-- [`byte`](https://lua.org/manual/5.4/manual.html#pdf-string.byte)
474474-- [`char`](https://lua.org/manual/5.4/manual.html#pdf-string.char)
475475-- [`find`](https://lua.org/manual/5.4/manual.html#pdf-string.find)
476476-- [`format`](https://lua.org/manual/5.4/manual.html#pdf-string.format)
477477-- [`gmatch`](https://lua.org/manual/5.4/manual.html#pdf-string.gmatch)
478478-- [`gsub`](https://lua.org/manual/5.4/manual.html#pdf-string.gsub)
479479-- [`len`](https://lua.org/manual/5.4/manual.html#pdf-string.len)
480480-- [`lower`](https://lua.org/manual/5.4/manual.html#pdf-string.lower)
481481-- [`match`](https://lua.org/manual/5.4/manual.html#pdf-string.match)
482482-- [`rep`](https://lua.org/manual/5.4/manual.html#pdf-string.rep)
483483-- [`reverse`](https://lua.org/manual/5.4/manual.html#pdf-string.reverse)
484484-- [`sub`](https://lua.org/manual/5.4/manual.html#pdf-string.sub)
485485-- [`upper`](https://lua.org/manual/5.4/manual.html#pdf-string.upper)
486486-</details>
487487-488488-<details>
489489-<summary>
490490-`table`
491491-</summary>
492492-- [`concat`](https://lua.org/manual/5.4/manual.html#pdf-table.concat)
493493-- [`insert`](https://lua.org/manual/5.4/manual.html#pdf-table.insert)
494494-- [`remove`](https://lua.org/manual/5.4/manual.html#pdf-table.remove)
495495-- [`sort`](https://lua.org/manual/5.4/manual.html#pdf-table.sort)
496496-- [`unpack`](https://lua.org/manual/5.4/manual.html#pdf-table.unpack)
497497-</details>
498498-499499-<details>
500500-<summary>
501501-`math`
502502-</summary>
503503-- [`abs`](https://lua.org/manual/5.4/manual.html#pdf-math.abs)
504504-- [`ceil`](https://lua.org/manual/5.4/manual.html#pdf-math.ceil)
505505-- [`floor`](https://lua.org/manual/5.4/manual.html#pdf-math.floor)
506506-- [`max`](https://lua.org/manual/5.4/manual.html#pdf-math.max)
507507-- [`min`](https://lua.org/manual/5.4/manual.html#pdf-math.min)
508508-- [`random`](https://lua.org/manual/5.4/manual.html#pdf-math.random)
509509-- [`sqrt`](https://lua.org/manual/5.4/manual.html#pdf-math.sqrt)
510510-- [`huge`](https://lua.org/manual/5.4/manual.html#pdf-math.huge)
511511-- [`pi`](https://lua.org/manual/5.4/manual.html#pdf-math.pi)
512512-</details>
513513-514514-<details>
515515-<summary>
516516-Standard builtins
517517-</summary>
518518-- [`print`](https://lua.org/manual/5.4/manual.html#pdf-print)
519519-- [`tostring`](https://lua.org/manual/5.4/manual.html#pdf-tostring)
520520-- [`tonumber`](https://lua.org/manual/5.4/manual.html#pdf-tonumber)
521521-- [`type`](https://lua.org/manual/5.4/manual.html#pdf-type)
522522-- [`pairs`](https://lua.org/manual/5.4/manual.html#pdf-pairs)
523523-- [`ipairs`](https://lua.org/manual/5.4/manual.html#pdf-ipairs)
524524-- [`next`](https://lua.org/manual/5.4/manual.html#pdf-next)
525525-- [`select`](https://lua.org/manual/5.4/manual.html#pdf-select)
526526-- [`unpack`](https://lua.org/manual/5.4/manual.html#pdf-table.unpack)
527527-- [`error`](https://lua.org/manual/5.4/manual.html#pdf-error)
528528-- [`pcall`](https://lua.org/manual/5.4/manual.html#pdf-pcall)
529529-- [`xpcall`](https://lua.org/manual/5.4/manual.html#pdf-xpcall)
530530-- [`assert`](https://lua.org/manual/5.4/manual.html#pdf-assert)
531531-- [`setmetatable`](https://lua.org/manual/5.4/manual.html#pdf-setmetatable)
532532-- [`getmetatable`](https://lua.org/manual/5.4/manual.html#pdf-getmetatable)
533533-- [`rawget`](https://lua.org/manual/5.4/manual.html#pdf-rawget)
534534-- [`rawset`](https://lua.org/manual/5.4/manual.html#pdf-rawset)
535535-- [`rawequal`](https://lua.org/manual/5.4/manual.html#pdf-rawequal)
536536-</details>
140140+See the full [JSON API reference](../reference/lua/json-api.md) for `json.encode` and `json.decode`.
537141538142## Debugging
539143
···11+# Migrating from SQLite to Postgres
22+33+This guide covers migrating an existing HappyView deployment from SQLite to Postgres. If you are staying on SQLite, no action is required.
44+55+## Overview
66+77+HappyView writes all internal SQL in SQLite syntax and translates to Postgres automatically at runtime. This means your **Lua scripts do not need any changes** when switching from SQLite to Postgres — they continue to work as-is.
88+99+The main steps are: set up the Postgres database, update your environment variables, and re-index your data.
1010+1111+## Step 1: Set up Postgres
1212+1313+Create a Postgres database for HappyView:
1414+1515+```sh
1616+createdb happyview
1717+```
1818+1919+If you are using Docker Compose, uncomment the `postgres` service and `pgdata` volume in your `docker-compose.yml`. See the [database setup guide](database-setup.md#docker-compose) for details.
2020+2121+## Step 2: Back up your SQLite database
2222+2323+Copy your SQLite database file before making any changes:
2424+2525+```sh
2626+cp data/happyview.db data/happyview.db.backup
2727+```
2828+2929+## Step 3: Update environment variables
3030+3131+Change your `.env` to use Postgres:
3232+3333+```sh
3434+# Before
3535+DATABASE_URL=sqlite://data/happyview.db?mode=rwc
3636+3737+# After
3838+DATABASE_URL=postgres://happyview:happyview@localhost/happyview
3939+```
4040+4141+If you had `DATABASE_BACKEND` set, update it as well:
4242+4343+```sh
4444+DATABASE_BACKEND=postgres
4545+```
4646+4747+## Step 4: Start HappyView
4848+4949+Start HappyView with the new `DATABASE_URL`. It will connect to Postgres and run migrations automatically, creating all necessary tables.
5050+5151+## Step 5: Re-index your data
5252+5353+Since HappyView indexes records from the AT Protocol network, the simplest way to populate your new Postgres database is to re-run the backfill:
5454+5555+1. Upload your lexicons via the dashboard or admin API (or they will already be there if you exported and re-imported them)
5656+2. Run a backfill for each collection (dashboard or `POST /admin/backfill`)
5757+5858+Backfill fetches all records fresh from the network, so no data transfer between databases is needed.
5959+6060+:::tip
6161+If you have many lexicons, you can export them from the old instance before switching. Use `GET /admin/lexicons` to list them and `POST /admin/lexicons` to re-upload after switching to Postgres.
6262+:::
6363+6464+## Step 6: Re-create admin settings
6565+6666+Instance settings (app name, logo, TOS/privacy URIs), API keys, users, and labeler subscriptions are stored in the database and are not carried over automatically. Re-create these via the dashboard or admin API after switching.
6767+6868+## Lua scripts
6969+7070+No changes needed. Lua scripts use SQLite syntax by default, and HappyView translates to Postgres automatically at runtime. This includes:
7171+7272+- `?` placeholders (translated to `$1`, `$2`, etc.)
7373+- `json_extract()` calls (translated to Postgres JSON operators)
7474+- `datetime('now')` (translated to `NOW()`)
7575+- Boolean literals `1`/`0` (work in both backends)
7676+7777+If you have scripts that already use Postgres-native syntax (e.g., from direct `db.raw()` calls), they will **not** work after switching — HappyView expects SQLite syntax. Use the [codemod tool](postgres-to-sqlite-migration.md#run-the-codemod-tool) to convert them.
7878+7979+## Rollback
8080+8181+To switch back to SQLite, revert your `DATABASE_URL` to the SQLite connection string. Your SQLite database file remains unchanged — HappyView does not modify it during the migration to Postgres.
8282+8383+## Next steps
8484+8585+- [Postgres → SQLite migration](postgres-to-sqlite-migration.md) — migrate in the opposite direction
8686+- [Database setup](database-setup.md) — choose between SQLite and Postgres for new instances
8787+- [Backfill](backfill.md) — re-index records from the network after switching backends
8888+- [Lua scripting](scripting.md) — write SQL that works against either backend
+78-1199
packages/docs/docs/reference/admin-api.md
···4455## Auth
6677-The admin API supports three authentication methods:
77+The admin API supports two authentication methods:
8899-1. **Session cookie** (web UI) — Set during the OAuth login flow. The signed cookie contains the user's DID.
1010-2. **API keys** — read/write tokens starting with `hv_`, passed as `Authorization: Bearer hv_...`. See the [API Keys guide](../guides/api-keys.md) for details.
1111-3. **Service auth JWT** — AT Protocol inter-service authentication via signed JWTs.
99+1. **API keys** — read/write tokens starting with `hv_`, passed as `Authorization: Bearer hv_...`. See the [API Keys guide](../guides/api-keys.md) for details.
1010+2. **Service auth JWT** — AT Protocol inter-service authentication via signed JWTs.
12111312In all cases the resolved DID is checked against the `users` table, and the user's permissions are loaded to authorize the request.
1413···16151716Non-user DIDs receive a `403 Forbidden` response. Users without the required permission for a specific endpoint also receive `403 Forbidden`.
18171818+## Errors
1919+1920All error responses return JSON with an `error` field:
20212122```json
···2425}
2526```
26272727-| Status | Meaning |
2828-| ------------------ | -------------------------------------------------------------------------------------------------------------- |
2929-| `400 Bad Request` | Invalid input (missing required fields, malformed lexicon JSON) |
3030-| `401 Unauthorized` | Missing or invalid session cookie, API key, or service auth JWT |
3131-| `403 Forbidden` | Authenticated DID is not in the users table, or user lacks the required permission |
3232-| `404 Not Found` | Lexicon, user, or backfill job not found |
2828+| Status | Meaning |
2929+| ------------------ | -------------------------------------------------------------------------------------- |
3030+| `400 Bad Request` | Invalid input (missing required fields, malformed lexicon JSON) |
3131+| `401 Unauthorized` | Missing or invalid API key or service auth JWT |
3232+| `403 Forbidden` | Authenticated DID is not in the users table, or user lacks the required permission |
3333+| `404 Not Found` | Lexicon, user, or backfill job not found |
33343435```sh
3536# All examples assume $TOKEN is an API key (hv_...)
3637AUTH="Authorization: Bearer $TOKEN"
3738```
38393939-## Lexicons
4040-4141-### Upload / upsert a lexicon
4242-4343-```
4444-POST /admin/lexicons
4545-```
4646-4747-```sh
4848-curl -X POST http://localhost:3000/admin/lexicons \
4949- -H "$AUTH" \
5050- -H "Content-Type: application/json" \
5151- -d '{
5252- "lexicon_json": { "lexicon": 1, "id": "xyz.statusphere.status", "defs": { "main": { "type": "record", "key": "tid", "record": { "type": "object", "required": ["status", "createdAt"], "properties": { "status": { "type": "string", "maxGraphemes": 1 }, "createdAt": { "type": "string", "format": "datetime" } } } } } },
5353- "backfill": true,
5454- "target_collection": null
5555- }'
5656-```
5757-5858-| Field | Type | Required | Description |
5959-| ------------------- | ------- | -------- | --------------------------------------------------------------------- |
6060-| `lexicon_json` | object | yes | Raw lexicon JSON (must have `lexicon: 1` and `id`) |
6161-| `backfill` | boolean | no | Whether uploading triggers historical backfill (default `true`) |
6262-| `target_collection` | string | no | For query/procedure lexicons, the record collection they operate on |
6363-| `script` | string | no | Lua script for query/procedure endpoints |
6464-| `index_hook` | string | no | [Index hook](../guides/index-hooks.md) Lua script for record lexicons |
6565-6666-**Response**: `201 Created` (new) or `200 OK` (upsert)
6767-6868-```json
6969-{
7070- "id": "xyz.statusphere.status",
7171- "revision": 1
7272-}
7373-```
7474-7575-### List lexicons
7676-7777-```
7878-GET /admin/lexicons
7979-```
8080-8181-```sh
8282-curl http://localhost:3000/admin/lexicons -H "$AUTH"
8383-```
8484-8585-**Response**: `200 OK`
8686-8787-```json
8888-[
8989- {
9090- "id": "xyz.statusphere.status",
9191- "revision": 1,
9292- "lexicon_type": "record",
9393- "backfill": true,
9494- "created_at": "2025-01-01T00:00:00Z",
9595- "updated_at": "2025-01-01T00:00:00Z"
9696- }
9797-]
9898-```
9999-100100-### Get a lexicon
101101-102102-```
103103-GET /admin/lexicons/{id}
104104-```
105105-106106-```sh
107107-curl http://localhost:3000/admin/lexicons/xyz.statusphere.status -H "$AUTH"
108108-```
109109-110110-**Response**: `200 OK` with full lexicon details including raw JSON.
111111-112112-### Delete a lexicon
113113-114114-```
115115-DELETE /admin/lexicons/{id}
116116-```
117117-118118-```sh
119119-curl -X DELETE http://localhost:3000/admin/lexicons/xyz.statusphere.status -H "$AUTH"
120120-```
121121-122122-**Response**: `204 No Content`
123123-124124-## Network Lexicons
125125-126126-Network lexicons are fetched from the AT Protocol network via DNS TXT resolution and kept updated via the Jetstream subscription. See [Lexicons - Network lexicons](../guides/lexicons.md#network-lexicons) for background.
127127-128128-### Add a network lexicon
129129-130130-```
131131-POST /admin/network-lexicons
132132-```
133133-134134-```sh
135135-curl -X POST http://localhost:3000/admin/network-lexicons \
136136- -H "$AUTH" \
137137- -H "Content-Type: application/json" \
138138- -d '{
139139- "nsid": "xyz.statusphere.status",
140140- "target_collection": null
141141- }'
142142-```
143143-144144-| Field | Type | Required | Description |
145145-| ------------------- | ------ | -------- | ------------------------------------------------------------------- |
146146-| `nsid` | string | yes | The NSID of the lexicon to watch |
147147-| `target_collection` | string | no | For query/procedure lexicons, the record collection they operate on |
148148-149149-HappyView resolves the NSID authority via DNS TXT, fetches the lexicon from the authority's PDS, parses it, and stores it.
150150-151151-**Response**: `201 Created`
152152-153153-```json
154154-{
155155- "nsid": "xyz.statusphere.status",
156156- "authority_did": "did:plc:authority",
157157- "revision": 1
158158-}
159159-```
160160-161161-### List network lexicons
162162-163163-```
164164-GET /admin/network-lexicons
165165-```
166166-167167-```sh
168168-curl http://localhost:3000/admin/network-lexicons -H "$AUTH"
169169-```
170170-171171-**Response**: `200 OK`
172172-173173-```json
174174-[
175175- {
176176- "nsid": "xyz.statusphere.status",
177177- "authority_did": "did:plc:authority",
178178- "target_collection": null,
179179- "last_fetched_at": "2025-01-01T00:00:00Z",
180180- "created_at": "2025-01-01T00:00:00Z"
181181- }
182182-]
183183-```
184184-185185-### Remove a network lexicon
186186-187187-```
188188-DELETE /admin/network-lexicons/{nsid}
189189-```
190190-191191-```sh
192192-curl -X DELETE http://localhost:3000/admin/network-lexicons/xyz.statusphere.status \
193193- -H "$AUTH"
194194-```
195195-196196-Removes the network lexicon tracking and also deletes the lexicon from the `lexicons` table and in-memory registry.
197197-198198-**Response**: `204 No Content`
199199-200200-## Stats
201201-202202-### Record counts
203203-204204-```
205205-GET /admin/stats
206206-```
207207-208208-```sh
209209-curl http://localhost:3000/admin/stats -H "$AUTH"
210210-```
211211-212212-**Response**: `200 OK`
213213-214214-```json
215215-{
216216- "total_records": 12345,
217217- "collections": [{ "collection": "xyz.statusphere.status", "count": 500 }]
218218-}
219219-```
220220-221221-## Backfill
222222-223223-### Create a backfill job
224224-225225-```
226226-POST /admin/backfill
227227-```
228228-229229-```sh
230230-curl -X POST http://localhost:3000/admin/backfill \
231231- -H "$AUTH" \
232232- -H "Content-Type: application/json" \
233233- -d '{ "collection": "xyz.statusphere.status" }'
234234-```
235235-236236-| Field | Type | Required | Description |
237237-| ------------ | ------ | -------- | ---------------------------------------------------------- |
238238-| `collection` | string | no | Limit to a single collection (backfills all if omitted) |
239239-| `did` | string | no | Limit to a single DID (discovers all via relay if omitted) |
240240-241241-**Response**: `201 Created`
242242-243243-```json
244244-{
245245- "id": "550e8400-e29b-41d4-a716-446655440000",
246246- "status": "pending"
247247-}
248248-```
249249-250250-### List backfill jobs
251251-252252-```
253253-GET /admin/backfill/status
254254-```
255255-256256-```sh
257257-curl http://localhost:3000/admin/backfill/status -H "$AUTH"
258258-```
259259-260260-**Response**: `200 OK`
261261-262262-```json
263263-[
264264- {
265265- "id": "550e8400-e29b-41d4-a716-446655440000",
266266- "collection": "xyz.statusphere.status",
267267- "did": null,
268268- "status": "completed",
269269- "total_repos": 42,
270270- "processed_repos": 42,
271271- "total_records": 1000,
272272- "error": null,
273273- "started_at": "2025-01-01T00:01:00Z",
274274- "completed_at": "2025-01-01T00:05:00Z",
275275- "created_at": "2025-01-01T00:00:00Z"
276276- }
277277-]
278278-```
279279-280280-## Event Logs
281281-282282-HappyView records an audit trail of system events: lexicon changes, record operations, Lua script executions and errors, user actions, backfill jobs, and Jetstream connectivity. See the [Event Logs guide](../guides/event-logs.md) for details on event types and retention.
283283-284284-### List event logs
285285-286286-```
287287-GET /admin/events
288288-```
289289-290290-```sh
291291-curl "http://localhost:3000/admin/events?severity=error&limit=10" -H "$AUTH"
292292-```
293293-294294-| Param | Type | Required | Description |
295295-| ------------ | ------ | -------- | --------------------------------------------------------------------- |
296296-| `event_type` | string | no | Filter by exact event type (e.g. `script.error`) |
297297-| `category` | string | no | Filter by category prefix (e.g. `lexicon` matches all lexicon events) |
298298-| `severity` | string | no | Filter by severity: `info`, `warn`, or `error` |
299299-| `subject` | string | no | Filter by subject (lexicon ID, record URI, admin DID, etc.) |
300300-| `cursor` | string | no | Pagination cursor (ISO 8601 timestamp from previous response) |
301301-| `limit` | number | no | Results per page (default `50`, max `100`) |
4040+## Endpoint groups
30241303303-**Response**: `200 OK`
304304-305305-```json
306306-{
307307- "events": [
308308- {
309309- "id": "550e8400-e29b-41d4-a716-446655440000",
310310- "event_type": "script.error",
311311- "severity": "error",
312312- "actor_did": "did:plc:abc123",
313313- "subject": "com.example.feed.like",
314314- "detail": {
315315- "error": "attempt to index nil value",
316316- "script_source": "function handle() ... end",
317317- "input": { "status": "hello" },
318318- "caller_did": "did:plc:abc123",
319319- "method": "com.example.feed.like"
320320- },
321321- "created_at": "2026-03-01T12:00:00Z"
322322- }
323323- ],
324324- "cursor": "2026-03-01T11:59:00Z"
325325-}
326326-```
327327-328328-Events are returned in reverse chronological order (newest first). Pass the `cursor` value from the response to fetch the next page.
329329-330330-## API Keys
331331-332332-Manage API keys for programmatic access. See the [API Keys guide](../guides/api-keys.md) for usage details.
333333-334334-### Create an API key
335335-336336-```
337337-POST /admin/api-keys
338338-```
339339-340340-Requires `api-keys:create` permission.
341341-342342-```sh
343343-curl -X POST http://localhost:3000/admin/api-keys \
344344- -H "$AUTH" \
345345- -H "Content-Type: application/json" \
346346- -d '{
347347- "name": "CI Deploy",
348348- "permissions": ["lexicons:read", "lexicons:create", "backfill:create"]
349349- }'
350350-```
351351-352352-| Field | Type | Required | Description |
353353-| ------------- | -------- | -------- | -------------------------------------------------------------------------------------------- |
354354-| `name` | string | yes | A label to identify this key's usage |
355355-| `permissions` | string[] | yes | Permissions to grant the key (must be a subset of the creating user's own permissions) |
356356-357357-**Response**: `201 Created`
358358-359359-```json
360360-{
361361- "id": "550e8400-e29b-41d4-a716-446655440000",
362362- "name": "CI Deploy",
363363- "key": "hv_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
364364- "key_prefix": "hv_a1b2c3d4",
365365- "permissions": ["lexicons:read", "lexicons:create", "backfill:create"]
366366-}
367367-```
368368-369369-The `key` field contains the full API key. It is only returned in this response — store it securely. The key's effective permissions are the **intersection** of the permissions specified here and the creating user's permissions at the time of each request.
370370-371371-### List API keys
372372-373373-```
374374-GET /admin/api-keys
375375-```
376376-377377-Requires `api-keys:read` permission.
378378-379379-```sh
380380-curl http://localhost:3000/admin/api-keys -H "$AUTH"
381381-```
382382-383383-**Response**: `200 OK`
384384-385385-```json
386386-[
387387- {
388388- "id": "550e8400-e29b-41d4-a716-446655440000",
389389- "name": "CI Deploy",
390390- "key_prefix": "hv_a1b2c3d4",
391391- "permissions": ["lexicons:read", "lexicons:create", "backfill:create"],
392392- "created_at": "2026-03-01T00:00:00Z",
393393- "last_used_at": "2026-03-06T12:00:00Z",
394394- "revoked_at": null
395395- }
396396-]
397397-```
398398-399399-Only returns keys belonging to the authenticated user. The full key is never included — only the prefix.
400400-401401-### Revoke an API key
402402-403403-```
404404-DELETE /admin/api-keys/{id}
405405-```
406406-407407-Requires `api-keys:delete` permission.
408408-409409-```sh
410410-curl -X DELETE http://localhost:3000/admin/api-keys/550e8400-e29b-41d4-a716-446655440000 \
411411- -H "$AUTH"
412412-```
413413-414414-Sets `revoked_at` on the key. The key remains in the database for audit purposes but can no longer authenticate.
415415-416416-**Response**: `204 No Content`
417417-418418-## User Management
419419-420420-### Create a user
421421-422422-```
423423-POST /admin/users
424424-```
425425-426426-Requires `users:create` permission. You cannot grant permissions you don't have yourself (escalation guard).
427427-428428-```sh
429429-curl -X POST http://localhost:3000/admin/users \
430430- -H "$AUTH" \
431431- -H "Content-Type: application/json" \
432432- -d '{
433433- "did": "did:plc:newuser",
434434- "template": "operator"
435435- }'
436436-```
437437-438438-| Field | Type | Required | Description |
439439-| ------------- | -------- | -------- | ------------------------------------------------------------------------------------------------- |
440440-| `did` | string | yes | The AT Protocol DID of the user to add |
441441-| `template` | string | no | Permission template: `viewer`, `operator`, `manager`, or `full_access` |
442442-| `permissions` | string[] | no | Explicit list of permissions to grant (used instead of or in addition to `template`) |
443443-444444-If neither `template` nor `permissions` is provided, the user is created with no permissions.
445445-446446-**Response**: `201 Created`
447447-448448-```json
449449-{
450450- "id": "550e8400-e29b-41d4-a716-446655440000",
451451- "did": "did:plc:newuser",
452452- "is_super": false,
453453- "permissions": ["lexicons:read", "records:read", "script-variables:read", "users:read", "api-keys:read", "api-keys:create", "api-keys:delete", "backfill:read", "backfill:create", "stats:read", "events:read"]
454454-}
455455-```
456456-457457-### List users
458458-459459-```
460460-GET /admin/users
461461-```
462462-463463-Requires `users:read` permission.
464464-465465-```sh
466466-curl http://localhost:3000/admin/users -H "$AUTH"
467467-```
468468-469469-**Response**: `200 OK`
470470-471471-```json
472472-[
473473- {
474474- "id": "550e8400-e29b-41d4-a716-446655440000",
475475- "did": "did:plc:admin",
476476- "is_super": true,
477477- "permissions": ["lexicons:create", "lexicons:read", "lexicons:delete", "records:read", "records:delete", "records:delete-collection", "script-variables:create", "script-variables:read", "script-variables:delete", "users:create", "users:read", "users:update", "users:delete", "api-keys:create", "api-keys:read", "api-keys:delete", "backfill:create", "backfill:read", "stats:read", "events:read"],
478478- "created_at": "2025-01-01T00:00:00Z",
479479- "last_used_at": "2025-01-02T12:00:00Z"
480480- }
481481-]
482482-```
483483-484484-### Get a user
485485-486486-```
487487-GET /admin/users/{id}
488488-```
489489-490490-Requires `users:read` permission.
491491-492492-```sh
493493-curl http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 -H "$AUTH"
494494-```
495495-496496-**Response**: `200 OK` with the same shape as a single item from the list response.
497497-498498-### Update user permissions
499499-500500-```
501501-PATCH /admin/users/{id}/permissions
502502-```
503503-504504-Requires `users:update` permission. You cannot grant permissions you don't have yourself, and you cannot modify the super user's permissions.
505505-506506-```sh
507507-curl -X PATCH http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000/permissions \
508508- -H "$AUTH" \
509509- -H "Content-Type: application/json" \
510510- -d '{
511511- "grant": ["lexicons:create", "lexicons:delete"],
512512- "revoke": ["records:delete"]
513513- }'
514514-```
515515-516516-| Field | Type | Required | Description |
517517-| -------- | -------- | -------- | ------------------------------ |
518518-| `grant` | string[] | no | Permissions to add |
519519-| `revoke` | string[] | no | Permissions to remove |
520520-521521-**Response**: `200 OK` with the updated user object.
522522-523523-### Transfer super user
524524-525525-```
526526-POST /admin/users/transfer-super
527527-```
528528-529529-Only the current super user can call this endpoint. Transfers super user status to another existing user.
530530-531531-```sh
532532-curl -X POST http://localhost:3000/admin/users/transfer-super \
533533- -H "$AUTH" \
534534- -H "Content-Type: application/json" \
535535- -d '{ "target_user_id": "550e8400-e29b-41d4-a716-446655440000" }'
536536-```
537537-538538-| Field | Type | Required | Description |
539539-| ---------------- | ------ | -------- | ---------------------------------------- |
540540-| `target_user_id` | string | yes | The ID of the user to receive super status |
541541-542542-**Response**: `200 OK`
543543-544544-### Delete a user
545545-546546-```
547547-DELETE /admin/users/{id}
548548-```
549549-550550-Requires `users:delete` permission. You cannot delete the super user or yourself.
551551-552552-```sh
553553-curl -X DELETE http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 \
554554- -H "$AUTH"
555555-```
556556-557557-**Response**: `204 No Content`
558558-559559-## Labelers
560560-561561-Manage external labeler subscriptions. See the [Labelers guide](../guides/labelers.md) for background.
562562-563563-### Add a labeler
564564-565565-```
566566-POST /admin/labelers
567567-```
568568-569569-Requires `labelers:create` permission.
570570-571571-```sh
572572-curl -X POST http://localhost:3000/admin/labelers \
573573- -H "$AUTH" \
574574- -H "Content-Type: application/json" \
575575- -d '{ "did": "did:plc:ar7c4by46qjdydhdevvrndac" }'
576576-```
577577-578578-| Field | Type | Required | Description |
579579-| ----- | ------ | -------- | ---------------------- |
580580-| `did` | string | yes | The labeler's AT Protocol DID |
581581-582582-**Response**: `201 Created` (empty body)
583583-584584-### List labelers
585585-586586-```
587587-GET /admin/labelers
588588-```
589589-590590-Requires `labelers:read` permission.
591591-592592-```sh
593593-curl http://localhost:3000/admin/labelers -H "$AUTH"
594594-```
595595-596596-**Response**: `200 OK`
597597-598598-```json
599599-[
600600- {
601601- "did": "did:plc:ar7c4by46qjdydhdevvrndac",
602602- "status": "active",
603603- "cursor": 1234,
604604- "created_at": "2026-03-15T00:00:00Z",
605605- "updated_at": "2026-03-15T00:00:00Z"
606606- }
607607-]
608608-```
609609-610610-| Field | Type | Description |
611611-| ------------ | ------------ | ------------------------------------------------ |
612612-| `did` | string | The labeler's DID |
613613-| `status` | string | `active` or `paused` |
614614-| `cursor` | number\|null | Last processed event cursor (null if never synced) |
615615-| `created_at` | string | ISO 8601 creation timestamp |
616616-| `updated_at` | string | ISO 8601 last-updated timestamp |
617617-618618-### Update a labeler
619619-620620-```
621621-PATCH /admin/labelers/{did}
622622-```
623623-624624-Requires `labelers:create` permission.
625625-626626-```sh
627627-curl -X PATCH http://localhost:3000/admin/labelers/did:plc:ar7c4by46qjdydhdevvrndac \
628628- -H "$AUTH" \
629629- -H "Content-Type: application/json" \
630630- -d '{ "status": "paused" }'
631631-```
632632-633633-| Field | Type | Required | Description |
634634-| -------- | ------ | -------- | ---------------------------- |
635635-| `status` | string | yes | New status: `active` or `paused` |
636636-637637-**Response**: `200 OK`
638638-639639-### Delete a labeler
640640-641641-```
642642-DELETE /admin/labelers/{did}
643643-```
644644-645645-Requires `labelers:delete` permission. Removes the subscription and all labels emitted by this labeler.
646646-647647-```sh
648648-curl -X DELETE http://localhost:3000/admin/labelers/did:plc:ar7c4by46qjdydhdevvrndac \
649649- -H "$AUTH"
650650-```
651651-652652-**Response**: `204 No Content`
653653-654654-## Instance Settings
655655-656656-Instance settings are key/value entries used to override environment-variable defaults at runtime (for example, the application name, terms-of-service URL, privacy policy URL, and uploaded logo). Settings stored here take precedence over the corresponding environment variables. All endpoints require the `settings:manage` permission.
657657-658658-### List settings
659659-660660-```
661661-GET /admin/settings
662662-```
663663-664664-```sh
665665-curl http://localhost:3000/admin/settings -H "$AUTH"
666666-```
667667-668668-Returns all key/value pairs stored in the `instance_settings` table.
669669-670670-### Upsert a setting
671671-672672-```
673673-PUT /admin/settings/{key}
674674-```
675675-676676-```sh
677677-curl -X PUT http://localhost:3000/admin/settings/app_name \
678678- -H "$AUTH" \
679679- -H "Content-Type: application/json" \
680680- -d '{ "value": "My HappyView" }'
681681-```
682682-683683-### Delete a setting
684684-685685-```
686686-DELETE /admin/settings/{key}
687687-```
688688-689689-Removes the override; the corresponding environment variable (if any) takes effect again.
690690-691691-### Upload / delete logo
692692-693693-```
694694-PUT /admin/settings/logo
695695-DELETE /admin/settings/logo
696696-```
697697-698698-`PUT` accepts a binary image body and stores it as the instance logo (served via the public dashboard). `DELETE` removes the stored logo.
699699-700700-## Domain Management
701701-702702-Manage the domains a HappyView instance serves. Each domain gets its own AT Protocol OAuth client identity. The primary domain is auto-seeded from `PUBLIC_URL` on first boot. All endpoints require the `settings:manage` permission.
703703-704704-### List domains
705705-706706-```
707707-GET /admin/domains
708708-```
709709-710710-```sh
711711-curl http://localhost:3000/admin/domains -H "$AUTH"
712712-```
713713-714714-**Response**: `200 OK`
715715-716716-```json
717717-[
718718- {
719719- "id": "550e8400-e29b-41d4-a716-446655440000",
720720- "url": "https://gamesgamesgamesgames.games",
721721- "is_primary": true,
722722- "created_at": "2026-04-16T00:00:00Z",
723723- "updated_at": "2026-04-16T00:00:00Z"
724724- }
725725-]
726726-```
727727-728728-### Add a domain
729729-730730-```
731731-POST /admin/domains
732732-```
733733-734734-```sh
735735-curl -X POST http://localhost:3000/admin/domains \
736736- -H "$AUTH" \
737737- -H "Content-Type: application/json" \
738738- -d '{ "url": "https://api.cartridge.dev" }'
739739-```
740740-741741-| Field | Type | Required | Description |
742742-| ----- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
743743-| `url` | string | yes | Valid origin (scheme + host, no path or trailing slash). Must be `https` unless `PUBLIC_URL` is a loopback address. |
744744-745745-Returns `400 Bad Request` if the URL is invalid or already registered.
746746-747747-**Response**: `201 Created`
748748-749749-```json
750750-{
751751- "id": "550e8400-e29b-41d4-a716-446655440001",
752752- "url": "https://api.cartridge.dev",
753753- "is_primary": false,
754754- "created_at": "2026-04-16T00:00:00Z",
755755- "updated_at": "2026-04-16T00:00:00Z"
756756-}
757757-```
758758-759759-Side effects: builds an OAuth client for the domain, updates the in-memory domain cache.
760760-761761-### Remove a domain
762762-763763-```
764764-DELETE /admin/domains/{id}
765765-```
766766-767767-```sh
768768-curl -X DELETE http://localhost:3000/admin/domains/550e8400-e29b-41d4-a716-446655440001 \
769769- -H "$AUTH"
770770-```
771771-772772-Returns `400 Bad Request` if the domain is primary — set a different domain as primary first. Returns `404 Not Found` if the domain doesn't exist.
773773-774774-**Response**: `204 No Content`
775775-776776-Side effects: removes the domain's OAuth client and cache entry.
777777-778778-### Set primary domain
779779-780780-```
781781-POST /admin/domains/{id}/primary
782782-```
783783-784784-```sh
785785-curl -X POST http://localhost:3000/admin/domains/550e8400-e29b-41d4-a716-446655440001/primary \
786786- -H "$AUTH"
787787-```
788788-789789-Sets the target domain as the primary. Unsets the current primary in a single operation. Returns `404 Not Found` if the domain doesn't exist.
790790-791791-**Response**: `204 No Content`
792792-793793-Side effects: updates the in-memory cache and the OAuth client registry's primary client reference.
794794-795795-## Script Variables
796796-797797-Script variables are encrypted key/value pairs available to Lua scripts via the `vars` global. Use them for secrets like API tokens.
798798-799799-### List script variables
800800-801801-```
802802-GET /admin/script-variables
803803-```
804804-805805-Requires `script-variables:read`. Returns a list of variable keys (values are not returned).
806806-807807-### Upsert a script variable
808808-809809-```
810810-POST /admin/script-variables
811811-```
812812-813813-Requires `script-variables:create`.
814814-815815-```sh
816816-curl -X POST http://localhost:3000/admin/script-variables \
817817- -H "$AUTH" \
818818- -H "Content-Type: application/json" \
819819- -d '{ "key": "ALGOLIA_API_KEY", "value": "..." }'
820820-```
821821-822822-The value is encrypted at rest using `TOKEN_ENCRYPTION_KEY`.
823823-824824-### Delete a script variable
825825-826826-```
827827-DELETE /admin/script-variables/{key}
828828-```
829829-830830-Requires `script-variables:delete`.
831831-832832-## API Clients
833833-834834-API clients represent third-party applications that call HappyView's XRPC endpoints. **Every XRPC request** — including unauthenticated queries — must identify itself with a registered client via the `X-Client-Key` header (or session cookie, or `client_key` query param). The client key is HappyView's rate-limit bucket and caller identity; a request without one gets `401 Unauthorized`.
835835-836836-Each client has an `hvc_`-prefixed client key and an `hvs_`-prefixed client secret. The secret is only returned once (at creation) and is sha256-hashed in the database. Server-to-server callers pass the secret as `X-Client-Secret`; browser callers rely on the `Origin` header matching the client's registered `client_uri`. Both checks currently log warnings on mismatch rather than rejecting the request, but the rate-limit bucket is applied either way. See [Authentication — XRPC](../getting-started/authentication.md#xrpc-api-client-identification) for the client-side view, and the [API Keys guide](../guides/api-keys.md) for how admin API keys differ from API clients.
837837-838838-### List API clients
839839-840840-```
841841-GET /admin/api-clients
842842-```
843843-844844-Requires `api-clients:view`. Returns clients ordered by `created_at` descending. Secrets are never returned.
845845-846846-```sh
847847-curl http://localhost:3000/admin/api-clients -H "$AUTH"
848848-```
849849-850850-**Response**: `200 OK`
851851-852852-```json
853853-[
854854- {
855855- "id": "01J9...",
856856- "client_key": "hvc_a1b2c3...",
857857- "name": "My Game Client",
858858- "client_id_url": "https://example.com/client-metadata.json",
859859- "client_uri": "https://example.com",
860860- "redirect_uris": ["https://example.com/callback"],
861861- "scopes": "atproto",
862862- "rate_limit_capacity": 200,
863863- "rate_limit_refill_rate": 5.0,
864864- "is_active": true,
865865- "created_by": "did:plc:...",
866866- "created_at": "2026-04-13T12:00:00Z",
867867- "updated_at": "2026-04-13T12:00:00Z"
868868- }
869869-]
870870-```
871871-872872-### Create an API client
873873-874874-```
875875-POST /admin/api-clients
876876-```
877877-878878-Requires `api-clients:create`. Generates a fresh `client_key` and `client_secret`. **The secret is only returned in this response** — store it immediately.
879879-880880-```sh
881881-curl -X POST http://localhost:3000/admin/api-clients \
882882- -H "$AUTH" \
883883- -H "Content-Type: application/json" \
884884- -d '{
885885- "name": "My Game Client",
886886- "client_id_url": "https://example.com/client-metadata.json",
887887- "client_uri": "https://example.com",
888888- "redirect_uris": ["https://example.com/callback"],
889889- "scopes": "atproto",
890890- "rate_limit_capacity": 200,
891891- "rate_limit_refill_rate": 5.0
892892- }'
893893-```
894894-895895-| Field | Type | Required | Description |
896896-| ------------------------ | -------- | -------- | -------------------------------------------------------------------------------------- |
897897-| `name` | string | yes | Human-readable display name |
898898-| `client_id_url` | string | yes | URL to the client's published OAuth client metadata document |
899899-| `client_uri` | string | yes | The client's home/landing URL |
900900-| `redirect_uris` | string[] | yes | Allowed OAuth redirect URIs |
901901-| `scopes` | string | no | Space-separated OAuth scopes (default `"atproto"`) |
902902-| `rate_limit_capacity` | integer | no | Per-client token bucket capacity. Falls back to `DEFAULT_RATE_LIMIT_CAPACITY` if unset |
903903-| `rate_limit_refill_rate` | number | no | Tokens added per second. Falls back to `DEFAULT_RATE_LIMIT_REFILL_RATE` if unset |
904904-905905-**Response**: `201 Created`
906906-907907-```json
908908-{
909909- "id": "01J9...",
910910- "client_key": "hvc_a1b2c3...",
911911- "client_secret": "hvs_d4e5f6...",
912912- "name": "My Game Client",
913913- "client_id_url": "https://example.com/client-metadata.json"
914914-}
915915-```
916916-917917-The new client is immediately registered with the OAuth registry and rate limiter, so it can authenticate without restarting HappyView.
918918-919919-### Get an API client
920920-921921-```
922922-GET /admin/api-clients/{id}
923923-```
924924-925925-Requires `api-clients:view`. Returns the same `ApiClientSummary` shape as the list endpoint, or `404 Not Found`.
926926-927927-### Update an API client
928928-929929-```
930930-PUT /admin/api-clients/{id}
931931-```
932932-933933-Requires `api-clients:edit`. All fields are optional — only provided fields are changed. Updating either rate-limit field re-registers the client with the rate limiter using the new values.
934934-935935-| Field | Type | Description |
936936-| ------------------------ | -------- | ------------------------------------------------------------------------ |
937937-| `name` | string | New display name |
938938-| `client_uri` | string | New home URL |
939939-| `redirect_uris` | string[] | Replace the allowed redirect URIs |
940940-| `scopes` | string | Replace the OAuth scopes |
941941-| `rate_limit_capacity` | integer | New bucket capacity. Pass `null` to clear the override |
942942-| `rate_limit_refill_rate` | number | New refill rate. Pass `null` to clear the override |
943943-| `is_active` | boolean | Disable (`false`) or re-enable (`true`) the client without deleting it |
944944-945945-**Response**: `204 No Content`
946946-947947-The OAuth registry is updated in place. The `client_id_url` is immutable — to change it, delete and recreate the client.
948948-949949-### Delete an API client
950950-951951-```
952952-DELETE /admin/api-clients/{id}
953953-```
954954-955955-Requires `api-clients:delete`. Removes the client from the OAuth registry, the rate limiter, and the client identity store.
956956-957957-**Response**: `204 No Content`
958958-959959-## Plugins
960960-961961-Plugins extend HappyView with WebAssembly modules sourced from the [official plugin registry](../guides/plugins.md) or any URL serving a `manifest.json`. Most endpoints take a plugin manifest URL and load (or reload) the plugin in place — no restart needed. Encrypted plugin secrets require `TOKEN_ENCRYPTION_KEY` to be configured.
962962-963963-### List installed plugins
964964-965965-```
966966-GET /admin/plugins
967967-```
968968-969969-Requires `plugins:read`. Returns every loaded plugin with its source, required secrets, configuration status, and any pending updates from the official registry cache.
970970-971971-```sh
972972-curl http://localhost:3000/admin/plugins -H "$AUTH"
973973-```
974974-975975-**Response**: `200 OK`
976976-977977-```json
978978-{
979979- "encryption_configured": true,
980980- "plugins": [
981981- {
982982- "id": "steam",
983983- "name": "Steam",
984984- "version": "1.2.0",
985985- "source": "url",
986986- "url": "https://example.com/plugins/steam/manifest.json",
987987- "sha256": null,
988988- "enabled": true,
989989- "auth_type": "openid",
990990- "required_secrets": [
991991- {
992992- "key": "PLUGIN_STEAM_API_KEY",
993993- "name": "Steam Web API Key",
994994- "description": "Get your API key at steamcommunity.com/dev/apikey"
995995- }
996996- ],
997997- "secrets_configured": true,
998998- "loaded_at": null,
999999- "update_available": false,
10001000- "latest_version": "1.2.0",
10011001- "pending_releases": []
10021002- }
10031003- ]
10041004-}
10051005-```
10061006-10071007-`secrets_configured` is `true` if the plugin has no required secrets, or if a row exists for it in `plugin_configs`. `update_available` and `pending_releases` are populated from the cached official registry — call `POST /admin/plugins/{id}/check-update` to refresh them.
10081008-10091009-### Preview a plugin before installing
10101010-10111011-```
10121012-POST /admin/plugins/preview
10131013-```
10141014-10151015-Requires `plugins:create`. Fetches and parses a manifest without installing the plugin, so the dashboard can show what it would register.
10161016-10171017-```sh
10181018-curl -X POST http://localhost:3000/admin/plugins/preview \
10191019- -H "$AUTH" \
10201020- -H "Content-Type: application/json" \
10211021- -d '{ "url": "https://example.com/plugins/steam/manifest.json" }'
10221022-```
10231023-10241024-**Response**: `200 OK`
10251025-10261026-```json
10271027-{
10281028- "id": "steam",
10291029- "name": "Steam",
10301030- "version": "1.2.0",
10311031- "description": "Import your Steam game library and playtime data.",
10321032- "icon_url": "https://example.com/steam-icon.png",
10331033- "auth_type": "openid",
10341034- "required_secrets": [
10351035- { "key": "PLUGIN_STEAM_API_KEY", "name": "Steam Web API Key", "description": "..." }
10361036- ],
10371037- "manifest_url": "https://example.com/plugins/steam/manifest.json",
10381038- "wasm_url": "https://example.com/plugins/steam/steam.wasm"
10391039-}
10401040-```
10411041-10421042-Returns `400 Bad Request` if the manifest can't be fetched or parsed.
10431043-10441044-### Install a plugin
10451045-10461046-```
10471047-POST /admin/plugins
10481048-```
10491049-10501050-Requires `plugins:create`. Fetches the manifest, downloads the WASM, registers the plugin, and persists it.
10511051-10521052-```sh
10531053-curl -X POST http://localhost:3000/admin/plugins \
10541054- -H "$AUTH" \
10551055- -H "Content-Type: application/json" \
10561056- -d '{
10571057- "url": "https://example.com/plugins/steam/manifest.json",
10581058- "sha256": "abc123..."
10591059- }'
10601060-```
10611061-10621062-| Field | Type | Required | Description |
10631063-| -------- | ------ | -------- | -------------------------------------------------------------------------------------------- |
10641064-| `url` | string | yes | URL to the plugin's `manifest.json` |
10651065-| `sha256` | string | no | Optional sha256 of the WASM binary. If provided, install fails when the downloaded hash mismatches |
10661066-10671067-**Response**: `200 OK` returning the same `PluginSummary` shape as the list endpoint. `secrets_configured` will be `false` if the plugin requires any secrets — call `PUT /admin/plugins/{id}/secrets` to configure them before the plugin can run.
10681068-10691069-### List official plugins
10701070-10711071-```
10721072-GET /admin/plugins/official
10731073-```
10741074-10751075-Requires `plugins:read`. Returns the cached catalog of plugins from the official registry. The cache is refreshed periodically by the server; use `POST /admin/plugins/{id}/check-update` to force-refresh a single entry.
10761076-10771077-**Response**: `200 OK`
10781078-10791079-```json
10801080-{
10811081- "last_refreshed_at": "2026-04-13T11:00:00Z",
10821082- "plugins": [
10831083- {
10841084- "id": "steam",
10851085- "name": "Steam",
10861086- "description": "Import your Steam game library and playtime data.",
10871087- "icon_url": "https://example.com/steam-icon.png",
10881088- "latest_version": "1.2.0",
10891089- "manifest_url": "https://example.com/plugins/steam/manifest.json"
10901090- }
10911091- ]
10921092-}
10931093-```
10941094-10951095-### Remove a plugin
10961096-10971097-```
10981098-DELETE /admin/plugins/{id}
10991099-```
11001100-11011101-Requires `plugins:delete`. Unregisters the plugin from the runtime and deletes its row from the `plugins` table. Plugin secrets in `plugin_configs` are not removed automatically — they're available again if you reinstall the same plugin.
11021102-11031103-**Response**: `204 No Content`. Returns `404 Not Found` if no plugin with that id is loaded.
11041104-11051105-### Reload a plugin
11061106-11071107-```
11081108-POST /admin/plugins/{id}/reload
11091109-```
11101110-11111111-Requires `plugins:create`. Re-fetches the plugin from its current source URL and re-registers it. Useful after publishing a new version of a plugin you host yourself.
11121112-11131113-The body is optional. To point the plugin at a new URL, pass:
11141114-11151115-```json
11161116-{ "url": "https://example.com/plugins/steam/manifest.json" }
11171117-```
11181118-11191119-When a new URL is provided, the stored `sha256` is cleared (the new version has its own hash). File-based plugins cannot be reloaded via this endpoint and return `400 Bad Request`.
11201120-11211121-**Response**: `200 OK` with the refreshed `PluginSummary`.
11221122-11231123-### Check for plugin updates
11241124-11251125-```
11261126-POST /admin/plugins/{id}/check-update
11271127-```
11281128-11291129-Requires `plugins:create`. Forces a cache refresh for one plugin from the official registry, then returns the updated `PluginSummary` with `update_available`, `latest_version`, and `pending_releases` reflecting the latest catalog state.
11301130-11311131-**Response**: `200 OK` with a `PluginSummary`.
11321132-11331133-### Get plugin secrets
11341134-11351135-```
11361136-GET /admin/plugins/{id}/secrets
11371137-```
11381138-11391139-Requires `plugins:read`. Returns the plugin's configured secrets with values masked (last 4 characters shown for values longer than 8 characters, otherwise fully masked). Requires `TOKEN_ENCRYPTION_KEY` to be configured.
11401140-11411141-**Response**: `200 OK`
11421142-11431143-```json
11441144-{
11451145- "plugin_id": "steam",
11461146- "secrets": {
11471147- "PLUGIN_STEAM_API_KEY": "********ABCD"
11481148- }
11491149-}
11501150-```
11511151-11521152-### Update plugin secrets
11531153-11541154-```
11551155-PUT /admin/plugins/{id}/secrets
11561156-```
11571157-11581158-Requires `plugins:create`. Encrypts the provided secret values with `TOKEN_ENCRYPTION_KEY` (AES-256-GCM) and upserts them into `plugin_configs`.
11591159-11601160-```sh
11611161-curl -X PUT http://localhost:3000/admin/plugins/steam/secrets \
11621162- -H "$AUTH" \
11631163- -H "Content-Type: application/json" \
11641164- -d '{
11651165- "secrets": {
11661166- "PLUGIN_STEAM_API_KEY": "your-new-api-key"
11671167- }
11681168- }'
11691169-```
11701170-11711171-Special handling:
11721172-11731173-- Values starting with `********` are treated as masked placeholders and the existing encrypted value is preserved (so you can `GET` then `PUT` without re-typing every secret).
11741174-- Empty string values are not stored — use them to clear a secret.
11751175-11761176-**Response**: `204 No Content`
4242+| Group | Description |
4343+| ----- | ----------- |
4444+| [Lexicons](admin/lexicons.md) | Upload, list, get, and delete lexicons and network lexicons |
4545+| [Stats](admin/stats.md) | Record counts by collection |
4646+| [Backfill](admin/backfill.md) | Create and monitor historical backfill jobs |
4747+| [Event Logs](admin/events.md) | Query the audit trail of system events |
4848+| [API Keys](admin/api-keys.md) | Create, list, and revoke API keys |
4949+| [Users](admin/users.md) | Create, list, update, and delete admin users |
5050+| [Labelers](admin/labelers.md) | Manage external labeler subscriptions |
5151+| [Instance Settings](admin/settings.md) | Configure app name, logo, and policy URLs |
5252+| [Domains](admin/domains.md) | Manage domains and their OAuth client identities |
5353+| [Script Variables](admin/script-variables.md) | Encrypted key/value pairs for Lua scripts |
5454+| [API Clients](admin/api-clients.md) | Register and manage third-party XRPC clients |
5555+| [Plugins](admin/plugins.md) | Install, configure, and manage WASM plugins |
117756117857## Permissions
117958118059Each admin API endpoint requires a specific permission. See the [Permissions guide](../guides/permissions.md) for the full list of permissions and templates.
11816011821182-| Endpoint | Required Permission |
11831183-| ------------------------------------- | ---------------------------- |
11841184-| `POST /admin/lexicons` | `lexicons:create` |
11851185-| `GET /admin/lexicons` | `lexicons:read` |
11861186-| `GET /admin/lexicons/{id}` | `lexicons:read` |
11871187-| `DELETE /admin/lexicons/{id}` | `lexicons:delete` |
11881188-| `POST /admin/network-lexicons` | `lexicons:create` |
11891189-| `GET /admin/network-lexicons` | `lexicons:read` |
11901190-| `DELETE /admin/network-lexicons/{id}` | `lexicons:delete` |
11911191-| `GET /admin/stats` | `stats:read` |
11921192-| `POST /admin/backfill` | `backfill:create` |
11931193-| `GET /admin/backfill/status` | `backfill:read` |
11941194-| `GET /admin/events` | `events:read` |
11951195-| `POST /admin/api-keys` | `api-keys:create` |
11961196-| `GET /admin/api-keys` | `api-keys:read` |
11971197-| `DELETE /admin/api-keys/{id}` | `api-keys:delete` |
11981198-| `POST /admin/users` | `users:create` |
11991199-| `GET /admin/users` | `users:read` |
12001200-| `GET /admin/users/{id}` | `users:read` |
12011201-| `PATCH /admin/users/{id}/permissions`| `users:update` |
12021202-| `DELETE /admin/users/{id}` | `users:delete` |
12031203-| `POST /admin/users/transfer-super` | Super user only |
12041204-| `GET /admin/script-variables` | `script-variables:read` |
12051205-| `POST /admin/script-variables` | `script-variables:create` |
12061206-| `DELETE /admin/script-variables/{key}`| `script-variables:delete` |
12071207-| `POST /admin/labelers` | `labelers:create` |
12081208-| `GET /admin/labelers` | `labelers:read` |
12091209-| `PATCH /admin/labelers/{did}` | `labelers:create` |
12101210-| `DELETE /admin/labelers/{did}` | `labelers:delete` |
12111211-| `GET /admin/settings` | `settings:manage` |
12121212-| `PUT /admin/settings/{key}` | `settings:manage` |
12131213-| `DELETE /admin/settings/{key}` | `settings:manage` |
12141214-| `PUT /admin/settings/logo` | `settings:manage` |
12151215-| `DELETE /admin/settings/logo` | `settings:manage` |
12161216-| `GET /admin/plugins` | `plugins:read` |
12171217-| `POST /admin/plugins` | `plugins:create` |
12181218-| `POST /admin/plugins/preview` | `plugins:read` |
12191219-| `GET /admin/plugins/official` | `plugins:read` |
12201220-| `DELETE /admin/plugins/{id}` | `plugins:delete` |
12211221-| `POST /admin/plugins/{id}/reload` | `plugins:create` |
12221222-| `POST /admin/plugins/{id}/check-update` | `plugins:read` |
12231223-| `GET /admin/plugins/{id}/secrets` | `plugins:read` |
12241224-| `PUT /admin/plugins/{id}/secrets` | `plugins:create` |
12251225-| `GET /admin/domains` | `settings:manage` |
12261226-| `POST /admin/domains` | `settings:manage` |
12271227-| `DELETE /admin/domains/{id}` | `settings:manage` |
12281228-| `POST /admin/domains/{id}/primary` | `settings:manage` |
12291229-| `GET /admin/api-clients` | `api-clients:view` |
12301230-| `POST /admin/api-clients` | `api-clients:create` |
12311231-| `GET /admin/api-clients/{id}` | `api-clients:view` |
12321232-| `PUT /admin/api-clients/{id}` | `api-clients:edit` |
12331233-| `DELETE /admin/api-clients/{id}` | `api-clients:delete` |
6161+| Endpoint | Required Permission |
6262+| ---------------------------------------- | -------------------------- |
6363+| `POST /admin/lexicons` | `lexicons:create` |
6464+| `GET /admin/lexicons` | `lexicons:read` |
6565+| `GET /admin/lexicons/{id}` | `lexicons:read` |
6666+| `DELETE /admin/lexicons/{id}` | `lexicons:delete` |
6767+| `POST /admin/network-lexicons` | `lexicons:create` |
6868+| `GET /admin/network-lexicons` | `lexicons:read` |
6969+| `DELETE /admin/network-lexicons/{id}` | `lexicons:delete` |
7070+| `GET /admin/stats` | `stats:read` |
7171+| `POST /admin/backfill` | `backfill:create` |
7272+| `GET /admin/backfill/status` | `backfill:read` |
7373+| `GET /admin/events` | `events:read` |
7474+| `POST /admin/api-keys` | `api-keys:create` |
7575+| `GET /admin/api-keys` | `api-keys:read` |
7676+| `DELETE /admin/api-keys/{id}` | `api-keys:delete` |
7777+| `POST /admin/users` | `users:create` |
7878+| `GET /admin/users` | `users:read` |
7979+| `GET /admin/users/{id}` | `users:read` |
8080+| `PATCH /admin/users/{id}/permissions` | `users:update` |
8181+| `DELETE /admin/users/{id}` | `users:delete` |
8282+| `POST /admin/users/transfer-super` | Super user only |
8383+| `GET /admin/script-variables` | `script-variables:read` |
8484+| `POST /admin/script-variables` | `script-variables:create` |
8585+| `DELETE /admin/script-variables/{key}` | `script-variables:delete` |
8686+| `POST /admin/labelers` | `labelers:create` |
8787+| `GET /admin/labelers` | `labelers:read` |
8888+| `PATCH /admin/labelers/{did}` | `labelers:create` |
8989+| `DELETE /admin/labelers/{did}` | `labelers:delete` |
9090+| `GET /admin/settings` | `settings:manage` |
9191+| `PUT /admin/settings/{key}` | `settings:manage` |
9292+| `DELETE /admin/settings/{key}` | `settings:manage` |
9393+| `PUT /admin/settings/logo` | `settings:manage` |
9494+| `DELETE /admin/settings/logo` | `settings:manage` |
9595+| `GET /admin/plugins` | `plugins:read` |
9696+| `POST /admin/plugins` | `plugins:create` |
9797+| `POST /admin/plugins/preview` | `plugins:read` |
9898+| `GET /admin/plugins/official` | `plugins:read` |
9999+| `DELETE /admin/plugins/{id}` | `plugins:delete` |
100100+| `POST /admin/plugins/{id}/reload` | `plugins:create` |
101101+| `POST /admin/plugins/{id}/check-update` | `plugins:read` |
102102+| `GET /admin/plugins/{id}/secrets` | `plugins:read` |
103103+| `PUT /admin/plugins/{id}/secrets` | `plugins:create` |
104104+| `GET /admin/domains` | `settings:manage` |
105105+| `POST /admin/domains` | `settings:manage` |
106106+| `DELETE /admin/domains/{id}` | `settings:manage` |
107107+| `POST /admin/domains/{id}/primary` | `settings:manage` |
108108+| `GET /admin/api-clients` | `api-clients:view` |
109109+| `POST /admin/api-clients` | `api-clients:create` |
110110+| `GET /admin/api-clients/{id}` | `api-clients:view` |
111111+| `PUT /admin/api-clients/{id}` | `api-clients:edit` |
112112+| `DELETE /admin/api-clients/{id}` | `api-clients:delete` |
+131
packages/docs/docs/reference/admin/api-clients.md
···11+# Admin API: API Clients
22+33+API clients represent third-party applications that call HappyView's XRPC endpoints. **Every XRPC request** — including unauthenticated queries — must identify itself with a registered client via the `X-Client-Key` header (or `client_key` query param). The client key is HappyView's rate-limit bucket and caller identity; a request without one gets `401 Unauthorized`.
44+55+Each client has an `hvc_`-prefixed client key and an `hvs_`-prefixed client secret. The secret is only returned once (at creation) and is sha256-hashed in the database. Server-to-server callers pass the secret as `X-Client-Secret`; browser callers rely on the `Origin` header matching the client's registered `client_uri`. Both checks currently log warnings on mismatch rather than rejecting the request, but the rate-limit bucket is applied either way. See [Authentication — XRPC](../../getting-started/authentication.md#xrpc-api-client-identification) for the client-side view, and the [API Keys guide](../../guides/api-keys.md) for how admin API keys differ from API clients.
66+77+```sh
88+# All examples assume $TOKEN is an API key (hv_...)
99+AUTH="Authorization: Bearer $TOKEN"
1010+```
1111+1212+## List API clients
1313+1414+```
1515+GET /admin/api-clients
1616+```
1717+1818+Requires `api-clients:view`. Returns clients ordered by `created_at` descending. Secrets are never returned.
1919+2020+```sh
2121+curl http://localhost:3000/admin/api-clients -H "$AUTH"
2222+```
2323+2424+**Response**: `200 OK`
2525+2626+```json
2727+[
2828+ {
2929+ "id": "01J9...",
3030+ "client_key": "hvc_a1b2c3...",
3131+ "name": "My Game Client",
3232+ "client_id_url": "https://example.com/client-metadata.json",
3333+ "client_uri": "https://example.com",
3434+ "redirect_uris": ["https://example.com/callback"],
3535+ "scopes": "atproto",
3636+ "rate_limit_capacity": 200,
3737+ "rate_limit_refill_rate": 5.0,
3838+ "is_active": true,
3939+ "created_by": "did:plc:...",
4040+ "created_at": "2026-04-13T12:00:00Z",
4141+ "updated_at": "2026-04-13T12:00:00Z"
4242+ }
4343+]
4444+```
4545+4646+## Create an API client
4747+4848+```
4949+POST /admin/api-clients
5050+```
5151+5252+Requires `api-clients:create`. Generates a fresh `client_key` and `client_secret`. **The secret is only returned in this response** — store it immediately.
5353+5454+```sh
5555+curl -X POST http://localhost:3000/admin/api-clients \
5656+ -H "$AUTH" \
5757+ -H "Content-Type: application/json" \
5858+ -d '{
5959+ "name": "My Game Client",
6060+ "client_id_url": "https://example.com/client-metadata.json",
6161+ "client_uri": "https://example.com",
6262+ "redirect_uris": ["https://example.com/callback"],
6363+ "scopes": "atproto",
6464+ "rate_limit_capacity": 200,
6565+ "rate_limit_refill_rate": 5.0
6666+ }'
6767+```
6868+6969+| Field | Type | Required | Description |
7070+| ------------------------ | -------- | -------- | -------------------------------------------------------------------------------------- |
7171+| `name` | string | yes | Human-readable display name |
7272+| `client_id_url` | string | yes | URL to the client's published OAuth client metadata document |
7373+| `client_uri` | string | yes | The client's home/landing URL |
7474+| `redirect_uris` | string[] | yes | Allowed OAuth redirect URIs |
7575+| `scopes` | string | no | Space-separated OAuth scopes (default `"atproto"`) |
7676+| `rate_limit_capacity` | integer | no | Per-client token bucket capacity. Falls back to `DEFAULT_RATE_LIMIT_CAPACITY` if unset |
7777+| `rate_limit_refill_rate` | number | no | Tokens added per second. Falls back to `DEFAULT_RATE_LIMIT_REFILL_RATE` if unset |
7878+7979+**Response**: `201 Created`
8080+8181+```json
8282+{
8383+ "id": "01J9...",
8484+ "client_key": "hvc_a1b2c3...",
8585+ "client_secret": "hvs_d4e5f6...",
8686+ "name": "My Game Client",
8787+ "client_id_url": "https://example.com/client-metadata.json"
8888+}
8989+```
9090+9191+The new client is immediately registered with the OAuth registry and rate limiter, so it can authenticate without restarting HappyView.
9292+9393+## Get an API client
9494+9595+```
9696+GET /admin/api-clients/{id}
9797+```
9898+9999+Requires `api-clients:view`. Returns the same shape as the list endpoint, or `404 Not Found`.
100100+101101+## Update an API client
102102+103103+```
104104+PUT /admin/api-clients/{id}
105105+```
106106+107107+Requires `api-clients:edit`. All fields are optional — only provided fields are changed. Updating either rate-limit field re-registers the client with the rate limiter using the new values.
108108+109109+| Field | Type | Description |
110110+| ------------------------ | -------- | ---------------------------------------------------------------------- |
111111+| `name` | string | New display name |
112112+| `client_uri` | string | New home URL |
113113+| `redirect_uris` | string[] | Replace the allowed redirect URIs |
114114+| `scopes` | string | Replace the OAuth scopes |
115115+| `rate_limit_capacity` | integer | New bucket capacity. Pass `null` to clear the override |
116116+| `rate_limit_refill_rate` | number | New refill rate. Pass `null` to clear the override |
117117+| `is_active` | boolean | Disable (`false`) or re-enable (`true`) the client without deleting it |
118118+119119+**Response**: `204 No Content`
120120+121121+The OAuth registry is updated in place. The `client_id_url` is immutable — to change it, delete and recreate the client.
122122+123123+## Delete an API client
124124+125125+```
126126+DELETE /admin/api-clients/{id}
127127+```
128128+129129+Requires `api-clients:delete`. Removes the client from the OAuth registry, the rate limiter, and the client identity store.
130130+131131+**Response**: `204 No Content`
+92
packages/docs/docs/reference/admin/api-keys.md
···11+# Admin API: API Keys
22+33+Manage API keys for programmatic access. See the [API Keys guide](../../guides/api-keys.md) for usage details.
44+55+```sh
66+# All examples assume $TOKEN is an API key (hv_...)
77+AUTH="Authorization: Bearer $TOKEN"
88+```
99+1010+## Create an API key
1111+1212+```
1313+POST /admin/api-keys
1414+```
1515+1616+Requires `api-keys:create` permission.
1717+1818+```sh
1919+curl -X POST http://localhost:3000/admin/api-keys \
2020+ -H "$AUTH" \
2121+ -H "Content-Type: application/json" \
2222+ -d '{
2323+ "name": "CI Deploy",
2424+ "permissions": ["lexicons:read", "lexicons:create", "backfill:create"]
2525+ }'
2626+```
2727+2828+| Field | Type | Required | Description |
2929+| ------------- | -------- | -------- | ------------------------------------------------------------------------------------- |
3030+| `name` | string | yes | A label to identify this key's usage |
3131+| `permissions` | string[] | yes | Permissions to grant the key (must be a subset of the creating user's own permissions) |
3232+3333+**Response**: `201 Created`
3434+3535+```json
3636+{
3737+ "id": "550e8400-e29b-41d4-a716-446655440000",
3838+ "name": "CI Deploy",
3939+ "key": "hv_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
4040+ "key_prefix": "hv_a1b2c3d4",
4141+ "permissions": ["lexicons:read", "lexicons:create", "backfill:create"]
4242+}
4343+```
4444+4545+The `key` field contains the full API key. It is only returned in this response — store it securely. The key's effective permissions are the **intersection** of the permissions specified here and the creating user's permissions at the time of each request.
4646+4747+## List API keys
4848+4949+```
5050+GET /admin/api-keys
5151+```
5252+5353+Requires `api-keys:read` permission.
5454+5555+```sh
5656+curl http://localhost:3000/admin/api-keys -H "$AUTH"
5757+```
5858+5959+**Response**: `200 OK`
6060+6161+```json
6262+[
6363+ {
6464+ "id": "550e8400-e29b-41d4-a716-446655440000",
6565+ "name": "CI Deploy",
6666+ "key_prefix": "hv_a1b2c3d4",
6767+ "permissions": ["lexicons:read", "lexicons:create", "backfill:create"],
6868+ "created_at": "2026-03-01T00:00:00Z",
6969+ "last_used_at": "2026-03-06T12:00:00Z",
7070+ "revoked_at": null
7171+ }
7272+]
7373+```
7474+7575+Only returns keys belonging to the authenticated user. The full key is never included — only the prefix.
7676+7777+## Revoke an API key
7878+7979+```
8080+DELETE /admin/api-keys/{id}
8181+```
8282+8383+Requires `api-keys:delete` permission.
8484+8585+```sh
8686+curl -X DELETE http://localhost:3000/admin/api-keys/550e8400-e29b-41d4-a716-446655440000 \
8787+ -H "$AUTH"
8888+```
8989+9090+Sets `revoked_at` on the key. The key remains in the database for audit purposes but can no longer authenticate.
9191+9292+**Response**: `204 No Content`
+65
packages/docs/docs/reference/admin/backfill.md
···11+# Admin API: Backfill
22+33+Create and monitor historical backfill jobs. See the [Backfill guide](../../guides/backfill.md) for background.
44+55+```sh
66+# All examples assume $TOKEN is an API key (hv_...)
77+AUTH="Authorization: Bearer $TOKEN"
88+```
99+1010+## Create a backfill job
1111+1212+```
1313+POST /admin/backfill
1414+```
1515+1616+```sh
1717+curl -X POST http://localhost:3000/admin/backfill \
1818+ -H "$AUTH" \
1919+ -H "Content-Type: application/json" \
2020+ -d '{ "collection": "xyz.statusphere.status" }'
2121+```
2222+2323+| Field | Type | Required | Description |
2424+| ------------ | ------ | -------- | ---------------------------------------------------------- |
2525+| `collection` | string | no | Limit to a single collection (backfills all if omitted) |
2626+| `did` | string | no | Limit to a single DID (discovers all via relay if omitted) |
2727+2828+**Response**: `201 Created`
2929+3030+```json
3131+{
3232+ "id": "550e8400-e29b-41d4-a716-446655440000",
3333+ "status": "pending"
3434+}
3535+```
3636+3737+## List backfill jobs
3838+3939+```
4040+GET /admin/backfill/status
4141+```
4242+4343+```sh
4444+curl http://localhost:3000/admin/backfill/status -H "$AUTH"
4545+```
4646+4747+**Response**: `200 OK`
4848+4949+```json
5050+[
5151+ {
5252+ "id": "550e8400-e29b-41d4-a716-446655440000",
5353+ "collection": "xyz.statusphere.status",
5454+ "did": null,
5555+ "status": "completed",
5656+ "total_repos": 42,
5757+ "processed_repos": 42,
5858+ "total_records": 1000,
5959+ "error": null,
6060+ "started_at": "2025-01-01T00:01:00Z",
6161+ "completed_at": "2025-01-01T00:05:00Z",
6262+ "created_at": "2025-01-01T00:00:00Z"
6363+ }
6464+]
6565+```
+99
packages/docs/docs/reference/admin/domains.md
···11+# Admin API: Domains
22+33+Manage the domains a HappyView instance serves. Each domain gets its own AT Protocol OAuth client identity. The primary domain is auto-seeded from `PUBLIC_URL` on first boot. All endpoints require the `settings:manage` permission.
44+55+```sh
66+# All examples assume $TOKEN is an API key (hv_...)
77+AUTH="Authorization: Bearer $TOKEN"
88+```
99+1010+## List domains
1111+1212+```
1313+GET /admin/domains
1414+```
1515+1616+```sh
1717+curl http://localhost:3000/admin/domains -H "$AUTH"
1818+```
1919+2020+**Response**: `200 OK`
2121+2222+```json
2323+[
2424+ {
2525+ "id": "550e8400-e29b-41d4-a716-446655440000",
2626+ "url": "https://gamesgamesgamesgames.games",
2727+ "is_primary": true,
2828+ "created_at": "2026-04-16T00:00:00Z",
2929+ "updated_at": "2026-04-16T00:00:00Z"
3030+ }
3131+]
3232+```
3333+3434+## Add a domain
3535+3636+```
3737+POST /admin/domains
3838+```
3939+4040+```sh
4141+curl -X POST http://localhost:3000/admin/domains \
4242+ -H "$AUTH" \
4343+ -H "Content-Type: application/json" \
4444+ -d '{ "url": "https://api.cartridge.dev" }'
4545+```
4646+4747+| Field | Type | Required | Description |
4848+| ----- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------ |
4949+| `url` | string | yes | Valid origin (scheme + host, no path or trailing slash). Must be `https` unless `PUBLIC_URL` is a loopback address. |
5050+5151+Returns `400 Bad Request` if the URL is invalid or already registered.
5252+5353+**Response**: `201 Created`
5454+5555+```json
5656+{
5757+ "id": "550e8400-e29b-41d4-a716-446655440001",
5858+ "url": "https://api.cartridge.dev",
5959+ "is_primary": false,
6060+ "created_at": "2026-04-16T00:00:00Z",
6161+ "updated_at": "2026-04-16T00:00:00Z"
6262+}
6363+```
6464+6565+Side effects: builds an OAuth client for the domain, updates the in-memory domain cache.
6666+6767+## Remove a domain
6868+6969+```
7070+DELETE /admin/domains/{id}
7171+```
7272+7373+```sh
7474+curl -X DELETE http://localhost:3000/admin/domains/550e8400-e29b-41d4-a716-446655440001 \
7575+ -H "$AUTH"
7676+```
7777+7878+Returns `400 Bad Request` if the domain is primary — set a different domain as primary first. Returns `404 Not Found` if the domain doesn't exist.
7979+8080+**Response**: `204 No Content`
8181+8282+Side effects: removes the domain's OAuth client and cache entry.
8383+8484+## Set primary domain
8585+8686+```
8787+POST /admin/domains/{id}/primary
8888+```
8989+9090+```sh
9191+curl -X POST http://localhost:3000/admin/domains/550e8400-e29b-41d4-a716-446655440001/primary \
9292+ -H "$AUTH"
9393+```
9494+9595+Sets the target domain as the primary. Unsets the current primary in a single operation. Returns `404 Not Found` if the domain doesn't exist.
9696+9797+**Response**: `204 No Content`
9898+9999+Side effects: updates the in-memory cache and the OAuth client registry's primary client reference.
+54
packages/docs/docs/reference/admin/events.md
···11+# Admin API: Event Logs
22+33+HappyView records an audit trail of system events: lexicon changes, record operations, Lua script executions and errors, user actions, backfill jobs, and Jetstream connectivity. See the [Event Logs guide](../../guides/event-logs.md) for details on event types and retention.
44+55+```sh
66+# All examples assume $TOKEN is an API key (hv_...)
77+AUTH="Authorization: Bearer $TOKEN"
88+```
99+1010+## List event logs
1111+1212+```
1313+GET /admin/events
1414+```
1515+1616+```sh
1717+curl "http://localhost:3000/admin/events?severity=error&limit=10" -H "$AUTH"
1818+```
1919+2020+| Param | Type | Required | Description |
2121+| ------------ | ------ | -------- | --------------------------------------------------------------------- |
2222+| `event_type` | string | no | Filter by exact event type (e.g. `script.error`) |
2323+| `category` | string | no | Filter by category prefix (e.g. `lexicon` matches all lexicon events) |
2424+| `severity` | string | no | Filter by severity: `info`, `warn`, or `error` |
2525+| `subject` | string | no | Filter by subject (lexicon ID, record URI, admin DID, etc.) |
2626+| `cursor` | string | no | Pagination cursor (ISO 8601 timestamp from previous response) |
2727+| `limit` | number | no | Results per page (default `50`, max `100`) |
2828+2929+**Response**: `200 OK`
3030+3131+```json
3232+{
3333+ "events": [
3434+ {
3535+ "id": "550e8400-e29b-41d4-a716-446655440000",
3636+ "event_type": "script.error",
3737+ "severity": "error",
3838+ "actor_did": "did:plc:abc123",
3939+ "subject": "com.example.feed.like",
4040+ "detail": {
4141+ "error": "attempt to index nil value",
4242+ "script_source": "function handle() ... end",
4343+ "input": { "status": "hello" },
4444+ "caller_did": "did:plc:abc123",
4545+ "method": "com.example.feed.like"
4646+ },
4747+ "created_at": "2026-03-01T12:00:00Z"
4848+ }
4949+ ],
5050+ "cursor": "2026-03-01T11:59:00Z"
5151+}
5252+```
5353+5454+Events are returned in reverse chronological order (newest first). Pass the `cursor` value from the response to fetch the next page.
+99
packages/docs/docs/reference/admin/labelers.md
···11+# Admin API: Labelers
22+33+Manage external labeler subscriptions. See the [Labelers guide](../../guides/labelers.md) for background.
44+55+```sh
66+# All examples assume $TOKEN is an API key (hv_...)
77+AUTH="Authorization: Bearer $TOKEN"
88+```
99+1010+## Add a labeler
1111+1212+```
1313+POST /admin/labelers
1414+```
1515+1616+Requires `labelers:create` permission.
1717+1818+```sh
1919+curl -X POST http://localhost:3000/admin/labelers \
2020+ -H "$AUTH" \
2121+ -H "Content-Type: application/json" \
2222+ -d '{ "did": "did:plc:ar7c4by46qjdydhdevvrndac" }'
2323+```
2424+2525+| Field | Type | Required | Description |
2626+| ----- | ------ | -------- | ----------------------------- |
2727+| `did` | string | yes | The labeler's AT Protocol DID |
2828+2929+**Response**: `201 Created` (empty body)
3030+3131+## List labelers
3232+3333+```
3434+GET /admin/labelers
3535+```
3636+3737+Requires `labelers:read` permission.
3838+3939+```sh
4040+curl http://localhost:3000/admin/labelers -H "$AUTH"
4141+```
4242+4343+**Response**: `200 OK`
4444+4545+```json
4646+[
4747+ {
4848+ "did": "did:plc:ar7c4by46qjdydhdevvrndac",
4949+ "status": "active",
5050+ "cursor": 1234,
5151+ "created_at": "2026-03-15T00:00:00Z",
5252+ "updated_at": "2026-03-15T00:00:00Z"
5353+ }
5454+]
5555+```
5656+5757+| Field | Type | Description |
5858+| ------------ | ------------ | -------------------------------------------------- |
5959+| `did` | string | The labeler's DID |
6060+| `status` | string | `active` or `paused` |
6161+| `cursor` | number\|null | Last processed event cursor (null if never synced) |
6262+| `created_at` | string | ISO 8601 creation timestamp |
6363+| `updated_at` | string | ISO 8601 last-updated timestamp |
6464+6565+## Update a labeler
6666+6767+```
6868+PATCH /admin/labelers/{did}
6969+```
7070+7171+Requires `labelers:create` permission.
7272+7373+```sh
7474+curl -X PATCH http://localhost:3000/admin/labelers/did:plc:ar7c4by46qjdydhdevvrndac \
7575+ -H "$AUTH" \
7676+ -H "Content-Type: application/json" \
7777+ -d '{ "status": "paused" }'
7878+```
7979+8080+| Field | Type | Required | Description |
8181+| -------- | ------ | -------- | -------------------------------- |
8282+| `status` | string | yes | New status: `active` or `paused` |
8383+8484+**Response**: `200 OK`
8585+8686+## Delete a labeler
8787+8888+```
8989+DELETE /admin/labelers/{did}
9090+```
9191+9292+Requires `labelers:delete` permission. Removes the subscription and all labels emitted by this labeler.
9393+9494+```sh
9595+curl -X DELETE http://localhost:3000/admin/labelers/did:plc:ar7c4by46qjdydhdevvrndac \
9696+ -H "$AUTH"
9797+```
9898+9999+**Response**: `204 No Content`
+167
packages/docs/docs/reference/admin/lexicons.md
···11+# Admin API: Lexicons
22+33+Manage lexicons and network lexicons. See the [Lexicons guide](../../guides/lexicons.md) for background on how lexicons drive indexing and XRPC routing.
44+55+```sh
66+# All examples assume $TOKEN is an API key (hv_...)
77+AUTH="Authorization: Bearer $TOKEN"
88+```
99+1010+## Upload / upsert a lexicon
1111+1212+```
1313+POST /admin/lexicons
1414+```
1515+1616+```sh
1717+curl -X POST http://localhost:3000/admin/lexicons \
1818+ -H "$AUTH" \
1919+ -H "Content-Type: application/json" \
2020+ -d '{
2121+ "lexicon_json": { "lexicon": 1, "id": "xyz.statusphere.status", "defs": { "main": { "type": "record", "key": "tid", "record": { "type": "object", "required": ["status", "createdAt"], "properties": { "status": { "type": "string", "maxGraphemes": 1 }, "createdAt": { "type": "string", "format": "datetime" } } } } } },
2222+ "backfill": true,
2323+ "target_collection": null
2424+ }'
2525+```
2626+2727+| Field | Type | Required | Description |
2828+| ------------------- | ------- | -------- | --------------------------------------------------------------------- |
2929+| `lexicon_json` | object | yes | Raw lexicon JSON (must have `lexicon: 1` and `id`) |
3030+| `backfill` | boolean | no | Whether uploading triggers historical backfill (default `true`) |
3131+| `target_collection` | string | no | For query/procedure lexicons, the record collection they operate on |
3232+| `script` | string | no | Lua script for query/procedure endpoints |
3333+| `index_hook` | string | no | [Index hook](../../guides/index-hooks.md) Lua script for record lexicons |
3434+3535+**Response**: `201 Created` (new) or `200 OK` (upsert)
3636+3737+```json
3838+{
3939+ "id": "xyz.statusphere.status",
4040+ "revision": 1
4141+}
4242+```
4343+4444+## List lexicons
4545+4646+```
4747+GET /admin/lexicons
4848+```
4949+5050+```sh
5151+curl http://localhost:3000/admin/lexicons -H "$AUTH"
5252+```
5353+5454+**Response**: `200 OK`
5555+5656+```json
5757+[
5858+ {
5959+ "id": "xyz.statusphere.status",
6060+ "revision": 1,
6161+ "lexicon_type": "record",
6262+ "backfill": true,
6363+ "created_at": "2025-01-01T00:00:00Z",
6464+ "updated_at": "2025-01-01T00:00:00Z"
6565+ }
6666+]
6767+```
6868+6969+## Get a lexicon
7070+7171+```
7272+GET /admin/lexicons/{id}
7373+```
7474+7575+```sh
7676+curl http://localhost:3000/admin/lexicons/xyz.statusphere.status -H "$AUTH"
7777+```
7878+7979+**Response**: `200 OK` with full lexicon details including raw JSON.
8080+8181+## Delete a lexicon
8282+8383+```
8484+DELETE /admin/lexicons/{id}
8585+```
8686+8787+```sh
8888+curl -X DELETE http://localhost:3000/admin/lexicons/xyz.statusphere.status -H "$AUTH"
8989+```
9090+9191+**Response**: `204 No Content`
9292+9393+## Network Lexicons
9494+9595+Network lexicons are fetched from the AT Protocol network via DNS TXT resolution and kept updated via the Jetstream subscription. See [Lexicons - Network lexicons](../../guides/lexicons.md#network-lexicons) for background.
9696+9797+### Add a network lexicon
9898+9999+```
100100+POST /admin/network-lexicons
101101+```
102102+103103+```sh
104104+curl -X POST http://localhost:3000/admin/network-lexicons \
105105+ -H "$AUTH" \
106106+ -H "Content-Type: application/json" \
107107+ -d '{
108108+ "nsid": "xyz.statusphere.status",
109109+ "target_collection": null
110110+ }'
111111+```
112112+113113+| Field | Type | Required | Description |
114114+| ------------------- | ------ | -------- | ------------------------------------------------------------------- |
115115+| `nsid` | string | yes | The NSID of the lexicon to watch |
116116+| `target_collection` | string | no | For query/procedure lexicons, the record collection they operate on |
117117+118118+HappyView resolves the NSID authority via DNS TXT, fetches the lexicon from the authority's PDS, parses it, and stores it.
119119+120120+**Response**: `201 Created`
121121+122122+```json
123123+{
124124+ "nsid": "xyz.statusphere.status",
125125+ "authority_did": "did:plc:authority",
126126+ "revision": 1
127127+}
128128+```
129129+130130+### List network lexicons
131131+132132+```
133133+GET /admin/network-lexicons
134134+```
135135+136136+```sh
137137+curl http://localhost:3000/admin/network-lexicons -H "$AUTH"
138138+```
139139+140140+**Response**: `200 OK`
141141+142142+```json
143143+[
144144+ {
145145+ "nsid": "xyz.statusphere.status",
146146+ "authority_did": "did:plc:authority",
147147+ "target_collection": null,
148148+ "last_fetched_at": "2025-01-01T00:00:00Z",
149149+ "created_at": "2025-01-01T00:00:00Z"
150150+ }
151151+]
152152+```
153153+154154+### Remove a network lexicon
155155+156156+```
157157+DELETE /admin/network-lexicons/{nsid}
158158+```
159159+160160+```sh
161161+curl -X DELETE http://localhost:3000/admin/network-lexicons/xyz.statusphere.status \
162162+ -H "$AUTH"
163163+```
164164+165165+Removes the network lexicon tracking and also deletes the lexicon from the `lexicons` table and in-memory registry.
166166+167167+**Response**: `204 No Content`
+223
packages/docs/docs/reference/admin/plugins.md
···11+# Admin API: Plugins
22+33+Plugins extend HappyView with WebAssembly modules sourced from the [official plugin registry](../../guides/plugins.md) or any URL serving a `manifest.json`. Most endpoints take a plugin manifest URL and load (or reload) the plugin in place — no restart needed. Encrypted plugin secrets require `TOKEN_ENCRYPTION_KEY` to be configured.
44+55+```sh
66+# All examples assume $TOKEN is an API key (hv_...)
77+AUTH="Authorization: Bearer $TOKEN"
88+```
99+1010+## List installed plugins
1111+1212+```
1313+GET /admin/plugins
1414+```
1515+1616+Requires `plugins:read`. Returns every loaded plugin with its source, required secrets, configuration status, and any pending updates from the official registry cache.
1717+1818+```sh
1919+curl http://localhost:3000/admin/plugins -H "$AUTH"
2020+```
2121+2222+**Response**: `200 OK`
2323+2424+```json
2525+{
2626+ "encryption_configured": true,
2727+ "plugins": [
2828+ {
2929+ "id": "steam",
3030+ "name": "Steam",
3131+ "version": "1.2.0",
3232+ "source": "url",
3333+ "url": "https://example.com/plugins/steam/manifest.json",
3434+ "sha256": null,
3535+ "enabled": true,
3636+ "auth_type": "openid",
3737+ "required_secrets": [
3838+ {
3939+ "key": "PLUGIN_STEAM_API_KEY",
4040+ "name": "Steam Web API Key",
4141+ "description": "Get your API key at steamcommunity.com/dev/apikey"
4242+ }
4343+ ],
4444+ "secrets_configured": true,
4545+ "loaded_at": null,
4646+ "update_available": false,
4747+ "latest_version": "1.2.0",
4848+ "pending_releases": []
4949+ }
5050+ ]
5151+}
5252+```
5353+5454+`secrets_configured` is `true` if the plugin has no required secrets, or if a row exists for it in `plugin_configs`. `update_available` and `pending_releases` are populated from the cached official registry — call `POST /admin/plugins/{id}/check-update` to refresh them.
5555+5656+## Preview a plugin before installing
5757+5858+```
5959+POST /admin/plugins/preview
6060+```
6161+6262+Requires `plugins:create`. Fetches and parses a manifest without installing the plugin, so the dashboard can show what it would register.
6363+6464+```sh
6565+curl -X POST http://localhost:3000/admin/plugins/preview \
6666+ -H "$AUTH" \
6767+ -H "Content-Type: application/json" \
6868+ -d '{ "url": "https://example.com/plugins/steam/manifest.json" }'
6969+```
7070+7171+**Response**: `200 OK`
7272+7373+```json
7474+{
7575+ "id": "steam",
7676+ "name": "Steam",
7777+ "version": "1.2.0",
7878+ "description": "Import your Steam game library and playtime data.",
7979+ "icon_url": "https://example.com/steam-icon.png",
8080+ "auth_type": "openid",
8181+ "required_secrets": [
8282+ { "key": "PLUGIN_STEAM_API_KEY", "name": "Steam Web API Key", "description": "..." }
8383+ ],
8484+ "manifest_url": "https://example.com/plugins/steam/manifest.json",
8585+ "wasm_url": "https://example.com/plugins/steam/steam.wasm"
8686+}
8787+```
8888+8989+Returns `400 Bad Request` if the manifest can't be fetched or parsed.
9090+9191+## Install a plugin
9292+9393+```
9494+POST /admin/plugins
9595+```
9696+9797+Requires `plugins:create`. Fetches the manifest, downloads the WASM, registers the plugin, and persists it.
9898+9999+```sh
100100+curl -X POST http://localhost:3000/admin/plugins \
101101+ -H "$AUTH" \
102102+ -H "Content-Type: application/json" \
103103+ -d '{
104104+ "url": "https://example.com/plugins/steam/manifest.json",
105105+ "sha256": "abc123..."
106106+ }'
107107+```
108108+109109+| Field | Type | Required | Description |
110110+| -------- | ------ | -------- | ---------------------------------------------------------------------------------------------------- |
111111+| `url` | string | yes | URL to the plugin's `manifest.json` |
112112+| `sha256` | string | no | Optional sha256 of the WASM binary. If provided, install fails when the downloaded hash mismatches |
113113+114114+**Response**: `200 OK` returning the same `PluginSummary` shape as the list endpoint. `secrets_configured` will be `false` if the plugin requires any secrets — call `PUT /admin/plugins/{id}/secrets` to configure them before the plugin can run.
115115+116116+## List official plugins
117117+118118+```
119119+GET /admin/plugins/official
120120+```
121121+122122+Requires `plugins:read`. Returns the cached catalog of plugins from the official registry. The cache is refreshed periodically by the server; use `POST /admin/plugins/{id}/check-update` to force-refresh a single entry.
123123+124124+**Response**: `200 OK`
125125+126126+```json
127127+{
128128+ "last_refreshed_at": "2026-04-13T11:00:00Z",
129129+ "plugins": [
130130+ {
131131+ "id": "steam",
132132+ "name": "Steam",
133133+ "description": "Import your Steam game library and playtime data.",
134134+ "icon_url": "https://example.com/steam-icon.png",
135135+ "latest_version": "1.2.0",
136136+ "manifest_url": "https://example.com/plugins/steam/manifest.json"
137137+ }
138138+ ]
139139+}
140140+```
141141+142142+## Remove a plugin
143143+144144+```
145145+DELETE /admin/plugins/{id}
146146+```
147147+148148+Requires `plugins:delete`. Unregisters the plugin from the runtime and deletes its row from the `plugins` table. Plugin secrets in `plugin_configs` are not removed automatically — they're available again if you reinstall the same plugin.
149149+150150+**Response**: `204 No Content`. Returns `404 Not Found` if no plugin with that id is loaded.
151151+152152+## Reload a plugin
153153+154154+```
155155+POST /admin/plugins/{id}/reload
156156+```
157157+158158+Requires `plugins:create`. Re-fetches the plugin from its current source URL and re-registers it. Useful after publishing a new version of a plugin you host yourself.
159159+160160+The body is optional. To point the plugin at a new URL, pass:
161161+162162+```json
163163+{ "url": "https://example.com/plugins/steam/manifest.json" }
164164+```
165165+166166+When a new URL is provided, the stored `sha256` is cleared (the new version has its own hash). File-based plugins cannot be reloaded via this endpoint and return `400 Bad Request`.
167167+168168+**Response**: `200 OK` with the refreshed `PluginSummary`.
169169+170170+## Check for plugin updates
171171+172172+```
173173+POST /admin/plugins/{id}/check-update
174174+```
175175+176176+Requires `plugins:create`. Forces a cache refresh for one plugin from the official registry, then returns the updated `PluginSummary` with `update_available`, `latest_version`, and `pending_releases` reflecting the latest catalog state.
177177+178178+**Response**: `200 OK` with a `PluginSummary`.
179179+180180+## Get plugin secrets
181181+182182+```
183183+GET /admin/plugins/{id}/secrets
184184+```
185185+186186+Requires `plugins:read`. Returns the plugin's configured secrets with values masked (last 4 characters shown for values longer than 8 characters, otherwise fully masked). Requires `TOKEN_ENCRYPTION_KEY` to be configured.
187187+188188+**Response**: `200 OK`
189189+190190+```json
191191+{
192192+ "plugin_id": "steam",
193193+ "secrets": {
194194+ "PLUGIN_STEAM_API_KEY": "********ABCD"
195195+ }
196196+}
197197+```
198198+199199+## Update plugin secrets
200200+201201+```
202202+PUT /admin/plugins/{id}/secrets
203203+```
204204+205205+Requires `plugins:create`. Encrypts the provided secret values with `TOKEN_ENCRYPTION_KEY` (AES-256-GCM) and upserts them into `plugin_configs`.
206206+207207+```sh
208208+curl -X PUT http://localhost:3000/admin/plugins/steam/secrets \
209209+ -H "$AUTH" \
210210+ -H "Content-Type: application/json" \
211211+ -d '{
212212+ "secrets": {
213213+ "PLUGIN_STEAM_API_KEY": "your-new-api-key"
214214+ }
215215+ }'
216216+```
217217+218218+Special handling:
219219+220220+- Values starting with `********` are treated as masked placeholders and the existing encrypted value is preserved (so you can `GET` then `PUT` without re-typing every secret).
221221+- Empty string values are not stored — use them to clear a secret.
222222+223223+**Response**: `204 No Content`
···11+# Admin API: Script Variables
22+33+Script variables are encrypted key/value pairs available to Lua scripts via the `vars` global. Use them for secrets like API tokens.
44+55+```sh
66+# All examples assume $TOKEN is an API key (hv_...)
77+AUTH="Authorization: Bearer $TOKEN"
88+```
99+1010+## List script variables
1111+1212+```
1313+GET /admin/script-variables
1414+```
1515+1616+Requires `script-variables:read`. Returns a list of variable keys (values are not returned).
1717+1818+## Upsert a script variable
1919+2020+```
2121+POST /admin/script-variables
2222+```
2323+2424+Requires `script-variables:create`.
2525+2626+```sh
2727+curl -X POST http://localhost:3000/admin/script-variables \
2828+ -H "$AUTH" \
2929+ -H "Content-Type: application/json" \
3030+ -d '{ "key": "ALGOLIA_API_KEY", "value": "..." }'
3131+```
3232+3333+The value is encrypted at rest using `TOKEN_ENCRYPTION_KEY`.
3434+3535+## Delete a script variable
3636+3737+```
3838+DELETE /admin/script-variables/{key}
3939+```
4040+4141+Requires `script-variables:delete`.
+50
packages/docs/docs/reference/admin/settings.md
···11+# Admin API: Instance Settings
22+33+Instance settings are key/value entries used to override environment-variable defaults at runtime (for example, the application name, terms-of-service URL, privacy policy URL, and uploaded logo). Settings stored here take precedence over the corresponding environment variables. All endpoints require the `settings:manage` permission.
44+55+```sh
66+# All examples assume $TOKEN is an API key (hv_...)
77+AUTH="Authorization: Bearer $TOKEN"
88+```
99+1010+## List settings
1111+1212+```
1313+GET /admin/settings
1414+```
1515+1616+```sh
1717+curl http://localhost:3000/admin/settings -H "$AUTH"
1818+```
1919+2020+Returns all key/value pairs stored in the `instance_settings` table.
2121+2222+## Upsert a setting
2323+2424+```
2525+PUT /admin/settings/{key}
2626+```
2727+2828+```sh
2929+curl -X PUT http://localhost:3000/admin/settings/app_name \
3030+ -H "$AUTH" \
3131+ -H "Content-Type: application/json" \
3232+ -d '{ "value": "My HappyView" }'
3333+```
3434+3535+## Delete a setting
3636+3737+```
3838+DELETE /admin/settings/{key}
3939+```
4040+4141+Removes the override; the corresponding environment variable (if any) takes effect again.
4242+4343+## Upload / delete logo
4444+4545+```
4646+PUT /admin/settings/logo
4747+DELETE /admin/settings/logo
4848+```
4949+5050+`PUT` accepts a binary image body and stores it as the instance logo (served via the public dashboard). `DELETE` removes the stored logo.
+25
packages/docs/docs/reference/admin/stats.md
···11+# Admin API: Stats
22+33+```sh
44+# All examples assume $TOKEN is an API key (hv_...)
55+AUTH="Authorization: Bearer $TOKEN"
66+```
77+88+## Record counts
99+1010+```
1111+GET /admin/stats
1212+```
1313+1414+```sh
1515+curl http://localhost:3000/admin/stats -H "$AUTH"
1616+```
1717+1818+**Response**: `200 OK`
1919+2020+```json
2121+{
2222+ "total_records": 12345,
2323+ "collections": [{ "collection": "xyz.statusphere.status", "count": 500 }]
2424+}
2525+```
+147
packages/docs/docs/reference/admin/users.md
···11+# Admin API: Users
22+33+Manage admin users and their permissions. See the [Permissions guide](../../guides/permissions.md) for available permissions and templates.
44+55+```sh
66+# All examples assume $TOKEN is an API key (hv_...)
77+AUTH="Authorization: Bearer $TOKEN"
88+```
99+1010+## Create a user
1111+1212+```
1313+POST /admin/users
1414+```
1515+1616+Requires `users:create` permission. You cannot grant permissions you don't have yourself (escalation guard).
1717+1818+```sh
1919+curl -X POST http://localhost:3000/admin/users \
2020+ -H "$AUTH" \
2121+ -H "Content-Type: application/json" \
2222+ -d '{
2323+ "did": "did:plc:newuser",
2424+ "template": "operator"
2525+ }'
2626+```
2727+2828+| Field | Type | Required | Description |
2929+| ------------- | -------- | -------- | ---------------------------------------------------------------------------------- |
3030+| `did` | string | yes | The AT Protocol DID of the user to add |
3131+| `template` | string | no | Permission template: `viewer`, `operator`, `manager`, or `full_access` |
3232+| `permissions` | string[] | no | Explicit list of permissions to grant (used instead of or in addition to `template`) |
3333+3434+If neither `template` nor `permissions` is provided, the user is created with no permissions.
3535+3636+**Response**: `201 Created`
3737+3838+```json
3939+{
4040+ "id": "550e8400-e29b-41d4-a716-446655440000",
4141+ "did": "did:plc:newuser",
4242+ "is_super": false,
4343+ "permissions": ["lexicons:read", "records:read", "script-variables:read", "users:read", "api-keys:read", "api-keys:create", "api-keys:delete", "backfill:read", "backfill:create", "stats:read", "events:read"]
4444+}
4545+```
4646+4747+## List users
4848+4949+```
5050+GET /admin/users
5151+```
5252+5353+Requires `users:read` permission.
5454+5555+```sh
5656+curl http://localhost:3000/admin/users -H "$AUTH"
5757+```
5858+5959+**Response**: `200 OK`
6060+6161+```json
6262+[
6363+ {
6464+ "id": "550e8400-e29b-41d4-a716-446655440000",
6565+ "did": "did:plc:admin",
6666+ "is_super": true,
6767+ "permissions": ["lexicons:create", "lexicons:read", "lexicons:delete", "records:read", "records:delete", "records:delete-collection", "script-variables:create", "script-variables:read", "script-variables:delete", "users:create", "users:read", "users:update", "users:delete", "api-keys:create", "api-keys:read", "api-keys:delete", "backfill:create", "backfill:read", "stats:read", "events:read"],
6868+ "created_at": "2025-01-01T00:00:00Z",
6969+ "last_used_at": "2025-01-02T12:00:00Z"
7070+ }
7171+]
7272+```
7373+7474+## Get a user
7575+7676+```
7777+GET /admin/users/{id}
7878+```
7979+8080+Requires `users:read` permission.
8181+8282+```sh
8383+curl http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 -H "$AUTH"
8484+```
8585+8686+**Response**: `200 OK` with the same shape as a single item from the list response.
8787+8888+## Update user permissions
8989+9090+```
9191+PATCH /admin/users/{id}/permissions
9292+```
9393+9494+Requires `users:update` permission. You cannot grant permissions you don't have yourself, and you cannot modify the super user's permissions.
9595+9696+```sh
9797+curl -X PATCH http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000/permissions \
9898+ -H "$AUTH" \
9999+ -H "Content-Type: application/json" \
100100+ -d '{
101101+ "grant": ["lexicons:create", "lexicons:delete"],
102102+ "revoke": ["records:delete"]
103103+ }'
104104+```
105105+106106+| Field | Type | Required | Description |
107107+| -------- | -------- | -------- | --------------------- |
108108+| `grant` | string[] | no | Permissions to add |
109109+| `revoke` | string[] | no | Permissions to remove |
110110+111111+**Response**: `200 OK` with the updated user object.
112112+113113+## Transfer super user
114114+115115+```
116116+POST /admin/users/transfer-super
117117+```
118118+119119+Only the current super user can call this endpoint. Transfers super user status to another existing user.
120120+121121+```sh
122122+curl -X POST http://localhost:3000/admin/users/transfer-super \
123123+ -H "$AUTH" \
124124+ -H "Content-Type: application/json" \
125125+ -d '{ "target_user_id": "550e8400-e29b-41d4-a716-446655440000" }'
126126+```
127127+128128+| Field | Type | Required | Description |
129129+| ---------------- | ------ | -------- | ------------------------------------------ |
130130+| `target_user_id` | string | yes | The ID of the user to receive super status |
131131+132132+**Response**: `200 OK`
133133+134134+## Delete a user
135135+136136+```
137137+DELETE /admin/users/{id}
138138+```
139139+140140+Requires `users:delete` permission. You cannot delete the super user or yourself.
141141+142142+```sh
143143+curl -X DELETE http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 \
144144+ -H "$AUTH"
145145+```
146146+147147+**Response**: `204 No Content`
+108-99
packages/docs/docs/reference/architecture.md
···31313232Reads flow top-down through the query handler to the database (SQLite by default, or Postgres). Writes flow through the procedure handler to the user's PDS, then HappyView indexes the record locally. Real-time record events stream in via [Jetstream](https://github.com/bluesky-social/jetstream); historical records are backfilled in-process by discovering repos via the relay's `listReposByCollection` and fetching records directly from each PDS.
33333434-## Module overview
3535-3636-```
3737-src/
3838- main.rs Startup: config, DB, migrations, build OAuth client, spawn Jetstream worker, start server
3939- lib.rs AppState struct (incl. OAuth client + cookie key), module declarations
4040- config.rs Environment variable loading
4141- dns.rs DNS TXT resolver for atrium handle resolution
4242- error.rs AppError enum (Auth, BadRequest, Forbidden, Internal, NotFound, PdsError)
4343- server.rs Axum router: fixed routes + admin nest + auth routes + XRPC catch-all + static files
4444- lexicon.rs ParsedLexicon, LexiconRegistry (Arc<RwLock<HashMap>>)
4545- profile.rs DID document resolution, PDS discovery, profile fetching
4646- jetstream.rs Jetstream WebSocket listener, collection filter sync, cursor persistence
4747- resolve.rs NSID authority resolution (DNS TXT → DID → PDS)
4848- auth/
4949- mod.rs Re-exports, COOKIE_NAME constant
5050- middleware.rs Claims extractor (cookie auth, API key, or service auth JWT)
5151- routes.rs OAuth endpoints (/auth/login, /auth/callback, /auth/logout, /auth/me)
5252- oauth_store.rs Database-backed session and state stores for atrium-oauth
5353- service_auth.rs XRPC service-to-service JWT validation (ES256/ES256K)
5454- admin/
5555- mod.rs Admin route definitions
5656- auth.rs UserAuth extractor (Claims + DID lookup + permission check + auto-bootstrap)
5757- users.rs User CRUD handlers (create, list, get, delete, update permissions, transfer super)
5858- permissions.rs Permission enum (20 permissions), templates (Viewer, Operator, Manager, FullAccess)
5959- api_keys.rs API key CRUD handlers (create, list, revoke) with scoped permissions
6060- events.rs Event log query handler
6161- settings.rs Instance settings CRUD handlers (list, upsert, delete, logo upload/serve)
6262- script_variables.rs Script variable CRUD handlers (list, upsert, delete)
6363- lexicons.rs Lexicon CRUD handlers
6464- network_lexicons.rs Network lexicon tracking (add, list, remove)
6565- records.rs Record listing handler
6666- stats.rs Record count stats
6767- backfill.rs Backfill job runner (relay discovery + per-PDS listRecords)
6868- types.rs Request/response structs for admin endpoints
6969- lua/
7070- mod.rs Re-exports
7171- context.rs Lua context globals (method, params, input, caller_did, collection)
7272- db_api.rs Lua database API (db.query, db.get, db.count)
7373- execute.rs Script execution and sandbox setup
7474- record.rs Lua Record API (constructor, save, delete, load)
7575- sandbox.rs Restricted Lua environment (removed modules, instruction limit)
7676- tid.rs TID generation for Lua scripts
7777- repo/
7878- mod.rs Re-exports
7979- pds.rs PDS proxy helpers (JSON POST, blob POST, response forwarding via OAuth session)
8080- session.rs OAuth session restoration from atrium store
8181- upload_blob.rs Blob upload handler
8282- xrpc/
8383- mod.rs Re-exports
8484- query.rs Dynamic GET handler (Lua script or default: single record + list)
8585- procedure.rs Dynamic POST handler (Lua script or default: create vs put)
8686-```
8787-8834## Request flow
89359036### Reads (queries)
91379292-```
9393-Client GET /xrpc/{method}?params
9494- -> xrpc::xrpc_get()
9595- -> LexiconRegistry lookup (must be Query type)
9696- -> If Lua script attached: execute script (has access to db API)
9797- -> Else: default SQL query on records table (collection from target_collection)
9898- -> JSON response
3838+```mermaid
3939+sequenceDiagram
4040+ participant C as Client
4141+ participant X as xrpc_get()
4242+ participant R as LexiconRegistry
4343+ participant L as Lua Script
4444+ participant D as Database
4545+4646+ C->>X: GET /xrpc/{method}?params
4747+ X->>R: Lookup (must be Query type)
4848+ alt Lua script attached
4949+ R->>L: Execute script
5050+ L->>D: db.query / db.get / db.raw
5151+ D-->>L: Results
5252+ L-->>X: Response table
5353+ else No script
5454+ R->>D: Default SQL query (collection from target_collection)
5555+ D-->>X: Results
5656+ end
5757+ X-->>C: JSON response
9958```
1005910160### Writes (procedures)
10261103103-```
104104-Client POST /xrpc/{method} + session cookie or Bearer token
105105- -> Claims extractor (cookie, API key, or service auth JWT)
106106- -> xrpc::xrpc_post()
107107- -> LexiconRegistry lookup (must be Procedure type)
108108- -> If Lua script attached: execute script (has access to Record API)
109109- -> Else: default create/update (auto-detect based on uri field)
110110- -> Restore OAuth session from atrium store (by DID)
111111- -> atrium handles DPoP proof generation and token refresh
112112- -> Proxy to user's PDS (createRecord or putRecord)
113113- -> Upsert record locally
114114- -> Forward PDS response
6262+```mermaid
6363+sequenceDiagram
6464+ participant C as Client
6565+ participant A as Claims Extractor
6666+ participant X as xrpc_post()
6767+ participant R as LexiconRegistry
6868+ participant L as Lua Script
6969+ participant S as OAuth Session
7070+ participant P as User PDS
7171+ participant D as Database
7272+7373+ C->>A: POST /xrpc/{method} + DPoP auth + X-Client-Key
7474+ A->>X: Validated claims
7575+ X->>R: Lookup (must be Procedure type)
7676+ alt Lua script attached
7777+ R->>L: Execute script (Record API)
7878+ L->>S: Record:save()
7979+ else No script
8080+ R->>S: Default create/update (auto-detect from uri field)
8181+ end
8282+ S->>P: Proxy write (createRecord or putRecord)
8383+ P-->>S: PDS response
8484+ S->>D: Upsert record locally
8585+ S-->>C: Forward PDS response
11586```
1168711788### Admin endpoints
11889119119-```
120120-Client request + session cookie or Bearer token
121121- -> AdminAuth extractor:
122122- 1. Claims validation (cookie, API key, or service auth JWT)
123123- 2. DID lookup in users table (auto-bootstrap super user if empty)
124124- 3. Permission check (403 if missing required permission)
125125- -> Admin handler
126126- -> JSON response
9090+```mermaid
9191+sequenceDiagram
9292+ participant C as Client
9393+ participant A as AdminAuth Extractor
9494+ participant U as Users Table
9595+ participant H as Admin Handler
9696+ participant D as Database
9797+9898+ C->>A: Request + Bearer token
9999+ A->>A: Validate claims (API key or service auth JWT)
100100+ A->>U: DID lookup
101101+ alt Users table empty
102102+ U-->>A: Auto-bootstrap as super user
103103+ else User found
104104+ U-->>A: Load permissions
105105+ end
106106+ A->>A: Permission check (403 if missing)
107107+ A->>H: Authorized request
108108+ H->>D: Database operation
109109+ D-->>H: Result
110110+ H-->>C: JSON response
127111```
128112129113## Data flow
130114131115### Real-time indexing
132116133133-```
134134-Jetstream WebSocket connection (jetstream::spawn)
135135- -> Collection filters built from indexed lexicons and applied to subscription URL
136136- -> Reconnects on collection filter changes (lexicon add/remove)
137137- -> Record commit events:
138138- create/update -> UPSERT into records table
139139- delete -> DELETE from records table
140140- -> Lexicon schema events (com.atproto.lexicon.schema):
141141- -> Update tracked network lexicons in DB and registry
142142- -> Cursor persisted to instance_settings for resume on reconnect
117117+```mermaid
118118+sequenceDiagram
119119+ participant J as Jetstream WebSocket
120120+ participant H as HappyView
121121+ participant D as Database
122122+ participant R as LexiconRegistry
123123+124124+ H->>J: Connect (collection filters from indexed lexicons)
125125+ loop Stream events
126126+ J->>H: Record commit event
127127+ alt create / update
128128+ H->>D: UPSERT into records table
129129+ else delete
130130+ H->>D: DELETE from records table
131131+ end
132132+ end
133133+ J->>H: Lexicon schema event (com.atproto.lexicon.schema)
134134+ H->>D: Update tracked network lexicons
135135+ H->>R: Update in-memory registry
136136+ Note over H,D: Cursor persisted to instance_settings for resume on reconnect
137137+ Note over H,J: Reconnects on collection filter changes (lexicon add/remove)
143138```
144139145140### Backfill
146141147147-```
148148-POST /admin/backfill
149149- -> Create backfill_jobs record (status = running)
150150- -> Relay listReposByCollection -> list of DIDs (paginated)
151151- -> For each DID: resolve PDS via PLC, listRecords from that PDS (paginated)
152152- -> UPSERT each record into records table
153153- -> Update processed_repos / total_records counters
154154- -> Mark job as completed (or failed with error message)
142142+```mermaid
143143+sequenceDiagram
144144+ participant A as Admin
145145+ participant H as HappyView
146146+ participant D as Database
147147+ participant Relay as Relay
148148+ participant PLC as PLC Directory
149149+ participant PDS as User PDS
150150+151151+ A->>H: POST /admin/backfill
152152+ H->>D: Create backfill_jobs record (status = running)
153153+ H->>Relay: listReposByCollection (paginated)
154154+ Relay-->>H: List of DIDs
155155+ loop For each DID
156156+ H->>PLC: Resolve DID document
157157+ PLC-->>H: PDS endpoint
158158+ H->>PDS: listRecords (paginated)
159159+ PDS-->>H: Records
160160+ H->>D: UPSERT each record
161161+ H->>D: Update processed_repos / total_records
162162+ end
163163+ H->>D: Mark job completed (or failed)
155164```
156165157166## Database schema
+1-1
packages/docs/docs/reference/changelog.md
···22222323## v1.9.0 — Event Logs
24242525-- **Event logging** — system-wide audit trail for lexicon changes, record operations, Lua script executions/errors, admin actions, backfill jobs, and firehose connectivity
2525+- **Event logging** — system-wide audit trail for lexicon changes, record operations, Lua script executions/errors, admin actions, backfill jobs, and Jetstream connectivity
2626- **`GET /admin/events`** — query event logs with filtering by event type, category, severity, and subject, with cursor pagination
2727- **Lua error context** — script errors capture full debugging context: error message, script source, input payload, and caller DID
2828- **Automatic retention cleanup** — configurable via `EVENT_LOG_RETENTION_DAYS` (default 30 days)
+107
packages/docs/docs/reference/lua/atproto-api.md
···11+# AT Protocol API
22+33+The `atproto` table provides AT Protocol utility functions. Available in queries, procedures, and [index hooks](../../guides/index-hooks.md).
44+55+## atproto.resolve_service_endpoint
66+77+```lua
88+local endpoint = atproto.resolve_service_endpoint(did)
99+```
1010+1111+Resolves a DID to its AT Protocol service endpoint URL by fetching the DID document. Supports both `did:plc:*` (via the PLC directory) and `did:web:*` (via `.well-known/did.json`).
1212+1313+| Parameter | Type | Description |
1414+| --------- | ------ | ------------------------ |
1515+| `did` | string | The DID to resolve |
1616+1717+**Returns:** The service endpoint URL as a string, or `nil` if resolution fails (DID not found, no PDS service in document, network error).
1818+1919+### Examples
2020+2121+```lua
2222+-- Resolve a did:plc DID
2323+local endpoint = atproto.resolve_service_endpoint("did:plc:abc123")
2424+-- endpoint = "https://pds.example.com"
2525+2626+-- Resolve a did:web DID
2727+local endpoint = atproto.resolve_service_endpoint("did:web:example.com")
2828+-- endpoint = "https://example.com"
2929+3030+-- Handle resolution failure
3131+local endpoint = atproto.resolve_service_endpoint("did:plc:unknown")
3232+if not endpoint then
3333+ return { error = "Could not resolve DID" }
3434+end
3535+3636+-- Use with HTTP API to call a remote XRPC endpoint
3737+local endpoint = atproto.resolve_service_endpoint(did)
3838+if endpoint then
3939+ local resp = http.get(endpoint .. "/xrpc/com.example.method")
4040+ local data = json.decode(resp.body)
4141+end
4242+```
4343+4444+## atproto.get_labels
4545+4646+```lua
4747+local labels = atproto.get_labels(uri)
4848+```
4949+5050+Returns an array of labels for a single AT URI. Merges external labels (from subscribed labelers) with self-labels (from the record's `labels.values[]` field).
5151+5252+| Parameter | Type | Description |
5353+| --------- | ------ | ------------------------------ |
5454+| `uri` | string | AT URI of the record to query |
5555+5656+Each label in the array is a table with:
5757+5858+| Field | Type | Description |
5959+| ----- | ------ | ---------------------------------------- |
6060+| `src` | string | DID of the labeler (or record author) |
6161+| `uri` | string | AT URI this label applies to |
6262+| `val` | string | Label value (e.g. "nsfw", "!hide") |
6363+| `cts` | string | Timestamp when the label was created |
6464+6565+Expired labels are automatically filtered out. Returns an empty array if no labels exist.
6666+6767+## atproto.get_labels_batch
6868+6969+```lua
7070+local labels_by_uri = atproto.get_labels_batch(uris)
7171+```
7272+7373+Batch version of `get_labels`. Takes an array of AT URIs and returns a table keyed by URI, where each value is an array of labels.
7474+7575+| Parameter | Type | Description |
7676+| --------- | ----- | ------------------------ |
7777+| `uris` | table | Array of AT URI strings |
7878+7979+**Returns:** A table keyed by URI. Each value is an array of label tables (same shape as `get_labels`). URIs with no labels have an empty array.
8080+8181+### Label examples
8282+8383+```lua
8484+-- Get labels for a single game
8585+local labels = atproto.get_labels("at://did:plc:abc/games.gamesgamesgamesgames.game/rkey1")
8686+for _, label in ipairs(labels) do
8787+ if label.val == "!hide" then
8888+ -- skip this game in feed results
8989+ end
9090+end
9191+9292+-- Batch fetch labels for multiple games (efficient for feed hydration)
9393+local uris = {}
9494+for _, item in ipairs(skeleton) do
9595+ uris[#uris + 1] = item.game
9696+end
9797+9898+local labels_by_uri = atproto.get_labels_batch(uris)
9999+for _, uri in ipairs(uris) do
100100+ local labels = labels_by_uri[uri]
101101+ for _, label in ipairs(labels) do
102102+ if label.val == "!hide" then
103103+ -- filter out this game
104104+ end
105105+ end
106106+end
107107+```
+106
packages/docs/docs/reference/lua/database-api.md
···11+# Database API
22+33+The `db` table provides access to the database. Available in queries, procedures, and [index hooks](../../guides/index-hooks.md).
44+55+## db.query
66+77+```lua
88+local result = db.query({
99+ collection = "xyz.statusphere.status", -- required
1010+ did = "did:plc:abc", -- optional: filter by DID
1111+ limit = 20, -- optional: max 100, default 20
1212+ cursor = params.cursor, -- optional: opaque cursor from a previous response
1313+ sort = "name", -- optional: field to sort by, default "indexed_at"
1414+ sortDirection = "asc", -- optional: "asc" or "desc", default "desc"
1515+})
1616+1717+-- result.records — array of record tables (each includes a "uri" field)
1818+-- result.cursor — present when more records exist (opaque string, pass back as-is)
1919+```
2020+2121+The `cursor` is an opaque string returned in a previous response. Pass it through directly — don't parse or modify it. When no `sort` field is specified, `db.query` uses keyset pagination (based on `created_at` and `uri`), which is stable even when records are inserted between pages. When a custom `sort` field is specified, offset-based pagination is used instead.
2222+2323+The `sort` field can be a top-level column (`indexed_at`, `did`, `uri`) or any field inside the record's `value` object (e.g. `name`, `createdAt`). Field names must contain only alphanumeric characters and underscores.
2424+2525+## db.get
2626+2727+```lua
2828+local record = db.get("at://did:plc:abc/xyz.statusphere.status/abc123")
2929+-- Returns the record table or nil
3030+-- The returned table includes a "uri" field
3131+```
3232+3333+## db.search
3434+3535+```lua
3636+local result = db.search({
3737+ collection = "xyz.statusphere.status", -- required
3838+ field = "displayName", -- required: record field to search
3939+ query = "alice", -- required: search term
4040+ limit = 10, -- optional: max 100, default 10
4141+})
4242+4343+-- result.records — array of matching records, ranked by relevance:
4444+-- exact match > prefix match > contains match, then alphabetical
4545+```
4646+4747+## db.backlinks
4848+4949+Find records that reference a given AT URI anywhere in their data. Useful for finding likes on a post, replies to a thread, or any record that links to another.
5050+5151+```lua
5252+local result = db.backlinks({
5353+ collection = "xyz.statusphere.status", -- required
5454+ uri = "at://did:plc:abc/xyz.statusphere.status/foo", -- required: the URI to find references to
5555+ did = "did:plc:abc", -- optional: filter by DID
5656+ limit = 20, -- optional: max 100, default 20
5757+ cursor = params.cursor, -- optional: opaque cursor from a previous response
5858+})
5959+6060+-- result.records — array of records whose data contains the given URI
6161+-- result.cursor — present when more records exist (opaque string, pass back as-is)
6262+```
6363+6464+The search checks the full record data, so it works regardless of which field holds the reference (`subject`, `parent`, `reply.root`, etc.).
6565+6666+## db.count
6767+6868+```lua
6969+local n = db.count("xyz.statusphere.status")
7070+local n = db.count("xyz.statusphere.status", "did:plc:abc") -- filter by DID
7171+```
7272+7373+## db.raw
7474+7575+Run a raw SQL query against the database. Supports `SELECT`, `INSERT`, `UPDATE`, `DELETE`, and `CREATE TABLE` statements.
7676+7777+```lua
7878+-- Read query
7979+local rows = db.raw(
8080+ "SELECT uri, did, record FROM records WHERE collection = $1 AND did = $2 LIMIT $3",
8181+ { "xyz.statusphere.status", "did:plc:abc", 10 }
8282+)
8383+8484+for _, row in ipairs(rows) do
8585+ -- row.uri, row.did, row.record (JSONB is returned as a Lua table)
8686+end
8787+8888+-- Write query (returns affected rows, if any)
8989+db.raw("CREATE TABLE IF NOT EXISTS my_table (id TEXT PRIMARY KEY, value TEXT NOT NULL)")
9090+db.raw("INSERT INTO my_table (id, value) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET value = $2",
9191+ { "key1", "hello" })
9292+```
9393+9494+Parameters are passed as an array and bound to `$1`, `$2`, etc. Supported parameter types: strings, integers, numbers, booleans, and nil.
9595+9696+### Column type mapping
9797+9898+| Postgres type | Lua type |
9999+| ---------------------- | -------- |
100100+| `TEXT`, `VARCHAR` | string |
101101+| `INT4`, `INT8` | integer |
102102+| `FLOAT4`, `FLOAT8` | number |
103103+| `BOOL` | boolean |
104104+| `JSON`, `JSONB` | table |
105105+| `TIMESTAMPTZ` | string (ISO 8601) |
106106+| Other | string (fallback) |
+60
packages/docs/docs/reference/lua/http-api.md
···11+# HTTP API
22+33+The `http` table provides async HTTP client functions. Available in queries, procedures, and [index hooks](../../guides/index-hooks.md).
44+55+## Methods
66+77+All methods take a URL and an optional options table, and return a [response table](#response).
88+99+```lua
1010+http.get(url, opts?)
1111+http.post(url, opts?)
1212+http.put(url, opts?)
1313+http.patch(url, opts?)
1414+http.delete(url, opts?)
1515+http.head(url, opts?)
1616+```
1717+1818+## Options
1919+2020+The optional second argument is a table with:
2121+2222+| Field | Type | Description |
2323+| --------- | ------ | ---------------------------------------------- |
2424+| `headers` | table | Request headers as key-value string pairs |
2525+| `body` | string | Request body (ignored for GET and HEAD) |
2626+2727+## Response
2828+2929+Every method returns a table with:
3030+3131+| Field | Type | Description |
3232+| --------- | ------- | ---------------------------------------------------- |
3333+| `status` | integer | HTTP status code |
3434+| `body` | string | Response body text (empty string for HEAD) |
3535+| `headers` | table | Response headers as key-value pairs (lowercase keys) |
3636+3737+## Examples
3838+3939+```lua
4040+-- Simple GET
4141+local resp = http.get("https://api.example.com/data")
4242+-- resp.status = 200, resp.body = "...", resp.headers["content-type"] = "application/json"
4343+4444+-- GET with custom headers
4545+local resp = http.get("https://api.example.com/data", {
4646+ headers = { ["authorization"] = "Bearer token123" }
4747+})
4848+4949+-- POST with JSON body
5050+local resp = http.post("https://api.example.com/hook", {
5151+ body = '{"key": "value"}',
5252+ headers = { ["content-type"] = "application/json" }
5353+})
5454+5555+-- PUT, PATCH, DELETE, HEAD follow the same pattern
5656+local resp = http.put(url, { body = data, headers = { ... } })
5757+local resp = http.patch(url, { body = data, headers = { ... } })
5858+local resp = http.delete(url, { headers = { ... } })
5959+local resp = http.head(url)
6060+```
+21
packages/docs/docs/reference/lua/json-api.md
···11+# JSON API
22+33+The `json` global provides JSON serialization and deserialization. Available in queries, procedures, and [index hooks](../../guides/index-hooks.md).
44+55+## json.encode
66+77+```lua
88+local str = json.encode({ key = "value", items = { 1, 2, 3 } })
99+-- '{"key":"value","items":[1,2,3]}'
1010+```
1111+1212+Converts a Lua table to a JSON string.
1313+1414+## json.decode
1515+1616+```lua
1717+local tbl = json.decode('{"key": "value"}')
1818+-- tbl.key == "value"
1919+```
2020+2121+Parses a JSON string into a Lua table. Returns an error if the input is not valid JSON.
+84
packages/docs/docs/reference/lua/record-api.md
···11+# Record API
22+33+The `Record` API is only available in **procedure** scripts. It handles creating, updating, loading, and deleting AT Protocol records. Writes are proxied to the caller's PDS and indexed locally.
44+55+## Constructor
66+77+```lua
88+local r = Record("xyz.statusphere.status", { status = "\ud83d\ude0a", createdAt = now() })
99+```
1010+1111+Creates a new record instance for the given collection. The optional second argument sets initial field values. The record's `_key_type` is automatically set from the lexicon's `key` definition. Default values from the schema are populated for any missing fields.
1212+1313+## Static methods
1414+1515+```lua
1616+-- Save multiple records in parallel
1717+Record.save_all({ record1, record2, record3 })
1818+1919+-- Load a record from the local database by AT URI
2020+local r = Record.load("at://did:plc:abc/xyz.statusphere.status/abc123")
2121+-- Returns nil if not found
2222+2323+-- Load multiple records in parallel
2424+local records = Record.load_all({ uri1, uri2 })
2525+-- Returns nil entries for URIs not found
2626+```
2727+2828+## Instance methods
2929+3030+```lua
3131+-- Save (creates or updates depending on whether _uri is set)
3232+r:save()
3333+3434+-- Delete from PDS and local database
3535+r:delete()
3636+3737+-- Set the record key type (tid, any, nsid, or literal:*)
3838+r:set_key_type("tid")
3939+4040+-- Set a specific record key
4141+r:set_rkey("my-key")
4242+4343+-- Auto-generate a record key based on _key_type
4444+local key = r:generate_rkey()
4545+```
4646+4747+**Key type behavior for `generate_rkey()`:**
4848+4949+| Key type | Generated rkey |
5050+| --------------- | --------------------------------- |
5151+| `tid` | Sortable timestamp-based ID |
5252+| `any` | Same as `tid` |
5353+| `literal:value` | The literal value after the colon |
5454+| `nsid` | Error — use `set_rkey()` instead |
5555+5656+## Instance fields
5757+5858+These fields are set automatically and are read-only (writes raise an error):
5959+6060+| Field | Type | Description |
6161+| ------------- | ------- | ----------------------------------------------------------- |
6262+| `_uri` | string? | AT URI — set after `save()`, cleared after `delete()` |
6363+| `_cid` | string? | Content hash — set after `save()`, cleared after `delete()` |
6464+| `_key_type` | string? | Record key type from the lexicon definition |
6565+| `_rkey` | string? | Record key — set via `set_rkey()` or `generate_rkey()` |
6666+| `_collection` | string | Collection NSID (always set) |
6767+| `_schema` | table? | Schema definition from the lexicon (used for validation) |
6868+6969+## Schema validation
7070+7171+When a record has a schema (loaded from the lexicon):
7272+7373+- **On save:** required fields are checked, and missing required fields raise an error
7474+- **On construction:** default values from schema properties are auto-populated
7575+- **On save:** only fields defined in the schema's `properties` are sent to the PDS
7676+7777+## Save behavior
7878+7979+`r:save()` auto-detects create vs update:
8080+8181+- If `_uri` is nil → calls `createRecord` on the PDS
8282+- If `_uri` is set → calls `putRecord` on the PDS
8383+8484+After a successful save, `_uri` and `_cid` are updated on the record instance.
···4455## Session secret
6677-Set `SESSION_SECRET` to a strong random value (at least 32 bytes). This signs the session cookies issued during OAuth login; rotating it invalidates every existing session.
77+Set `SESSION_SECRET` to a random string of at least 64 characters. This signs the session cookies issued during OAuth login; rotating it invalidates every existing session.
8899```sh
1010openssl rand -base64 48
···46464747HappyView has a per-client token-bucket rate limiter for XRPC endpoints. The defaults (set via `DEFAULT_RATE_LIMIT_CAPACITY` and `DEFAULT_RATE_LIMIT_REFILL_RATE`) apply to any [API client](../guides/api-keys.md) that doesn't have per-client overrides. Raise the defaults cautiously — they exist so one misbehaving integrator can't saturate the server.
48484949-Per-client overrides are set at client creation or via `PUT /admin/api-clients/{id}` (see [Admin API — API Clients](admin-api.md#api-clients)).
4949+Per-client overrides are set at client creation or via `PUT /admin/api-clients/{id}` (see [Admin API — API Clients](admin/api-clients.md)).
50505151## Logging
5252
···23232424## How it works
25252626-1. Iterate over `input.items` and create a [`Record`](../../guides/scripting.md#record-api) instance for each item.
2727-2. Call [`Record.save_all()`](../../guides/scripting.md#static-methods) to save all records in parallel, rather than one at a time.
2626+1. Iterate over `input.items` and create a [`Record`](../lua/record-api.md) instance for each item.
2727+2. Call [`Record.save_all()`](../lua/record-api.md#static-methods) to save all records in parallel, rather than one at a time.
28283. Collect the resulting AT URIs and return them.
29293030## Usage
···494950501. Load the primary record by URI. Return early if it doesn't exist.
51512. Query for related records, in this example comments by the same user that reference the primary record's URI.
5252-3. Load each related record with [`Record.load`](../../guides/scripting.md#static-methods) to get a deletable `Record` instance.
5252+3. Load each related record with [`Record.load`](../lua/record-api.md#static-methods) to get a deletable `Record` instance.
53534. Delete everything. Each `r:delete()` removes the record from the user's PDS and the local index.
54545555## Usage
···56565757## How it works
58585959-1. Load the existing record with [`Record.load`](../../guides/scripting.md#static-methods). This gives you a mutable `Record` instance with all the current field values.
5959+1. Load the existing record with [`Record.load`](../lua/record-api.md#static-methods). This gives you a mutable `Record` instance with all the current field values.
60602. Apply transformations directly on the record's fields:
6161 - **Increment a counter**: use `or 0` to handle the field being `nil` on first access.
6262 - **Merge tags**: iterate over `input.tags`, skip duplicates already in `r.tags`, append new ones, then trim the list to 10.
···14141515## How it works
16161717-1. Create a new [`Record`](../../guides/scripting.md#record-api) instance from the target collection, populated with the fields from the request body.
1717+1. Create a new [`Record`](../lua/record-api.md) instance from the target collection, populated with the fields from the request body.
18182. Call `r:save()`, which creates the record on the caller's PDS and indexes it locally.
19193. Return the AT URI and CID of the newly created record.
2020
···1313 collection = "xyz.statusphere.status",
1414 did = params.did,
1515 limit = limit,
1616- offset = tonumber(params.cursor) or 0,
1616+ cursor = params.cursor,
1717 })
18181919 -- Collect unique DIDs from the statuses
···51511. Query statuses from the target collection with pagination, same as a normal list query.
52522. Extract the unique DIDs from the returned status URIs using `string.match`.
53533. Build an AT URI for each DID's `app.bsky.actor.profile/self` record (this is where Bluesky profiles live).
5454-4. Load all profiles in parallel with [`Record.load_all`](../../guides/scripting.md#static-methods). Profiles that aren't indexed locally return `nil` and are skipped.
5454+4. Load all profiles in parallel with [`Record.load_all`](../lua/record-api.md#static-methods). Profiles that aren't indexed locally return `nil` and are skipped.
55555. Return statuses and profiles as separate keys, with the cursor from the status query.
56565757## Usage
···5959```
6060GET /xrpc/xyz.statusphere.listStatusesWithProfiles?limit=10
6161GET /xrpc/xyz.statusphere.listStatusesWithProfiles?did=did:plc:abc
6262-GET /xrpc/xyz.statusphere.listStatusesWithProfiles?cursor=20&limit=20
6262+GET /xrpc/xyz.statusphere.listStatusesWithProfiles?cursor=<opaque>&limit=20
6363```
64646565```json
···7272 { "uri": "at://did:plc:abc/app.bsky.actor.profile/self", "displayName": "Alice", "avatar": "..." },
7373 { "uri": "at://did:plc:def/app.bsky.actor.profile/self", "displayName": "Bob", "avatar": "..." }
7474 ],
7575- "cursor": "10"
7575+ "cursor": "MjAyNi0wMS0wMVQxMjowMDowMFp8YXQ6Ly9kaWQ6..."
7676}
7777```
7878
···2222## How it works
232324241. Check that the `uri` query parameter is present. Return a structured error if missing.
2525-2. Look up the record with [`db.get`](../../guides/scripting.md#dbget), which returns the record table or `nil`.
2525+2. Look up the record with [`db.get`](../lua/database-api.md#dbget), which returns the record table or `nil`.
26263. Return the record wrapped in an object.
27272828## Usage
···1818 collection = collection,
1919 did = params.did,
2020 limit = tonumber(params.limit) or 20,
2121- offset = tonumber(params.cursor) or 0,
2121+ cursor = params.cursor,
2222 })
2323end
2424```
25252626## How it works
27272828-1. If a `uri` query parameter is provided, fetch that single record with [`db.get`](../../guides/scripting.md#dbget) and return it. If it doesn't exist, return a structured error (using `error()` would trigger a 500 response).
2929-2. Otherwise, list records from the target collection using [`db.query`](../../guides/scripting.md#dbquery), with optional filtering by `did` and pagination via `limit`/`offset`. Since query parameters arrive as strings, `tonumber()` converts them to numbers.
2828+1. If a `uri` query parameter is provided, fetch that single record with [`db.get`](../lua/database-api.md#dbget) and return it. If it doesn't exist, return a structured error (using `error()` would trigger a 500 response).
2929+2. Otherwise, list records from the target collection using [`db.query`](../lua/database-api.md#dbquery), with optional filtering by `did` and cursor-based pagination. The `cursor` is an opaque string from a previous response — pass it through directly. Since `limit` arrives as a string, `tonumber()` converts it to a number.
30303131## Usage
3232
···1313 collection = collection,
1414 did = params.did,
1515 limit = limit,
1616- offset = tonumber(params.cursor) or 0,
1616+ cursor = params.cursor,
1717 })
18181919 return result
···2323## How it works
242425251. Parse `limit` from the query string, defaulting to 20 and capping at 100.
2626-2. Call [`db.query`](../../guides/scripting.md#dbquery) with the target collection, optional DID filter, and offset-based pagination.
2727-3. Return the result directly. `db.query` returns `{ records = [...], cursor = "..." }` where `cursor` is present when more records exist.
2626+2. Call [`db.query`](../lua/database-api.md#dbquery) with the target collection, optional DID filter, and cursor for pagination.
2727+3. Return the result directly. `db.query` returns `{ records = [...], cursor = "..." }` where `cursor` is an opaque string present when more records exist.
28282929## Usage
3030···3232GET /xrpc/xyz.statusphere.listStatuses
3333GET /xrpc/xyz.statusphere.listStatuses?limit=50
3434GET /xrpc/xyz.statusphere.listStatuses?did=did:plc:abc&limit=10
3535-GET /xrpc/xyz.statusphere.listStatuses?cursor=20&limit=20
3535+GET /xrpc/xyz.statusphere.listStatuses?cursor=<opaque>&limit=20
3636```
37373838## Use case
39394040-A straightforward list endpoint for feeds, timelines, or browsing records by collection. The `cursor` value returned by `db.query` is an offset. Clients pass it back as the `cursor` parameter to fetch the next page. Since all query parameters arrive as strings, use `tonumber()` to convert `limit` and `cursor` to numbers.
4040+A straightforward list endpoint for feeds, timelines, or browsing records by collection. The `cursor` value returned by `db.query` is an opaque string. Clients pass it back as the `cursor` parameter to fetch the next page — don't parse or modify it.
···343435351. Generate a single [`TID()`](../../guides/scripting.md#utility-globals) to use as the rkey for both records.
36362. Create a `Record` for each collection and call `r:set_rkey()` with the shared rkey.
3737-3. Save both records in parallel with [`Record.save_all()`](../../guides/scripting.md#static-methods).
3737+3. Save both records in parallel with [`Record.save_all()`](../lua/record-api.md#static-methods).
38384. Return both URIs so the client knows the identity of each record.
39394040## Usage
···30303131## How it works
32323333-1. If `input.delete` is truthy and `input.uri` is provided, load the record with [`Record.load`](../../guides/scripting.md#static-methods) and delete it.
3434-2. If only `input.uri` is provided, load the existing record with [`Record.load`](../../guides/scripting.md#static-methods), update its fields, and save it back. Since `_uri` is already set, `r:save()` calls `putRecord` instead of `createRecord`.
3333+1. If `input.delete` is truthy and `input.uri` is provided, load the record with [`Record.load`](../lua/record-api.md#static-methods) and delete it.
3434+2. If only `input.uri` is provided, load the existing record with [`Record.load`](../lua/record-api.md#static-methods), update its fields, and save it back. Since `_uri` is already set, `r:save()` calls `putRecord` instead of `createRecord`.
35353. If neither condition matches, create a new record from the input.
36363737## Usage
···3333## How it works
343435351. Use the client-provided `input.rkey` if present, otherwise generate a new [`TID()`](../../guides/scripting.md#utility-globals). This means omitting `rkey` always creates, while providing one enables updates.
3636-2. Build the AT URI from the caller's DID, the target collection, and the rkey, then try to load it with [`Record.load`](../../guides/scripting.md#static-methods).
3636+2. Build the AT URI from the caller's DID, the target collection, and the rkey, then try to load it with [`Record.load`](../lua/record-api.md#static-methods).
37373. If the record exists, update its fields and save. Since `_uri` is already set, `r:save()` calls `putRecord`.
38384. If it doesn't exist, create a new record, set the rkey explicitly with `r:set_rkey()`, and save. This calls `createRecord` with the specified rkey.
3939
+52-4
packages/docs/docs/reference/troubleshooting.md
···28282929**Causes**:
30303131-- No session cookie or `Authorization: Bearer` header is present.
3232-- The session cookie has expired or was signed with a different `SESSION_SECRET`.
3333-- The API key has been revoked or is invalid.
3131+- No `Authorization: DPoP` header or `X-Client-Key` header is present.
3232+- The DPoP proof is invalid or expired.
3333+- The API client key is not registered or is inactive.
34343535## Admin endpoints return 403 Forbidden
3636···8686- No record-type lexicon exists for the collection. HappyView only indexes collections that have a corresponding record-type lexicon.
8787- The Jetstream subscription hasn't reconnected with the new collection filter after a lexicon change. This should happen automatically. Check server logs for connection errors.
88888989+## Lua script can't find records
9090+9191+**Symptom**: `db.query` or `db.get` returns empty results inside a Lua script, even though the admin dashboard shows records exist.
9292+9393+**Causes**:
9494+9595+- The `collection` global is only set when the lexicon has a `target_collection`. If you're using `db.raw` with a hardcoded collection name, double-check the spelling matches exactly.
9696+- `db.get` expects a full AT URI (`at://did:plc:abc/collection/rkey`), not just an rkey.
9797+- If querying by DID, make sure you're passing the full DID string including the `did:plc:` or `did:web:` prefix.
9898+9999+## Plugin secrets not working
100100+101101+**Symptom**: A plugin fails with authentication errors even though you've configured its secrets.
102102+103103+**Causes**:
104104+105105+- `TOKEN_ENCRYPTION_KEY` is not set. Plugin secrets are encrypted at rest and cannot be read without this key. See [Plugins - Configuration](../guides/plugins.md#plugin-configuration).
106106+- If `TOKEN_ENCRYPTION_KEY` changed since the secrets were saved, the existing encrypted values are unreadable. Re-enter the secrets via the dashboard or `PUT /admin/plugins/{id}/secrets`.
107107+- Environment variable secrets (`PLUGIN_<ID>_<KEY>`) are overridden by dashboard-configured secrets. If you've set both, the dashboard values take precedence.
108108+89109## OAuth or login issues
9011091111HappyView handles AT Protocol OAuth internally via the `atrium-oauth` library. If users can't log in:
92112931131. Verify `PUBLIC_URL` is set correctly and the URL is publicly accessible (required for OAuth callbacks).
941142. Check that the user's PDS authorization server is reachable.
9595-3. Verify `SESSION_SECRET` hasn't changed since sessions were created (changing it invalidates all existing session cookies).
115115+3. Verify `SESSION_SECRET` hasn't changed since sessions were created (changing it invalidates all existing dashboard sessions).
961164. Check server logs for OAuth-specific error messages.
97117118118+## Third-party app can't authenticate
119119+120120+**Symptom**: A third-party app using DPoP authentication gets 401 errors on XRPC endpoints.
121121+122122+**Causes**:
123123+124124+- The app hasn't registered an API client. Every XRPC request needs an `X-Client-Key` header with a valid `hvc_`-prefixed client key. Register one via **Settings > API Clients** or `POST /admin/api-clients`.
125125+- The DPoP proof is malformed or expired. Proofs include a timestamp and are valid for a short window.
126126+- The API client has been deactivated (`is_active: false`). Re-enable it via the dashboard or `PUT /admin/api-clients/{id}`.
127127+98128## Database connection errors
99129100130**Symptom**: HappyView fails to start or returns 500 errors.
···106136- Postgres version is too old. HappyView requires Postgres 17+.
107137108138See [Configuration](../getting-started/configuration.md) for environment variable details.
139139+140140+## Switching databases loses data
141141+142142+**Symptom**: After changing `DATABASE_URL` from SQLite to Postgres (or vice versa), all records, lexicons, and users are gone.
143143+144144+**Explanation**: Each database is independent. Switching `DATABASE_URL` points HappyView at a fresh database. Your old data is still in the previous database file or Postgres instance.
145145+146146+**Recovery**: Re-upload your lexicons and run backfills to re-index records from the network. Admin settings, users, and API keys need to be re-created manually. See the [SQLite → Postgres](../guides/sqlite-to-postgres-migration.md) or [Postgres → SQLite](../guides/postgres-to-sqlite-migration.md) migration guides.
147147+148148+## Jetstream disconnects frequently
149149+150150+**Symptom**: Server logs show repeated `jetstream.disconnected` / `jetstream.connected` events.
151151+152152+**Causes**:
153153+154154+- Network instability between HappyView and the Jetstream server. Verify `JETSTREAM_URL` is reachable.
155155+- The default Jetstream instance may be under heavy load. Consider pointing `JETSTREAM_URL` at a different instance if available.
156156+- HappyView reconnects automatically and resumes from its last cursor, so brief disconnections don't cause data loss. Prolonged outages may require a backfill to catch up on missed records.
+6-6
packages/docs/docs/reference/xrpc-api.md
···77## Auth
8899- **Queries** (`GET /xrpc/{method}`): unauthenticated
1010-- **Procedures** (`POST /xrpc/{method}`): require authentication (session cookie, API key, or service auth JWT)
1010+- **Procedures** (`POST /xrpc/{method}`): require DPoP authentication (`Authorization: DPoP` + `DPoP` proof header + `X-Client-Key`)
1111- **getProfile**: requires auth
1212- **uploadBlob**: requires auth
1313···101101### List records
102102103103```
104104-GET /xrpc/{method}?limit=20&cursor=0&did=optional
104104+GET /xrpc/{method}?limit=20&cursor=<opaque>&did=optional
105105```
106106107107| Param | Type | Default | Description |
108108|-------|------|---------|-------------|
109109| `limit` | integer | 20 | Max records to return (max 100) |
110110-| `cursor` | string | `0` | Pagination cursor (opaque, pass from previous response) |
110110+| `cursor` | string | --- | Opaque pagination cursor from a previous response |
111111| `did` | string | --- | Filter records by DID |
112112113113```sh
···125125 "createdAt": "2025-01-01T12:00:00Z"
126126 }
127127 ],
128128- "cursor": "10"
128128+ "cursor": "MjAyNS0wMS0wMVQxMjowMDowMFp8YXQ6Ly9kaWQ6..."
129129}
130130```
131131132132-The `cursor` field is present only when more records exist.
132132+The `cursor` field is an opaque string present only when more records exist. Pass it back as-is to fetch the next page.
133133134134## Dynamic procedure endpoints
135135···184184| Status | Meaning | Common causes |
185185|--------|---------|---------------|
186186| `400 Bad Request` | Invalid input | Missing required fields, malformed JSON, invalid AT URI |
187187-| `401 Unauthorized` | Authentication failed | Missing or invalid session cookie, API key, or service auth JWT |
187187+| `401 Unauthorized` | Authentication failed | Missing or invalid client identification or DPoP authentication |
188188| `404 Not Found` | Method or record not found | XRPC method has no matching lexicon, or the requested record doesn't exist |
189189| `500 Internal Server Error` | Server-side failure | Lua script error, database error, or upstream PDS failure |
190190