From 066b0f7bdfd885ee6898074a575a58de1d2ba522 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 17 Apr 2026 17:33:00 -0500 Subject: [PATCH] docs: update for v2 --- packages/docs/docs/README.md | 4 +- .../docs/getting-started/authentication.md | 61 +- .../docs/getting-started/configuration.md | 2 +- .../docs/docs/getting-started/dashboard.md | 65 +- .../docs/getting-started/deployment/docker.md | 2 +- .../getting-started/deployment/railway.md | 5 +- packages/docs/docs/guides/backfill.md | 6 +- packages/docs/docs/guides/database-setup.md | 1 + .../docs/docs/guides/developing-plugins.md | 105 ++ packages/docs/docs/guides/event-logs.md | 8 +- packages/docs/docs/guides/index-hooks.md | 16 +- packages/docs/docs/guides/labelers.md | 6 +- packages/docs/docs/guides/lexicons.md | 20 +- packages/docs/docs/guides/permissions.md | 62 +- packages/docs/docs/guides/plugins.md | 103 +- .../guides/postgres-to-sqlite-migration.md | 19 +- packages/docs/docs/guides/scripting.md | 438 +----- .../guides/sqlite-to-postgres-migration.md | 88 ++ packages/docs/docs/reference/admin-api.md | 1277 +---------------- .../docs/docs/reference/admin/api-clients.md | 131 ++ .../docs/docs/reference/admin/api-keys.md | 92 ++ .../docs/docs/reference/admin/backfill.md | 65 + packages/docs/docs/reference/admin/domains.md | 99 ++ packages/docs/docs/reference/admin/events.md | 54 + .../docs/docs/reference/admin/labelers.md | 99 ++ .../docs/docs/reference/admin/lexicons.md | 167 +++ packages/docs/docs/reference/admin/plugins.md | 223 +++ .../docs/reference/admin/script-variables.md | 41 + .../docs/docs/reference/admin/settings.md | 50 + packages/docs/docs/reference/admin/stats.md | 25 + packages/docs/docs/reference/admin/users.md | 147 ++ packages/docs/docs/reference/architecture.md | 207 +-- packages/docs/docs/reference/changelog.md | 2 +- .../docs/docs/reference/lua/atproto-api.md | 107 ++ .../docs/docs/reference/lua/database-api.md | 106 ++ packages/docs/docs/reference/lua/http-api.md | 60 + packages/docs/docs/reference/lua/json-api.md | 21 + .../docs/docs/reference/lua/record-api.md | 84 ++ .../docs/reference/lua/standard-libraries.md | 77 + .../docs/reference/production-deployment.md | 4 +- .../docs/docs/reference/scripts/batch-save.md | 4 +- .../reference/scripts/cascading-delete.md | 2 +- .../reference/scripts/complex-mutations.md | 2 +- .../docs/reference/scripts/create-record.md | 2 +- .../docs/reference/scripts/expanded-query.md | 8 +- .../docs/docs/reference/scripts/get-record.md | 2 +- .../docs/reference/scripts/list-or-fetch.md | 6 +- .../docs/reference/scripts/paginated-list.md | 10 +- .../docs/reference/scripts/sidecar-records.md | 2 +- .../reference/scripts/update-or-delete.md | 4 +- .../docs/reference/scripts/upsert-record.md | 2 +- .../docs/docs/reference/troubleshooting.md | 56 +- packages/docs/docs/reference/xrpc-api.md | 12 +- packages/docs/docs/tutorials/statusphere.md | 6 +- packages/docs/sidebars.ts | 116 +- 55 files changed, 2420 insertions(+), 1963 deletions(-) create mode 100644 packages/docs/docs/guides/developing-plugins.md create mode 100644 packages/docs/docs/guides/sqlite-to-postgres-migration.md create mode 100644 packages/docs/docs/reference/admin/api-clients.md create mode 100644 packages/docs/docs/reference/admin/api-keys.md create mode 100644 packages/docs/docs/reference/admin/backfill.md create mode 100644 packages/docs/docs/reference/admin/domains.md create mode 100644 packages/docs/docs/reference/admin/events.md create mode 100644 packages/docs/docs/reference/admin/labelers.md create mode 100644 packages/docs/docs/reference/admin/lexicons.md create mode 100644 packages/docs/docs/reference/admin/plugins.md create mode 100644 packages/docs/docs/reference/admin/script-variables.md create mode 100644 packages/docs/docs/reference/admin/settings.md create mode 100644 packages/docs/docs/reference/admin/stats.md create mode 100644 packages/docs/docs/reference/admin/users.md create mode 100644 packages/docs/docs/reference/lua/atproto-api.md create mode 100644 packages/docs/docs/reference/lua/database-api.md create mode 100644 packages/docs/docs/reference/lua/http-api.md create mode 100644 packages/docs/docs/reference/lua/json-api.md create mode 100644 packages/docs/docs/reference/lua/record-api.md create mode 100644 packages/docs/docs/reference/lua/standard-libraries.md diff --git a/packages/docs/docs/README.md b/packages/docs/docs/README.md index 335fe5e..03bd6f2 100644 --- a/packages/docs/docs/README.md +++ b/packages/docs/docs/README.md @@ -2,7 +2,7 @@ HappyView 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. -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. +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. ## Features @@ -16,7 +16,7 @@ Building an AppView from scratch means wiring up firehose connections, record st - **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. -- **Zero boilerplate**: HappyView handles AppView infrastructure (firehose, backfill, OAuth, PDS proxying) for you. You should be writing application logic from minute one, not plumbing. +- **Zero boilerplate**: HappyView handles AppView infrastructure (Jetstream, backfill, OAuth, PDS proxying) for you. You should be writing application logic from minute one, not plumbing. - **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. diff --git a/packages/docs/docs/getting-started/authentication.md b/packages/docs/docs/getting-started/authentication.md index 91bbd7c..7a66307 100644 --- a/packages/docs/docs/getting-started/authentication.md +++ b/packages/docs/docs/getting-started/authentication.md @@ -3,16 +3,16 @@ HappyView has two distinct authentication surfaces: - **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). -- **Admin API** (`/admin/*`) — user-level authentication via session cookies, admin API keys, or service auth JWTs, gated by [permissions](../guides/permissions.md). +- **Admin API** (`/admin/*`) — user-level authentication via admin API keys or service auth JWTs, gated by [permissions](../guides/permissions.md). ## Which endpoints require what? -| Endpoint type | Client identification | User authentication | -| ----------------------------------- | ------------------------ | ------------------------------------------------------------------------------------ | -| Queries (`GET /xrpc/{method}`) | `X-Client-Key` required | Optional — DPoP auth if the query needs to know who the user is | -| Procedures (`POST /xrpc/{method}`) | `X-Client-Key` required | Required — DPoP auth so HappyView can proxy writes to the user's PDS | -| Admin API (`/admin/*`) | — | Required — session cookie, admin API key, or service auth JWT with the right [permissions](../guides/permissions.md) | -| Health check (`GET /health`) | — | — | +| Endpoint type | Client identification | User authentication | +| ---------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------ | +| Queries (`GET /xrpc/{method}`) | `X-Client-Key` required | Optional — DPoP auth if the query needs to know who the user is | +| Procedures (`POST /xrpc/{method}`) | `X-Client-Key` required | Required — DPoP auth so HappyView can proxy writes to the user's PDS | +| Admin API (`/admin/*`) | — | Required — admin API key or service auth JWT with the right [permissions](../guides/permissions.md) | +| Health check (`GET /health`) | — | — | ## XRPC: API client identification @@ -22,9 +22,8 @@ Register a client in the dashboard (**Settings > API Clients > New client**) or HappyView resolves the client key from the first of: -1. The session cookie, if the user logged in through this client's OAuth flow (the cookie carries the `client_key` that minted it). -2. The `X-Client-Key` request header. -3. A `client_key` query-string parameter. +1. The `X-Client-Key` request header. +2. A `client_key` query-string parameter. On top of the client key, HappyView does best-effort validation that the caller actually controls the client: @@ -52,7 +51,7 @@ curl 'https://happyview.example.com/xrpc/com.example.feed.getHot' \ Queries 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. -XRPC routes only accept **DPoP auth** (`Authorization: DPoP ` + `DPoP` proof header + `X-Client-Key`). Bearer tokens, service auth JWTs, and session cookies are not accepted on XRPC endpoints. +XRPC routes only accept **DPoP auth** (`Authorization: DPoP ` + `DPoP` proof header + `X-Client-Key`). Bearer tokens and service auth JWTs are not accepted on XRPC endpoints. Third-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. @@ -86,17 +85,9 @@ await lex.xrpc(myLexicons.com.example.createPost, { For 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). -:::note -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. -::: - ## Admin API: user authentication -Admin endpoints don't use API clients. They require a real HappyView user, identified by one of three methods: - -### Session cookie (dashboard) - -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. +Admin endpoints don't use API clients. They require a real HappyView user, identified by one of two methods: ### Admin API key @@ -125,22 +116,19 @@ As with the other methods, the resolved DID still has to exist in the HappyView ### Admin access and the first user -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. +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. -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. +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. ## Proxying procedures to the user's PDS -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: - -- **Cookie auth (dashboard)** — `atrium-oauth` attaches a DPoP proof and a DPoP-bound access token to the outbound request automatically. -- **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). +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. -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. +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. ## DPoP key provisioning for third-party apps -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. +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. The 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 ` plus a `DPoP` proof header and `X-Client-Key` will have HappyView proxy writes using the stored session. @@ -184,7 +172,13 @@ Response: ```json { "provision_id": "hvp_...", - "dpop_key": { "kty": "EC", "crv": "P-256", "x": "...", "y": "...", "d": "..." } + "dpop_key": { + "kty": "EC", + "crv": "P-256", + "x": "...", + "y": "...", + "d": "..." + } } ``` @@ -223,7 +217,12 @@ For public clients, omit `X-Client-Secret` and include the PKCE verifier in the "provision_id": "hvp_...", "pkce_verifier": "...", "did": "did:plc:user123", - ... + "access_token": "...", + "refresh_token": "...", + "expires_at": "2026-04-17T00:00:00Z", + "scopes": "atproto transition:generic", + "pds_url": "https://bsky.social", + "issuer": "https://bsky.social" } ``` @@ -283,4 +282,4 @@ This deletes the stored session and the associated DPoP key. - [JavaScript SDK](../sdk/overview.md) — authenticate and make XRPC calls from JavaScript - [Permissions](../guides/permissions.md) — full list of permissions and what each one grants - [API Keys](../guides/api-keys.md) — create scoped admin API keys for automation -- [Admin API — API Clients](../reference/admin-api.md#api-clients) — register API clients and configure rate limits +- [Admin API — API Clients](../reference/admin/api-clients.md) — register API clients and configure rate limits diff --git a/packages/docs/docs/getting-started/configuration.md b/packages/docs/docs/getting-started/configuration.md index 89e1870..a38d5cd 100644 --- a/packages/docs/docs/getting-started/configuration.md +++ b/packages/docs/docs/getting-started/configuration.md @@ -9,7 +9,7 @@ HappyView is configured via environment variables. A `.env` file in the project | `DATABASE_URL` | yes | --- | Database connection string. SQLite (`sqlite://path/to/db?mode=rwc`) or Postgres (`postgres://user:pass@host/db`) | | `DATABASE_BACKEND` | no | auto-detected | Force `sqlite` or `postgres`. Auto-detected from `DATABASE_URL` scheme if not set | | `PUBLIC_URL` | yes | --- | Public-facing URL for HappyView (used for OAuth callbacks, e.g. `https://happyview.example.com`) | -| `SESSION_SECRET` | no | dev default | Secret key for signing session cookies. **Must be set in production** | +| `SESSION_SECRET` | no | dev default | Secret key for signing session cookies (at least 64 characters). **Must be set in production** | | `HOST` | no | `0.0.0.0` | Bind host | | `PORT` | no | `3000` | Bind port | | `JETSTREAM_URL` | no | `wss://jetstream1.us-east.bsky.network` | Jetstream WebSocket URL for real-time record streaming | diff --git a/packages/docs/docs/getting-started/dashboard.md b/packages/docs/docs/getting-started/dashboard.md index f28060d..da36e54 100644 --- a/packages/docs/docs/getting-started/dashboard.md +++ b/packages/docs/docs/getting-started/dashboard.md @@ -1,12 +1,16 @@ # Dashboard -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. +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. -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. +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. -## Adding a lexicon +## Lexicons -Navigate to **Lexicons > Add Lexicon** and choose **Local** or **Network**. +Navigate to **Lexicons** to see all uploaded lexicons. Each entry shows the NSID, type (record, query, procedure), and whether a Lua script is attached. + +### Adding a lexicon + +Click **Add Lexicon** and choose **Local** or **Network**. **Local** lexicons are defined by you. The editor shows two side-by-side panels (stacked on mobile): @@ -35,8 +39,61 @@ The Lua editor provides context-aware code completions, including suggestions fo See [Lua Scripting](../guides/scripting.md) for the full runtime reference and examples. +## Records + +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. + +## Backfill + +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. + +## Users + +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. + +## Events + +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). + +## Settings + +The **Settings** section contains several sub-pages: + +### General + +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. + +### API Clients + +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. + +### API Keys + +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. + +### Users + +An alternative path to the top-level Users page for managing user accounts and permissions. + +### Plugins + +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. + +### Labelers + +Configure labeler subscriptions for content labeling. See [Labelers](../guides/labelers.md) for details. + +### Environment Variables + +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. + +### Accounts + +Manage connected AT Protocol accounts used by the instance. + ## Next steps - [Lexicons](../guides/lexicons.md) — how lexicons drive HappyView's indexing and routing - [Lua Scripting](../guides/scripting.md) — write custom query and procedure logic - [Permissions](../guides/permissions.md) — manage user access to admin features +- [Configuration](configuration.md) — full list of environment variables diff --git a/packages/docs/docs/getting-started/deployment/docker.md b/packages/docs/docs/getting-started/deployment/docker.md index f9888b2..f9ef722 100644 --- a/packages/docs/docs/getting-started/deployment/docker.md +++ b/packages/docs/docs/getting-started/deployment/docker.md @@ -14,7 +14,7 @@ cd happyview cp .env.example .env ``` -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. +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. ## 2. Start the stack diff --git a/packages/docs/docs/getting-started/deployment/railway.md b/packages/docs/docs/getting-started/deployment/railway.md index 1d4bd2d..1864c48 100644 --- a/packages/docs/docs/getting-started/deployment/railway.md +++ b/packages/docs/docs/getting-started/deployment/railway.md @@ -8,7 +8,10 @@ The fastest way to get HappyView running is with Railway. This template deploys After deploying the template, you'll need to configure a few things before the stack works properly: -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. +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. + ```sh + openssl rand -base64 48 + ``` 2. **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`). :::note diff --git a/packages/docs/docs/guides/backfill.md b/packages/docs/docs/guides/backfill.md index 1e50524..a7ad503 100644 --- a/packages/docs/docs/guides/backfill.md +++ b/packages/docs/docs/guides/backfill.md @@ -7,7 +7,7 @@ When you add a new record-type lexicon, HappyView starts indexing new records fr - **Automatically** when a record-type lexicon is uploaded with `backfill: true` (the default). See [Lexicons - Backfill flag](lexicons.md#backfill-flag). - **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. -See the [admin API](../reference/admin-api.md#backfill) for endpoint details. +See the [admin API](../reference/admin/backfill.md) for endpoint details. ## How it works @@ -19,7 +19,7 @@ See the [admin API](../reference/admin-api.md#backfill) for endpoint details. ## Job lifecycle -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. +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. If 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). @@ -34,4 +34,4 @@ Deleting records from HappyView (via the dashboard or API) only removes them fro ## Next steps - [Lexicons](lexicons.md#backfill-flag): Control whether lexicons trigger backfill on upload -- [Admin API](../reference/admin-api.md#backfill): Full reference for backfill endpoints +- [Admin API — Backfill](../reference/admin/backfill.md): Full reference for backfill endpoints diff --git a/packages/docs/docs/guides/database-setup.md b/packages/docs/docs/guides/database-setup.md index f718649..b26a50e 100644 --- a/packages/docs/docs/guides/database-setup.md +++ b/packages/docs/docs/guides/database-setup.md @@ -72,6 +72,7 @@ If you are migrating existing Lua scripts from Postgres SQL syntax to SQLite syn ## Next steps +- [SQLite → Postgres migration](sqlite-to-postgres-migration.md) — switch an existing instance from SQLite to Postgres - [Postgres → SQLite migration](postgres-to-sqlite-migration.md) — switch an existing instance from Postgres to SQLite - [Lua scripting](scripting.md) — write queries that target either backend - [Configuration](../getting-started/configuration.md) — `DATABASE_URL` and related variables diff --git a/packages/docs/docs/guides/developing-plugins.md b/packages/docs/docs/guides/developing-plugins.md new file mode 100644 index 0000000..34828f5 --- /dev/null +++ b/packages/docs/docs/guides/developing-plugins.md @@ -0,0 +1,105 @@ +# Developing Plugins + +This guide covers how to build your own HappyView WASM plugins. For installing and configuring plugins, see the [Plugins guide](plugins.md). + +See the [happyview-plugins](https://github.com/gamesgamesgamesgames/happyview-plugins) repository for examples and the plugin SDK. + +## Plugin Manifest + +Each plugin has a `manifest.json` that describes its metadata: + +```json +{ + "id": "steam", + "name": "Steam", + "version": "1.0.0", + "api_version": "1", + "description": "Import your Steam game library and playtime data.", + "icon_url": "https://example.com/steam-icon.png", + "auth_type": "openid", + "wasm_file": "steam.wasm", + "required_secrets": [ + { + "key": "PLUGIN_STEAM_API_KEY", + "name": "Steam Web API Key", + "description": "Get your API key at steamcommunity.com/dev/apikey" + } + ] +} +``` + +| Field | Description | +| ------------------ | ----------------------------------------------------- | +| `id` | Unique plugin identifier | +| `name` | Display name | +| `version` | Semantic version | +| `api_version` | Plugin API version (currently "1") | +| `description` | Brief description shown during install | +| `icon_url` | Optional icon URL | +| `auth_type` | Authentication type: `oauth2`, `openid`, or `api_key` | +| `wasm_file` | WASM binary filename (default: `plugin.wasm`) | +| `required_secrets` | Array of secrets the plugin needs | + +## API Endpoints + +### Public Endpoints + +| Endpoint | Description | +| --------------------------------------- | ---------------------------------------------- | +| `GET /external-auth/providers` | List available auth providers | +| `GET /external-auth/accounts` | List user's linked accounts | +| `GET /external-auth/{plugin}/authorize` | Start OAuth flow | +| `GET /external-auth/{plugin}/callback` | OAuth callback handler | +| `POST /external-auth/{plugin}/sync` | Sync data from linked account | +| `POST /external-auth/{plugin}/unlink` | Unlink account | +| `POST /external-auth/{plugin}/connect` | Connect with API key (for `api_key` auth type) | + +### Admin Endpoints + +| Endpoint | Description | +| --------------------------------------- | ------------------------------------------- | +| `GET /admin/plugins` | List installed plugins | +| `POST /admin/plugins` | Install a plugin | +| `POST /admin/plugins/preview` | Preview plugin before installing | +| `GET /admin/plugins/official` | Browse the official plugin registry catalog | +| `DELETE /admin/plugins/{id}` | Remove a plugin | +| `POST /admin/plugins/{id}/reload` | Reload plugin from source | +| `POST /admin/plugins/{id}/check-update` | Check whether a newer version is available | +| `GET /admin/plugins/{id}/secrets` | Get configured secrets (masked) | +| `PUT /admin/plugins/{id}/secrets` | Update plugin secrets | + +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. + +## Plugin Exports + +Plugins must export these functions: + +| Export | Signature | Description | +| ------------------- | ----------------------------- | ---------------------------- | +| `alloc` | `(size: u32) -> u32` | Allocate memory | +| `dealloc` | `(ptr: u32, size: u32)` | Deallocate memory | +| `get_authorize_url` | `(ptr: u32, len: u32) -> i64` | Generate OAuth authorize URL | +| `handle_callback` | `(ptr: u32, len: u32) -> i64` | Handle OAuth callback | +| `refresh_tokens` | `(ptr: u32, len: u32) -> i64` | Refresh expired tokens | +| `get_profile` | `(ptr: u32, len: u32) -> i64` | Get external profile info | +| `sync_account` | `(ptr: u32, len: u32) -> i64` | Sync data and return records | + +## Host Functions + +Plugins can import these host functions: + +| Import | Description | +| ------------------- | ----------------------- | +| `host_http_request` | Make HTTP requests | +| `host_get_secret` | Read configured secrets | +| `host_log` | Write to server logs | +| `host_kv_get` | Read from KV storage | +| `host_kv_set` | Write to KV storage | +| `host_kv_delete` | Delete from KV storage | + +## Next steps + +- [Official plugins repository](https://github.com/gamesgamesgamesgames/happyview-plugins) — ready-to-use plugins and the plugin SDK +- [Plugins guide](plugins.md) — install and configure plugins +- [API Keys](api-keys.md) — authenticate programmatic access to admin endpoints +- [Permissions](permissions.md) — configure user access to plugin management diff --git a/packages/docs/docs/guides/event-logs.md b/packages/docs/docs/guides/event-logs.md index b9cfd0b..88ae61e 100644 --- a/packages/docs/docs/guides/event-logs.md +++ b/packages/docs/docs/guides/event-logs.md @@ -1,6 +1,6 @@ # Event Logs -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). +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). ## Event types @@ -14,7 +14,7 @@ Events follow a `category.action` naming convention. Each event has a severity l | `lexicon.updated` | info | Lexicon NSID | `revision`, `has_script`, `source` | | `lexicon.deleted` | info | Lexicon NSID | — | -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. +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. ### Record events @@ -118,7 +118,7 @@ curl "http://localhost:3000/admin/events?category=lexicon" -H "$AUTH" curl "http://localhost:3000/admin/events?limit=20&cursor=2026-03-01T11:59:00Z" -H "$AUTH" ``` -See the [Admin API reference](../reference/admin-api.md#list-event-logs) for full parameter documentation. +See the [Admin API reference](../reference/admin/events.md#list-event-logs) for full parameter documentation. ## Retention @@ -130,6 +130,6 @@ See [Configuration](../getting-started/configuration.md) for all environment var ## Next steps -- [Admin API — Event Logs](../reference/admin-api.md#event-logs) — full query parameters and response format +- [Admin API — Event Logs](../reference/admin/events.md) — full query parameters and response format - [Permissions](permissions.md) — control which users can read event logs - [Troubleshooting](../reference/troubleshooting.md) — using event logs to diagnose issues diff --git a/packages/docs/docs/guides/index-hooks.md b/packages/docs/docs/guides/index-hooks.md index c834172..166545c 100644 --- a/packages/docs/docs/guides/index-hooks.md +++ b/packages/docs/docs/guides/index-hooks.md @@ -2,11 +2,11 @@ Index 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. -Unlike [query and procedure scripts](scripting.md) that run in response to XRPC requests, index hooks are triggered by the firehose. +Unlike [query and procedure scripts](scripting.md) that run in response to XRPC requests, index hooks are triggered by incoming Jetstream events. ## Attaching a hook -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. +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. ## Script structure @@ -50,15 +50,15 @@ These globals are set before `handle()` is called: | `rkey` | string | The record key | | `record` | table? | The full record as a Lua table (nil on delete) | -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. +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. ## Available APIs Index hooks have access to: -- **[Database API](scripting.md#database-api)** — `db.query`, `db.get`, `db.search`, `db.backlinks`, `db.count`, `db.raw` -- **[HTTP API](scripting.md#http-api)** — `http.get`, `http.post`, `http.put`, `http.patch`, `http.delete`, `http.head` -- **[JSON API](scripting.md#json-api)** — `json.encode`, `json.decode` +- **[Database API](../reference/lua/database-api.md)** — `db.query`, `db.get`, `db.search`, `db.backlinks`, `db.count`, `db.raw` +- **[HTTP API](../reference/lua/http-api.md)** — `http.get`, `http.post`, `http.put`, `http.patch`, `http.delete`, `http.head` +- **[JSON API](../reference/lua/json-api.md)** — `json.encode`, `json.decode` - **[Utility globals](scripting.md#utility-globals)** — `log()`, `now()`, `TID()`, `toarray()` ## Error handling and retries @@ -73,7 +73,7 @@ Failed hooks are logged as errors. Check the [event logs](event-logs.md) or quer ### Performance considerations -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. +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. ### Dead letter table @@ -218,4 +218,4 @@ See the full [Meilisearch sync reference](../reference/scripts/meilisearch-sync. - [Lua Scripting](scripting.md): Full reference for the sandbox, APIs, and debugging - [Lexicons](lexicons.md): Understand how record, query, and procedure lexicons work together -- [Admin API](../reference/admin-api.md#upload--upsert-a-lexicon): Upload lexicons with index hooks via the API +- [Admin API — Lexicons](../reference/admin/lexicons.md#upload--upsert-a-lexicon): Upload lexicons with index hooks via the API diff --git a/packages/docs/docs/guides/labelers.md b/packages/docs/docs/guides/labelers.md index 6bb09c8..f12e789 100644 --- a/packages/docs/docs/guides/labelers.md +++ b/packages/docs/docs/guides/labelers.md @@ -65,7 +65,7 @@ Labels appear in the **Labels** column on the Records page as color-coded badges Self-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. -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. +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. ## Permissions @@ -77,6 +77,6 @@ Labels are also available in the records API response and in Lua scripts via the ## Next steps -- [Admin API reference](../reference/admin-api.md#labelers) — full endpoint documentation -- [Scripting](scripting.md) — access labels in Lua scripts with `get_labels` and `get_labels_batch` +- [Admin API — Labelers](../reference/admin/labelers.md) — full endpoint documentation +- [AT Protocol API](../reference/lua/atproto-api.md) — access labels in Lua scripts with `get_labels` and `get_labels_batch` - [Permissions](permissions.md) — manage user access to labeler operations diff --git a/packages/docs/docs/guides/lexicons.md b/packages/docs/docs/guides/lexicons.md index 1abd836..a45b422 100644 --- a/packages/docs/docs/guides/lexicons.md +++ b/packages/docs/docs/guides/lexicons.md @@ -2,7 +2,7 @@ Lexicons 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. -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). +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). ## Supported lexicon types @@ -21,7 +21,7 @@ Query and procedure lexicons don't store data themselves. They operate on record For example, a query lexicon `xyz.statusphere.listStatuses` would set `target_collection` to `xyz.statusphere.status` to read from that record collection. -See the [admin API](../reference/admin-api.md#upload--upsert-a-lexicon) for how to set `target_collection` when uploading. +See the [admin API](../reference/admin/lexicons.md#upload--upsert-a-lexicon) for how to set `target_collection` when uploading. :::note The `target_collection` is available in Lua scripts as the `collection` global, but it is not required if your endpoint uses a Lua script. @@ -35,7 +35,7 @@ When uploading a record-type lexicon, HappyView automatically creates a backfill When 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. -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). +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). ## Network lexicons @@ -67,10 +67,10 @@ The `value` field of the response is the raw lexicon JSON. ### Live updates via Jetstream -The Jetstream subscription always includes `com.atproto.lexicon.schema` alongside the dynamic record collections. When a record event arrives: +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: -- **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. -- **delete**: The lexicon is removed from the `lexicons` table and registry. +- **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. +- **delete**: The lexicon is removed from the `lexicons` table and registry, and collection filters are updated accordingly. ### Startup re-fetch @@ -78,7 +78,7 @@ On every startup, HappyView re-fetches all network lexicons from their respectiv ## XRPC routing for unknown methods -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: +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: 1. Extract the authority from the NSID (all segments except the last). `com.example.foo.getBar` → authority `com.example.foo`. 2. Reverse it to form a domain: `foo.example.com`. @@ -90,10 +90,10 @@ A few things to note: - 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. - 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)). -- 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. -- "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`. +- 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. +- 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`. -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. +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. ## Next steps diff --git a/packages/docs/docs/guides/permissions.md b/packages/docs/docs/guides/permissions.md index 294a276..35d2f80 100644 --- a/packages/docs/docs/guides/permissions.md +++ b/packages/docs/docs/guides/permissions.md @@ -8,53 +8,53 @@ HappyView defines 20 permissions organized by category: ### Lexicons -| Permission | Description | -|---|---| +| Permission | Description | +| ----------------- | ---------------------------------------------- | | `lexicons:create` | Upload and upsert lexicons (local and network) | -| `lexicons:read` | List and view lexicon details | -| `lexicons:delete` | Delete lexicons | +| `lexicons:read` | List and view lexicon details | +| `lexicons:delete` | Delete lexicons | ### Records -| Permission | Description | -|---|---| -| `records:read` | List and view indexed records | -| `records:delete` | Delete individual records | +| Permission | Description | +| --------------------------- | --------------------------------------- | +| `records:read` | List and view indexed records | +| `records:delete` | Delete individual records | | `records:delete-collection` | Bulk-delete all records in a collection | ### Script Variables -| Permission | Description | -|---|---| -| `script-variables:create` | Create and update script variables | -| `script-variables:read` | List script variables (values are masked) | -| `script-variables:delete` | Delete script variables | +| Permission | Description | +| ------------------------- | ----------------------------------------- | +| `script-variables:create` | Create and update script variables | +| `script-variables:read` | List script variables (values are masked) | +| `script-variables:delete` | Delete script variables | ### Users -| Permission | Description | -|---|---| -| `users:create` | Add new users | -| `users:read` | List and view user details | -| `users:update` | Modify user permissions | -| `users:delete` | Remove users | +| Permission | Description | +| -------------- | -------------------------- | +| `users:create` | Add new users | +| `users:read` | List and view user details | +| `users:update` | Modify user permissions | +| `users:delete` | Remove users | ### API Keys -| Permission | Description | -|---|---| +| Permission | Description | +| ----------------- | ------------------- | | `api-keys:create` | Create new API keys | -| `api-keys:read` | List API keys | -| `api-keys:delete` | Revoke API keys | +| `api-keys:read` | List API keys | +| `api-keys:delete` | Revoke API keys | ### Operations -| Permission | Description | -|---|---| -| `backfill:create` | Start backfill jobs | -| `backfill:read` | View backfill job status | -| `stats:read` | View record statistics | -| `events:read` | Query the event log | +| Permission | Description | +| ----------------- | ------------------------ | +| `backfill:create` | Start backfill jobs | +| `backfill:read` | View backfill job status | +| `stats:read` | View record statistics | +| `events:read` | Query the event log | ## Permission templates @@ -91,7 +91,7 @@ The super user is a special user created automatically when the first person log - Cannot be deleted - Cannot have their permissions modified by other users -There is always exactly one super user. Super status can be transferred to another user via the transfer endpoint. +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. ## Escalation guards @@ -132,7 +132,7 @@ Go to **Settings > Users** to view and manage user permissions. Click on a user - `PATCH /admin/users/{id}/permissions` — grant or revoke individual permissions - `POST /admin/users/transfer-super` — transfer super user status (super user only) -See the [Admin API reference](../reference/admin-api.md#user-management) for full details. +See the [Admin API — Users](../reference/admin/users.md) for full details. ## Next steps diff --git a/packages/docs/docs/guides/plugins.md b/packages/docs/docs/guides/plugins.md index 0ae5f38..4ec8d77 100644 --- a/packages/docs/docs/guides/plugins.md +++ b/packages/docs/docs/guides/plugins.md @@ -1,6 +1,6 @@ # Plugins -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. +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. Official plugins for Steam, Xbox, itch.io, and other platforms are available in the [happyview-plugins](https://github.com/gamesgamesgamesgames/happyview-plugins) repository. @@ -46,6 +46,7 @@ Click the settings icon next to a plugin to enter secrets. These are encrypted u **Requires:** `TOKEN_ENCRYPTION_KEY` environment variable (base64-encoded 32-byte key). Generate one with: + ```bash openssl rand -base64 32 ``` @@ -60,73 +61,7 @@ PLUGIN_XBOX_CLIENT_ID=your-client-id PLUGIN_XBOX_CLIENT_SECRET=your-client-secret ``` -Dashboard-configured secrets take precedence over environment variables. - -## Plugin Manifest - -Each plugin has a `manifest.json` that describes its metadata: - -```json -{ - "id": "steam", - "name": "Steam", - "version": "1.0.0", - "api_version": "1", - "description": "Import your Steam game library and playtime data.", - "icon_url": "https://example.com/steam-icon.png", - "auth_type": "openid", - "wasm_file": "steam.wasm", - "required_secrets": [ - { - "key": "PLUGIN_STEAM_API_KEY", - "name": "Steam Web API Key", - "description": "Get your API key at steamcommunity.com/dev/apikey" - } - ] -} -``` - -| Field | Description | -|-------|-------------| -| `id` | Unique plugin identifier | -| `name` | Display name | -| `version` | Semantic version | -| `api_version` | Plugin API version (currently "1") | -| `description` | Brief description shown during install | -| `icon_url` | Optional icon URL | -| `auth_type` | Authentication type: `oauth2`, `openid`, or `api_key` | -| `wasm_file` | WASM binary filename (default: `plugin.wasm`) | -| `required_secrets` | Array of secrets the plugin needs | - -## API Endpoints - -### Public Endpoints - -| Endpoint | Description | -|----------|-------------| -| `GET /external-auth/providers` | List available auth providers | -| `GET /external-auth/accounts` | List user's linked accounts | -| `GET /external-auth/{plugin}/authorize` | Start OAuth flow | -| `GET /external-auth/{plugin}/callback` | OAuth callback handler | -| `POST /external-auth/{plugin}/sync` | Sync data from linked account | -| `POST /external-auth/{plugin}/unlink` | Unlink account | -| `POST /external-auth/{plugin}/connect` | Connect with API key (for `api_key` auth type) | - -### Admin Endpoints - -| Endpoint | Description | -|----------|-------------| -| `GET /admin/plugins` | List installed plugins | -| `POST /admin/plugins` | Install a plugin | -| `POST /admin/plugins/preview` | Preview plugin before installing | -| `GET /admin/plugins/official` | Browse the official plugin registry catalog | -| `DELETE /admin/plugins/{id}` | Remove a plugin | -| `POST /admin/plugins/{id}/reload` | Reload plugin from source | -| `POST /admin/plugins/{id}/check-update` | Check whether a newer version is available | -| `GET /admin/plugins/{id}/secrets` | Get configured secrets (masked) | -| `PUT /admin/plugins/{id}/secrets` | Update plugin secrets | - -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. +These are only necessary if you can't configure variables via the dashboard. Dashboard-configured secrets take precedence over environment variables. ## Security @@ -136,39 +71,9 @@ The dashboard's **Settings > Plugins** page calls `GET /admin/plugins/official` - **Scoped storage**: Plugin KV storage is isolated per-plugin and per-user - **No filesystem access**: Plugins cannot access the host filesystem -## Developing Plugins - -See the [happyview-plugins](https://github.com/gamesgamesgamesgames/happyview-plugins) repository for examples and the plugin SDK. - -### Plugin Exports - -Plugins must export these functions: - -| Export | Signature | Description | -|--------|-----------|-------------| -| `alloc` | `(size: u32) -> u32` | Allocate memory | -| `dealloc` | `(ptr: u32, size: u32)` | Deallocate memory | -| `get_authorize_url` | `(ptr: u32, len: u32) -> i64` | Generate OAuth authorize URL | -| `handle_callback` | `(ptr: u32, len: u32) -> i64` | Handle OAuth callback | -| `refresh_tokens` | `(ptr: u32, len: u32) -> i64` | Refresh expired tokens | -| `get_profile` | `(ptr: u32, len: u32) -> i64` | Get external profile info | -| `sync_account` | `(ptr: u32, len: u32) -> i64` | Sync data and return records | - -### Host Functions - -Plugins can import these host functions: - -| Import | Description | -|--------|-------------| -| `host_http_request` | Make HTTP requests | -| `host_get_secret` | Read configured secrets | -| `host_log` | Write to server logs | -| `host_kv_get` | Read from KV storage | -| `host_kv_set` | Write to KV storage | -| `host_kv_delete` | Delete from KV storage | - ## Next steps +- [Developing Plugins](developing-plugins.md) — create your own plugins with the WASM plugin API - [Official plugins repository](https://github.com/gamesgamesgamesgames/happyview-plugins) — ready-to-use plugins for Steam, Xbox, itch.io, and more - [API Keys](api-keys.md) — authenticate programmatic access to admin endpoints - [Permissions](permissions.md) — configure user access to plugin management diff --git a/packages/docs/docs/guides/postgres-to-sqlite-migration.md b/packages/docs/docs/guides/postgres-to-sqlite-migration.md index 6977075..bec47b6 100644 --- a/packages/docs/docs/guides/postgres-to-sqlite-migration.md +++ b/packages/docs/docs/guides/postgres-to-sqlite-migration.md @@ -46,24 +46,22 @@ The tool scans all `.lua` files in the given directory and rewrites Postgres SQL ### What the codemod converts automatically -- `$1`, `$2`, etc. parameter placeholders to `?` positional parameters -- `jsonb` operators (`->`, `->>`, `@>`, `?`) to SQLite `json_extract()` calls +- `$1`, `$2`, etc. parameter placeholders to `?` +- JSON operators (`->`, `->>`) and `::jsonb` casts to `json_extract()` calls - `ILIKE` to `LIKE` (SQLite `LIKE` is case-insensitive for ASCII by default) - `NOW()` to `datetime('now')` -- `::text`, `::integer`, etc. type casts to SQLite equivalents (`CAST(... AS ...)`) -- `COALESCE` and other standard SQL functions (no change needed) +- `NOW() + INTERVAL '...'` / `NOW() - INTERVAL '...'` to `datetime('now', '...')` - `TRUE`/`FALSE` literals to `1`/`0` -- `RETURNING *` clauses (removed, as SQLite has limited RETURNING support) ### What it flags for manual review The tool prints warnings for patterns it cannot convert automatically: -- Complex Postgres-specific functions (`array_agg`, `string_agg`, `generate_series`, etc.) -- Window functions with Postgres-specific syntax -- `ON CONFLICT` clauses with complex conditions -- CTEs (`WITH` queries) that use Postgres-specific features -- Any SQL that the parser cannot confidently transform +- JSONB `?` (contains-key) operator — consider using `json_each()` with an `EXISTS` subquery +- `make_interval()` — Postgres-specific, needs manual conversion +- `SIMILAR TO` — use `LIKE` or `GLOB` instead +- `ANY()` / `ALL()` array operators — no direct SQLite equivalent +- Type casts other than `::jsonb` (e.g., `::text`, `::integer`) — may need manual conversion to `CAST(... AS ...)` Review the flagged lines and update them manually. @@ -87,6 +85,7 @@ To switch back to Postgres, revert your `DATABASE_URL` to the Postgres connectio ## Next steps +- [SQLite → Postgres migration](sqlite-to-postgres-migration.md) — migrate in the opposite direction - [Database setup](database-setup.md) — choose between SQLite and Postgres for new instances - [Backfill](backfill.md) — re-index records from the network after switching backends - [Lua scripting](scripting.md) — write SQL that works against either backend diff --git a/packages/docs/docs/guides/scripting.md b/packages/docs/docs/guides/scripting.md index 55f55f5..6ac53b5 100644 --- a/packages/docs/docs/guides/scripting.md +++ b/packages/docs/docs/guides/scripting.md @@ -35,6 +35,8 @@ The `os` module is replaced with a safe subset exposing only `os.time`, `os.date An instruction limit of 1,000,000 prevents infinite loops. Exceeding it terminates the script with an error. +See the [Standard Libraries](../reference/lua/standard-libraries.md) reference for the full list of available Lua modules and builtins. + ## Context globals These globals are set automatically before `handle()` is called. @@ -85,455 +87,57 @@ You don't need `toarray()` on results from `db.query`, `db.search`, `db.backlink 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. -### Constructor - -```lua -local r = Record("xyz.statusphere.status", { status = "\ud83d\ude0a", createdAt = now() }) -``` - -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. +See the full [Record API reference](../reference/lua/record-api.md) for constructor, static methods, instance methods, fields, schema validation, and save behavior. -### Static methods +Quick example: ```lua --- Save multiple records in parallel -Record.save_all({ record1, record2, record3 }) - --- Load a record from the local database by AT URI -local r = Record.load("at://did:plc:abc/xyz.statusphere.status/abc123") --- Returns nil if not found - --- Load multiple records in parallel -local records = Record.load_all({ uri1, uri2 }) --- Returns nil entries for URIs not found -``` - -### Instance methods - -```lua --- Save (creates or updates depending on whether _uri is set) -r:save() - --- Delete from PDS and local database -r:delete() - --- Set the record key type (tid, any, nsid, or literal:*) -r:set_key_type("tid") - --- Set a specific record key -r:set_rkey("my-key") - --- Auto-generate a record key based on _key_type -local key = r:generate_rkey() +function handle() + local r = Record(collection, input) + r:save() + return { uri = r._uri, cid = r._cid } +end ``` -**Key type behavior for `generate_rkey()`:** - -| Key type | Generated rkey | -| --------------- | --------------------------------- | -| `tid` | Sortable timestamp-based ID | -| `any` | Same as `tid` | -| `literal:value` | The literal value after the colon | -| `nsid` | Error — use `set_rkey()` instead | - -### Instance fields - -These fields are set automatically and are read-only (writes raise an error): - -| Field | Type | Description | -| ------------- | ------- | ----------------------------------------------------------- | -| `_uri` | string? | AT URI — set after `save()`, cleared after `delete()` | -| `_cid` | string? | Content hash — set after `save()`, cleared after `delete()` | -| `_key_type` | string? | Record key type from the lexicon definition | -| `_rkey` | string? | Record key — set via `set_rkey()` or `generate_rkey()` | -| `_collection` | string | Collection NSID (always set) | -| `_schema` | table? | Schema definition from the lexicon (used for validation) | - -### Schema validation - -When a record has a schema (loaded from the lexicon): - -- **On save:** required fields are checked, and missing required fields raise an error -- **On construction:** default values from schema properties are auto-populated -- **On save:** only fields defined in the schema's `properties` are sent to the PDS - -### Save behavior - -`r:save()` auto-detects create vs update: - -- If `_uri` is nil → calls `createRecord` on the PDS -- If `_uri` is set → calls `putRecord` on the PDS - -After a successful save, `_uri` and `_cid` are updated on the record instance. - ## Database API The `db` table provides access to the database. Available in both queries and procedures. -### db.query +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`. -```lua -local result = db.query({ - collection = "xyz.statusphere.status", -- required - did = "did:plc:abc", -- optional: filter by DID - limit = 20, -- optional: max 100, default 20 - offset = 0, -- optional: for pagination - sort = "name", -- optional: field to sort by, default "indexed_at" - sortDirection = "asc", -- optional: "asc" or "desc", default "desc" -}) - --- result.records — array of record tables (each includes a "uri" field) --- result.cursor — present when more records exist -``` - -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. - -### db.get - -```lua -local record = db.get("at://did:plc:abc/xyz.statusphere.status/abc123") --- Returns the record table or nil --- The returned table includes a "uri" field -``` - -### db.search - -```lua -local result = db.search({ - collection = "xyz.statusphere.status", -- required - field = "displayName", -- required: record field to search - query = "alice", -- required: search term - limit = 10, -- optional: max 100, default 10 -}) - --- result.records — array of matching records, ranked by relevance: --- exact match > prefix match > contains match, then alphabetical -``` - -### db.backlinks - -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. - -```lua -local result = db.backlinks({ - collection = "xyz.statusphere.status", -- required - uri = "at://did:plc:abc/xyz.statusphere.status/foo", -- required: the URI to find references to - did = "did:plc:abc", -- optional: filter by DID - limit = 20, -- optional: max 100, default 20 - offset = 0, -- optional: for pagination -}) - --- result.records — array of records whose data contains the given URI --- result.cursor — present when more records exist -``` - -The search checks the full record data, so it works regardless of which field holds the reference (`subject`, `parent`, `reply.root`, etc.). - -### db.count - -```lua -local n = db.count("xyz.statusphere.status") -local n = db.count("xyz.statusphere.status", "did:plc:abc") -- filter by DID -``` - -### db.raw - -Run a raw SQL query against the database. Supports `SELECT`, `INSERT`, `UPDATE`, `DELETE`, and `CREATE TABLE` statements. +Quick example: ```lua --- Read query -local rows = db.raw( - "SELECT uri, did, record FROM records WHERE collection = $1 AND did = $2 LIMIT $3", - { "xyz.statusphere.status", "did:plc:abc", 10 } -) - -for _, row in ipairs(rows) do - -- row.uri, row.did, row.record (JSONB is returned as a Lua table) +function handle() + local result = db.query({ collection = collection, limit = 20 }) + return { records = result.records, cursor = result.cursor } end - --- Write query (returns affected rows, if any) -db.raw("CREATE TABLE IF NOT EXISTS my_table (id TEXT PRIMARY KEY, value TEXT NOT NULL)") -db.raw("INSERT INTO my_table (id, value) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET value = $2", - { "key1", "hello" }) ``` -Parameters are passed as an array and bound to `$1`, `$2`, etc. Supported parameter types: strings, integers, numbers, booleans, and nil. - -Column types are mapped automatically: - -| Postgres type | Lua type | -| ---------------------- | -------- | -| `TEXT`, `VARCHAR` | string | -| `INT4`, `INT8` | integer | -| `FLOAT4`, `FLOAT8` | number | -| `BOOL` | boolean | -| `JSON`, `JSONB` | table | -| `TIMESTAMPTZ` | string (ISO 8601) | -| Other | string (fallback) | - ## HTTP API The `http` table provides async HTTP client functions. Available in both queries and procedures. -### Methods +See the full [HTTP API reference](../reference/lua/http-api.md) for all methods, options, and response format. -All methods take a URL and an optional options table, and return a [response table](#response). +Quick example: ```lua -http.get(url, opts?) -http.post(url, opts?) -http.put(url, opts?) -http.patch(url, opts?) -http.delete(url, opts?) -http.head(url, opts?) -``` - -### Options - -The optional second argument is a table with: - -| Field | Type | Description | -| --------- | ------ | ---------------------------------------------- | -| `headers` | table | Request headers as key-value string pairs | -| `body` | string | Request body (ignored for GET and HEAD) | - -### Response - -Every method returns a table with: - -| Field | Type | Description | -| --------- | ------- | ---------------------------------------------------- | -| `status` | integer | HTTP status code | -| `body` | string | Response body text (empty string for HEAD) | -| `headers` | table | Response headers as key-value pairs (lowercase keys) | - -### Examples - -```lua --- Simple GET local resp = http.get("https://api.example.com/data") --- resp.status = 200, resp.body = "...", resp.headers["content-type"] = "application/json" - --- GET with custom headers -local resp = http.get("https://api.example.com/data", { - headers = { ["authorization"] = "Bearer token123" } -}) - --- POST with JSON body -local resp = http.post("https://api.example.com/hook", { - body = '{"key": "value"}', - headers = { ["content-type"] = "application/json" } -}) - --- PUT, PATCH, DELETE, HEAD follow the same pattern -local resp = http.put(url, { body = data, headers = { ... } }) -local resp = http.patch(url, { body = data, headers = { ... } }) -local resp = http.delete(url, { headers = { ... } }) -local resp = http.head(url) +local data = json.decode(resp.body) ``` ## AT Protocol API -The `atproto` table provides AT Protocol utility functions. Available in queries, procedures, and [index hooks](index-hooks.md). - -### atproto.resolve_service_endpoint - -```lua -local endpoint = atproto.resolve_service_endpoint(did) -``` - -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`). - -| Parameter | Type | Description | -| --------- | ------ | ------------------------ | -| `did` | string | The DID to resolve | - -**Returns:** The service endpoint URL as a string, or `nil` if resolution fails (DID not found, no PDS service in document, network error). - -### Examples - -```lua --- Resolve a did:plc DID -local endpoint = atproto.resolve_service_endpoint("did:plc:abc123") --- endpoint = "https://pds.example.com" - --- Resolve a did:web DID -local endpoint = atproto.resolve_service_endpoint("did:web:example.com") --- endpoint = "https://example.com" - --- Handle resolution failure -local endpoint = atproto.resolve_service_endpoint("did:plc:unknown") -if not endpoint then - return { error = "Could not resolve DID" } -end - --- Use with HTTP API to call a remote XRPC endpoint -local endpoint = atproto.resolve_service_endpoint(did) -if endpoint then - local resp = http.get(endpoint .. "/xrpc/com.example.method") - local data = json.decode(resp.body) -end -``` - -### atproto.get_labels - -```lua -local labels = atproto.get_labels(uri) -``` - -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). +The `atproto` table provides AT Protocol utility functions like DID resolution and label queries. -| Parameter | Type | Description | -| --------- | ------ | ------------------------------ | -| `uri` | string | AT URI of the record to query | - -Each label in the array is a table with: - -| Field | Type | Description | -| ----- | ------ | ---------------------------------------- | -| `src` | string | DID of the labeler (or record author) | -| `uri` | string | AT URI this label applies to | -| `val` | string | Label value (e.g. "nsfw", "!hide") | -| `cts` | string | Timestamp when the label was created | - -Expired labels are automatically filtered out. Returns an empty array if no labels exist. - -### atproto.get_labels_batch - -```lua -local labels_by_uri = atproto.get_labels_batch(uris) -``` - -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. - -| Parameter | Type | Description | -| --------- | ----- | ------------------------ | -| `uris` | table | Array of AT URI strings | - -**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. - -### Label Examples - -```lua --- Get labels for a single game -local labels = atproto.get_labels("at://did:plc:abc/games.gamesgamesgamesgames.game/rkey1") -for _, label in ipairs(labels) do - if label.val == "!hide" then - -- skip this game in feed results - end -end - --- Batch fetch labels for multiple games (efficient for feed hydration) -local uris = {} -for _, item in ipairs(skeleton) do - uris[#uris + 1] = item.game -end - -local labels_by_uri = atproto.get_labels_batch(uris) -for _, uri in ipairs(uris) do - local labels = labels_by_uri[uri] - for _, label in ipairs(labels) do - if label.val == "!hide" then - -- filter out this game - end - end -end -``` +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`. ## JSON API -The `json` global provides JSON serialization and deserialization. Available in queries, procedures, and [index hooks](index-hooks.md). - -### json.encode - -```lua -local str = json.encode({ key = "value", items = { 1, 2, 3 } }) --- '{"key":"value","items":[1,2,3]}' -``` - -Converts a Lua table to a JSON string. - -### json.decode - -```lua -local tbl = json.decode('{"key": "value"}') --- tbl.key == "value" -``` +The `json` global provides JSON serialization and deserialization. -Parses a JSON string into a Lua table. Returns an error if the input is not valid JSON. - -## Standard libraries - -The following Lua 5.4 standard library modules are available: - -
- -`string` - -- [`byte`](https://lua.org/manual/5.4/manual.html#pdf-string.byte) -- [`char`](https://lua.org/manual/5.4/manual.html#pdf-string.char) -- [`find`](https://lua.org/manual/5.4/manual.html#pdf-string.find) -- [`format`](https://lua.org/manual/5.4/manual.html#pdf-string.format) -- [`gmatch`](https://lua.org/manual/5.4/manual.html#pdf-string.gmatch) -- [`gsub`](https://lua.org/manual/5.4/manual.html#pdf-string.gsub) -- [`len`](https://lua.org/manual/5.4/manual.html#pdf-string.len) -- [`lower`](https://lua.org/manual/5.4/manual.html#pdf-string.lower) -- [`match`](https://lua.org/manual/5.4/manual.html#pdf-string.match) -- [`rep`](https://lua.org/manual/5.4/manual.html#pdf-string.rep) -- [`reverse`](https://lua.org/manual/5.4/manual.html#pdf-string.reverse) -- [`sub`](https://lua.org/manual/5.4/manual.html#pdf-string.sub) -- [`upper`](https://lua.org/manual/5.4/manual.html#pdf-string.upper) -
- -
- -`table` - -- [`concat`](https://lua.org/manual/5.4/manual.html#pdf-table.concat) -- [`insert`](https://lua.org/manual/5.4/manual.html#pdf-table.insert) -- [`remove`](https://lua.org/manual/5.4/manual.html#pdf-table.remove) -- [`sort`](https://lua.org/manual/5.4/manual.html#pdf-table.sort) -- [`unpack`](https://lua.org/manual/5.4/manual.html#pdf-table.unpack) -
- -
- -`math` - -- [`abs`](https://lua.org/manual/5.4/manual.html#pdf-math.abs) -- [`ceil`](https://lua.org/manual/5.4/manual.html#pdf-math.ceil) -- [`floor`](https://lua.org/manual/5.4/manual.html#pdf-math.floor) -- [`max`](https://lua.org/manual/5.4/manual.html#pdf-math.max) -- [`min`](https://lua.org/manual/5.4/manual.html#pdf-math.min) -- [`random`](https://lua.org/manual/5.4/manual.html#pdf-math.random) -- [`sqrt`](https://lua.org/manual/5.4/manual.html#pdf-math.sqrt) -- [`huge`](https://lua.org/manual/5.4/manual.html#pdf-math.huge) -- [`pi`](https://lua.org/manual/5.4/manual.html#pdf-math.pi) -
- -
- -Standard builtins - -- [`print`](https://lua.org/manual/5.4/manual.html#pdf-print) -- [`tostring`](https://lua.org/manual/5.4/manual.html#pdf-tostring) -- [`tonumber`](https://lua.org/manual/5.4/manual.html#pdf-tonumber) -- [`type`](https://lua.org/manual/5.4/manual.html#pdf-type) -- [`pairs`](https://lua.org/manual/5.4/manual.html#pdf-pairs) -- [`ipairs`](https://lua.org/manual/5.4/manual.html#pdf-ipairs) -- [`next`](https://lua.org/manual/5.4/manual.html#pdf-next) -- [`select`](https://lua.org/manual/5.4/manual.html#pdf-select) -- [`unpack`](https://lua.org/manual/5.4/manual.html#pdf-table.unpack) -- [`error`](https://lua.org/manual/5.4/manual.html#pdf-error) -- [`pcall`](https://lua.org/manual/5.4/manual.html#pdf-pcall) -- [`xpcall`](https://lua.org/manual/5.4/manual.html#pdf-xpcall) -- [`assert`](https://lua.org/manual/5.4/manual.html#pdf-assert) -- [`setmetatable`](https://lua.org/manual/5.4/manual.html#pdf-setmetatable) -- [`getmetatable`](https://lua.org/manual/5.4/manual.html#pdf-getmetatable) -- [`rawget`](https://lua.org/manual/5.4/manual.html#pdf-rawget) -- [`rawset`](https://lua.org/manual/5.4/manual.html#pdf-rawset) -- [`rawequal`](https://lua.org/manual/5.4/manual.html#pdf-rawequal) -
+See the full [JSON API reference](../reference/lua/json-api.md) for `json.encode` and `json.decode`. ## Debugging diff --git a/packages/docs/docs/guides/sqlite-to-postgres-migration.md b/packages/docs/docs/guides/sqlite-to-postgres-migration.md new file mode 100644 index 0000000..00260b0 --- /dev/null +++ b/packages/docs/docs/guides/sqlite-to-postgres-migration.md @@ -0,0 +1,88 @@ +# Migrating from SQLite to Postgres + +This guide covers migrating an existing HappyView deployment from SQLite to Postgres. If you are staying on SQLite, no action is required. + +## Overview + +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. + +The main steps are: set up the Postgres database, update your environment variables, and re-index your data. + +## Step 1: Set up Postgres + +Create a Postgres database for HappyView: + +```sh +createdb happyview +``` + +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. + +## Step 2: Back up your SQLite database + +Copy your SQLite database file before making any changes: + +```sh +cp data/happyview.db data/happyview.db.backup +``` + +## Step 3: Update environment variables + +Change your `.env` to use Postgres: + +```sh +# Before +DATABASE_URL=sqlite://data/happyview.db?mode=rwc + +# After +DATABASE_URL=postgres://happyview:happyview@localhost/happyview +``` + +If you had `DATABASE_BACKEND` set, update it as well: + +```sh +DATABASE_BACKEND=postgres +``` + +## Step 4: Start HappyView + +Start HappyView with the new `DATABASE_URL`. It will connect to Postgres and run migrations automatically, creating all necessary tables. + +## Step 5: Re-index your data + +Since HappyView indexes records from the AT Protocol network, the simplest way to populate your new Postgres database is to re-run the backfill: + +1. Upload your lexicons via the dashboard or admin API (or they will already be there if you exported and re-imported them) +2. Run a backfill for each collection (dashboard or `POST /admin/backfill`) + +Backfill fetches all records fresh from the network, so no data transfer between databases is needed. + +:::tip +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. +::: + +## Step 6: Re-create admin settings + +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. + +## Lua scripts + +No changes needed. Lua scripts use SQLite syntax by default, and HappyView translates to Postgres automatically at runtime. This includes: + +- `?` placeholders (translated to `$1`, `$2`, etc.) +- `json_extract()` calls (translated to Postgres JSON operators) +- `datetime('now')` (translated to `NOW()`) +- Boolean literals `1`/`0` (work in both backends) + +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. + +## Rollback + +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. + +## Next steps + +- [Postgres → SQLite migration](postgres-to-sqlite-migration.md) — migrate in the opposite direction +- [Database setup](database-setup.md) — choose between SQLite and Postgres for new instances +- [Backfill](backfill.md) — re-index records from the network after switching backends +- [Lua scripting](scripting.md) — write SQL that works against either backend diff --git a/packages/docs/docs/reference/admin-api.md b/packages/docs/docs/reference/admin-api.md index 95d3678..3dd9cea 100644 --- a/packages/docs/docs/reference/admin-api.md +++ b/packages/docs/docs/reference/admin-api.md @@ -4,11 +4,10 @@ The admin API lets you manage lexicons, monitor records, run backfill jobs, and ## Auth -The admin API supports three authentication methods: +The admin API supports two authentication methods: -1. **Session cookie** (web UI) — Set during the OAuth login flow. The signed cookie contains the user's DID. -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. -3. **Service auth JWT** — AT Protocol inter-service authentication via signed JWTs. +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. +2. **Service auth JWT** — AT Protocol inter-service authentication via signed JWTs. In all cases the resolved DID is checked against the `users` table, and the user's permissions are loaded to authorize the request. @@ -16,6 +15,8 @@ In all cases the resolved DID is checked against the `users` table, and the user Non-user DIDs receive a `403 Forbidden` response. Users without the required permission for a specific endpoint also receive `403 Forbidden`. +## Errors + All error responses return JSON with an `error` field: ```json @@ -24,1210 +25,88 @@ All error responses return JSON with an `error` field: } ``` -| Status | Meaning | -| ------------------ | -------------------------------------------------------------------------------------------------------------- | -| `400 Bad Request` | Invalid input (missing required fields, malformed lexicon JSON) | -| `401 Unauthorized` | Missing or invalid session cookie, API key, or service auth JWT | -| `403 Forbidden` | Authenticated DID is not in the users table, or user lacks the required permission | -| `404 Not Found` | Lexicon, user, or backfill job not found | +| Status | Meaning | +| ------------------ | -------------------------------------------------------------------------------------- | +| `400 Bad Request` | Invalid input (missing required fields, malformed lexicon JSON) | +| `401 Unauthorized` | Missing or invalid API key or service auth JWT | +| `403 Forbidden` | Authenticated DID is not in the users table, or user lacks the required permission | +| `404 Not Found` | Lexicon, user, or backfill job not found | ```sh # All examples assume $TOKEN is an API key (hv_...) AUTH="Authorization: Bearer $TOKEN" ``` -## Lexicons - -### Upload / upsert a lexicon - -``` -POST /admin/lexicons -``` - -```sh -curl -X POST http://localhost:3000/admin/lexicons \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ - "lexicon_json": { "lexicon": 1, "id": "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" } } } } } }, - "backfill": true, - "target_collection": null - }' -``` - -| Field | Type | Required | Description | -| ------------------- | ------- | -------- | --------------------------------------------------------------------- | -| `lexicon_json` | object | yes | Raw lexicon JSON (must have `lexicon: 1` and `id`) | -| `backfill` | boolean | no | Whether uploading triggers historical backfill (default `true`) | -| `target_collection` | string | no | For query/procedure lexicons, the record collection they operate on | -| `script` | string | no | Lua script for query/procedure endpoints | -| `index_hook` | string | no | [Index hook](../guides/index-hooks.md) Lua script for record lexicons | - -**Response**: `201 Created` (new) or `200 OK` (upsert) - -```json -{ - "id": "xyz.statusphere.status", - "revision": 1 -} -``` - -### List lexicons - -``` -GET /admin/lexicons -``` - -```sh -curl http://localhost:3000/admin/lexicons -H "$AUTH" -``` - -**Response**: `200 OK` - -```json -[ - { - "id": "xyz.statusphere.status", - "revision": 1, - "lexicon_type": "record", - "backfill": true, - "created_at": "2025-01-01T00:00:00Z", - "updated_at": "2025-01-01T00:00:00Z" - } -] -``` - -### Get a lexicon - -``` -GET /admin/lexicons/{id} -``` - -```sh -curl http://localhost:3000/admin/lexicons/xyz.statusphere.status -H "$AUTH" -``` - -**Response**: `200 OK` with full lexicon details including raw JSON. - -### Delete a lexicon - -``` -DELETE /admin/lexicons/{id} -``` - -```sh -curl -X DELETE http://localhost:3000/admin/lexicons/xyz.statusphere.status -H "$AUTH" -``` - -**Response**: `204 No Content` - -## Network Lexicons - -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. - -### Add a network lexicon - -``` -POST /admin/network-lexicons -``` - -```sh -curl -X POST http://localhost:3000/admin/network-lexicons \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ - "nsid": "xyz.statusphere.status", - "target_collection": null - }' -``` - -| Field | Type | Required | Description | -| ------------------- | ------ | -------- | ------------------------------------------------------------------- | -| `nsid` | string | yes | The NSID of the lexicon to watch | -| `target_collection` | string | no | For query/procedure lexicons, the record collection they operate on | - -HappyView resolves the NSID authority via DNS TXT, fetches the lexicon from the authority's PDS, parses it, and stores it. - -**Response**: `201 Created` - -```json -{ - "nsid": "xyz.statusphere.status", - "authority_did": "did:plc:authority", - "revision": 1 -} -``` - -### List network lexicons - -``` -GET /admin/network-lexicons -``` - -```sh -curl http://localhost:3000/admin/network-lexicons -H "$AUTH" -``` - -**Response**: `200 OK` - -```json -[ - { - "nsid": "xyz.statusphere.status", - "authority_did": "did:plc:authority", - "target_collection": null, - "last_fetched_at": "2025-01-01T00:00:00Z", - "created_at": "2025-01-01T00:00:00Z" - } -] -``` - -### Remove a network lexicon - -``` -DELETE /admin/network-lexicons/{nsid} -``` - -```sh -curl -X DELETE http://localhost:3000/admin/network-lexicons/xyz.statusphere.status \ - -H "$AUTH" -``` - -Removes the network lexicon tracking and also deletes the lexicon from the `lexicons` table and in-memory registry. - -**Response**: `204 No Content` - -## Stats - -### Record counts - -``` -GET /admin/stats -``` - -```sh -curl http://localhost:3000/admin/stats -H "$AUTH" -``` - -**Response**: `200 OK` - -```json -{ - "total_records": 12345, - "collections": [{ "collection": "xyz.statusphere.status", "count": 500 }] -} -``` - -## Backfill - -### Create a backfill job - -``` -POST /admin/backfill -``` - -```sh -curl -X POST http://localhost:3000/admin/backfill \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ "collection": "xyz.statusphere.status" }' -``` - -| Field | Type | Required | Description | -| ------------ | ------ | -------- | ---------------------------------------------------------- | -| `collection` | string | no | Limit to a single collection (backfills all if omitted) | -| `did` | string | no | Limit to a single DID (discovers all via relay if omitted) | - -**Response**: `201 Created` - -```json -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "status": "pending" -} -``` - -### List backfill jobs - -``` -GET /admin/backfill/status -``` - -```sh -curl http://localhost:3000/admin/backfill/status -H "$AUTH" -``` - -**Response**: `200 OK` - -```json -[ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "collection": "xyz.statusphere.status", - "did": null, - "status": "completed", - "total_repos": 42, - "processed_repos": 42, - "total_records": 1000, - "error": null, - "started_at": "2025-01-01T00:01:00Z", - "completed_at": "2025-01-01T00:05:00Z", - "created_at": "2025-01-01T00:00:00Z" - } -] -``` - -## Event Logs - -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. - -### List event logs - -``` -GET /admin/events -``` - -```sh -curl "http://localhost:3000/admin/events?severity=error&limit=10" -H "$AUTH" -``` - -| Param | Type | Required | Description | -| ------------ | ------ | -------- | --------------------------------------------------------------------- | -| `event_type` | string | no | Filter by exact event type (e.g. `script.error`) | -| `category` | string | no | Filter by category prefix (e.g. `lexicon` matches all lexicon events) | -| `severity` | string | no | Filter by severity: `info`, `warn`, or `error` | -| `subject` | string | no | Filter by subject (lexicon ID, record URI, admin DID, etc.) | -| `cursor` | string | no | Pagination cursor (ISO 8601 timestamp from previous response) | -| `limit` | number | no | Results per page (default `50`, max `100`) | - -**Response**: `200 OK` - -```json -{ - "events": [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "event_type": "script.error", - "severity": "error", - "actor_did": "did:plc:abc123", - "subject": "com.example.feed.like", - "detail": { - "error": "attempt to index nil value", - "script_source": "function handle() ... end", - "input": { "status": "hello" }, - "caller_did": "did:plc:abc123", - "method": "com.example.feed.like" - }, - "created_at": "2026-03-01T12:00:00Z" - } - ], - "cursor": "2026-03-01T11:59:00Z" -} -``` - -Events are returned in reverse chronological order (newest first). Pass the `cursor` value from the response to fetch the next page. - -## API Keys - -Manage API keys for programmatic access. See the [API Keys guide](../guides/api-keys.md) for usage details. - -### Create an API key - -``` -POST /admin/api-keys -``` - -Requires `api-keys:create` permission. - -```sh -curl -X POST http://localhost:3000/admin/api-keys \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "CI Deploy", - "permissions": ["lexicons:read", "lexicons:create", "backfill:create"] - }' -``` - -| Field | Type | Required | Description | -| ------------- | -------- | -------- | -------------------------------------------------------------------------------------------- | -| `name` | string | yes | A label to identify this key's usage | -| `permissions` | string[] | yes | Permissions to grant the key (must be a subset of the creating user's own permissions) | - -**Response**: `201 Created` - -```json -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "CI Deploy", - "key": "hv_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", - "key_prefix": "hv_a1b2c3d4", - "permissions": ["lexicons:read", "lexicons:create", "backfill:create"] -} -``` - -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. - -### List API keys - -``` -GET /admin/api-keys -``` - -Requires `api-keys:read` permission. - -```sh -curl http://localhost:3000/admin/api-keys -H "$AUTH" -``` - -**Response**: `200 OK` - -```json -[ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "CI Deploy", - "key_prefix": "hv_a1b2c3d4", - "permissions": ["lexicons:read", "lexicons:create", "backfill:create"], - "created_at": "2026-03-01T00:00:00Z", - "last_used_at": "2026-03-06T12:00:00Z", - "revoked_at": null - } -] -``` - -Only returns keys belonging to the authenticated user. The full key is never included — only the prefix. - -### Revoke an API key - -``` -DELETE /admin/api-keys/{id} -``` - -Requires `api-keys:delete` permission. - -```sh -curl -X DELETE http://localhost:3000/admin/api-keys/550e8400-e29b-41d4-a716-446655440000 \ - -H "$AUTH" -``` - -Sets `revoked_at` on the key. The key remains in the database for audit purposes but can no longer authenticate. - -**Response**: `204 No Content` - -## User Management - -### Create a user - -``` -POST /admin/users -``` - -Requires `users:create` permission. You cannot grant permissions you don't have yourself (escalation guard). - -```sh -curl -X POST http://localhost:3000/admin/users \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ - "did": "did:plc:newuser", - "template": "operator" - }' -``` - -| Field | Type | Required | Description | -| ------------- | -------- | -------- | ------------------------------------------------------------------------------------------------- | -| `did` | string | yes | The AT Protocol DID of the user to add | -| `template` | string | no | Permission template: `viewer`, `operator`, `manager`, or `full_access` | -| `permissions` | string[] | no | Explicit list of permissions to grant (used instead of or in addition to `template`) | - -If neither `template` nor `permissions` is provided, the user is created with no permissions. - -**Response**: `201 Created` - -```json -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "did": "did:plc:newuser", - "is_super": false, - "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"] -} -``` - -### List users - -``` -GET /admin/users -``` - -Requires `users:read` permission. - -```sh -curl http://localhost:3000/admin/users -H "$AUTH" -``` - -**Response**: `200 OK` - -```json -[ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "did": "did:plc:admin", - "is_super": true, - "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"], - "created_at": "2025-01-01T00:00:00Z", - "last_used_at": "2025-01-02T12:00:00Z" - } -] -``` - -### Get a user - -``` -GET /admin/users/{id} -``` - -Requires `users:read` permission. - -```sh -curl http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 -H "$AUTH" -``` - -**Response**: `200 OK` with the same shape as a single item from the list response. - -### Update user permissions - -``` -PATCH /admin/users/{id}/permissions -``` - -Requires `users:update` permission. You cannot grant permissions you don't have yourself, and you cannot modify the super user's permissions. - -```sh -curl -X PATCH http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000/permissions \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ - "grant": ["lexicons:create", "lexicons:delete"], - "revoke": ["records:delete"] - }' -``` - -| Field | Type | Required | Description | -| -------- | -------- | -------- | ------------------------------ | -| `grant` | string[] | no | Permissions to add | -| `revoke` | string[] | no | Permissions to remove | - -**Response**: `200 OK` with the updated user object. - -### Transfer super user - -``` -POST /admin/users/transfer-super -``` - -Only the current super user can call this endpoint. Transfers super user status to another existing user. - -```sh -curl -X POST http://localhost:3000/admin/users/transfer-super \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ "target_user_id": "550e8400-e29b-41d4-a716-446655440000" }' -``` - -| Field | Type | Required | Description | -| ---------------- | ------ | -------- | ---------------------------------------- | -| `target_user_id` | string | yes | The ID of the user to receive super status | - -**Response**: `200 OK` - -### Delete a user - -``` -DELETE /admin/users/{id} -``` - -Requires `users:delete` permission. You cannot delete the super user or yourself. - -```sh -curl -X DELETE http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 \ - -H "$AUTH" -``` - -**Response**: `204 No Content` - -## Labelers - -Manage external labeler subscriptions. See the [Labelers guide](../guides/labelers.md) for background. - -### Add a labeler - -``` -POST /admin/labelers -``` - -Requires `labelers:create` permission. - -```sh -curl -X POST http://localhost:3000/admin/labelers \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ "did": "did:plc:ar7c4by46qjdydhdevvrndac" }' -``` - -| Field | Type | Required | Description | -| ----- | ------ | -------- | ---------------------- | -| `did` | string | yes | The labeler's AT Protocol DID | - -**Response**: `201 Created` (empty body) - -### List labelers - -``` -GET /admin/labelers -``` - -Requires `labelers:read` permission. - -```sh -curl http://localhost:3000/admin/labelers -H "$AUTH" -``` - -**Response**: `200 OK` - -```json -[ - { - "did": "did:plc:ar7c4by46qjdydhdevvrndac", - "status": "active", - "cursor": 1234, - "created_at": "2026-03-15T00:00:00Z", - "updated_at": "2026-03-15T00:00:00Z" - } -] -``` - -| Field | Type | Description | -| ------------ | ------------ | ------------------------------------------------ | -| `did` | string | The labeler's DID | -| `status` | string | `active` or `paused` | -| `cursor` | number\|null | Last processed event cursor (null if never synced) | -| `created_at` | string | ISO 8601 creation timestamp | -| `updated_at` | string | ISO 8601 last-updated timestamp | - -### Update a labeler - -``` -PATCH /admin/labelers/{did} -``` - -Requires `labelers:create` permission. - -```sh -curl -X PATCH http://localhost:3000/admin/labelers/did:plc:ar7c4by46qjdydhdevvrndac \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ "status": "paused" }' -``` - -| Field | Type | Required | Description | -| -------- | ------ | -------- | ---------------------------- | -| `status` | string | yes | New status: `active` or `paused` | - -**Response**: `200 OK` - -### Delete a labeler - -``` -DELETE /admin/labelers/{did} -``` - -Requires `labelers:delete` permission. Removes the subscription and all labels emitted by this labeler. - -```sh -curl -X DELETE http://localhost:3000/admin/labelers/did:plc:ar7c4by46qjdydhdevvrndac \ - -H "$AUTH" -``` - -**Response**: `204 No Content` - -## Instance Settings - -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. - -### List settings - -``` -GET /admin/settings -``` - -```sh -curl http://localhost:3000/admin/settings -H "$AUTH" -``` - -Returns all key/value pairs stored in the `instance_settings` table. - -### Upsert a setting - -``` -PUT /admin/settings/{key} -``` - -```sh -curl -X PUT http://localhost:3000/admin/settings/app_name \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ "value": "My HappyView" }' -``` - -### Delete a setting - -``` -DELETE /admin/settings/{key} -``` - -Removes the override; the corresponding environment variable (if any) takes effect again. - -### Upload / delete logo - -``` -PUT /admin/settings/logo -DELETE /admin/settings/logo -``` - -`PUT` accepts a binary image body and stores it as the instance logo (served via the public dashboard). `DELETE` removes the stored logo. - -## Domain Management - -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. - -### List domains - -``` -GET /admin/domains -``` - -```sh -curl http://localhost:3000/admin/domains -H "$AUTH" -``` - -**Response**: `200 OK` - -```json -[ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "url": "https://gamesgamesgamesgames.games", - "is_primary": true, - "created_at": "2026-04-16T00:00:00Z", - "updated_at": "2026-04-16T00:00:00Z" - } -] -``` - -### Add a domain - -``` -POST /admin/domains -``` - -```sh -curl -X POST http://localhost:3000/admin/domains \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ "url": "https://api.cartridge.dev" }' -``` - -| Field | Type | Required | Description | -| ----- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `url` | string | yes | Valid origin (scheme + host, no path or trailing slash). Must be `https` unless `PUBLIC_URL` is a loopback address. | - -Returns `400 Bad Request` if the URL is invalid or already registered. - -**Response**: `201 Created` - -```json -{ - "id": "550e8400-e29b-41d4-a716-446655440001", - "url": "https://api.cartridge.dev", - "is_primary": false, - "created_at": "2026-04-16T00:00:00Z", - "updated_at": "2026-04-16T00:00:00Z" -} -``` - -Side effects: builds an OAuth client for the domain, updates the in-memory domain cache. - -### Remove a domain - -``` -DELETE /admin/domains/{id} -``` - -```sh -curl -X DELETE http://localhost:3000/admin/domains/550e8400-e29b-41d4-a716-446655440001 \ - -H "$AUTH" -``` - -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. - -**Response**: `204 No Content` - -Side effects: removes the domain's OAuth client and cache entry. - -### Set primary domain - -``` -POST /admin/domains/{id}/primary -``` - -```sh -curl -X POST http://localhost:3000/admin/domains/550e8400-e29b-41d4-a716-446655440001/primary \ - -H "$AUTH" -``` - -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. - -**Response**: `204 No Content` - -Side effects: updates the in-memory cache and the OAuth client registry's primary client reference. - -## Script Variables - -Script variables are encrypted key/value pairs available to Lua scripts via the `vars` global. Use them for secrets like API tokens. - -### List script variables - -``` -GET /admin/script-variables -``` - -Requires `script-variables:read`. Returns a list of variable keys (values are not returned). - -### Upsert a script variable - -``` -POST /admin/script-variables -``` - -Requires `script-variables:create`. - -```sh -curl -X POST http://localhost:3000/admin/script-variables \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ "key": "ALGOLIA_API_KEY", "value": "..." }' -``` - -The value is encrypted at rest using `TOKEN_ENCRYPTION_KEY`. - -### Delete a script variable - -``` -DELETE /admin/script-variables/{key} -``` - -Requires `script-variables:delete`. - -## API Clients - -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`. - -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. - -### List API clients - -``` -GET /admin/api-clients -``` - -Requires `api-clients:view`. Returns clients ordered by `created_at` descending. Secrets are never returned. - -```sh -curl http://localhost:3000/admin/api-clients -H "$AUTH" -``` - -**Response**: `200 OK` - -```json -[ - { - "id": "01J9...", - "client_key": "hvc_a1b2c3...", - "name": "My Game Client", - "client_id_url": "https://example.com/client-metadata.json", - "client_uri": "https://example.com", - "redirect_uris": ["https://example.com/callback"], - "scopes": "atproto", - "rate_limit_capacity": 200, - "rate_limit_refill_rate": 5.0, - "is_active": true, - "created_by": "did:plc:...", - "created_at": "2026-04-13T12:00:00Z", - "updated_at": "2026-04-13T12:00:00Z" - } -] -``` - -### Create an API client - -``` -POST /admin/api-clients -``` - -Requires `api-clients:create`. Generates a fresh `client_key` and `client_secret`. **The secret is only returned in this response** — store it immediately. - -```sh -curl -X POST http://localhost:3000/admin/api-clients \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "My Game Client", - "client_id_url": "https://example.com/client-metadata.json", - "client_uri": "https://example.com", - "redirect_uris": ["https://example.com/callback"], - "scopes": "atproto", - "rate_limit_capacity": 200, - "rate_limit_refill_rate": 5.0 - }' -``` - -| Field | Type | Required | Description | -| ------------------------ | -------- | -------- | -------------------------------------------------------------------------------------- | -| `name` | string | yes | Human-readable display name | -| `client_id_url` | string | yes | URL to the client's published OAuth client metadata document | -| `client_uri` | string | yes | The client's home/landing URL | -| `redirect_uris` | string[] | yes | Allowed OAuth redirect URIs | -| `scopes` | string | no | Space-separated OAuth scopes (default `"atproto"`) | -| `rate_limit_capacity` | integer | no | Per-client token bucket capacity. Falls back to `DEFAULT_RATE_LIMIT_CAPACITY` if unset | -| `rate_limit_refill_rate` | number | no | Tokens added per second. Falls back to `DEFAULT_RATE_LIMIT_REFILL_RATE` if unset | - -**Response**: `201 Created` - -```json -{ - "id": "01J9...", - "client_key": "hvc_a1b2c3...", - "client_secret": "hvs_d4e5f6...", - "name": "My Game Client", - "client_id_url": "https://example.com/client-metadata.json" -} -``` - -The new client is immediately registered with the OAuth registry and rate limiter, so it can authenticate without restarting HappyView. - -### Get an API client - -``` -GET /admin/api-clients/{id} -``` - -Requires `api-clients:view`. Returns the same `ApiClientSummary` shape as the list endpoint, or `404 Not Found`. - -### Update an API client - -``` -PUT /admin/api-clients/{id} -``` - -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. - -| Field | Type | Description | -| ------------------------ | -------- | ------------------------------------------------------------------------ | -| `name` | string | New display name | -| `client_uri` | string | New home URL | -| `redirect_uris` | string[] | Replace the allowed redirect URIs | -| `scopes` | string | Replace the OAuth scopes | -| `rate_limit_capacity` | integer | New bucket capacity. Pass `null` to clear the override | -| `rate_limit_refill_rate` | number | New refill rate. Pass `null` to clear the override | -| `is_active` | boolean | Disable (`false`) or re-enable (`true`) the client without deleting it | - -**Response**: `204 No Content` - -The OAuth registry is updated in place. The `client_id_url` is immutable — to change it, delete and recreate the client. - -### Delete an API client - -``` -DELETE /admin/api-clients/{id} -``` - -Requires `api-clients:delete`. Removes the client from the OAuth registry, the rate limiter, and the client identity store. - -**Response**: `204 No Content` - -## Plugins - -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. - -### List installed plugins - -``` -GET /admin/plugins -``` - -Requires `plugins:read`. Returns every loaded plugin with its source, required secrets, configuration status, and any pending updates from the official registry cache. - -```sh -curl http://localhost:3000/admin/plugins -H "$AUTH" -``` - -**Response**: `200 OK` - -```json -{ - "encryption_configured": true, - "plugins": [ - { - "id": "steam", - "name": "Steam", - "version": "1.2.0", - "source": "url", - "url": "https://example.com/plugins/steam/manifest.json", - "sha256": null, - "enabled": true, - "auth_type": "openid", - "required_secrets": [ - { - "key": "PLUGIN_STEAM_API_KEY", - "name": "Steam Web API Key", - "description": "Get your API key at steamcommunity.com/dev/apikey" - } - ], - "secrets_configured": true, - "loaded_at": null, - "update_available": false, - "latest_version": "1.2.0", - "pending_releases": [] - } - ] -} -``` - -`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. - -### Preview a plugin before installing - -``` -POST /admin/plugins/preview -``` - -Requires `plugins:create`. Fetches and parses a manifest without installing the plugin, so the dashboard can show what it would register. - -```sh -curl -X POST http://localhost:3000/admin/plugins/preview \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ "url": "https://example.com/plugins/steam/manifest.json" }' -``` - -**Response**: `200 OK` - -```json -{ - "id": "steam", - "name": "Steam", - "version": "1.2.0", - "description": "Import your Steam game library and playtime data.", - "icon_url": "https://example.com/steam-icon.png", - "auth_type": "openid", - "required_secrets": [ - { "key": "PLUGIN_STEAM_API_KEY", "name": "Steam Web API Key", "description": "..." } - ], - "manifest_url": "https://example.com/plugins/steam/manifest.json", - "wasm_url": "https://example.com/plugins/steam/steam.wasm" -} -``` - -Returns `400 Bad Request` if the manifest can't be fetched or parsed. - -### Install a plugin - -``` -POST /admin/plugins -``` - -Requires `plugins:create`. Fetches the manifest, downloads the WASM, registers the plugin, and persists it. - -```sh -curl -X POST http://localhost:3000/admin/plugins \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ - "url": "https://example.com/plugins/steam/manifest.json", - "sha256": "abc123..." - }' -``` - -| Field | Type | Required | Description | -| -------- | ------ | -------- | -------------------------------------------------------------------------------------------- | -| `url` | string | yes | URL to the plugin's `manifest.json` | -| `sha256` | string | no | Optional sha256 of the WASM binary. If provided, install fails when the downloaded hash mismatches | - -**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. - -### List official plugins - -``` -GET /admin/plugins/official -``` - -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. - -**Response**: `200 OK` - -```json -{ - "last_refreshed_at": "2026-04-13T11:00:00Z", - "plugins": [ - { - "id": "steam", - "name": "Steam", - "description": "Import your Steam game library and playtime data.", - "icon_url": "https://example.com/steam-icon.png", - "latest_version": "1.2.0", - "manifest_url": "https://example.com/plugins/steam/manifest.json" - } - ] -} -``` - -### Remove a plugin - -``` -DELETE /admin/plugins/{id} -``` - -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. - -**Response**: `204 No Content`. Returns `404 Not Found` if no plugin with that id is loaded. - -### Reload a plugin - -``` -POST /admin/plugins/{id}/reload -``` - -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. - -The body is optional. To point the plugin at a new URL, pass: - -```json -{ "url": "https://example.com/plugins/steam/manifest.json" } -``` - -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`. - -**Response**: `200 OK` with the refreshed `PluginSummary`. - -### Check for plugin updates - -``` -POST /admin/plugins/{id}/check-update -``` - -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. - -**Response**: `200 OK` with a `PluginSummary`. - -### Get plugin secrets - -``` -GET /admin/plugins/{id}/secrets -``` - -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. - -**Response**: `200 OK` - -```json -{ - "plugin_id": "steam", - "secrets": { - "PLUGIN_STEAM_API_KEY": "********ABCD" - } -} -``` - -### Update plugin secrets - -``` -PUT /admin/plugins/{id}/secrets -``` - -Requires `plugins:create`. Encrypts the provided secret values with `TOKEN_ENCRYPTION_KEY` (AES-256-GCM) and upserts them into `plugin_configs`. - -```sh -curl -X PUT http://localhost:3000/admin/plugins/steam/secrets \ - -H "$AUTH" \ - -H "Content-Type: application/json" \ - -d '{ - "secrets": { - "PLUGIN_STEAM_API_KEY": "your-new-api-key" - } - }' -``` - -Special handling: - -- 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). -- Empty string values are not stored — use them to clear a secret. +## Endpoint groups -**Response**: `204 No Content` +| Group | Description | +| ----- | ----------- | +| [Lexicons](admin/lexicons.md) | Upload, list, get, and delete lexicons and network lexicons | +| [Stats](admin/stats.md) | Record counts by collection | +| [Backfill](admin/backfill.md) | Create and monitor historical backfill jobs | +| [Event Logs](admin/events.md) | Query the audit trail of system events | +| [API Keys](admin/api-keys.md) | Create, list, and revoke API keys | +| [Users](admin/users.md) | Create, list, update, and delete admin users | +| [Labelers](admin/labelers.md) | Manage external labeler subscriptions | +| [Instance Settings](admin/settings.md) | Configure app name, logo, and policy URLs | +| [Domains](admin/domains.md) | Manage domains and their OAuth client identities | +| [Script Variables](admin/script-variables.md) | Encrypted key/value pairs for Lua scripts | +| [API Clients](admin/api-clients.md) | Register and manage third-party XRPC clients | +| [Plugins](admin/plugins.md) | Install, configure, and manage WASM plugins | ## Permissions Each admin API endpoint requires a specific permission. See the [Permissions guide](../guides/permissions.md) for the full list of permissions and templates. -| Endpoint | Required Permission | -| ------------------------------------- | ---------------------------- | -| `POST /admin/lexicons` | `lexicons:create` | -| `GET /admin/lexicons` | `lexicons:read` | -| `GET /admin/lexicons/{id}` | `lexicons:read` | -| `DELETE /admin/lexicons/{id}` | `lexicons:delete` | -| `POST /admin/network-lexicons` | `lexicons:create` | -| `GET /admin/network-lexicons` | `lexicons:read` | -| `DELETE /admin/network-lexicons/{id}` | `lexicons:delete` | -| `GET /admin/stats` | `stats:read` | -| `POST /admin/backfill` | `backfill:create` | -| `GET /admin/backfill/status` | `backfill:read` | -| `GET /admin/events` | `events:read` | -| `POST /admin/api-keys` | `api-keys:create` | -| `GET /admin/api-keys` | `api-keys:read` | -| `DELETE /admin/api-keys/{id}` | `api-keys:delete` | -| `POST /admin/users` | `users:create` | -| `GET /admin/users` | `users:read` | -| `GET /admin/users/{id}` | `users:read` | -| `PATCH /admin/users/{id}/permissions`| `users:update` | -| `DELETE /admin/users/{id}` | `users:delete` | -| `POST /admin/users/transfer-super` | Super user only | -| `GET /admin/script-variables` | `script-variables:read` | -| `POST /admin/script-variables` | `script-variables:create` | -| `DELETE /admin/script-variables/{key}`| `script-variables:delete` | -| `POST /admin/labelers` | `labelers:create` | -| `GET /admin/labelers` | `labelers:read` | -| `PATCH /admin/labelers/{did}` | `labelers:create` | -| `DELETE /admin/labelers/{did}` | `labelers:delete` | -| `GET /admin/settings` | `settings:manage` | -| `PUT /admin/settings/{key}` | `settings:manage` | -| `DELETE /admin/settings/{key}` | `settings:manage` | -| `PUT /admin/settings/logo` | `settings:manage` | -| `DELETE /admin/settings/logo` | `settings:manage` | -| `GET /admin/plugins` | `plugins:read` | -| `POST /admin/plugins` | `plugins:create` | -| `POST /admin/plugins/preview` | `plugins:read` | -| `GET /admin/plugins/official` | `plugins:read` | -| `DELETE /admin/plugins/{id}` | `plugins:delete` | -| `POST /admin/plugins/{id}/reload` | `plugins:create` | -| `POST /admin/plugins/{id}/check-update` | `plugins:read` | -| `GET /admin/plugins/{id}/secrets` | `plugins:read` | -| `PUT /admin/plugins/{id}/secrets` | `plugins:create` | -| `GET /admin/domains` | `settings:manage` | -| `POST /admin/domains` | `settings:manage` | -| `DELETE /admin/domains/{id}` | `settings:manage` | -| `POST /admin/domains/{id}/primary` | `settings:manage` | -| `GET /admin/api-clients` | `api-clients:view` | -| `POST /admin/api-clients` | `api-clients:create` | -| `GET /admin/api-clients/{id}` | `api-clients:view` | -| `PUT /admin/api-clients/{id}` | `api-clients:edit` | -| `DELETE /admin/api-clients/{id}` | `api-clients:delete` | +| Endpoint | Required Permission | +| ---------------------------------------- | -------------------------- | +| `POST /admin/lexicons` | `lexicons:create` | +| `GET /admin/lexicons` | `lexicons:read` | +| `GET /admin/lexicons/{id}` | `lexicons:read` | +| `DELETE /admin/lexicons/{id}` | `lexicons:delete` | +| `POST /admin/network-lexicons` | `lexicons:create` | +| `GET /admin/network-lexicons` | `lexicons:read` | +| `DELETE /admin/network-lexicons/{id}` | `lexicons:delete` | +| `GET /admin/stats` | `stats:read` | +| `POST /admin/backfill` | `backfill:create` | +| `GET /admin/backfill/status` | `backfill:read` | +| `GET /admin/events` | `events:read` | +| `POST /admin/api-keys` | `api-keys:create` | +| `GET /admin/api-keys` | `api-keys:read` | +| `DELETE /admin/api-keys/{id}` | `api-keys:delete` | +| `POST /admin/users` | `users:create` | +| `GET /admin/users` | `users:read` | +| `GET /admin/users/{id}` | `users:read` | +| `PATCH /admin/users/{id}/permissions` | `users:update` | +| `DELETE /admin/users/{id}` | `users:delete` | +| `POST /admin/users/transfer-super` | Super user only | +| `GET /admin/script-variables` | `script-variables:read` | +| `POST /admin/script-variables` | `script-variables:create` | +| `DELETE /admin/script-variables/{key}` | `script-variables:delete` | +| `POST /admin/labelers` | `labelers:create` | +| `GET /admin/labelers` | `labelers:read` | +| `PATCH /admin/labelers/{did}` | `labelers:create` | +| `DELETE /admin/labelers/{did}` | `labelers:delete` | +| `GET /admin/settings` | `settings:manage` | +| `PUT /admin/settings/{key}` | `settings:manage` | +| `DELETE /admin/settings/{key}` | `settings:manage` | +| `PUT /admin/settings/logo` | `settings:manage` | +| `DELETE /admin/settings/logo` | `settings:manage` | +| `GET /admin/plugins` | `plugins:read` | +| `POST /admin/plugins` | `plugins:create` | +| `POST /admin/plugins/preview` | `plugins:read` | +| `GET /admin/plugins/official` | `plugins:read` | +| `DELETE /admin/plugins/{id}` | `plugins:delete` | +| `POST /admin/plugins/{id}/reload` | `plugins:create` | +| `POST /admin/plugins/{id}/check-update` | `plugins:read` | +| `GET /admin/plugins/{id}/secrets` | `plugins:read` | +| `PUT /admin/plugins/{id}/secrets` | `plugins:create` | +| `GET /admin/domains` | `settings:manage` | +| `POST /admin/domains` | `settings:manage` | +| `DELETE /admin/domains/{id}` | `settings:manage` | +| `POST /admin/domains/{id}/primary` | `settings:manage` | +| `GET /admin/api-clients` | `api-clients:view` | +| `POST /admin/api-clients` | `api-clients:create` | +| `GET /admin/api-clients/{id}` | `api-clients:view` | +| `PUT /admin/api-clients/{id}` | `api-clients:edit` | +| `DELETE /admin/api-clients/{id}` | `api-clients:delete` | diff --git a/packages/docs/docs/reference/admin/api-clients.md b/packages/docs/docs/reference/admin/api-clients.md new file mode 100644 index 0000000..168f7d8 --- /dev/null +++ b/packages/docs/docs/reference/admin/api-clients.md @@ -0,0 +1,131 @@ +# Admin API: API Clients + +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`. + +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. + +```sh +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## List API clients + +``` +GET /admin/api-clients +``` + +Requires `api-clients:view`. Returns clients ordered by `created_at` descending. Secrets are never returned. + +```sh +curl http://localhost:3000/admin/api-clients -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "id": "01J9...", + "client_key": "hvc_a1b2c3...", + "name": "My Game Client", + "client_id_url": "https://example.com/client-metadata.json", + "client_uri": "https://example.com", + "redirect_uris": ["https://example.com/callback"], + "scopes": "atproto", + "rate_limit_capacity": 200, + "rate_limit_refill_rate": 5.0, + "is_active": true, + "created_by": "did:plc:...", + "created_at": "2026-04-13T12:00:00Z", + "updated_at": "2026-04-13T12:00:00Z" + } +] +``` + +## Create an API client + +``` +POST /admin/api-clients +``` + +Requires `api-clients:create`. Generates a fresh `client_key` and `client_secret`. **The secret is only returned in this response** — store it immediately. + +```sh +curl -X POST http://localhost:3000/admin/api-clients \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "My Game Client", + "client_id_url": "https://example.com/client-metadata.json", + "client_uri": "https://example.com", + "redirect_uris": ["https://example.com/callback"], + "scopes": "atproto", + "rate_limit_capacity": 200, + "rate_limit_refill_rate": 5.0 + }' +``` + +| Field | Type | Required | Description | +| ------------------------ | -------- | -------- | -------------------------------------------------------------------------------------- | +| `name` | string | yes | Human-readable display name | +| `client_id_url` | string | yes | URL to the client's published OAuth client metadata document | +| `client_uri` | string | yes | The client's home/landing URL | +| `redirect_uris` | string[] | yes | Allowed OAuth redirect URIs | +| `scopes` | string | no | Space-separated OAuth scopes (default `"atproto"`) | +| `rate_limit_capacity` | integer | no | Per-client token bucket capacity. Falls back to `DEFAULT_RATE_LIMIT_CAPACITY` if unset | +| `rate_limit_refill_rate` | number | no | Tokens added per second. Falls back to `DEFAULT_RATE_LIMIT_REFILL_RATE` if unset | + +**Response**: `201 Created` + +```json +{ + "id": "01J9...", + "client_key": "hvc_a1b2c3...", + "client_secret": "hvs_d4e5f6...", + "name": "My Game Client", + "client_id_url": "https://example.com/client-metadata.json" +} +``` + +The new client is immediately registered with the OAuth registry and rate limiter, so it can authenticate without restarting HappyView. + +## Get an API client + +``` +GET /admin/api-clients/{id} +``` + +Requires `api-clients:view`. Returns the same shape as the list endpoint, or `404 Not Found`. + +## Update an API client + +``` +PUT /admin/api-clients/{id} +``` + +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. + +| Field | Type | Description | +| ------------------------ | -------- | ---------------------------------------------------------------------- | +| `name` | string | New display name | +| `client_uri` | string | New home URL | +| `redirect_uris` | string[] | Replace the allowed redirect URIs | +| `scopes` | string | Replace the OAuth scopes | +| `rate_limit_capacity` | integer | New bucket capacity. Pass `null` to clear the override | +| `rate_limit_refill_rate` | number | New refill rate. Pass `null` to clear the override | +| `is_active` | boolean | Disable (`false`) or re-enable (`true`) the client without deleting it | + +**Response**: `204 No Content` + +The OAuth registry is updated in place. The `client_id_url` is immutable — to change it, delete and recreate the client. + +## Delete an API client + +``` +DELETE /admin/api-clients/{id} +``` + +Requires `api-clients:delete`. Removes the client from the OAuth registry, the rate limiter, and the client identity store. + +**Response**: `204 No Content` diff --git a/packages/docs/docs/reference/admin/api-keys.md b/packages/docs/docs/reference/admin/api-keys.md new file mode 100644 index 0000000..7da604f --- /dev/null +++ b/packages/docs/docs/reference/admin/api-keys.md @@ -0,0 +1,92 @@ +# Admin API: API Keys + +Manage API keys for programmatic access. See the [API Keys guide](../../guides/api-keys.md) for usage details. + +```sh +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## Create an API key + +``` +POST /admin/api-keys +``` + +Requires `api-keys:create` permission. + +```sh +curl -X POST http://localhost:3000/admin/api-keys \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "CI Deploy", + "permissions": ["lexicons:read", "lexicons:create", "backfill:create"] + }' +``` + +| Field | Type | Required | Description | +| ------------- | -------- | -------- | ------------------------------------------------------------------------------------- | +| `name` | string | yes | A label to identify this key's usage | +| `permissions` | string[] | yes | Permissions to grant the key (must be a subset of the creating user's own permissions) | + +**Response**: `201 Created` + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "CI Deploy", + "key": "hv_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + "key_prefix": "hv_a1b2c3d4", + "permissions": ["lexicons:read", "lexicons:create", "backfill:create"] +} +``` + +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. + +## List API keys + +``` +GET /admin/api-keys +``` + +Requires `api-keys:read` permission. + +```sh +curl http://localhost:3000/admin/api-keys -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "CI Deploy", + "key_prefix": "hv_a1b2c3d4", + "permissions": ["lexicons:read", "lexicons:create", "backfill:create"], + "created_at": "2026-03-01T00:00:00Z", + "last_used_at": "2026-03-06T12:00:00Z", + "revoked_at": null + } +] +``` + +Only returns keys belonging to the authenticated user. The full key is never included — only the prefix. + +## Revoke an API key + +``` +DELETE /admin/api-keys/{id} +``` + +Requires `api-keys:delete` permission. + +```sh +curl -X DELETE http://localhost:3000/admin/api-keys/550e8400-e29b-41d4-a716-446655440000 \ + -H "$AUTH" +``` + +Sets `revoked_at` on the key. The key remains in the database for audit purposes but can no longer authenticate. + +**Response**: `204 No Content` diff --git a/packages/docs/docs/reference/admin/backfill.md b/packages/docs/docs/reference/admin/backfill.md new file mode 100644 index 0000000..a0369c2 --- /dev/null +++ b/packages/docs/docs/reference/admin/backfill.md @@ -0,0 +1,65 @@ +# Admin API: Backfill + +Create and monitor historical backfill jobs. See the [Backfill guide](../../guides/backfill.md) for background. + +```sh +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## Create a backfill job + +``` +POST /admin/backfill +``` + +```sh +curl -X POST http://localhost:3000/admin/backfill \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "collection": "xyz.statusphere.status" }' +``` + +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ---------------------------------------------------------- | +| `collection` | string | no | Limit to a single collection (backfills all if omitted) | +| `did` | string | no | Limit to a single DID (discovers all via relay if omitted) | + +**Response**: `201 Created` + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "status": "pending" +} +``` + +## List backfill jobs + +``` +GET /admin/backfill/status +``` + +```sh +curl http://localhost:3000/admin/backfill/status -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "collection": "xyz.statusphere.status", + "did": null, + "status": "completed", + "total_repos": 42, + "processed_repos": 42, + "total_records": 1000, + "error": null, + "started_at": "2025-01-01T00:01:00Z", + "completed_at": "2025-01-01T00:05:00Z", + "created_at": "2025-01-01T00:00:00Z" + } +] +``` diff --git a/packages/docs/docs/reference/admin/domains.md b/packages/docs/docs/reference/admin/domains.md new file mode 100644 index 0000000..b5c188a --- /dev/null +++ b/packages/docs/docs/reference/admin/domains.md @@ -0,0 +1,99 @@ +# Admin API: Domains + +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. + +```sh +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## List domains + +``` +GET /admin/domains +``` + +```sh +curl http://localhost:3000/admin/domains -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "url": "https://gamesgamesgamesgames.games", + "is_primary": true, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" + } +] +``` + +## Add a domain + +``` +POST /admin/domains +``` + +```sh +curl -X POST http://localhost:3000/admin/domains \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "url": "https://api.cartridge.dev" }' +``` + +| Field | Type | Required | Description | +| ----- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------ | +| `url` | string | yes | Valid origin (scheme + host, no path or trailing slash). Must be `https` unless `PUBLIC_URL` is a loopback address. | + +Returns `400 Bad Request` if the URL is invalid or already registered. + +**Response**: `201 Created` + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440001", + "url": "https://api.cartridge.dev", + "is_primary": false, + "created_at": "2026-04-16T00:00:00Z", + "updated_at": "2026-04-16T00:00:00Z" +} +``` + +Side effects: builds an OAuth client for the domain, updates the in-memory domain cache. + +## Remove a domain + +``` +DELETE /admin/domains/{id} +``` + +```sh +curl -X DELETE http://localhost:3000/admin/domains/550e8400-e29b-41d4-a716-446655440001 \ + -H "$AUTH" +``` + +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. + +**Response**: `204 No Content` + +Side effects: removes the domain's OAuth client and cache entry. + +## Set primary domain + +``` +POST /admin/domains/{id}/primary +``` + +```sh +curl -X POST http://localhost:3000/admin/domains/550e8400-e29b-41d4-a716-446655440001/primary \ + -H "$AUTH" +``` + +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. + +**Response**: `204 No Content` + +Side effects: updates the in-memory cache and the OAuth client registry's primary client reference. diff --git a/packages/docs/docs/reference/admin/events.md b/packages/docs/docs/reference/admin/events.md new file mode 100644 index 0000000..39f1054 --- /dev/null +++ b/packages/docs/docs/reference/admin/events.md @@ -0,0 +1,54 @@ +# Admin API: Event Logs + +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. + +```sh +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## List event logs + +``` +GET /admin/events +``` + +```sh +curl "http://localhost:3000/admin/events?severity=error&limit=10" -H "$AUTH" +``` + +| Param | Type | Required | Description | +| ------------ | ------ | -------- | --------------------------------------------------------------------- | +| `event_type` | string | no | Filter by exact event type (e.g. `script.error`) | +| `category` | string | no | Filter by category prefix (e.g. `lexicon` matches all lexicon events) | +| `severity` | string | no | Filter by severity: `info`, `warn`, or `error` | +| `subject` | string | no | Filter by subject (lexicon ID, record URI, admin DID, etc.) | +| `cursor` | string | no | Pagination cursor (ISO 8601 timestamp from previous response) | +| `limit` | number | no | Results per page (default `50`, max `100`) | + +**Response**: `200 OK` + +```json +{ + "events": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "event_type": "script.error", + "severity": "error", + "actor_did": "did:plc:abc123", + "subject": "com.example.feed.like", + "detail": { + "error": "attempt to index nil value", + "script_source": "function handle() ... end", + "input": { "status": "hello" }, + "caller_did": "did:plc:abc123", + "method": "com.example.feed.like" + }, + "created_at": "2026-03-01T12:00:00Z" + } + ], + "cursor": "2026-03-01T11:59:00Z" +} +``` + +Events are returned in reverse chronological order (newest first). Pass the `cursor` value from the response to fetch the next page. diff --git a/packages/docs/docs/reference/admin/labelers.md b/packages/docs/docs/reference/admin/labelers.md new file mode 100644 index 0000000..14ea249 --- /dev/null +++ b/packages/docs/docs/reference/admin/labelers.md @@ -0,0 +1,99 @@ +# Admin API: Labelers + +Manage external labeler subscriptions. See the [Labelers guide](../../guides/labelers.md) for background. + +```sh +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## Add a labeler + +``` +POST /admin/labelers +``` + +Requires `labelers:create` permission. + +```sh +curl -X POST http://localhost:3000/admin/labelers \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "did": "did:plc:ar7c4by46qjdydhdevvrndac" }' +``` + +| Field | Type | Required | Description | +| ----- | ------ | -------- | ----------------------------- | +| `did` | string | yes | The labeler's AT Protocol DID | + +**Response**: `201 Created` (empty body) + +## List labelers + +``` +GET /admin/labelers +``` + +Requires `labelers:read` permission. + +```sh +curl http://localhost:3000/admin/labelers -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "did": "did:plc:ar7c4by46qjdydhdevvrndac", + "status": "active", + "cursor": 1234, + "created_at": "2026-03-15T00:00:00Z", + "updated_at": "2026-03-15T00:00:00Z" + } +] +``` + +| Field | Type | Description | +| ------------ | ------------ | -------------------------------------------------- | +| `did` | string | The labeler's DID | +| `status` | string | `active` or `paused` | +| `cursor` | number\|null | Last processed event cursor (null if never synced) | +| `created_at` | string | ISO 8601 creation timestamp | +| `updated_at` | string | ISO 8601 last-updated timestamp | + +## Update a labeler + +``` +PATCH /admin/labelers/{did} +``` + +Requires `labelers:create` permission. + +```sh +curl -X PATCH http://localhost:3000/admin/labelers/did:plc:ar7c4by46qjdydhdevvrndac \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "status": "paused" }' +``` + +| Field | Type | Required | Description | +| -------- | ------ | -------- | -------------------------------- | +| `status` | string | yes | New status: `active` or `paused` | + +**Response**: `200 OK` + +## Delete a labeler + +``` +DELETE /admin/labelers/{did} +``` + +Requires `labelers:delete` permission. Removes the subscription and all labels emitted by this labeler. + +```sh +curl -X DELETE http://localhost:3000/admin/labelers/did:plc:ar7c4by46qjdydhdevvrndac \ + -H "$AUTH" +``` + +**Response**: `204 No Content` diff --git a/packages/docs/docs/reference/admin/lexicons.md b/packages/docs/docs/reference/admin/lexicons.md new file mode 100644 index 0000000..6b6b3ec --- /dev/null +++ b/packages/docs/docs/reference/admin/lexicons.md @@ -0,0 +1,167 @@ +# Admin API: Lexicons + +Manage lexicons and network lexicons. See the [Lexicons guide](../../guides/lexicons.md) for background on how lexicons drive indexing and XRPC routing. + +```sh +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## Upload / upsert a lexicon + +``` +POST /admin/lexicons +``` + +```sh +curl -X POST http://localhost:3000/admin/lexicons \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ + "lexicon_json": { "lexicon": 1, "id": "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" } } } } } }, + "backfill": true, + "target_collection": null + }' +``` + +| Field | Type | Required | Description | +| ------------------- | ------- | -------- | --------------------------------------------------------------------- | +| `lexicon_json` | object | yes | Raw lexicon JSON (must have `lexicon: 1` and `id`) | +| `backfill` | boolean | no | Whether uploading triggers historical backfill (default `true`) | +| `target_collection` | string | no | For query/procedure lexicons, the record collection they operate on | +| `script` | string | no | Lua script for query/procedure endpoints | +| `index_hook` | string | no | [Index hook](../../guides/index-hooks.md) Lua script for record lexicons | + +**Response**: `201 Created` (new) or `200 OK` (upsert) + +```json +{ + "id": "xyz.statusphere.status", + "revision": 1 +} +``` + +## List lexicons + +``` +GET /admin/lexicons +``` + +```sh +curl http://localhost:3000/admin/lexicons -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "id": "xyz.statusphere.status", + "revision": 1, + "lexicon_type": "record", + "backfill": true, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + } +] +``` + +## Get a lexicon + +``` +GET /admin/lexicons/{id} +``` + +```sh +curl http://localhost:3000/admin/lexicons/xyz.statusphere.status -H "$AUTH" +``` + +**Response**: `200 OK` with full lexicon details including raw JSON. + +## Delete a lexicon + +``` +DELETE /admin/lexicons/{id} +``` + +```sh +curl -X DELETE http://localhost:3000/admin/lexicons/xyz.statusphere.status -H "$AUTH" +``` + +**Response**: `204 No Content` + +## Network Lexicons + +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. + +### Add a network lexicon + +``` +POST /admin/network-lexicons +``` + +```sh +curl -X POST http://localhost:3000/admin/network-lexicons \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ + "nsid": "xyz.statusphere.status", + "target_collection": null + }' +``` + +| Field | Type | Required | Description | +| ------------------- | ------ | -------- | ------------------------------------------------------------------- | +| `nsid` | string | yes | The NSID of the lexicon to watch | +| `target_collection` | string | no | For query/procedure lexicons, the record collection they operate on | + +HappyView resolves the NSID authority via DNS TXT, fetches the lexicon from the authority's PDS, parses it, and stores it. + +**Response**: `201 Created` + +```json +{ + "nsid": "xyz.statusphere.status", + "authority_did": "did:plc:authority", + "revision": 1 +} +``` + +### List network lexicons + +``` +GET /admin/network-lexicons +``` + +```sh +curl http://localhost:3000/admin/network-lexicons -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "nsid": "xyz.statusphere.status", + "authority_did": "did:plc:authority", + "target_collection": null, + "last_fetched_at": "2025-01-01T00:00:00Z", + "created_at": "2025-01-01T00:00:00Z" + } +] +``` + +### Remove a network lexicon + +``` +DELETE /admin/network-lexicons/{nsid} +``` + +```sh +curl -X DELETE http://localhost:3000/admin/network-lexicons/xyz.statusphere.status \ + -H "$AUTH" +``` + +Removes the network lexicon tracking and also deletes the lexicon from the `lexicons` table and in-memory registry. + +**Response**: `204 No Content` diff --git a/packages/docs/docs/reference/admin/plugins.md b/packages/docs/docs/reference/admin/plugins.md new file mode 100644 index 0000000..5cf5e77 --- /dev/null +++ b/packages/docs/docs/reference/admin/plugins.md @@ -0,0 +1,223 @@ +# Admin API: Plugins + +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. + +```sh +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## List installed plugins + +``` +GET /admin/plugins +``` + +Requires `plugins:read`. Returns every loaded plugin with its source, required secrets, configuration status, and any pending updates from the official registry cache. + +```sh +curl http://localhost:3000/admin/plugins -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +{ + "encryption_configured": true, + "plugins": [ + { + "id": "steam", + "name": "Steam", + "version": "1.2.0", + "source": "url", + "url": "https://example.com/plugins/steam/manifest.json", + "sha256": null, + "enabled": true, + "auth_type": "openid", + "required_secrets": [ + { + "key": "PLUGIN_STEAM_API_KEY", + "name": "Steam Web API Key", + "description": "Get your API key at steamcommunity.com/dev/apikey" + } + ], + "secrets_configured": true, + "loaded_at": null, + "update_available": false, + "latest_version": "1.2.0", + "pending_releases": [] + } + ] +} +``` + +`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. + +## Preview a plugin before installing + +``` +POST /admin/plugins/preview +``` + +Requires `plugins:create`. Fetches and parses a manifest without installing the plugin, so the dashboard can show what it would register. + +```sh +curl -X POST http://localhost:3000/admin/plugins/preview \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "url": "https://example.com/plugins/steam/manifest.json" }' +``` + +**Response**: `200 OK` + +```json +{ + "id": "steam", + "name": "Steam", + "version": "1.2.0", + "description": "Import your Steam game library and playtime data.", + "icon_url": "https://example.com/steam-icon.png", + "auth_type": "openid", + "required_secrets": [ + { "key": "PLUGIN_STEAM_API_KEY", "name": "Steam Web API Key", "description": "..." } + ], + "manifest_url": "https://example.com/plugins/steam/manifest.json", + "wasm_url": "https://example.com/plugins/steam/steam.wasm" +} +``` + +Returns `400 Bad Request` if the manifest can't be fetched or parsed. + +## Install a plugin + +``` +POST /admin/plugins +``` + +Requires `plugins:create`. Fetches the manifest, downloads the WASM, registers the plugin, and persists it. + +```sh +curl -X POST http://localhost:3000/admin/plugins \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://example.com/plugins/steam/manifest.json", + "sha256": "abc123..." + }' +``` + +| Field | Type | Required | Description | +| -------- | ------ | -------- | ---------------------------------------------------------------------------------------------------- | +| `url` | string | yes | URL to the plugin's `manifest.json` | +| `sha256` | string | no | Optional sha256 of the WASM binary. If provided, install fails when the downloaded hash mismatches | + +**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. + +## List official plugins + +``` +GET /admin/plugins/official +``` + +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. + +**Response**: `200 OK` + +```json +{ + "last_refreshed_at": "2026-04-13T11:00:00Z", + "plugins": [ + { + "id": "steam", + "name": "Steam", + "description": "Import your Steam game library and playtime data.", + "icon_url": "https://example.com/steam-icon.png", + "latest_version": "1.2.0", + "manifest_url": "https://example.com/plugins/steam/manifest.json" + } + ] +} +``` + +## Remove a plugin + +``` +DELETE /admin/plugins/{id} +``` + +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. + +**Response**: `204 No Content`. Returns `404 Not Found` if no plugin with that id is loaded. + +## Reload a plugin + +``` +POST /admin/plugins/{id}/reload +``` + +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. + +The body is optional. To point the plugin at a new URL, pass: + +```json +{ "url": "https://example.com/plugins/steam/manifest.json" } +``` + +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`. + +**Response**: `200 OK` with the refreshed `PluginSummary`. + +## Check for plugin updates + +``` +POST /admin/plugins/{id}/check-update +``` + +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. + +**Response**: `200 OK` with a `PluginSummary`. + +## Get plugin secrets + +``` +GET /admin/plugins/{id}/secrets +``` + +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. + +**Response**: `200 OK` + +```json +{ + "plugin_id": "steam", + "secrets": { + "PLUGIN_STEAM_API_KEY": "********ABCD" + } +} +``` + +## Update plugin secrets + +``` +PUT /admin/plugins/{id}/secrets +``` + +Requires `plugins:create`. Encrypts the provided secret values with `TOKEN_ENCRYPTION_KEY` (AES-256-GCM) and upserts them into `plugin_configs`. + +```sh +curl -X PUT http://localhost:3000/admin/plugins/steam/secrets \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ + "secrets": { + "PLUGIN_STEAM_API_KEY": "your-new-api-key" + } + }' +``` + +Special handling: + +- 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). +- Empty string values are not stored — use them to clear a secret. + +**Response**: `204 No Content` diff --git a/packages/docs/docs/reference/admin/script-variables.md b/packages/docs/docs/reference/admin/script-variables.md new file mode 100644 index 0000000..4448ae8 --- /dev/null +++ b/packages/docs/docs/reference/admin/script-variables.md @@ -0,0 +1,41 @@ +# Admin API: Script Variables + +Script variables are encrypted key/value pairs available to Lua scripts via the `vars` global. Use them for secrets like API tokens. + +```sh +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## List script variables + +``` +GET /admin/script-variables +``` + +Requires `script-variables:read`. Returns a list of variable keys (values are not returned). + +## Upsert a script variable + +``` +POST /admin/script-variables +``` + +Requires `script-variables:create`. + +```sh +curl -X POST http://localhost:3000/admin/script-variables \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "key": "ALGOLIA_API_KEY", "value": "..." }' +``` + +The value is encrypted at rest using `TOKEN_ENCRYPTION_KEY`. + +## Delete a script variable + +``` +DELETE /admin/script-variables/{key} +``` + +Requires `script-variables:delete`. diff --git a/packages/docs/docs/reference/admin/settings.md b/packages/docs/docs/reference/admin/settings.md new file mode 100644 index 0000000..ec91385 --- /dev/null +++ b/packages/docs/docs/reference/admin/settings.md @@ -0,0 +1,50 @@ +# Admin API: Instance Settings + +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. + +```sh +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## List settings + +``` +GET /admin/settings +``` + +```sh +curl http://localhost:3000/admin/settings -H "$AUTH" +``` + +Returns all key/value pairs stored in the `instance_settings` table. + +## Upsert a setting + +``` +PUT /admin/settings/{key} +``` + +```sh +curl -X PUT http://localhost:3000/admin/settings/app_name \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "value": "My HappyView" }' +``` + +## Delete a setting + +``` +DELETE /admin/settings/{key} +``` + +Removes the override; the corresponding environment variable (if any) takes effect again. + +## Upload / delete logo + +``` +PUT /admin/settings/logo +DELETE /admin/settings/logo +``` + +`PUT` accepts a binary image body and stores it as the instance logo (served via the public dashboard). `DELETE` removes the stored logo. diff --git a/packages/docs/docs/reference/admin/stats.md b/packages/docs/docs/reference/admin/stats.md new file mode 100644 index 0000000..bc43c8c --- /dev/null +++ b/packages/docs/docs/reference/admin/stats.md @@ -0,0 +1,25 @@ +# Admin API: Stats + +```sh +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## Record counts + +``` +GET /admin/stats +``` + +```sh +curl http://localhost:3000/admin/stats -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +{ + "total_records": 12345, + "collections": [{ "collection": "xyz.statusphere.status", "count": 500 }] +} +``` diff --git a/packages/docs/docs/reference/admin/users.md b/packages/docs/docs/reference/admin/users.md new file mode 100644 index 0000000..51aee60 --- /dev/null +++ b/packages/docs/docs/reference/admin/users.md @@ -0,0 +1,147 @@ +# Admin API: Users + +Manage admin users and their permissions. See the [Permissions guide](../../guides/permissions.md) for available permissions and templates. + +```sh +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## Create a user + +``` +POST /admin/users +``` + +Requires `users:create` permission. You cannot grant permissions you don't have yourself (escalation guard). + +```sh +curl -X POST http://localhost:3000/admin/users \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ + "did": "did:plc:newuser", + "template": "operator" + }' +``` + +| Field | Type | Required | Description | +| ------------- | -------- | -------- | ---------------------------------------------------------------------------------- | +| `did` | string | yes | The AT Protocol DID of the user to add | +| `template` | string | no | Permission template: `viewer`, `operator`, `manager`, or `full_access` | +| `permissions` | string[] | no | Explicit list of permissions to grant (used instead of or in addition to `template`) | + +If neither `template` nor `permissions` is provided, the user is created with no permissions. + +**Response**: `201 Created` + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "did": "did:plc:newuser", + "is_super": false, + "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"] +} +``` + +## List users + +``` +GET /admin/users +``` + +Requires `users:read` permission. + +```sh +curl http://localhost:3000/admin/users -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "did": "did:plc:admin", + "is_super": true, + "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"], + "created_at": "2025-01-01T00:00:00Z", + "last_used_at": "2025-01-02T12:00:00Z" + } +] +``` + +## Get a user + +``` +GET /admin/users/{id} +``` + +Requires `users:read` permission. + +```sh +curl http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 -H "$AUTH" +``` + +**Response**: `200 OK` with the same shape as a single item from the list response. + +## Update user permissions + +``` +PATCH /admin/users/{id}/permissions +``` + +Requires `users:update` permission. You cannot grant permissions you don't have yourself, and you cannot modify the super user's permissions. + +```sh +curl -X PATCH http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000/permissions \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ + "grant": ["lexicons:create", "lexicons:delete"], + "revoke": ["records:delete"] + }' +``` + +| Field | Type | Required | Description | +| -------- | -------- | -------- | --------------------- | +| `grant` | string[] | no | Permissions to add | +| `revoke` | string[] | no | Permissions to remove | + +**Response**: `200 OK` with the updated user object. + +## Transfer super user + +``` +POST /admin/users/transfer-super +``` + +Only the current super user can call this endpoint. Transfers super user status to another existing user. + +```sh +curl -X POST http://localhost:3000/admin/users/transfer-super \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "target_user_id": "550e8400-e29b-41d4-a716-446655440000" }' +``` + +| Field | Type | Required | Description | +| ---------------- | ------ | -------- | ------------------------------------------ | +| `target_user_id` | string | yes | The ID of the user to receive super status | + +**Response**: `200 OK` + +## Delete a user + +``` +DELETE /admin/users/{id} +``` + +Requires `users:delete` permission. You cannot delete the super user or yourself. + +```sh +curl -X DELETE http://localhost:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 \ + -H "$AUTH" +``` + +**Response**: `204 No Content` diff --git a/packages/docs/docs/reference/architecture.md b/packages/docs/docs/reference/architecture.md index 587fc05..1b8b01d 100644 --- a/packages/docs/docs/reference/architecture.md +++ b/packages/docs/docs/reference/architecture.md @@ -31,127 +31,136 @@ graph LR Reads 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. -## Module overview - -``` -src/ - main.rs Startup: config, DB, migrations, build OAuth client, spawn Jetstream worker, start server - lib.rs AppState struct (incl. OAuth client + cookie key), module declarations - config.rs Environment variable loading - dns.rs DNS TXT resolver for atrium handle resolution - error.rs AppError enum (Auth, BadRequest, Forbidden, Internal, NotFound, PdsError) - server.rs Axum router: fixed routes + admin nest + auth routes + XRPC catch-all + static files - lexicon.rs ParsedLexicon, LexiconRegistry (Arc>) - profile.rs DID document resolution, PDS discovery, profile fetching - jetstream.rs Jetstream WebSocket listener, collection filter sync, cursor persistence - resolve.rs NSID authority resolution (DNS TXT → DID → PDS) - auth/ - mod.rs Re-exports, COOKIE_NAME constant - middleware.rs Claims extractor (cookie auth, API key, or service auth JWT) - routes.rs OAuth endpoints (/auth/login, /auth/callback, /auth/logout, /auth/me) - oauth_store.rs Database-backed session and state stores for atrium-oauth - service_auth.rs XRPC service-to-service JWT validation (ES256/ES256K) - admin/ - mod.rs Admin route definitions - auth.rs UserAuth extractor (Claims + DID lookup + permission check + auto-bootstrap) - users.rs User CRUD handlers (create, list, get, delete, update permissions, transfer super) - permissions.rs Permission enum (20 permissions), templates (Viewer, Operator, Manager, FullAccess) - api_keys.rs API key CRUD handlers (create, list, revoke) with scoped permissions - events.rs Event log query handler - settings.rs Instance settings CRUD handlers (list, upsert, delete, logo upload/serve) - script_variables.rs Script variable CRUD handlers (list, upsert, delete) - lexicons.rs Lexicon CRUD handlers - network_lexicons.rs Network lexicon tracking (add, list, remove) - records.rs Record listing handler - stats.rs Record count stats - backfill.rs Backfill job runner (relay discovery + per-PDS listRecords) - types.rs Request/response structs for admin endpoints - lua/ - mod.rs Re-exports - context.rs Lua context globals (method, params, input, caller_did, collection) - db_api.rs Lua database API (db.query, db.get, db.count) - execute.rs Script execution and sandbox setup - record.rs Lua Record API (constructor, save, delete, load) - sandbox.rs Restricted Lua environment (removed modules, instruction limit) - tid.rs TID generation for Lua scripts - repo/ - mod.rs Re-exports - pds.rs PDS proxy helpers (JSON POST, blob POST, response forwarding via OAuth session) - session.rs OAuth session restoration from atrium store - upload_blob.rs Blob upload handler - xrpc/ - mod.rs Re-exports - query.rs Dynamic GET handler (Lua script or default: single record + list) - procedure.rs Dynamic POST handler (Lua script or default: create vs put) -``` - ## Request flow ### Reads (queries) -``` -Client GET /xrpc/{method}?params - -> xrpc::xrpc_get() - -> LexiconRegistry lookup (must be Query type) - -> If Lua script attached: execute script (has access to db API) - -> Else: default SQL query on records table (collection from target_collection) - -> JSON response +```mermaid +sequenceDiagram + participant C as Client + participant X as xrpc_get() + participant R as LexiconRegistry + participant L as Lua Script + participant D as Database + + C->>X: GET /xrpc/{method}?params + X->>R: Lookup (must be Query type) + alt Lua script attached + R->>L: Execute script + L->>D: db.query / db.get / db.raw + D-->>L: Results + L-->>X: Response table + else No script + R->>D: Default SQL query (collection from target_collection) + D-->>X: Results + end + X-->>C: JSON response ``` ### Writes (procedures) -``` -Client POST /xrpc/{method} + session cookie or Bearer token - -> Claims extractor (cookie, API key, or service auth JWT) - -> xrpc::xrpc_post() - -> LexiconRegistry lookup (must be Procedure type) - -> If Lua script attached: execute script (has access to Record API) - -> Else: default create/update (auto-detect based on uri field) - -> Restore OAuth session from atrium store (by DID) - -> atrium handles DPoP proof generation and token refresh - -> Proxy to user's PDS (createRecord or putRecord) - -> Upsert record locally - -> Forward PDS response +```mermaid +sequenceDiagram + participant C as Client + participant A as Claims Extractor + participant X as xrpc_post() + participant R as LexiconRegistry + participant L as Lua Script + participant S as OAuth Session + participant P as User PDS + participant D as Database + + C->>A: POST /xrpc/{method} + DPoP auth + X-Client-Key + A->>X: Validated claims + X->>R: Lookup (must be Procedure type) + alt Lua script attached + R->>L: Execute script (Record API) + L->>S: Record:save() + else No script + R->>S: Default create/update (auto-detect from uri field) + end + S->>P: Proxy write (createRecord or putRecord) + P-->>S: PDS response + S->>D: Upsert record locally + S-->>C: Forward PDS response ``` ### Admin endpoints -``` -Client request + session cookie or Bearer token - -> AdminAuth extractor: - 1. Claims validation (cookie, API key, or service auth JWT) - 2. DID lookup in users table (auto-bootstrap super user if empty) - 3. Permission check (403 if missing required permission) - -> Admin handler - -> JSON response +```mermaid +sequenceDiagram + participant C as Client + participant A as AdminAuth Extractor + participant U as Users Table + participant H as Admin Handler + participant D as Database + + C->>A: Request + Bearer token + A->>A: Validate claims (API key or service auth JWT) + A->>U: DID lookup + alt Users table empty + U-->>A: Auto-bootstrap as super user + else User found + U-->>A: Load permissions + end + A->>A: Permission check (403 if missing) + A->>H: Authorized request + H->>D: Database operation + D-->>H: Result + H-->>C: JSON response ``` ## Data flow ### Real-time indexing -``` -Jetstream WebSocket connection (jetstream::spawn) - -> Collection filters built from indexed lexicons and applied to subscription URL - -> Reconnects on collection filter changes (lexicon add/remove) - -> Record commit events: - create/update -> UPSERT into records table - delete -> DELETE from records table - -> Lexicon schema events (com.atproto.lexicon.schema): - -> Update tracked network lexicons in DB and registry - -> Cursor persisted to instance_settings for resume on reconnect +```mermaid +sequenceDiagram + participant J as Jetstream WebSocket + participant H as HappyView + participant D as Database + participant R as LexiconRegistry + + H->>J: Connect (collection filters from indexed lexicons) + loop Stream events + J->>H: Record commit event + alt create / update + H->>D: UPSERT into records table + else delete + H->>D: DELETE from records table + end + end + J->>H: Lexicon schema event (com.atproto.lexicon.schema) + H->>D: Update tracked network lexicons + H->>R: Update in-memory registry + Note over H,D: Cursor persisted to instance_settings for resume on reconnect + Note over H,J: Reconnects on collection filter changes (lexicon add/remove) ``` ### Backfill -``` -POST /admin/backfill - -> Create backfill_jobs record (status = running) - -> Relay listReposByCollection -> list of DIDs (paginated) - -> For each DID: resolve PDS via PLC, listRecords from that PDS (paginated) - -> UPSERT each record into records table - -> Update processed_repos / total_records counters - -> Mark job as completed (or failed with error message) +```mermaid +sequenceDiagram + participant A as Admin + participant H as HappyView + participant D as Database + participant Relay as Relay + participant PLC as PLC Directory + participant PDS as User PDS + + A->>H: POST /admin/backfill + H->>D: Create backfill_jobs record (status = running) + H->>Relay: listReposByCollection (paginated) + Relay-->>H: List of DIDs + loop For each DID + H->>PLC: Resolve DID document + PLC-->>H: PDS endpoint + H->>PDS: listRecords (paginated) + PDS-->>H: Records + H->>D: UPSERT each record + H->>D: Update processed_repos / total_records + end + H->>D: Mark job completed (or failed) ``` ## Database schema diff --git a/packages/docs/docs/reference/changelog.md b/packages/docs/docs/reference/changelog.md index 3658abe..2ce9a94 100644 --- a/packages/docs/docs/reference/changelog.md +++ b/packages/docs/docs/reference/changelog.md @@ -22,7 +22,7 @@ ## v1.9.0 — Event Logs -- **Event logging** — system-wide audit trail for lexicon changes, record operations, Lua script executions/errors, admin actions, backfill jobs, and firehose connectivity +- **Event logging** — system-wide audit trail for lexicon changes, record operations, Lua script executions/errors, admin actions, backfill jobs, and Jetstream connectivity - **`GET /admin/events`** — query event logs with filtering by event type, category, severity, and subject, with cursor pagination - **Lua error context** — script errors capture full debugging context: error message, script source, input payload, and caller DID - **Automatic retention cleanup** — configurable via `EVENT_LOG_RETENTION_DAYS` (default 30 days) diff --git a/packages/docs/docs/reference/lua/atproto-api.md b/packages/docs/docs/reference/lua/atproto-api.md new file mode 100644 index 0000000..df77a82 --- /dev/null +++ b/packages/docs/docs/reference/lua/atproto-api.md @@ -0,0 +1,107 @@ +# AT Protocol API + +The `atproto` table provides AT Protocol utility functions. Available in queries, procedures, and [index hooks](../../guides/index-hooks.md). + +## atproto.resolve_service_endpoint + +```lua +local endpoint = atproto.resolve_service_endpoint(did) +``` + +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`). + +| Parameter | Type | Description | +| --------- | ------ | ------------------------ | +| `did` | string | The DID to resolve | + +**Returns:** The service endpoint URL as a string, or `nil` if resolution fails (DID not found, no PDS service in document, network error). + +### Examples + +```lua +-- Resolve a did:plc DID +local endpoint = atproto.resolve_service_endpoint("did:plc:abc123") +-- endpoint = "https://pds.example.com" + +-- Resolve a did:web DID +local endpoint = atproto.resolve_service_endpoint("did:web:example.com") +-- endpoint = "https://example.com" + +-- Handle resolution failure +local endpoint = atproto.resolve_service_endpoint("did:plc:unknown") +if not endpoint then + return { error = "Could not resolve DID" } +end + +-- Use with HTTP API to call a remote XRPC endpoint +local endpoint = atproto.resolve_service_endpoint(did) +if endpoint then + local resp = http.get(endpoint .. "/xrpc/com.example.method") + local data = json.decode(resp.body) +end +``` + +## atproto.get_labels + +```lua +local labels = atproto.get_labels(uri) +``` + +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). + +| Parameter | Type | Description | +| --------- | ------ | ------------------------------ | +| `uri` | string | AT URI of the record to query | + +Each label in the array is a table with: + +| Field | Type | Description | +| ----- | ------ | ---------------------------------------- | +| `src` | string | DID of the labeler (or record author) | +| `uri` | string | AT URI this label applies to | +| `val` | string | Label value (e.g. "nsfw", "!hide") | +| `cts` | string | Timestamp when the label was created | + +Expired labels are automatically filtered out. Returns an empty array if no labels exist. + +## atproto.get_labels_batch + +```lua +local labels_by_uri = atproto.get_labels_batch(uris) +``` + +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. + +| Parameter | Type | Description | +| --------- | ----- | ------------------------ | +| `uris` | table | Array of AT URI strings | + +**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. + +### Label examples + +```lua +-- Get labels for a single game +local labels = atproto.get_labels("at://did:plc:abc/games.gamesgamesgamesgames.game/rkey1") +for _, label in ipairs(labels) do + if label.val == "!hide" then + -- skip this game in feed results + end +end + +-- Batch fetch labels for multiple games (efficient for feed hydration) +local uris = {} +for _, item in ipairs(skeleton) do + uris[#uris + 1] = item.game +end + +local labels_by_uri = atproto.get_labels_batch(uris) +for _, uri in ipairs(uris) do + local labels = labels_by_uri[uri] + for _, label in ipairs(labels) do + if label.val == "!hide" then + -- filter out this game + end + end +end +``` diff --git a/packages/docs/docs/reference/lua/database-api.md b/packages/docs/docs/reference/lua/database-api.md new file mode 100644 index 0000000..5c63ff9 --- /dev/null +++ b/packages/docs/docs/reference/lua/database-api.md @@ -0,0 +1,106 @@ +# Database API + +The `db` table provides access to the database. Available in queries, procedures, and [index hooks](../../guides/index-hooks.md). + +## db.query + +```lua +local result = db.query({ + collection = "xyz.statusphere.status", -- required + did = "did:plc:abc", -- optional: filter by DID + limit = 20, -- optional: max 100, default 20 + cursor = params.cursor, -- optional: opaque cursor from a previous response + sort = "name", -- optional: field to sort by, default "indexed_at" + sortDirection = "asc", -- optional: "asc" or "desc", default "desc" +}) + +-- result.records — array of record tables (each includes a "uri" field) +-- result.cursor — present when more records exist (opaque string, pass back as-is) +``` + +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. + +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. + +## db.get + +```lua +local record = db.get("at://did:plc:abc/xyz.statusphere.status/abc123") +-- Returns the record table or nil +-- The returned table includes a "uri" field +``` + +## db.search + +```lua +local result = db.search({ + collection = "xyz.statusphere.status", -- required + field = "displayName", -- required: record field to search + query = "alice", -- required: search term + limit = 10, -- optional: max 100, default 10 +}) + +-- result.records — array of matching records, ranked by relevance: +-- exact match > prefix match > contains match, then alphabetical +``` + +## db.backlinks + +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. + +```lua +local result = db.backlinks({ + collection = "xyz.statusphere.status", -- required + uri = "at://did:plc:abc/xyz.statusphere.status/foo", -- required: the URI to find references to + did = "did:plc:abc", -- optional: filter by DID + limit = 20, -- optional: max 100, default 20 + cursor = params.cursor, -- optional: opaque cursor from a previous response +}) + +-- result.records — array of records whose data contains the given URI +-- result.cursor — present when more records exist (opaque string, pass back as-is) +``` + +The search checks the full record data, so it works regardless of which field holds the reference (`subject`, `parent`, `reply.root`, etc.). + +## db.count + +```lua +local n = db.count("xyz.statusphere.status") +local n = db.count("xyz.statusphere.status", "did:plc:abc") -- filter by DID +``` + +## db.raw + +Run a raw SQL query against the database. Supports `SELECT`, `INSERT`, `UPDATE`, `DELETE`, and `CREATE TABLE` statements. + +```lua +-- Read query +local rows = db.raw( + "SELECT uri, did, record FROM records WHERE collection = $1 AND did = $2 LIMIT $3", + { "xyz.statusphere.status", "did:plc:abc", 10 } +) + +for _, row in ipairs(rows) do + -- row.uri, row.did, row.record (JSONB is returned as a Lua table) +end + +-- Write query (returns affected rows, if any) +db.raw("CREATE TABLE IF NOT EXISTS my_table (id TEXT PRIMARY KEY, value TEXT NOT NULL)") +db.raw("INSERT INTO my_table (id, value) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET value = $2", + { "key1", "hello" }) +``` + +Parameters are passed as an array and bound to `$1`, `$2`, etc. Supported parameter types: strings, integers, numbers, booleans, and nil. + +### Column type mapping + +| Postgres type | Lua type | +| ---------------------- | -------- | +| `TEXT`, `VARCHAR` | string | +| `INT4`, `INT8` | integer | +| `FLOAT4`, `FLOAT8` | number | +| `BOOL` | boolean | +| `JSON`, `JSONB` | table | +| `TIMESTAMPTZ` | string (ISO 8601) | +| Other | string (fallback) | diff --git a/packages/docs/docs/reference/lua/http-api.md b/packages/docs/docs/reference/lua/http-api.md new file mode 100644 index 0000000..e1ef264 --- /dev/null +++ b/packages/docs/docs/reference/lua/http-api.md @@ -0,0 +1,60 @@ +# HTTP API + +The `http` table provides async HTTP client functions. Available in queries, procedures, and [index hooks](../../guides/index-hooks.md). + +## Methods + +All methods take a URL and an optional options table, and return a [response table](#response). + +```lua +http.get(url, opts?) +http.post(url, opts?) +http.put(url, opts?) +http.patch(url, opts?) +http.delete(url, opts?) +http.head(url, opts?) +``` + +## Options + +The optional second argument is a table with: + +| Field | Type | Description | +| --------- | ------ | ---------------------------------------------- | +| `headers` | table | Request headers as key-value string pairs | +| `body` | string | Request body (ignored for GET and HEAD) | + +## Response + +Every method returns a table with: + +| Field | Type | Description | +| --------- | ------- | ---------------------------------------------------- | +| `status` | integer | HTTP status code | +| `body` | string | Response body text (empty string for HEAD) | +| `headers` | table | Response headers as key-value pairs (lowercase keys) | + +## Examples + +```lua +-- Simple GET +local resp = http.get("https://api.example.com/data") +-- resp.status = 200, resp.body = "...", resp.headers["content-type"] = "application/json" + +-- GET with custom headers +local resp = http.get("https://api.example.com/data", { + headers = { ["authorization"] = "Bearer token123" } +}) + +-- POST with JSON body +local resp = http.post("https://api.example.com/hook", { + body = '{"key": "value"}', + headers = { ["content-type"] = "application/json" } +}) + +-- PUT, PATCH, DELETE, HEAD follow the same pattern +local resp = http.put(url, { body = data, headers = { ... } }) +local resp = http.patch(url, { body = data, headers = { ... } }) +local resp = http.delete(url, { headers = { ... } }) +local resp = http.head(url) +``` diff --git a/packages/docs/docs/reference/lua/json-api.md b/packages/docs/docs/reference/lua/json-api.md new file mode 100644 index 0000000..ce46d42 --- /dev/null +++ b/packages/docs/docs/reference/lua/json-api.md @@ -0,0 +1,21 @@ +# JSON API + +The `json` global provides JSON serialization and deserialization. Available in queries, procedures, and [index hooks](../../guides/index-hooks.md). + +## json.encode + +```lua +local str = json.encode({ key = "value", items = { 1, 2, 3 } }) +-- '{"key":"value","items":[1,2,3]}' +``` + +Converts a Lua table to a JSON string. + +## json.decode + +```lua +local tbl = json.decode('{"key": "value"}') +-- tbl.key == "value" +``` + +Parses a JSON string into a Lua table. Returns an error if the input is not valid JSON. diff --git a/packages/docs/docs/reference/lua/record-api.md b/packages/docs/docs/reference/lua/record-api.md new file mode 100644 index 0000000..3a348ae --- /dev/null +++ b/packages/docs/docs/reference/lua/record-api.md @@ -0,0 +1,84 @@ +# Record API + +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. + +## Constructor + +```lua +local r = Record("xyz.statusphere.status", { status = "\ud83d\ude0a", createdAt = now() }) +``` + +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. + +## Static methods + +```lua +-- Save multiple records in parallel +Record.save_all({ record1, record2, record3 }) + +-- Load a record from the local database by AT URI +local r = Record.load("at://did:plc:abc/xyz.statusphere.status/abc123") +-- Returns nil if not found + +-- Load multiple records in parallel +local records = Record.load_all({ uri1, uri2 }) +-- Returns nil entries for URIs not found +``` + +## Instance methods + +```lua +-- Save (creates or updates depending on whether _uri is set) +r:save() + +-- Delete from PDS and local database +r:delete() + +-- Set the record key type (tid, any, nsid, or literal:*) +r:set_key_type("tid") + +-- Set a specific record key +r:set_rkey("my-key") + +-- Auto-generate a record key based on _key_type +local key = r:generate_rkey() +``` + +**Key type behavior for `generate_rkey()`:** + +| Key type | Generated rkey | +| --------------- | --------------------------------- | +| `tid` | Sortable timestamp-based ID | +| `any` | Same as `tid` | +| `literal:value` | The literal value after the colon | +| `nsid` | Error — use `set_rkey()` instead | + +## Instance fields + +These fields are set automatically and are read-only (writes raise an error): + +| Field | Type | Description | +| ------------- | ------- | ----------------------------------------------------------- | +| `_uri` | string? | AT URI — set after `save()`, cleared after `delete()` | +| `_cid` | string? | Content hash — set after `save()`, cleared after `delete()` | +| `_key_type` | string? | Record key type from the lexicon definition | +| `_rkey` | string? | Record key — set via `set_rkey()` or `generate_rkey()` | +| `_collection` | string | Collection NSID (always set) | +| `_schema` | table? | Schema definition from the lexicon (used for validation) | + +## Schema validation + +When a record has a schema (loaded from the lexicon): + +- **On save:** required fields are checked, and missing required fields raise an error +- **On construction:** default values from schema properties are auto-populated +- **On save:** only fields defined in the schema's `properties` are sent to the PDS + +## Save behavior + +`r:save()` auto-detects create vs update: + +- If `_uri` is nil → calls `createRecord` on the PDS +- If `_uri` is set → calls `putRecord` on the PDS + +After a successful save, `_uri` and `_cid` are updated on the record instance. diff --git a/packages/docs/docs/reference/lua/standard-libraries.md b/packages/docs/docs/reference/lua/standard-libraries.md new file mode 100644 index 0000000..c5a4d24 --- /dev/null +++ b/packages/docs/docs/reference/lua/standard-libraries.md @@ -0,0 +1,77 @@ +# Standard Libraries + +The following Lua 5.4 standard library modules and builtins are available in the HappyView sandbox. + +## string + +- [`byte`](https://lua.org/manual/5.4/manual.html#pdf-string.byte) +- [`char`](https://lua.org/manual/5.4/manual.html#pdf-string.char) +- [`find`](https://lua.org/manual/5.4/manual.html#pdf-string.find) +- [`format`](https://lua.org/manual/5.4/manual.html#pdf-string.format) +- [`gmatch`](https://lua.org/manual/5.4/manual.html#pdf-string.gmatch) +- [`gsub`](https://lua.org/manual/5.4/manual.html#pdf-string.gsub) +- [`len`](https://lua.org/manual/5.4/manual.html#pdf-string.len) +- [`lower`](https://lua.org/manual/5.4/manual.html#pdf-string.lower) +- [`match`](https://lua.org/manual/5.4/manual.html#pdf-string.match) +- [`rep`](https://lua.org/manual/5.4/manual.html#pdf-string.rep) +- [`reverse`](https://lua.org/manual/5.4/manual.html#pdf-string.reverse) +- [`sub`](https://lua.org/manual/5.4/manual.html#pdf-string.sub) +- [`upper`](https://lua.org/manual/5.4/manual.html#pdf-string.upper) + +## table + +- [`concat`](https://lua.org/manual/5.4/manual.html#pdf-table.concat) +- [`insert`](https://lua.org/manual/5.4/manual.html#pdf-table.insert) +- [`remove`](https://lua.org/manual/5.4/manual.html#pdf-table.remove) +- [`sort`](https://lua.org/manual/5.4/manual.html#pdf-table.sort) +- [`unpack`](https://lua.org/manual/5.4/manual.html#pdf-table.unpack) + +## math + +- [`abs`](https://lua.org/manual/5.4/manual.html#pdf-math.abs) +- [`ceil`](https://lua.org/manual/5.4/manual.html#pdf-math.ceil) +- [`floor`](https://lua.org/manual/5.4/manual.html#pdf-math.floor) +- [`max`](https://lua.org/manual/5.4/manual.html#pdf-math.max) +- [`min`](https://lua.org/manual/5.4/manual.html#pdf-math.min) +- [`random`](https://lua.org/manual/5.4/manual.html#pdf-math.random) +- [`sqrt`](https://lua.org/manual/5.4/manual.html#pdf-math.sqrt) +- [`huge`](https://lua.org/manual/5.4/manual.html#pdf-math.huge) +- [`pi`](https://lua.org/manual/5.4/manual.html#pdf-math.pi) + +## os (safe subset) + +Only the following safe functions are available from the `os` module: + +- [`time`](https://lua.org/manual/5.4/manual.html#pdf-os.time) +- [`date`](https://lua.org/manual/5.4/manual.html#pdf-os.date) +- [`difftime`](https://lua.org/manual/5.4/manual.html#pdf-os.difftime) +- [`clock`](https://lua.org/manual/5.4/manual.html#pdf-os.clock) + +Dangerous functions like `os.execute`, `os.remove`, `os.rename`, and `os.exit` are not available. + +## Builtins + +- [`print`](https://lua.org/manual/5.4/manual.html#pdf-print) +- [`tostring`](https://lua.org/manual/5.4/manual.html#pdf-tostring) +- [`tonumber`](https://lua.org/manual/5.4/manual.html#pdf-tonumber) +- [`type`](https://lua.org/manual/5.4/manual.html#pdf-type) +- [`pairs`](https://lua.org/manual/5.4/manual.html#pdf-pairs) +- [`ipairs`](https://lua.org/manual/5.4/manual.html#pdf-ipairs) +- [`next`](https://lua.org/manual/5.4/manual.html#pdf-next) +- [`select`](https://lua.org/manual/5.4/manual.html#pdf-select) +- [`unpack`](https://lua.org/manual/5.4/manual.html#pdf-table.unpack) +- [`error`](https://lua.org/manual/5.4/manual.html#pdf-error) +- [`pcall`](https://lua.org/manual/5.4/manual.html#pdf-pcall) +- [`xpcall`](https://lua.org/manual/5.4/manual.html#pdf-xpcall) +- [`assert`](https://lua.org/manual/5.4/manual.html#pdf-assert) +- [`setmetatable`](https://lua.org/manual/5.4/manual.html#pdf-setmetatable) +- [`getmetatable`](https://lua.org/manual/5.4/manual.html#pdf-getmetatable) +- [`rawget`](https://lua.org/manual/5.4/manual.html#pdf-rawget) +- [`rawset`](https://lua.org/manual/5.4/manual.html#pdf-rawset) +- [`rawequal`](https://lua.org/manual/5.4/manual.html#pdf-rawequal) + +## Removed modules + +The following standard Lua modules are **removed** and unavailable in the sandbox: + +`io`, `debug`, `package`, `require`, `dofile`, `loadfile`, `load`, `collectgarbage` diff --git a/packages/docs/docs/reference/production-deployment.md b/packages/docs/docs/reference/production-deployment.md index 7235d56..a72ed64 100644 --- a/packages/docs/docs/reference/production-deployment.md +++ b/packages/docs/docs/reference/production-deployment.md @@ -4,7 +4,7 @@ This page covers what to change when taking a HappyView instance from local deve ## Session secret -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. +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. ```sh openssl rand -base64 48 @@ -46,7 +46,7 @@ See the [database setup guide](../guides/database-setup.md) for configuration de HappyView 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. -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)). +Per-client overrides are set at client creation or via `PUT /admin/api-clients/{id}` (see [Admin API — API Clients](admin/api-clients.md)). ## Logging diff --git a/packages/docs/docs/reference/scripts/batch-save.md b/packages/docs/docs/reference/scripts/batch-save.md index 5989529..0ed2458 100644 --- a/packages/docs/docs/reference/scripts/batch-save.md +++ b/packages/docs/docs/reference/scripts/batch-save.md @@ -23,8 +23,8 @@ end ## How it works -1. Iterate over `input.items` and create a [`Record`](../../guides/scripting.md#record-api) instance for each item. -2. Call [`Record.save_all()`](../../guides/scripting.md#static-methods) to save all records in parallel, rather than one at a time. +1. Iterate over `input.items` and create a [`Record`](../lua/record-api.md) instance for each item. +2. Call [`Record.save_all()`](../lua/record-api.md#static-methods) to save all records in parallel, rather than one at a time. 3. Collect the resulting AT URIs and return them. ## Usage diff --git a/packages/docs/docs/reference/scripts/cascading-delete.md b/packages/docs/docs/reference/scripts/cascading-delete.md index c790944..0cc25e1 100644 --- a/packages/docs/docs/reference/scripts/cascading-delete.md +++ b/packages/docs/docs/reference/scripts/cascading-delete.md @@ -49,7 +49,7 @@ end 1. Load the primary record by URI. Return early if it doesn't exist. 2. Query for related records, in this example comments by the same user that reference the primary record's URI. -3. Load each related record with [`Record.load`](../../guides/scripting.md#static-methods) to get a deletable `Record` instance. +3. Load each related record with [`Record.load`](../lua/record-api.md#static-methods) to get a deletable `Record` instance. 4. Delete everything. Each `r:delete()` removes the record from the user's PDS and the local index. ## Usage diff --git a/packages/docs/docs/reference/scripts/complex-mutations.md b/packages/docs/docs/reference/scripts/complex-mutations.md index a57be9e..51840e5 100644 --- a/packages/docs/docs/reference/scripts/complex-mutations.md +++ b/packages/docs/docs/reference/scripts/complex-mutations.md @@ -56,7 +56,7 @@ end ## How it works -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. +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. 2. Apply transformations directly on the record's fields: - **Increment a counter**: use `or 0` to handle the field being `nil` on first access. - **Merge tags**: iterate over `input.tags`, skip duplicates already in `r.tags`, append new ones, then trim the list to 10. diff --git a/packages/docs/docs/reference/scripts/create-record.md b/packages/docs/docs/reference/scripts/create-record.md index 158ed6e..c257b19 100644 --- a/packages/docs/docs/reference/scripts/create-record.md +++ b/packages/docs/docs/reference/scripts/create-record.md @@ -14,7 +14,7 @@ end ## How it works -1. Create a new [`Record`](../../guides/scripting.md#record-api) instance from the target collection, populated with the fields from the request body. +1. Create a new [`Record`](../lua/record-api.md) instance from the target collection, populated with the fields from the request body. 2. Call `r:save()`, which creates the record on the caller's PDS and indexes it locally. 3. Return the AT URI and CID of the newly created record. diff --git a/packages/docs/docs/reference/scripts/expanded-query.md b/packages/docs/docs/reference/scripts/expanded-query.md index abfe50b..3234690 100644 --- a/packages/docs/docs/reference/scripts/expanded-query.md +++ b/packages/docs/docs/reference/scripts/expanded-query.md @@ -13,7 +13,7 @@ function handle() collection = "xyz.statusphere.status", did = params.did, limit = limit, - offset = tonumber(params.cursor) or 0, + cursor = params.cursor, }) -- Collect unique DIDs from the statuses @@ -51,7 +51,7 @@ end 1. Query statuses from the target collection with pagination, same as a normal list query. 2. Extract the unique DIDs from the returned status URIs using `string.match`. 3. Build an AT URI for each DID's `app.bsky.actor.profile/self` record (this is where Bluesky profiles live). -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. +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. 5. Return statuses and profiles as separate keys, with the cursor from the status query. ## Usage @@ -59,7 +59,7 @@ end ``` GET /xrpc/xyz.statusphere.listStatusesWithProfiles?limit=10 GET /xrpc/xyz.statusphere.listStatusesWithProfiles?did=did:plc:abc -GET /xrpc/xyz.statusphere.listStatusesWithProfiles?cursor=20&limit=20 +GET /xrpc/xyz.statusphere.listStatusesWithProfiles?cursor=&limit=20 ``` ```json @@ -72,7 +72,7 @@ GET /xrpc/xyz.statusphere.listStatusesWithProfiles?cursor=20&limit=20 { "uri": "at://did:plc:abc/app.bsky.actor.profile/self", "displayName": "Alice", "avatar": "..." }, { "uri": "at://did:plc:def/app.bsky.actor.profile/self", "displayName": "Bob", "avatar": "..." } ], - "cursor": "10" + "cursor": "MjAyNi0wMS0wMVQxMjowMDowMFp8YXQ6Ly9kaWQ6..." } ``` diff --git a/packages/docs/docs/reference/scripts/get-record.md b/packages/docs/docs/reference/scripts/get-record.md index 9476aa0..e42de61 100644 --- a/packages/docs/docs/reference/scripts/get-record.md +++ b/packages/docs/docs/reference/scripts/get-record.md @@ -22,7 +22,7 @@ end ## How it works 1. Check that the `uri` query parameter is present. Return a structured error if missing. -2. Look up the record with [`db.get`](../../guides/scripting.md#dbget), which returns the record table or `nil`. +2. Look up the record with [`db.get`](../lua/database-api.md#dbget), which returns the record table or `nil`. 3. Return the record wrapped in an object. ## Usage diff --git a/packages/docs/docs/reference/scripts/list-or-fetch.md b/packages/docs/docs/reference/scripts/list-or-fetch.md index b2053cb..c2be597 100644 --- a/packages/docs/docs/reference/scripts/list-or-fetch.md +++ b/packages/docs/docs/reference/scripts/list-or-fetch.md @@ -18,15 +18,15 @@ function handle() collection = collection, did = params.did, limit = tonumber(params.limit) or 20, - offset = tonumber(params.cursor) or 0, + cursor = params.cursor, }) end ``` ## How it works -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). -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. +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). +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. ## Usage diff --git a/packages/docs/docs/reference/scripts/paginated-list.md b/packages/docs/docs/reference/scripts/paginated-list.md index 01a67d3..5a54b64 100644 --- a/packages/docs/docs/reference/scripts/paginated-list.md +++ b/packages/docs/docs/reference/scripts/paginated-list.md @@ -13,7 +13,7 @@ function handle() collection = collection, did = params.did, limit = limit, - offset = tonumber(params.cursor) or 0, + cursor = params.cursor, }) return result @@ -23,8 +23,8 @@ end ## How it works 1. Parse `limit` from the query string, defaulting to 20 and capping at 100. -2. Call [`db.query`](../../guides/scripting.md#dbquery) with the target collection, optional DID filter, and offset-based pagination. -3. Return the result directly. `db.query` returns `{ records = [...], cursor = "..." }` where `cursor` is present when more records exist. +2. Call [`db.query`](../lua/database-api.md#dbquery) with the target collection, optional DID filter, and cursor for pagination. +3. Return the result directly. `db.query` returns `{ records = [...], cursor = "..." }` where `cursor` is an opaque string present when more records exist. ## Usage @@ -32,9 +32,9 @@ end GET /xrpc/xyz.statusphere.listStatuses GET /xrpc/xyz.statusphere.listStatuses?limit=50 GET /xrpc/xyz.statusphere.listStatuses?did=did:plc:abc&limit=10 -GET /xrpc/xyz.statusphere.listStatuses?cursor=20&limit=20 +GET /xrpc/xyz.statusphere.listStatuses?cursor=&limit=20 ``` ## Use case -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. +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. diff --git a/packages/docs/docs/reference/scripts/sidecar-records.md b/packages/docs/docs/reference/scripts/sidecar-records.md index 337b55c..4a0e6ac 100644 --- a/packages/docs/docs/reference/scripts/sidecar-records.md +++ b/packages/docs/docs/reference/scripts/sidecar-records.md @@ -34,7 +34,7 @@ end 1. Generate a single [`TID()`](../../guides/scripting.md#utility-globals) to use as the rkey for both records. 2. Create a `Record` for each collection and call `r:set_rkey()` with the shared rkey. -3. Save both records in parallel with [`Record.save_all()`](../../guides/scripting.md#static-methods). +3. Save both records in parallel with [`Record.save_all()`](../lua/record-api.md#static-methods). 4. Return both URIs so the client knows the identity of each record. ## Usage diff --git a/packages/docs/docs/reference/scripts/update-or-delete.md b/packages/docs/docs/reference/scripts/update-or-delete.md index fb83f8f..25a9217 100644 --- a/packages/docs/docs/reference/scripts/update-or-delete.md +++ b/packages/docs/docs/reference/scripts/update-or-delete.md @@ -30,8 +30,8 @@ end ## How it works -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. -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`. +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. +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`. 3. If neither condition matches, create a new record from the input. ## Usage diff --git a/packages/docs/docs/reference/scripts/upsert-record.md b/packages/docs/docs/reference/scripts/upsert-record.md index 2164df2..ce5cb9c 100644 --- a/packages/docs/docs/reference/scripts/upsert-record.md +++ b/packages/docs/docs/reference/scripts/upsert-record.md @@ -33,7 +33,7 @@ end ## How it works 1. 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. -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). +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). 3. If the record exists, update its fields and save. Since `_uri` is already set, `r:save()` calls `putRecord`. 4. 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. diff --git a/packages/docs/docs/reference/troubleshooting.md b/packages/docs/docs/reference/troubleshooting.md index 73576af..4d767ec 100644 --- a/packages/docs/docs/reference/troubleshooting.md +++ b/packages/docs/docs/reference/troubleshooting.md @@ -28,9 +28,9 @@ Common issues and how to resolve them. **Causes**: -- No session cookie or `Authorization: Bearer` header is present. -- The session cookie has expired or was signed with a different `SESSION_SECRET`. -- The API key has been revoked or is invalid. +- No `Authorization: DPoP` header or `X-Client-Key` header is present. +- The DPoP proof is invalid or expired. +- The API client key is not registered or is inactive. ## Admin endpoints return 403 Forbidden @@ -86,15 +86,45 @@ See [Backfill](../guides/backfill.md) for how the process works. - No record-type lexicon exists for the collection. HappyView only indexes collections that have a corresponding record-type lexicon. - 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. +## Lua script can't find records + +**Symptom**: `db.query` or `db.get` returns empty results inside a Lua script, even though the admin dashboard shows records exist. + +**Causes**: + +- 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. +- `db.get` expects a full AT URI (`at://did:plc:abc/collection/rkey`), not just an rkey. +- If querying by DID, make sure you're passing the full DID string including the `did:plc:` or `did:web:` prefix. + +## Plugin secrets not working + +**Symptom**: A plugin fails with authentication errors even though you've configured its secrets. + +**Causes**: + +- `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). +- 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`. +- Environment variable secrets (`PLUGIN__`) are overridden by dashboard-configured secrets. If you've set both, the dashboard values take precedence. + ## OAuth or login issues HappyView handles AT Protocol OAuth internally via the `atrium-oauth` library. If users can't log in: 1. Verify `PUBLIC_URL` is set correctly and the URL is publicly accessible (required for OAuth callbacks). 2. Check that the user's PDS authorization server is reachable. -3. Verify `SESSION_SECRET` hasn't changed since sessions were created (changing it invalidates all existing session cookies). +3. Verify `SESSION_SECRET` hasn't changed since sessions were created (changing it invalidates all existing dashboard sessions). 4. Check server logs for OAuth-specific error messages. +## Third-party app can't authenticate + +**Symptom**: A third-party app using DPoP authentication gets 401 errors on XRPC endpoints. + +**Causes**: + +- 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`. +- The DPoP proof is malformed or expired. Proofs include a timestamp and are valid for a short window. +- The API client has been deactivated (`is_active: false`). Re-enable it via the dashboard or `PUT /admin/api-clients/{id}`. + ## Database connection errors **Symptom**: HappyView fails to start or returns 500 errors. @@ -106,3 +136,21 @@ HappyView handles AT Protocol OAuth internally via the `atrium-oauth` library. I - Postgres version is too old. HappyView requires Postgres 17+. See [Configuration](../getting-started/configuration.md) for environment variable details. + +## Switching databases loses data + +**Symptom**: After changing `DATABASE_URL` from SQLite to Postgres (or vice versa), all records, lexicons, and users are gone. + +**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. + +**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. + +## Jetstream disconnects frequently + +**Symptom**: Server logs show repeated `jetstream.disconnected` / `jetstream.connected` events. + +**Causes**: + +- Network instability between HappyView and the Jetstream server. Verify `JETSTREAM_URL` is reachable. +- The default Jetstream instance may be under heavy load. Consider pointing `JETSTREAM_URL` at a different instance if available. +- 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. diff --git a/packages/docs/docs/reference/xrpc-api.md b/packages/docs/docs/reference/xrpc-api.md index 11d91ec..e09547d 100644 --- a/packages/docs/docs/reference/xrpc-api.md +++ b/packages/docs/docs/reference/xrpc-api.md @@ -7,7 +7,7 @@ If a query or procedure lexicon has a [Lua script](../guides/scripting.md) attac ## Auth - **Queries** (`GET /xrpc/{method}`): unauthenticated -- **Procedures** (`POST /xrpc/{method}`): require authentication (session cookie, API key, or service auth JWT) +- **Procedures** (`POST /xrpc/{method}`): require DPoP authentication (`Authorization: DPoP` + `DPoP` proof header + `X-Client-Key`) - **getProfile**: requires auth - **uploadBlob**: requires auth @@ -101,13 +101,13 @@ Media blobs are automatically enriched with a `url` field pointing to the user's ### List records ``` -GET /xrpc/{method}?limit=20&cursor=0&did=optional +GET /xrpc/{method}?limit=20&cursor=&did=optional ``` | Param | Type | Default | Description | |-------|------|---------|-------------| | `limit` | integer | 20 | Max records to return (max 100) | -| `cursor` | string | `0` | Pagination cursor (opaque, pass from previous response) | +| `cursor` | string | --- | Opaque pagination cursor from a previous response | | `did` | string | --- | Filter records by DID | ```sh @@ -125,11 +125,11 @@ curl "http://localhost:3000/xrpc/xyz.statusphere.listStatuses?limit=10&did=did:p "createdAt": "2025-01-01T12:00:00Z" } ], - "cursor": "10" + "cursor": "MjAyNS0wMS0wMVQxMjowMDowMFp8YXQ6Ly9kaWQ6..." } ``` -The `cursor` field is present only when more records exist. +The `cursor` field is an opaque string present only when more records exist. Pass it back as-is to fetch the next page. ## Dynamic procedure endpoints @@ -184,7 +184,7 @@ All error responses return JSON with an `error` field: | Status | Meaning | Common causes | |--------|---------|---------------| | `400 Bad Request` | Invalid input | Missing required fields, malformed JSON, invalid AT URI | -| `401 Unauthorized` | Authentication failed | Missing or invalid session cookie, API key, or service auth JWT | +| `401 Unauthorized` | Authentication failed | Missing or invalid client identification or DPoP authentication | | `404 Not Found` | Method or record not found | XRPC method has no matching lexicon, or the requested record doesn't exist | | `500 Internal Server Error` | Server-side failure | Lua script error, database error, or upstream PDS failure | diff --git a/packages/docs/docs/tutorials/statusphere.md b/packages/docs/docs/tutorials/statusphere.md index 9a34768..281bdf2 100644 --- a/packages/docs/docs/tutorials/statusphere.md +++ b/packages/docs/docs/tutorials/statusphere.md @@ -125,7 +125,7 @@ curl "http://localhost:3000/xrpc/xyz.statusphere.listStatuses?limit=5" "createdAt": "2025-01-01T11:30:00Z" } ], - "cursor": "5" + "cursor": "MjAyNS0wMS0wMVQxMjowMDowMFp8YXQ6Ly9kaWQ6..." } ``` @@ -149,7 +149,7 @@ function handle() collection = collection, did = params.did, limit = tonumber(params.limit) or 20, - offset = tonumber(params.cursor) or 0, + cursor = params.cursor, }) end ``` @@ -190,7 +190,7 @@ SCRIPT='function handle() collection = collection, did = params.did, limit = tonumber(params.limit) or 20, - offset = tonumber(params.cursor) or 0, + cursor = params.cursor, }) end' diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts index cf59a17..bab5d06 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -191,6 +191,11 @@ const sidebars: SidebarsConfig = { id: "guides/plugins", label: "Plugins", }, + { + type: "doc", + id: "guides/developing-plugins", + label: "Developing Plugins", + }, ], }, { @@ -228,6 +233,11 @@ const sidebars: SidebarsConfig = { id: "guides/postgres-to-sqlite-migration", label: "Postgres → SQLite Migration", }, + { + type: "doc", + id: "guides/sqlite-to-postgres-migration", + label: "SQLite → Postgres Migration", + }, ], }, ], @@ -268,9 +278,111 @@ const sidebars: SidebarsConfig = { label: "XRPC API", }, { - type: "doc", - id: "reference/admin-api", + type: "category", label: "Admin API", + items: [ + { + type: "doc", + id: "reference/admin-api", + label: "Overview", + }, + { + type: "doc", + id: "reference/admin/lexicons", + label: "Lexicons", + }, + { + type: "doc", + id: "reference/admin/stats", + label: "Stats", + }, + { + type: "doc", + id: "reference/admin/backfill", + label: "Backfill", + }, + { + type: "doc", + id: "reference/admin/events", + label: "Event Logs", + }, + { + type: "doc", + id: "reference/admin/api-keys", + label: "API Keys", + }, + { + type: "doc", + id: "reference/admin/users", + label: "Users", + }, + { + type: "doc", + id: "reference/admin/labelers", + label: "Labelers", + }, + { + type: "doc", + id: "reference/admin/settings", + label: "Instance Settings", + }, + { + type: "doc", + id: "reference/admin/domains", + label: "Domains", + }, + { + type: "doc", + id: "reference/admin/script-variables", + label: "Script Variables", + }, + { + type: "doc", + id: "reference/admin/api-clients", + label: "API Clients", + }, + { + type: "doc", + id: "reference/admin/plugins", + label: "Plugins", + }, + ], + }, + { + type: "category", + label: "Lua API", + items: [ + { + type: "doc", + id: "reference/lua/record-api", + label: "Record API", + }, + { + type: "doc", + id: "reference/lua/database-api", + label: "Database API", + }, + { + type: "doc", + id: "reference/lua/http-api", + label: "HTTP API", + }, + { + type: "doc", + id: "reference/lua/atproto-api", + label: "AT Protocol API", + }, + { + type: "doc", + id: "reference/lua/json-api", + label: "JSON API", + }, + { + type: "doc", + id: "reference/lua/standard-libraries", + label: "Standard Libraries", + }, + ], }, { type: "doc", -- 2.51.2