diff --git a/migrations/postgres/20260626000000_prefix_table_names.sql b/migrations/postgres/20260626000000_prefix_table_names.sql index 94b9caa..27220f1 100644 --- a/migrations/postgres/20260626000000_prefix_table_names.sql +++ b/migrations/postgres/20260626000000_prefix_table_names.sql @@ -18,16 +18,12 @@ ALTER TABLE IF EXISTS instance_settings RENAME TO happyview_instance_settings; ALTER TABLE IF EXISTS labeler_subscriptions RENAME TO happyview_labeler_subscriptions; ALTER TABLE IF EXISTS labels RENAME TO happyview_labels; ALTER TABLE IF EXISTS lexicons RENAME TO happyview_lexicons; -ALTER TABLE IF EXISTS network_lexicons RENAME TO happyview_network_lexicons; ALTER TABLE IF EXISTS oauth_sessions RENAME TO happyview_oauth_sessions; ALTER TABLE IF EXISTS oauth_state RENAME TO happyview_oauth_state; ALTER TABLE IF EXISTS plugin_configs RENAME TO happyview_plugin_configs; ALTER TABLE IF EXISTS plugin_dedup_keys RENAME TO happyview_plugin_dedup_keys; ALTER TABLE IF EXISTS plugin_kv RENAME TO happyview_plugin_kv; ALTER TABLE IF EXISTS plugins RENAME TO happyview_plugins; -ALTER TABLE IF EXISTS rate_limit_allowlist RENAME TO happyview_rate_limit_allowlist; -ALTER TABLE IF EXISTS rate_limit_settings RENAME TO happyview_rate_limit_settings; -ALTER TABLE IF EXISTS rate_limits RENAME TO happyview_rate_limits; ALTER TABLE IF EXISTS record_refs RENAME TO happyview_record_refs; ALTER TABLE IF EXISTS records RENAME TO happyview_records; ALTER TABLE IF EXISTS script_variables RENAME TO happyview_script_variables; diff --git a/migrations/sqlite/20260626000000_prefix_table_names.sql b/migrations/sqlite/20260626000000_prefix_table_names.sql index 7a6778a..5d38a66 100644 --- a/migrations/sqlite/20260626000000_prefix_table_names.sql +++ b/migrations/sqlite/20260626000000_prefix_table_names.sql @@ -18,16 +18,12 @@ ALTER TABLE instance_settings RENAME TO happyview_instance_settings; ALTER TABLE labeler_subscriptions RENAME TO happyview_labeler_subscriptions; ALTER TABLE labels RENAME TO happyview_labels; ALTER TABLE lexicons RENAME TO happyview_lexicons; -ALTER TABLE network_lexicons RENAME TO happyview_network_lexicons; ALTER TABLE oauth_sessions RENAME TO happyview_oauth_sessions; ALTER TABLE oauth_state RENAME TO happyview_oauth_state; ALTER TABLE plugin_configs RENAME TO happyview_plugin_configs; ALTER TABLE plugin_dedup_keys RENAME TO happyview_plugin_dedup_keys; ALTER TABLE plugin_kv RENAME TO happyview_plugin_kv; ALTER TABLE plugins RENAME TO happyview_plugins; -ALTER TABLE rate_limit_allowlist RENAME TO happyview_rate_limit_allowlist; -ALTER TABLE rate_limit_settings RENAME TO happyview_rate_limit_settings; -ALTER TABLE rate_limits RENAME TO happyview_rate_limits; ALTER TABLE record_refs RENAME TO happyview_record_refs; ALTER TABLE records RENAME TO happyview_records; ALTER TABLE script_variables RENAME TO happyview_script_variables; -- 2.51.2 From 76b754ca85a2abe26554747a03f89de78c7bc302 Mon Sep 17 00:00:00 2001 From: Trezy Date: Tue, 30 Jun 2026 17:17:30 -0500 Subject: [PATCH 2/5] docs: fix date and formatting in 2.10 blog Signed-off-by: Trezy --- packages/docs/content/blog/happyview-2.10.md | 40 ++++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/docs/content/blog/happyview-2.10.md b/packages/docs/content/blog/happyview-2.10.md index 35f1025..2e4a3e3 100644 --- a/packages/docs/content/blog/happyview-2.10.md +++ b/packages/docs/content/blog/happyview-2.10.md @@ -1,7 +1,7 @@ --- title: "HappyView v2.10" description: "Service identity, permissioned spaces, and new blob utilities." -date: 2026-06-27 +date: 2026-06-30 author: name: "Trezy" avatar: "/authors/trezy.webp" @@ -13,15 +13,15 @@ This one's been a long time coming. HappyView finally has a real AT Protocol ide ## Service identity -When a user's PDS routes a request to your AppView, it resolves the destination by looking up your DID — without a service identity, that lookup fails and standard atproto routing can't reach you. +When a user's PDS routes a request to your AppView, it resolves the destination by looking up your DID. Without a service identity, that lookup fails and standard atproto routing can't reach you. -There are three modes: +HappyView offers three modes: - **Domain identity (did:web)** - Your domain name becomes your identity. HappyView generates a signing keypair and serves a DID document at `/.well-known/did.json` automatically. The simplest option. - **Network identity (did:plc)** - Registers a new identity in the PLC directory. This is the most durable option — it survives domain changes if you ever need to migrate. - **Linked account** - Link your AppView to an existing AT Protocol account. -With a service identity in place, HappyView can act as a service proxy. A PDS sends a request with an `atproto-proxy` header pointing at your AppView, HappyView verifies the caller via service auth, runs your XRPC handler, and responds. This is how atproto apps are _supposed_ to work — until now HappyView only supported direct connections via DPoP. +With a service identity in place, HappyView can act as a service proxy. A PDS sends a request with an `atproto-proxy` header pointing at your AppView, HappyView verifies the caller via service auth, runs your XRPC handler, and responds. This is how atproto apps are _supposed_ to work! Up to this point HappyView only supported direct connections via DPoP. Full docs: [Service Identity](/docs/getting-started/service-identity). @@ -33,8 +33,8 @@ This is the big one. The spaces implementation now aligns with [Dan's proposal]( Endpoints moved from `dev.happyview.space.*` to two namespaces: -- **`com.atproto.space.*`** — protocol-level routes (queries, data access, credentials) -- **`com.atproto.simplespace.*`** — management routes (create/update/delete spaces, membership) +- **`com.atproto.space.*`** - protocol-level routes (queries, data access, credentials) +- **`com.atproto.simplespace.*`** - management routes (create/update/delete spaces, membership) The old `dev.happyview.space.*` endpoints will work as aliases until HappyView v3. @@ -44,24 +44,24 @@ The old `accessMode` / `appAllowlist` / `appDenylist` system is gone. **Mint policy** controls who can create permissioned repos in a space: -- `member-list` (default) — only members -- `public` — anyone -- `managing-app` — only the managing app +- `member-list` (default) - only members +- `public` - anyone +- `managing-app` - only the managing app **App access** controls which third-party apps can interact with the space: -- `open` (default) — any app -- `allowList` — only explicitly listed apps +- `open` (default) - any app +- `allowList` - only explicitly listed apps Also, `getMemberGrant` is now `getDelegationToken` (and it's a `GET`, not a `POST`). ### New concepts - **Authority DID** replaces `owner_did`. There's also a new `creator_did` for tracking who originally created the space -- **`read_self` access level** — members can only read their own data within the space -- **Deniable commit signatures** — per-user repo state uses LtHash (homomorphic set-hash) with deniable signatures. The user signs context (space + rev + random input keying material), not content -- **Record operation log** — `listRepoOps` returns the oplog for sync -- **Write notifications** — `registerNotify`, `notifyWrite`, `notifySpaceDeleted` +- **`read_self` access level** - members can only read their own data within the space +- **Deniable commit signatures** - per-user repo state uses LtHash (homomorphic set-hash) with deniable signatures. The user signs context (space + rev + random input keying material), not content +- **Record operation log** - `listRepoOps` returns the oplog for sync +- **Write notifications** - `registerNotify`, `notifyWrite`, `notifySpaceDeleted` Full docs: [Permissioned Spaces](/docs/experimental/spaces/). @@ -75,19 +75,19 @@ local uploaded = atproto.blob_upload(downloaded.handle, downloaded.mimeType) local new_blob_ref = uploaded.blob ``` -Full docs: [atproto API — `blob_download` / `blob_upload`](/docs/api-reference/lua/atproto-api#atprotoblob_download). +Full docs: [atproto API (`blob_download` / `blob_upload`)](/docs/api-reference/lua/atproto-api#atprotoblob_download). ## Prefixed database tables -All HappyView tables are now prefixed with `happyview_` (e.g. `records` → `happyview_records`) so they won't collide with your own tables if you're sharing a database. Existing databases are migrated automatically. +All HappyView tables are now prefixed with `happyview_` (e.g. `records` -> `happyview_records`) so they won't collide with your own tables if you're sharing a database. Existing databases are migrated automatically. If you use `db.raw()` in Lua scripts to query HappyView tables directly, you'll need to update your queries to use the prefixed names. ## Everything else -- **Setup wizard hardening** — the setup flow handles edge cases better, especially around re-auth and preventing unauthenticated redirects -- **Dynamic cookie security** — cookies now set their security flags based on the request context, which fixes some issues with service proxying behind a reverse proxy -- **Bluesky PDS scope handling** — fixed a compat issue with the scope format Bluesky's PDS returns during OAuth +- **Setup wizard hardening** - the setup flow handles edge cases better, especially around re-auth and preventing unauthenticated redirects +- **Dynamic cookie security** - cookies now set their security flags based on the request context, which fixes some issues with service proxying behind a reverse proxy +- **Bluesky PDS scope handling** - fixed a compat issue with the scope format Bluesky's PDS returns during OAuth ## Go play -- 2.51.2 From d18f67cedf94f848b4c9c5755e90d8b2e71761da Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 1 Jul 2026 09:12:30 -0500 Subject: [PATCH 3/5] docs: fix missing table prefixes Signed-off-by: Trezy --- .../docs/content/docs/api-reference/admin/admin-api.md | 6 +++--- .../docs/content/docs/api-reference/admin/lexicons.md | 2 +- .../docs/content/docs/api-reference/admin/plugins.md | 6 +++--- .../docs/content/docs/api-reference/admin/settings.md | 2 +- .../content/docs/api-reference/lua/database-api.md | 6 +++--- .../content/docs/getting-started/authentication.md | 4 ++-- .../docs/content/docs/guides/attestation-signing.md | 4 ++-- packages/docs/content/docs/guides/labelers.md | 2 +- packages/docs/content/docs/guides/lexicons.md | 4 ++-- packages/docs/content/docs/guides/record-scripts.md | 10 +++++----- .../docs/reference/script-examples/meilisearch-sync.md | 2 +- packages/docs/next.config.mts | 6 +++--- 12 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/docs/content/docs/api-reference/admin/admin-api.md b/packages/docs/content/docs/api-reference/admin/admin-api.md index 85c5aa0..5370d89 100644 --- a/packages/docs/content/docs/api-reference/admin/admin-api.md +++ b/packages/docs/content/docs/api-reference/admin/admin-api.md @@ -2,7 +2,7 @@ title: "Overview" --- -The admin API lets you manage lexicons, monitor records, run backfill jobs, and control user access. All endpoints live under `/admin` and require authentication from a DID that exists in the `users` table, with the appropriate [permissions](../../guides/permissions.md) for the endpoint being called. You can also manage all of this through the [web dashboard](../../getting-started/dashboard.md). +The admin API lets you manage lexicons, monitor records, run backfill jobs, and control user access. All endpoints live under `/admin` and require authentication from a DID that exists in the `happyview_users` table, with the appropriate [permissions](../../guides/permissions.md) for the endpoint being called. You can also manage all of this through the [web dashboard](../../getting-started/dashboard.md). ## Auth @@ -12,9 +12,9 @@ The admin API supports three authentication methods: 2. **Service auth JWT** — atproto inter-service authentication via signed JWTs. 3. **Cookie-based session auth** — signed session cookies set during the dashboard OAuth login flow. The [web dashboard](../../getting-started/dashboard.md) uses this method. -In all cases the resolved DID is checked against the `users` table, and the user's permissions are loaded to authorize the request. +In all cases the resolved DID is checked against the `happyview_users` table, and the user's permissions are loaded to authorize the request. -**Auto-bootstrap**: If the `users` table is empty, the first authenticated request automatically creates the caller as the **super user** with all permissions granted. +**Auto-bootstrap**: If the `happyview_users` table is empty, the first authenticated request automatically creates the caller as the **super user** with all permissions granted. Non-user DIDs receive a `403 Forbidden` response. Users without the required permission for a specific endpoint also receive `403 Forbidden`. diff --git a/packages/docs/content/docs/api-reference/admin/lexicons.md b/packages/docs/content/docs/api-reference/admin/lexicons.md index fddad4d..a3b162a 100644 --- a/packages/docs/content/docs/api-reference/admin/lexicons.md +++ b/packages/docs/content/docs/api-reference/admin/lexicons.md @@ -514,6 +514,6 @@ curl -X DELETE http://127.0.0.1:3000/admin/network-lexicons/xyz.statusphere.stat -H "$AUTH" ``` -Removes the network lexicon tracking and also deletes the lexicon from the `lexicons` table and in-memory registry. +Removes the network lexicon tracking and also deletes the lexicon from the `happyview_lexicons` table and in-memory registry. **Response**: `204 No Content` diff --git a/packages/docs/content/docs/api-reference/admin/plugins.md b/packages/docs/content/docs/api-reference/admin/plugins.md index 2bf0907..76eb8d3 100644 --- a/packages/docs/content/docs/api-reference/admin/plugins.md +++ b/packages/docs/content/docs/api-reference/admin/plugins.md @@ -120,7 +120,7 @@ curl http://127.0.0.1:3000/admin/plugins -H "$AUTH" } ``` -`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. +`secrets_configured` is `true` if the plugin has no required secrets, or if a row exists for it in `happyview_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 @@ -320,7 +320,7 @@ Requires `plugins:read`. Returns the cached catalog of plugins from the official DELETE /admin/plugins/{id} ``` -Requires `plugins:delete`. Unregisters the plugin from the runtime and deletes its row from the `plugins` table. Secrets stay in `plugin_configs`, so they're reused if you reinstall. +Requires `plugins:delete`. Unregisters the plugin from the runtime and deletes its row from the `happyview_plugins` table. Secrets stay in `happyview_plugin_configs`, so they're reused if you reinstall. **Response**: `204 No Content`. Returns `404 Not Found` if no plugin with that id is loaded. @@ -377,7 +377,7 @@ Requires `plugins:read`. Returns the plugin's configured secrets with values mas 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`. +Requires `plugins:create`. Encrypts the provided secret values with `TOKEN_ENCRYPTION_KEY` (AES-256-GCM) and upserts them into `happyview_plugin_configs`. ```ts tab="TypeScript" tab-group="language" const response = await fetch("http://127.0.0.1:3000/admin/plugins/steam/secrets", { diff --git a/packages/docs/content/docs/api-reference/admin/settings.md b/packages/docs/content/docs/api-reference/admin/settings.md index 791ddfe..5dea443 100644 --- a/packages/docs/content/docs/api-reference/admin/settings.md +++ b/packages/docs/content/docs/api-reference/admin/settings.md @@ -58,7 +58,7 @@ resp, err := http.DefaultClient.Do(req) curl http://127.0.0.1:3000/admin/settings -H "$AUTH" ``` -Returns all key/value pairs stored in the `instance_settings` table, plus any env-var fallback values for keys not stored in the database. Each entry includes a `source` field: `"database"` for stored values, `"env"` for env-var fallbacks. +Returns all key/value pairs stored in the `happyview_instance_settings` table, plus any env-var fallback values for keys not stored in the database. Each entry includes a `source` field: `"database"` for stored values, `"env"` for env-var fallbacks. ### Known settings diff --git a/packages/docs/content/docs/api-reference/lua/database-api.md b/packages/docs/content/docs/api-reference/lua/database-api.md index baf1b77..83ec672 100644 --- a/packages/docs/content/docs/api-reference/lua/database-api.md +++ b/packages/docs/content/docs/api-reference/lua/database-api.md @@ -153,7 +153,7 @@ Run a raw SQL query against the database. Supports `SELECT`, `INSERT`, `UPDATE`, ```lua -- Read query local rows = db.raw( - "SELECT uri, did, record FROM records WHERE collection = $1 AND did = $2 LIMIT $3", + "SELECT uri, did, record FROM happyview_records WHERE collection = $1 AND did = $2 LIMIT $3", { "xyz.statusphere.status", "did:plc:abc", 10 } ) @@ -196,9 +196,9 @@ Returns `"sqlite"` or `"postgres"`. Useful when you need database-specific SQL t ```lua if db.backend() == "postgres" then - db.raw("SELECT * FROM records WHERE record @> $1::jsonb", { json.encode({ status = "active" }) }) + db.raw("SELECT * FROM happyview_records WHERE record @> $1::jsonb", { json.encode({ status = "active" }) }) else -- SQLite fallback - db.raw("SELECT * FROM records WHERE json_extract(record, '$.status') = $1", { "active" }) + db.raw("SELECT * FROM happyview_records WHERE json_extract(record, '$.status') = $1", { "active" }) end ``` diff --git a/packages/docs/content/docs/getting-started/authentication.md b/packages/docs/content/docs/getting-started/authentication.md index b26f8d7..7af9627 100644 --- a/packages/docs/content/docs/getting-started/authentication.md +++ b/packages/docs/content/docs/getting-started/authentication.md @@ -231,11 +231,11 @@ For a service auth JWT to validate: - `exp` must be in the future. - The signature must verify against the issuer DID's atproto signing key. -As with the other methods, the resolved DID still has to exist in the HappyView `users` table with the right permissions to hit admin endpoints — service auth gets you identified, not privileged. +As with the other methods, the resolved DID still has to exist in the HappyView `happyview_users` table with the right permissions to hit admin endpoints — service auth gets you identified, not privileged. ### 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. 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. +On a fresh deployment, the `happyview_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 — Users](../api-reference/admin/users.md) for details. diff --git a/packages/docs/content/docs/guides/attestation-signing.md b/packages/docs/content/docs/guides/attestation-signing.md index 957ec54..d8c7f98 100644 --- a/packages/docs/content/docs/guides/attestation-signing.md +++ b/packages/docs/content/docs/guides/attestation-signing.md @@ -15,7 +15,7 @@ The repo DID is included in the signed data — a signature for one user's recor ## Setup -Attestation signing is enabled by default — HappyView generates a keypair on first startup and persists it to the `instance_settings` database table. No configuration is required. +Attestation signing is enabled by default — HappyView generates a keypair on first startup and persists it to the `happyview_instance_settings` database table. No configuration is required. To use an explicit key instead, set the `ATTESTATION_PRIVATE_KEY` environment variable: @@ -32,7 +32,7 @@ The key ID defaults to a `did:web` derived from your `PUBLIC_URL`. For example, HappyView checks for signing configuration in this order: 1. **Environment variables** — if `ATTESTATION_PRIVATE_KEY` is set, it's used -2. **Database** — if previously generated keys exist in `instance_settings`, they're loaded +2. **Database** — if previously generated keys exist in `happyview_instance_settings`, they're loaded 3. **Auto-generation** — a new key is generated and persisted to the database If key loading fails for any reason, signing is disabled and `atproto.sign` / `atproto.verify_signature` will be `nil` in Lua scripts. diff --git a/packages/docs/content/docs/guides/labelers.md b/packages/docs/content/docs/guides/labelers.md index 7610d15..faceace 100644 --- a/packages/docs/content/docs/guides/labelers.md +++ b/packages/docs/content/docs/guides/labelers.md @@ -8,7 +8,7 @@ Labelers are external services that apply content labels to records. They operat A labeler is identified by its DID. When you subscribe to a labeler, HappyView connects directly to the labeler's WebSocket and streams label events in real time. Each label targets a specific record URI and carries a value like `nudity`, `spam`, or any custom string the labeler defines. -Labels are stored in a `labels` table in the database. HappyView tracks a cursor per labeler subscription so it can resume from where it left off after a restart. +Labels are stored in a `happyview_labels` table in the database. HappyView tracks a cursor per labeler subscription so it can resume from where it left off after a restart. Records can also have **self-labels** — labels applied by the record's author and embedded directly in the record's `labels.values` array. These are not managed by external labelers but are displayed alongside external labels in the dashboard. diff --git a/packages/docs/content/docs/guides/lexicons.md b/packages/docs/content/docs/guides/lexicons.md index 81d3e9a..da0b46a 100644 --- a/packages/docs/content/docs/guides/lexicons.md +++ b/packages/docs/content/docs/guides/lexicons.md @@ -71,8 +71,8 @@ The `value` field of the response is the raw lexicon JSON. 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**: 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. +- **create/update**: The new schema is parsed and upserted into the `happyview_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 `happyview_lexicons` table and registry, and collection filters are updated accordingly. ### Startup re-fetch diff --git a/packages/docs/content/docs/guides/record-scripts.md b/packages/docs/content/docs/guides/record-scripts.md index 20cb256..f1ac958 100644 --- a/packages/docs/content/docs/guides/record-scripts.md +++ b/packages/docs/content/docs/guides/record-scripts.md @@ -6,11 +6,11 @@ Record and label scripts are Lua scripts that run in response to events on the A These scripts are event-driven -- they react to incoming Jetstream events (which include events caused by HappyView's own PDS writes), not to XRPC requests. For scripts that run in response to XRPC queries and procedures, see [Lua Scripting](./lua-scripting.md). -> **Migration note:** Prior to v2.9, record scripts were called "index hooks" and were attached directly to lexicons. They now live in their own `scripts` table and are managed separately. Existing index hooks were automatically migrated. +> **Migration note:** Prior to v2.9, record scripts were called "index hooks" and were attached directly to lexicons. They now live in their own `happyview_scripts` table and are managed separately. Existing index hooks were automatically migrated. ## Trigger grammar -Every script is identified by a **trigger string** -- the script's `id` in the `scripts` table IS its trigger binding. There is no separate name or host column; the trigger string determines which events the script receives. +Every script is identified by a **trigger string** -- the script's `id` in the `happyview_scripts` table IS its trigger binding. There is no separate name or host column; the trigger string determines which events the script receives. ### Record event triggers @@ -140,10 +140,10 @@ Record and label scripts have access to: Record and label scripts are designed to be resilient: 1. If a script fails, it retries up to **4 attempts total** (1 initial + 3 retries) with exponential backoff (1s, 2s, 4s delays). -2. If all attempts are exhausted, the failed event is inserted into the `dead_letter_scripts` table for later inspection. +2. If all attempts are exhausted, the failed event is inserted into the `happyview_dead_letter_scripts` table for later inspection. 3. On failure the system **fails open** -- the original record or label is stored as-is so indexing is not permanently blocked. The firehose has no caller to surface errors to. -Failed scripts are logged as errors. Check the [event logs](./event-logs.md) or query the `dead_letter_scripts` table directly to find and replay failures. +Failed scripts are logged as errors. Check the [event logs](./event-logs.md) or query the `happyview_dead_letter_scripts` table directly to find and replay failures. ### Performance considerations @@ -151,7 +151,7 @@ Because scripts run synchronously before indexing, they block the Jetstream cons ### Dead letter table -The `dead_letter_scripts` table stores events that failed all retry attempts: +The `happyview_dead_letter_scripts` table stores events that failed all retry attempts: | Column | Type | Description | | ------------ | --------- | ----------------------------------------------------- | diff --git a/packages/docs/content/docs/reference/script-examples/meilisearch-sync.md b/packages/docs/content/docs/reference/script-examples/meilisearch-sync.md index a8f4f26..2eb34a9 100644 --- a/packages/docs/content/docs/reference/script-examples/meilisearch-sync.md +++ b/packages/docs/content/docs/reference/script-examples/meilisearch-sync.md @@ -51,7 +51,7 @@ This script uses [script variables](../../guides/lua-scripting.md) instead of ha | `MEILISEARCH_URL` | Your Meilisearch instance URL (e.g. `http://meilisearch.railway.internal:7700`) | | `MEILISEARCH_API_KEY` | A Meilisearch API key with write permissions | -Script variables are stored in the `script_variables` table and accessible as `env.*` in Lua. +Script variables are stored in the `happyview_script_variables` table and accessible as `env.*` in Lua. ## Use case diff --git a/packages/docs/next.config.mts b/packages/docs/next.config.mts index 40f832f..e333034 100644 --- a/packages/docs/next.config.mts +++ b/packages/docs/next.config.mts @@ -1,8 +1,8 @@ -import { createMDX } from 'fumadocs-mdx/next'; -import type { NextConfig } from 'next'; +import { createMDX } from "fumadocs-mdx/next"; +import type { NextConfig } from "next"; const config: NextConfig = { - transpilePackages: ['@happyview/design-system'], + transpilePackages: ["@happyview/design-system"], }; const withMDX = createMDX(); -- 2.51.2 From a25c1abfaa86f734316f3a53f3cf07844f4fd6d2 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 1 Jul 2026 09:13:40 -0500 Subject: [PATCH 4/5] docs: add missing docs for permissioned spaces Signed-off-by: Trezy --- .../docs/experimental/spaces/invites.md | 6 +- .../experimental/spaces/managing-spaces.md | 178 +++++++++- .../docs/experimental/spaces/meta.json | 1 + .../docs/experimental/spaces/notifications.md | 300 ++++++++++++++++ .../docs/experimental/spaces/records.md | 326 +++++++++++++++++- 5 files changed, 803 insertions(+), 8 deletions(-) create mode 100644 packages/docs/content/docs/experimental/spaces/notifications.md diff --git a/packages/docs/content/docs/experimental/spaces/invites.md b/packages/docs/content/docs/experimental/spaces/invites.md index 981a8f5..b96736b 100644 --- a/packages/docs/content/docs/experimental/spaces/invites.md +++ b/packages/docs/content/docs/experimental/spaces/invites.md @@ -6,7 +6,7 @@ title: "Invites" This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. -Invites let space owners distribute membership tokens without knowing recipients' DIDs in advance. +Invites let space authorities distribute membership tokens without knowing recipients' DIDs in advance. Invites are a HappyView-specific feature, not part of the AT Protocol spaces spec. They may be replaced by a different mechanism in the future. @@ -14,7 +14,7 @@ Invites are a HappyView-specific feature, not part of the AT Protocol spaces spe ## Creating an invite -Only the space owner or a super admin can create invites. +Only the space authority or a super admin can create invites. ```ts tab="TypeScript" tab-group="language" const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createInvite", { @@ -292,7 +292,7 @@ Revoking an invite prevents future redemptions but does not remove members who a ## Listing invites -Only the space owner or a super admin can list invites. +Only the space authority or a super admin can list invites. ```ts tab="TypeScript" tab-group="language" const response = await fetch( diff --git a/packages/docs/content/docs/experimental/spaces/managing-spaces.md b/packages/docs/content/docs/experimental/spaces/managing-spaces.md index 3601956..0d99f1d 100644 --- a/packages/docs/content/docs/experimental/spaces/managing-spaces.md +++ b/packages/docs/content/docs/experimental/spaces/managing-spaces.md @@ -144,11 +144,12 @@ const response = await fetch( }, }, ); -interface Space { +interface GetSpaceResponse { uri: string; - isOwner: boolean; + space: Space; + config: SpaceConfig; } -const data: Space = await response.json(); +const data: GetSpaceResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( @@ -190,7 +191,7 @@ curl 'https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats:// -H 'DPoP: ' ``` -If `membershipPublic` is `false`, the caller must be authenticated and be a member (or the owner) to see the space. Non-members receive a `404 Not Found`. +If `membershipPublic` is `false`, the caller must be authenticated and be a member (or the authority) to see the space. Non-members receive a `404 Not Found`. ## Listing spaces @@ -424,3 +425,172 @@ curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.deleteS Deleting a space cascades to all associated records, members, repo state, oplog entries, notification registrations, and credentials. + +## Getting configuration + +Returns the simplespace configuration for a space. Requires admin access (space authority or super admin). + +```ts tab="TypeScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=ats://did:plc:abc123/com.example.forum/main", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +interface SpaceConfig { + $type: "com.atproto.simplespace.defs#spaceConfig"; + mintPolicy: string; + appAccess: object; + managingApp: string | null; +} +const data: SpaceConfig = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=ats://did:plc:abc123/com.example.forum/main", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .get("https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig") + .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) + .header("X-Client-Key", client_key) + .header("Authorization", format!("DPoP {}", access_token)) + .header("DPoP", &dpop_proof) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=ats://did:plc:abc123/com.example.forum/main", + nil) +req.Header.Set("X-Client-Key", clientKey) +req.Header.Set("Authorization", "DPoP "+accessToken) +req.Header.Set("DPoP", dpopProof) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl 'https://happyview.example.com/xrpc/com.atproto.simplespace.getConfig?space=ats://did:plc:abc123/com.example.forum/main' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Response:** + +```json +{ + "$type": "com.atproto.simplespace.defs#spaceConfig", + "mintPolicy": "member-list", + "appAccess": { "type": "open" }, + "managingApp": null +} +``` + +| Field | Type | Description | +| ------------- | ------ | ------------------------------------------------------------------------ | +| `mintPolicy` | string | `member-list`, `public`, or `managing-app` | +| `appAccess` | object | `{"type": "open"}` or `{"type": "allowList", "allowed": ["did:...", ...]}` | +| `managingApp` | string \| null | DID of the application that manages this space | + +## Updating configuration + +Updates the simplespace configuration for a space. Requires admin access (space authority or super admin). + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.updateConfig", { + method: "POST", + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + space: "ats://did:plc:abc123/com.example.forum/main", + mintPolicy: "public", + appAccess: { type: "allowList", allowed: ["did:web:myapp.example.com"] }, + }), +}); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.updateConfig", { + method: "POST", + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + space: "ats://did:plc:abc123/com.example.forum/main", + mintPolicy: "public", + appAccess: { type: "allowList", allowed: ["did:web:myapp.example.com"] }, + }), +}); +``` +```rust tab="Rust" tab-group="language" +let response = client + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.updateConfig") + .header("X-Client-Key", client_key) + .header("Authorization", format!("DPoP {}", access_token)) + .header("DPoP", &dpop_proof) + .json(&serde_json::json!({ + "space": "ats://did:plc:abc123/com.example.forum/main", + "mintPolicy": "public", + "appAccess": { "type": "allowList", "allowed": ["did:web:myapp.example.com"] } + })) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +body := bytes.NewBufferString(`{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "mintPolicy": "public", + "appAccess": {"type": "allowList", "allowed": ["did:web:myapp.example.com"]} +}`) +req, _ := http.NewRequest("POST", + "https://happyview.example.com/xrpc/com.atproto.simplespace.updateConfig", body) +req.Header.Set("X-Client-Key", clientKey) +req.Header.Set("Authorization", "DPoP "+accessToken) +req.Header.Set("DPoP", dpopProof) +req.Header.Set("Content-Type", "application/json") +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.updateConfig' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "mintPolicy": "public", + "appAccess": {"type": "allowList", "allowed": ["did:web:myapp.example.com"]} + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| -------------- | -------------- | -------- | ------------------------------------------------------------------------ | +| `space` | string | Yes | Space URI | +| `mintPolicy` | string | No | `member-list`, `public`, or `managing-app` | +| `appAccess` | object | No | `{"type": "open"}` or `{"type": "allowList", "allowed": ["did:...", ...]}` | +| `managingApp` | string \| null | No | DID of the managing app, or `null` to clear | + +All fields except `space` are optional. Only provided fields are updated. The response returns the updated configuration in the same format as `getConfig`. diff --git a/packages/docs/content/docs/experimental/spaces/meta.json b/packages/docs/content/docs/experimental/spaces/meta.json index cdfbf93..5693c82 100644 --- a/packages/docs/content/docs/experimental/spaces/meta.json +++ b/packages/docs/content/docs/experimental/spaces/meta.json @@ -6,6 +6,7 @@ "members", "records", "credentials", + "notifications", "invites", "changelog" ] diff --git a/packages/docs/content/docs/experimental/spaces/notifications.md b/packages/docs/content/docs/experimental/spaces/notifications.md new file mode 100644 index 0000000..ce4c93e --- /dev/null +++ b/packages/docs/content/docs/experimental/spaces/notifications.md @@ -0,0 +1,300 @@ +--- +title: "Write Notifications" +--- + + +This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. + + +Write notifications let external services receive webhooks when records change in a space. A service registers an endpoint, and HappyView pushes notifications to it when records are created, updated, or deleted — or when the space itself is deleted. + +Registrations expire after 24 hours and must be renewed. + +## Registering for notifications + +Requires DPoP auth or a space credential. The caller provides the DID of the service that will receive notifications and the HTTPS endpoint to deliver them to. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.registerNotify", { + method: "POST", + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + space: "ats://did:plc:abc123/com.example.forum/main", + serviceDid: "did:web:feed.example.com", + endpoint: "https://feed.example.com/webhooks/space-writes", + }), +}); +interface RegisterNotifyResponse { + id: string; +} +const data: RegisterNotifyResponse = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.registerNotify", { + method: "POST", + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + space: "ats://did:plc:abc123/com.example.forum/main", + serviceDid: "did:web:feed.example.com", + endpoint: "https://feed.example.com/webhooks/space-writes", + }), +}); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .post("https://happyview.example.com/xrpc/com.atproto.space.registerNotify") + .header("X-Client-Key", client_key) + .header("Authorization", format!("DPoP {}", access_token)) + .header("DPoP", &dpop_proof) + .json(&serde_json::json!({ + "space": "ats://did:plc:abc123/com.example.forum/main", + "serviceDid": "did:web:feed.example.com", + "endpoint": "https://feed.example.com/webhooks/space-writes" + })) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +body := bytes.NewBufferString(`{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "serviceDid": "did:web:feed.example.com", + "endpoint": "https://feed.example.com/webhooks/space-writes" +}`) +req, _ := http.NewRequest("POST", + "https://happyview.example.com/xrpc/com.atproto.space.registerNotify", body) +req.Header.Set("X-Client-Key", clientKey) +req.Header.Set("Authorization", "DPoP "+accessToken) +req.Header.Set("DPoP", dpopProof) +req.Header.Set("Content-Type", "application/json") +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.registerNotify' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "serviceDid": "did:web:feed.example.com", + "endpoint": "https://feed.example.com/webhooks/space-writes" + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------------ | ------ | -------- | ------------------------------------------------ | +| `space` | string | Yes | Space URI (`ats://...`) | +| `serviceDid` | string | Yes | DID of the service receiving notifications | +| `endpoint` | string | Yes | HTTPS endpoint to deliver notifications to | + +**Response (200):** + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +## Write notification payload + +When a record is created, updated, or deleted in a space, HappyView POSTs a JSON payload to each registered endpoint: + +```json +{ + "space": "space-id", + "did": "did:plc:author", + "collection": "com.example.forum.post", + "rkey": "3jwq5dya2gy2z", + "cid": "bafyreie5cvv4h45feadgeuwhbcutmh6t7ceseocckahdoe6uat64zmz454" +} +``` + +| Field | Type | Description | +| ------------ | ------------- | ------------------------------------------------ | +| `space` | string | Internal space ID | +| `did` | string | DID of the author who made the change | +| `collection` | string (NSID) | Collection the record belongs to | +| `rkey` | string | Record key | +| `cid` | string? | CID of the new record value (null for deletes) | + +Notifications are delivered to both per-author registrations (matching `serviceDid`) and space-wide registrations (no author filter). Delivery is best-effort — if the endpoint is unreachable, the notification is dropped. + +## Pushing a write notification + +Server-to-server endpoint. Triggers write notifications to all registered endpoints for a space. This is used internally by HappyView when records change, but can also be called externally. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.notifyWrite", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + space: "ats://did:plc:abc123/com.example.forum/main", + did: "did:plc:author456", + collection: "com.example.forum.post", + rkey: "3jwq5dya2gy2z", + cid: "bafyreie5cvv4h45feadgeuwhbcutmh6t7ceseocckahdoe6uat64zmz454", + }), +}); +const data = await response.json(); +// { "success": true } +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.notifyWrite", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + space: "ats://did:plc:abc123/com.example.forum/main", + did: "did:plc:author456", + collection: "com.example.forum.post", + rkey: "3jwq5dya2gy2z", + cid: "bafyreie5cvv4h45feadgeuwhbcutmh6t7ceseocckahdoe6uat64zmz454", + }), +}); +const data = await response.json(); +// { "success": true } +``` +```rust tab="Rust" tab-group="language" +let response = client + .post("https://happyview.example.com/xrpc/com.atproto.space.notifyWrite") + .json(&serde_json::json!({ + "space": "ats://did:plc:abc123/com.example.forum/main", + "did": "did:plc:author456", + "collection": "com.example.forum.post", + "rkey": "3jwq5dya2gy2z", + "cid": "bafyreie5cvv4h45feadgeuwhbcutmh6t7ceseocckahdoe6uat64zmz454" + })) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +body := bytes.NewBufferString(`{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "did": "did:plc:author456", + "collection": "com.example.forum.post", + "rkey": "3jwq5dya2gy2z", + "cid": "bafyreie5cvv4h45feadgeuwhbcutmh6t7ceseocckahdoe6uat64zmz454" +}`) +req, _ := http.NewRequest("POST", + "https://happyview.example.com/xrpc/com.atproto.space.notifyWrite", body) +req.Header.Set("Content-Type", "application/json") +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.notifyWrite' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main", + "did": "did:plc:author456", + "collection": "com.example.forum.post", + "rkey": "3jwq5dya2gy2z", + "cid": "bafyreie5cvv4h45feadgeuwhbcutmh6t7ceseocckahdoe6uat64zmz454" + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------------ | ------------- | -------- | ------------------------------------------------ | +| `space` | string | Yes | Space URI (`ats://...`) | +| `did` | string | Yes | DID of the author who made the change | +| `collection` | string (NSID) | Yes | Collection the record belongs to | +| `rkey` | string | Yes | Record key | +| `cid` | string | No | CID of the record (omit for deletes) | + +**Response (200):** + +```json +{ + "success": true +} +``` + +## Notifying space deletion + +Server-to-server endpoint. Notifies all registered endpoints that a space has been deleted. Registered endpoints receive `{ "space": "" }`. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.notifySpaceDeleted", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + space: "ats://did:plc:abc123/com.example.forum/main", + }), +}); +const data = await response.json(); +// { "success": true } +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.notifySpaceDeleted", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + space: "ats://did:plc:abc123/com.example.forum/main", + }), +}); +const data = await response.json(); +// { "success": true } +``` +```rust tab="Rust" tab-group="language" +let response = client + .post("https://happyview.example.com/xrpc/com.atproto.space.notifySpaceDeleted") + .json(&serde_json::json!({ + "space": "ats://did:plc:abc123/com.example.forum/main" + })) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +body := bytes.NewBufferString(`{ + "space": "ats://did:plc:abc123/com.example.forum/main" +}`) +req, _ := http.NewRequest("POST", + "https://happyview.example.com/xrpc/com.atproto.space.notifySpaceDeleted", body) +req.Header.Set("Content-Type", "application/json") +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.notifySpaceDeleted' \ + -H 'Content-Type: application/json' \ + -d '{ + "space": "ats://did:plc:abc123/com.example.forum/main" + }' +``` + +**Input:** + +| Field | Type | Required | Description | +| ------- | ------ | -------- | ----------------------- | +| `space` | string | Yes | Space URI (`ats://...`) | + +**Response (200):** + +```json +{ + "success": true +} +``` diff --git a/packages/docs/content/docs/experimental/spaces/records.md b/packages/docs/content/docs/experimental/spaces/records.md index ee6ae30..4e7425a 100644 --- a/packages/docs/content/docs/experimental/spaces/records.md +++ b/packages/docs/content/docs/experimental/spaces/records.md @@ -791,6 +791,330 @@ The space's current revision is available as `revision` in the space object retu } ``` +## Repo state + +Returns the per-user repo state for a space, including the current revision and deniable commit data. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.getRepoState?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +interface RepoStateResponse { + rev: string | null; + commit: { + hash: string; + ikm: string; + sig: string; + mac: string; + rev: string; + } | null; +} +const data: RepoStateResponse = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.getRepoState?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .get("https://happyview.example.com/xrpc/com.atproto.space.getRepoState") + .query(&[ + ("space", "ats://did:plc:abc123/com.example.forum/main"), + ("did", "did:plc:author"), + ]) + .header("X-Client-Key", client_key) + .header("Authorization", format!("DPoP {}", access_token)) + .header("DPoP", &dpop_proof) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.space.getRepoState?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + nil) +req.Header.Set("X-Client-Key", clientKey) +req.Header.Set("Authorization", "DPoP "+accessToken) +req.Header.Set("DPoP", dpopProof) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl 'https://happyview.example.com/xrpc/com.atproto.space.getRepoState?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Parameters:** + +| Field | Type | Required | Description | +| ------- | ------ | -------- | ------------------------------------ | +| `space` | string | Yes | The space URI | +| `did` | string | Yes | The DID of the user to get state for | + +**Response:** + +| Field | Type | Description | +| -------- | ------------ | -------------------------------------------------------------- | +| `rev` | string/null | Current revision for this user's repo in the space | +| `commit` | object/null | Deniable commit data (base64url-encoded `hash`, `ikm`, `sig`, `mac`, and `rev`) | + +## Record operation log + +Returns the operation log for a user in a space. Each write (create, update, delete) is recorded as an oplog entry. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const data = await response.json(); +// data.ops — array of oplog entries +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .get("https://happyview.example.com/xrpc/com.atproto.space.listRepoOps") + .query(&[ + ("space", "ats://did:plc:abc123/com.example.forum/main"), + ("did", "did:plc:author"), + ]) + .header("X-Client-Key", client_key) + .header("Authorization", format!("DPoP {}", access_token)) + .header("DPoP", &dpop_proof) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author", + nil) +req.Header.Set("X-Client-Key", clientKey) +req.Header.Set("Authorization", "DPoP "+accessToken) +req.Header.Set("DPoP", dpopProof) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl 'https://happyview.example.com/xrpc/com.atproto.space.listRepoOps?space=ats://did:plc:abc123/com.example.forum/main&did=did:plc:author' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Parameters:** + +| Field | Type | Required | Description | +| -------- | ------- | -------- | ---------------------------------------------- | +| `space` | string | Yes | The space URI | +| `did` | string | Yes | The DID of the user whose ops to list | +| `limit` | integer | No | Max number of entries to return (default 100, max 1000) | +| `cursor` | string | No | Revision to start after (for pagination) | + +**Response:** + +```json +{ + "ops": [ + { + "id": "...", + "rev": "3l2tkbx7225co", + "idx": 0, + "action": "create", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "cid": "bafyrei...", + "prev": null, + "createdAt": "2026-05-09T12:00:00Z" + } + ] +} +``` + +Each entry records a single write operation. The `action` is one of `create`, `update`, or `delete`. The `prev` field contains the CID of the record before the operation (for updates and deletes). + +## Listing repos + +Returns the list of users who have records in a space, along with their current revision. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=ats://did:plc:abc123/com.example.forum/main", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +interface Repo { + did: string; + rev: string | null; +} +const data: { repos: Repo[] } = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=ats://did:plc:abc123/com.example.forum/main", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .get("https://happyview.example.com/xrpc/com.atproto.space.listRepos") + .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) + .header("X-Client-Key", client_key) + .header("Authorization", format!("DPoP {}", access_token)) + .header("DPoP", &dpop_proof) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=ats://did:plc:abc123/com.example.forum/main", + nil) +req.Header.Set("X-Client-Key", clientKey) +req.Header.Set("Authorization", "DPoP "+accessToken) +req.Header.Set("DPoP", dpopProof) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl 'https://happyview.example.com/xrpc/com.atproto.space.listRepos?space=ats://did:plc:abc123/com.example.forum/main' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' +``` + +**Parameters:** + +| Field | Type | Required | Description | +| ------- | ------ | -------- | ------------- | +| `space` | string | Yes | The space URI | + +**Response:** + +```json +{ + "repos": [ + { "did": "did:plc:author1", "rev": "3l2tkbx7225co" }, + { "did": "did:plc:author2", "rev": null } + ] +} +``` + +## Getting a blob + +Retrieves a blob from a space. The blob is fetched from the author's PDS and proxied through HappyView with access control. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=ats://did:plc:abc123/com.example.forum/main&cid=bafyrei...", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const blob = await response.blob(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch( + "https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=ats://did:plc:abc123/com.example.forum/main&cid=bafyrei...", + { + headers: { + "X-Client-Key": CLIENT_KEY, + "Authorization": `DPoP ${ACCESS_TOKEN}`, + "DPoP": DPOP_PROOF, + }, + }, +); +const blob = await response.blob(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .get("https://happyview.example.com/xrpc/com.atproto.space.getBlob") + .query(&[ + ("space", "ats://did:plc:abc123/com.example.forum/main"), + ("cid", "bafyrei..."), + ]) + .header("X-Client-Key", client_key) + .header("Authorization", format!("DPoP {}", access_token)) + .header("DPoP", &dpop_proof) + .send() + .await?; +let bytes = response.bytes().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=ats://did:plc:abc123/com.example.forum/main&cid=bafyrei...", + nil) +req.Header.Set("X-Client-Key", clientKey) +req.Header.Set("Authorization", "DPoP "+accessToken) +req.Header.Set("DPoP", dpopProof) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl 'https://happyview.example.com/xrpc/com.atproto.space.getBlob?space=ats://did:plc:abc123/com.example.forum/main&cid=bafyrei...' \ + -H 'X-Client-Key: hvc_...' \ + -H 'Authorization: DPoP ' \ + -H 'DPoP: ' \ + --output image.jpg +``` + +**Parameters:** + +| Field | Type | Required | Description | +| ------- | ------ | -------- | ------------------------ | +| `space` | string | Yes | The space URI | +| `cid` | string | Yes | The CID of the blob | + +The response body is the raw blob data with the original `Content-Type` header preserved. + ## Cross-service access Records can also be read using a [space credential](credentials.md) instead of direct membership. Pass the credential as a Bearer token: @@ -838,4 +1162,4 @@ curl 'https://happyview.example.com/xrpc/com.atproto.space.getRecord?...' \ -H 'Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6InNwYWNlX2NyZWRlbnRpYWwifQ...' ``` -A feed generator or other service that isn't a direct member can use a credential issued by the space owner to read data without joining the space. No DPoP auth is needed — the credential itself authenticates the request. +A feed generator or other service that isn't a direct member can use a credential issued by the space authority to read data without joining the space. No DPoP auth is needed — the credential itself authenticates the request. -- 2.51.2 From 08f21fb1a627d149074a5eb6f7d043e4ea509300 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 1 Jul 2026 09:22:11 -0500 Subject: [PATCH 5/5] docs: add missing endpoint groups Signed-off-by: Trezy --- .../docs/api-reference/admin/admin-api.md | 36 ++ .../docs/api-reference/admin/dead-letters.md | 203 +++++++ .../docs/api-reference/admin/feature-flags.md | 89 +++ .../docs/api-reference/admin/meta.json | 8 +- .../docs/api-reference/admin/permissions.md | 102 ++++ .../api-reference/admin/service-entries.md | 520 ++++++++++++++++++ .../api-reference/admin/service-identity.md | 160 ++++++ .../admin/verification-methods.md | 216 ++++++++ 8 files changed, 1333 insertions(+), 1 deletion(-) create mode 100644 packages/docs/content/docs/api-reference/admin/dead-letters.md create mode 100644 packages/docs/content/docs/api-reference/admin/feature-flags.md create mode 100644 packages/docs/content/docs/api-reference/admin/permissions.md create mode 100644 packages/docs/content/docs/api-reference/admin/service-entries.md create mode 100644 packages/docs/content/docs/api-reference/admin/service-identity.md create mode 100644 packages/docs/content/docs/api-reference/admin/verification-methods.md diff --git a/packages/docs/content/docs/api-reference/admin/admin-api.md b/packages/docs/content/docs/api-reference/admin/admin-api.md index 5370d89..4794315 100644 --- a/packages/docs/content/docs/api-reference/admin/admin-api.md +++ b/packages/docs/content/docs/api-reference/admin/admin-api.md @@ -58,6 +58,12 @@ AUTH="Authorization: Bearer $TOKEN" | [Script Variables](script-variables.md) | Encrypted key/value pairs for Lua scripts | | [API Clients](api-clients.md) | Register and manage third-party XRPC clients | | [Plugins](plugins.md) | Install, configure, and manage WASM plugins | +| [Dead Letters](dead-letters.md) | List, inspect, dismiss, retry, and reindex dead-lettered events | +| [Service Identity](service-identity.md) | Get and update service identity configuration | +| [Service Entries](service-entries.md) | Manage service entries and their XRPC bindings | +| [Verification Methods](verification-methods.md) | Create, list, and delete DID verification methods | +| [Feature Flags](feature-flags.md) | List feature flag status | +| [Permissions](permissions.md) | List available permission definitions | ## Permissions @@ -133,3 +139,33 @@ Each admin API endpoint requires a specific permission. See the [Permissions gui | `GET /admin/api-clients/{id}` | `api-clients:view` | | `PUT /admin/api-clients/{id}` | `api-clients:edit` | | `DELETE /admin/api-clients/{id}` | `api-clients:delete` | +| `GET /admin/dead-letters` | `dead-letters:read` | +| `GET /admin/dead-letters/count` | `dead-letters:read` | +| `GET /admin/dead-letters/{id}` | `dead-letters:read` | +| `POST /admin/dead-letters/{id}/dismiss` | `dead-letters:manage` | +| `POST /admin/dead-letters/{id}/retry` | `dead-letters:manage` | +| `POST /admin/dead-letters/{id}/reindex` | `dead-letters:manage` | +| `POST /admin/dead-letters/bulk/dismiss` | `dead-letters:manage` | +| `POST /admin/dead-letters/bulk/retry` | `dead-letters:manage` | +| `POST /admin/dead-letters/bulk/reindex` | `dead-letters:manage` | +| `GET /admin/service-identity` | `settings:manage` | +| `PUT /admin/service-identity` | `settings:manage` | +| `GET /admin/service-entries` | `settings:manage` | +| `POST /admin/service-entries` | `settings:manage` | +| `PUT /admin/service-entries/{id}` | `settings:manage` | +| `DELETE /admin/service-entries/{id}` | `settings:manage` | +| `GET /admin/service-entries/{id}/xrpcs` | `settings:manage` | +| `POST /admin/service-entries/{id}/xrpcs` | `settings:manage` | +| `DELETE /admin/service-entries/{id}/xrpcs`| `settings:manage` | +| `POST /admin/service-entries/sync-plc` | `settings:manage` | +| `POST /admin/service-entries/sync-plc/request` | `settings:manage` | +| `POST /admin/service-entries/sync-plc/submit` | `settings:manage` | +| `GET /admin/lexicons/{id}/services` | `settings:manage` | +| `GET /admin/verification-methods` | `settings:manage` | +| `POST /admin/verification-methods` | `settings:manage` | +| `DELETE /admin/verification-methods/{fragment_id}` | `settings:manage` | +| `GET /admin/feature-flags` | `settings:manage` | +| `GET /admin/network-lexicons/resolve/{nsid}` | `lexicons:read` | +| `GET /admin/permissions` | `users:read` | +| `GET /admin/settings/xrpc-proxy` | `settings:manage` | +| `PUT /admin/settings/xrpc-proxy` | `settings:manage` | diff --git a/packages/docs/content/docs/api-reference/admin/dead-letters.md b/packages/docs/content/docs/api-reference/admin/dead-letters.md new file mode 100644 index 0000000..febc323 --- /dev/null +++ b/packages/docs/content/docs/api-reference/admin/dead-letters.md @@ -0,0 +1,203 @@ +--- +title: "Dead Letters" +--- + +Events that failed all retry attempts are stored as dead letters for inspection and manual resolution. Dead letters come from two sources: legacy index hooks (`happyview_dead_letter_hooks`) and trigger-keyed scripts (`happyview_dead_letter_scripts`). Both tables are surfaced through a single unified API. + +Read endpoints require `dead-letters:read`. Action endpoints (dismiss, retry, reindex) require `dead-letters:manage`. + +```sh tab="cURL" tab-group="language" +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## List dead letters + +``` +GET /admin/dead-letters +``` + +Paginated list of dead letters from both tables, merged and sorted by `created_at` descending. + +| Param | Type | Required | Description | +| ------------ | ------ | -------- | ----------------------------------------------------- | +| `collection` | string | no | Filter by collection NSID | +| `resolved` | string | no | `"true"`, `"false"` (default), or omit for all | +| `cursor` | string | no | Pagination cursor from a previous response | +| `limit` | number | no | Max results per page (default 50, max 100) | + +```sh tab="cURL" tab-group="language" +curl "http://127.0.0.1:3000/admin/dead-letters?limit=10" -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +{ + "dead_letters": [ + { + "id": "42", + "lexicon_id": "xyz.statusphere.status", + "uri": "at://did:plc:abc/xyz.statusphere.status/3k...", + "did": "did:plc:abc", + "collection": "xyz.statusphere.status", + "rkey": "3k...", + "action": "create", + "error": "script error: attempt to index nil value", + "attempts": 4, + "created_at": "2026-06-01T12:00:00Z" + } + ], + "cursor": "2026-06-01T11:00:00Z" +} +``` + +Resolved dead letters include a `resolved_at` timestamp. `cursor` is omitted when there are no more results. + +## Count dead letters + +``` +GET /admin/dead-letters/count +``` + +Returns the total count of dead letters across both tables. + +| Param | Type | Required | Description | +| ------------ | ------ | -------- | ---------------------------------------------- | +| `collection` | string | no | Filter by collection NSID | +| `resolved` | string | no | `"true"`, `"false"` (default), or omit for all | + +```sh tab="cURL" tab-group="language" +curl "http://127.0.0.1:3000/admin/dead-letters/count" -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +{ + "count": 7 +} +``` + +## Get dead letter detail + +``` +GET /admin/dead-letters/{id} +``` + +Returns the full dead letter including the record body. + +```sh tab="cURL" tab-group="language" +curl "http://127.0.0.1:3000/admin/dead-letters/42" -H "$AUTH" +``` + +**Response**: `200 OK` + +The response includes all fields from the list view plus a `record` field containing the original record data (if available). Returns `404` if the dead letter is not found. + +## Dismiss a dead letter + +``` +POST /admin/dead-letters/{id}/dismiss +``` + +Marks a dead letter as resolved without retrying it. + +```sh tab="cURL" tab-group="language" +curl -X POST "http://127.0.0.1:3000/admin/dead-letters/42/dismiss" -H "$AUTH" +``` + +**Response**: `200 OK` — `{"ok": true}` + +Returns `404` if the dead letter is not found. + +## Retry a dead letter + +``` +POST /admin/dead-letters/{id}/retry +``` + +Re-runs the dead letter's script with the original event payload. On success the dead letter is marked resolved. On failure the error and attempt count are updated. + +Label-arrival dead letters cannot be retried — the upstream label event is gone. These return `400 Bad Request`. + +```sh tab="cURL" tab-group="language" +curl -X POST "http://127.0.0.1:3000/admin/dead-letters/42/retry" -H "$AUTH" +``` + +**Response**: `200 OK` — `{"ok": true}` + +Returns `404` if no matching script binding is found, or if the dead letter is not found or already resolved. + +## Reindex a dead letter + +``` +POST /admin/dead-letters/{id}/reindex +``` + +Fetches the record fresh from the author's PDS and re-runs the full record indexing pipeline. On success the dead letter is marked resolved. Label-arrival dead letters cannot be reindexed and return `400 Bad Request`. + +```sh tab="cURL" tab-group="language" +curl -X POST "http://127.0.0.1:3000/admin/dead-letters/42/reindex" -H "$AUTH" +``` + +**Response**: `200 OK` — `{"ok": true}` + +## Bulk dismiss + +``` +POST /admin/dead-letters/bulk/dismiss +``` + +Dismiss multiple dead letters at once. + +| Field | Type | Required | Description | +| ------------ | -------- | -------- | ----------------------------------------------------- | +| `ids` | string[] | no | List of dead letter IDs to dismiss | +| `all` | boolean | no | Set to `true` to dismiss all unresolved dead letters | +| `collection` | string | no | When `all` is true, limit to this collection | + +One of `ids` or `all: true` is required. + +```sh tab="cURL" tab-group="language" +curl -X POST "http://127.0.0.1:3000/admin/dead-letters/bulk/dismiss" \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{"ids": ["42", "43", "44"]}' +``` + +**Response**: `200 OK` — `{"ok": true}` + +## Bulk retry + +``` +POST /admin/dead-letters/bulk/retry +``` + +Retry multiple dead letters. Accepts the same input as [bulk dismiss](#bulk-dismiss). + +```sh tab="cURL" tab-group="language" +curl -X POST "http://127.0.0.1:3000/admin/dead-letters/bulk/retry" \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{"all": true, "collection": "xyz.statusphere.status"}' +``` + +**Response**: `200 OK` — `{"ok": true}` + +## Bulk reindex + +``` +POST /admin/dead-letters/bulk/reindex +``` + +Reindex multiple dead letters. Accepts the same input as [bulk dismiss](#bulk-dismiss). + +```sh tab="cURL" tab-group="language" +curl -X POST "http://127.0.0.1:3000/admin/dead-letters/bulk/reindex" \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{"all": true}' +``` + +**Response**: `200 OK` — `{"ok": true}` diff --git a/packages/docs/content/docs/api-reference/admin/feature-flags.md b/packages/docs/content/docs/api-reference/admin/feature-flags.md new file mode 100644 index 0000000..914a6f5 --- /dev/null +++ b/packages/docs/content/docs/api-reference/admin/feature-flags.md @@ -0,0 +1,89 @@ +--- +title: "Feature Flags" +--- + +```ts tab="TypeScript" tab-group="language" +const TOKEN = "hv_..."; // your API key +const headers = { Authorization: `Bearer ${TOKEN}` }; +``` +```js tab="JavaScript" tab-group="language" +const TOKEN = "hv_..."; // your API key +const headers = { Authorization: `Bearer ${TOKEN}` }; +``` +```rust tab="Rust" tab-group="language" +let token = "hv_..."; // your API key +``` +```go tab="Go" tab-group="language" +token := "hv_..." // your API key +``` +```sh tab="cURL" tab-group="language" +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## List feature flags + +``` +GET /admin/feature-flags +``` + +Returns all feature flags and their current status. Flags are backed by the `happyview_instance_settings` table — a flag is enabled when its key is set to `"true"`. + +```ts tab="TypeScript" tab-group="language" +interface FeatureFlag { + key: string; + name: string; + description: string; + enabled: boolean; +} + +const response = await fetch("http://127.0.0.1:3000/admin/feature-flags", { + headers, +}); +const data: FeatureFlag[] = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/feature-flags", { + headers, +}); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let client = reqwest::Client::new(); +let response = client + .get("http://127.0.0.1:3000/admin/feature-flags") + .bearer_auth(token) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/feature-flags", nil) +req.Header.Set("Authorization", "Bearer "+token) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl http://127.0.0.1:3000/admin/feature-flags -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "key": "feature.spaces_enabled", + "name": "Permissioned Spaces", + "description": "Collaborative data spaces with granular permissions, membership, and invites.", + "enabled": false + } +] +``` + +| Field | Type | Description | +| ------------- | ------- | -------------------------------------------- | +| `key` | string | Settings key for the flag | +| `name` | string | Human-readable name | +| `description` | string | What the flag controls | +| `enabled` | boolean | Whether the flag is currently enabled | + +To toggle a flag, use `PUT /admin/settings/{key}` with the value `"true"` or `"false"`. See [Instance Settings](settings.md) for details. diff --git a/packages/docs/content/docs/api-reference/admin/meta.json b/packages/docs/content/docs/api-reference/admin/meta.json index f1e0c09..79305b5 100644 --- a/packages/docs/content/docs/api-reference/admin/meta.json +++ b/packages/docs/content/docs/api-reference/admin/meta.json @@ -16,6 +16,12 @@ "domains", "script-variables", "api-clients", - "plugins" + "plugins", + "dead-letters", + "service-identity", + "service-entries", + "verification-methods", + "feature-flags", + "permissions" ] } diff --git a/packages/docs/content/docs/api-reference/admin/permissions.md b/packages/docs/content/docs/api-reference/admin/permissions.md new file mode 100644 index 0000000..f2bf6de --- /dev/null +++ b/packages/docs/content/docs/api-reference/admin/permissions.md @@ -0,0 +1,102 @@ +--- +title: "Permissions" +--- + +```ts tab="TypeScript" tab-group="language" +const TOKEN = "hv_..."; // your API key +const headers = { Authorization: `Bearer ${TOKEN}` }; +``` +```js tab="JavaScript" tab-group="language" +const TOKEN = "hv_..."; // your API key +const headers = { Authorization: `Bearer ${TOKEN}` }; +``` +```rust tab="Rust" tab-group="language" +let token = "hv_..."; // your API key +``` +```go tab="Go" tab-group="language" +token := "hv_..." // your API key +``` +```sh tab="cURL" tab-group="language" +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## List permissions + +``` +GET /admin/permissions +``` + +Returns all available permission definitions and permission templates. When the Spaces feature flag is disabled, spaces-related permissions and template entries are excluded. + +```ts tab="TypeScript" tab-group="language" +interface PermissionDef { + key: string; + name: string; + description: string; + category: string; +} + +interface PermissionTemplate { + key: string; + label: string; + permissions: string[]; +} + +interface PermissionsResponse { + permissions: PermissionDef[]; + templates: PermissionTemplate[]; +} + +const response = await fetch("http://127.0.0.1:3000/admin/permissions", { + headers, +}); +const data: PermissionsResponse = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/permissions", { + headers, +}); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let client = reqwest::Client::new(); +let response = client + .get("http://127.0.0.1:3000/admin/permissions") + .bearer_auth(token) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/permissions", nil) +req.Header.Set("Authorization", "Bearer "+token) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl http://127.0.0.1:3000/admin/permissions -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +{ + "permissions": [ + { + "key": "lexicons:create", + "name": "Create Lexicons", + "description": "Upload lexicon schemas", + "category": "Lexicons" + } + ], + "templates": [ + { + "key": "viewer", + "label": "Viewer", + "permissions": ["lexicons:read", "records:read", "stats:read", "events:read"] + } + ] +} +``` + +Templates are predefined permission bundles used when creating or updating users. See the [Permissions guide](../../guides/permissions.md) for the full list. diff --git a/packages/docs/content/docs/api-reference/admin/service-entries.md b/packages/docs/content/docs/api-reference/admin/service-entries.md new file mode 100644 index 0000000..eefebc7 --- /dev/null +++ b/packages/docs/content/docs/api-reference/admin/service-entries.md @@ -0,0 +1,520 @@ +--- +title: "Service Entries" +--- + +Manage the service entries published in the instance's DID document. Each entry declares an XRPC service type (e.g. `AtprotoLabeler`, `BskyFeedGenerator`) and controls which lexicons route to HappyView's endpoint. All endpoints require the `settings:manage` permission. + +```ts tab="TypeScript" tab-group="language" +const TOKEN = "hv_..."; // your API key +const headers = { Authorization: `Bearer ${TOKEN}` }; +``` +```js tab="JavaScript" tab-group="language" +const TOKEN = "hv_..."; // your API key +const headers = { Authorization: `Bearer ${TOKEN}` }; +``` +```rust tab="Rust" tab-group="language" +let token = "hv_..."; // your API key +``` +```go tab="Go" tab-group="language" +token := "hv_..." // your API key +``` +```sh tab="cURL" tab-group="language" +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## List service entries + +``` +GET /admin/service-entries +``` + +```ts tab="TypeScript" tab-group="language" +interface ServiceEntry { + id: number; + fragment_id: string; + service_type: string; + access_mode: string; + created_at: string; + updated_at: string; +} + +const response = await fetch("http://127.0.0.1:3000/admin/service-entries", { + headers, +}); +const data: ServiceEntry[] = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/service-entries", { + headers, +}); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let client = reqwest::Client::new(); +let response = client + .get("http://127.0.0.1:3000/admin/service-entries") + .bearer_auth(token) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/service-entries", nil) +req.Header.Set("Authorization", "Bearer "+token) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl http://127.0.0.1:3000/admin/service-entries -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "id": 1, + "fragment_id": "#happyview", + "service_type": "AtprotoLabeler", + "access_mode": "all", + "created_at": "2026-05-09T12:00:00Z", + "updated_at": "2026-05-09T12:00:00Z" + } +] +``` + +## Create a service entry + +``` +POST /admin/service-entries +``` + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/service-entries", { + method: "POST", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + fragment_id: "#feed", + service_type: "BskyFeedGenerator", + }), +}); +const data: ServiceEntry = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/service-entries", { + method: "POST", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + fragment_id: "#feed", + service_type: "BskyFeedGenerator", + }), +}); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .post("http://127.0.0.1:3000/admin/service-entries") + .bearer_auth(token) + .json(&serde_json::json!({ + "fragment_id": "#feed", + "service_type": "BskyFeedGenerator" + })) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +body := bytes.NewBufferString(`{"fragment_id": "#feed", "service_type": "BskyFeedGenerator"}`) +req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/service-entries", body) +req.Header.Set("Authorization", "Bearer "+token) +req.Header.Set("Content-Type", "application/json") +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl -X POST http://127.0.0.1:3000/admin/service-entries \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "fragment_id": "#feed", "service_type": "BskyFeedGenerator" }' +``` + +| Field | Type | Required | Description | +| -------------- | ------ | -------- | ----------------------------------------------------------- | +| `fragment_id` | string | yes | DID document fragment (e.g. `#feed`, `#happyview`) | +| `service_type` | string | yes | AT Protocol service type (e.g. `BskyFeedGenerator`) | + +The entry is created with `access_mode` set to `all`. The endpoint URL is derived from `PUBLIC_URL`. + +**Response**: `201 Created` + +## Update a service entry + +``` +PUT /admin/service-entries/{id} +``` + +```ts tab="TypeScript" tab-group="language" +await fetch("http://127.0.0.1:3000/admin/service-entries/1", { + method: "PUT", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + service_type: "AtprotoLabeler", + access_mode: "allowlist", + }), +}); +``` +```js tab="JavaScript" tab-group="language" +await fetch("http://127.0.0.1:3000/admin/service-entries/1", { + method: "PUT", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + service_type: "AtprotoLabeler", + access_mode: "allowlist", + }), +}); +``` +```rust tab="Rust" tab-group="language" +client + .put("http://127.0.0.1:3000/admin/service-entries/1") + .bearer_auth(token) + .json(&serde_json::json!({ + "service_type": "AtprotoLabeler", + "access_mode": "allowlist" + })) + .send() + .await?; +``` +```go tab="Go" tab-group="language" +body := bytes.NewBufferString(`{"service_type": "AtprotoLabeler", "access_mode": "allowlist"}`) +req, _ := http.NewRequest("PUT", "http://127.0.0.1:3000/admin/service-entries/1", body) +req.Header.Set("Authorization", "Bearer "+token) +req.Header.Set("Content-Type", "application/json") +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl -X PUT http://127.0.0.1:3000/admin/service-entries/1 \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "service_type": "AtprotoLabeler", "access_mode": "allowlist" }' +``` + +| Field | Type | Required | Description | +| -------------- | ------ | -------- | -------------------------------------------------- | +| `fragment_id` | string | no | Updated DID document fragment | +| `service_type` | string | no | Updated service type | +| `access_mode` | string | no | `all`, `allowlist`, or `disabled` | + +All fields are optional. Only provided fields are updated. + +**Response**: `204 No Content` + +## Delete a service entry + +``` +DELETE /admin/service-entries/{id} +``` + +```ts tab="TypeScript" tab-group="language" +await fetch("http://127.0.0.1:3000/admin/service-entries/1", { + method: "DELETE", + headers, +}); +``` +```js tab="JavaScript" tab-group="language" +await fetch("http://127.0.0.1:3000/admin/service-entries/1", { + method: "DELETE", + headers, +}); +``` +```rust tab="Rust" tab-group="language" +client + .delete("http://127.0.0.1:3000/admin/service-entries/1") + .bearer_auth(token) + .send() + .await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("DELETE", "http://127.0.0.1:3000/admin/service-entries/1", nil) +req.Header.Set("Authorization", "Bearer "+token) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl -X DELETE http://127.0.0.1:3000/admin/service-entries/1 \ + -H "$AUTH" +``` + +Returns `404 Not Found` if the entry doesn't exist. + +**Response**: `204 No Content` + +## List XRPCs for a service entry + +``` +GET /admin/service-entries/{id}/xrpcs +``` + +Returns the lexicon IDs (NSIDs) bound to this service entry. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/service-entries/1/xrpcs", { + headers, +}); +const data: string[] = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/service-entries/1/xrpcs", { + headers, +}); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .get("http://127.0.0.1:3000/admin/service-entries/1/xrpcs") + .bearer_auth(token) + .send() + .await?; +let data: Vec = response.json().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/service-entries/1/xrpcs", nil) +req.Header.Set("Authorization", "Bearer "+token) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl http://127.0.0.1:3000/admin/service-entries/1/xrpcs -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +["app.bsky.feed.getFeedSkeleton", "app.bsky.feed.describeFeedGenerator"] +``` + +## Add XRPCs to a service entry + +``` +POST /admin/service-entries/{id}/xrpcs +``` + +```ts tab="TypeScript" tab-group="language" +await fetch("http://127.0.0.1:3000/admin/service-entries/1/xrpcs", { + method: "POST", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + lexicon_ids: ["app.bsky.feed.getFeedSkeleton"], + }), +}); +``` +```js tab="JavaScript" tab-group="language" +await fetch("http://127.0.0.1:3000/admin/service-entries/1/xrpcs", { + method: "POST", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + lexicon_ids: ["app.bsky.feed.getFeedSkeleton"], + }), +}); +``` +```rust tab="Rust" tab-group="language" +client + .post("http://127.0.0.1:3000/admin/service-entries/1/xrpcs") + .bearer_auth(token) + .json(&serde_json::json!({ + "lexicon_ids": ["app.bsky.feed.getFeedSkeleton"] + })) + .send() + .await?; +``` +```go tab="Go" tab-group="language" +body := bytes.NewBufferString(`{"lexicon_ids": ["app.bsky.feed.getFeedSkeleton"]}`) +req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/service-entries/1/xrpcs", body) +req.Header.Set("Authorization", "Bearer "+token) +req.Header.Set("Content-Type", "application/json") +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl -X POST http://127.0.0.1:3000/admin/service-entries/1/xrpcs \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "lexicon_ids": ["app.bsky.feed.getFeedSkeleton"] }' +``` + +| Field | Type | Required | Description | +| ------------ | -------- | -------- | ------------------------------------- | +| `lexicon_ids` | string[] | yes | Lexicon NSIDs to bind to this entry | + +**Response**: `204 No Content` + +## Remove XRPCs from a service entry + +``` +DELETE /admin/service-entries/{id}/xrpcs +``` + +```ts tab="TypeScript" tab-group="language" +await fetch("http://127.0.0.1:3000/admin/service-entries/1/xrpcs", { + method: "DELETE", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + lexicon_ids: ["app.bsky.feed.getFeedSkeleton"], + }), +}); +``` +```js tab="JavaScript" tab-group="language" +await fetch("http://127.0.0.1:3000/admin/service-entries/1/xrpcs", { + method: "DELETE", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + lexicon_ids: ["app.bsky.feed.getFeedSkeleton"], + }), +}); +``` +```rust tab="Rust" tab-group="language" +client + .delete("http://127.0.0.1:3000/admin/service-entries/1/xrpcs") + .bearer_auth(token) + .json(&serde_json::json!({ + "lexicon_ids": ["app.bsky.feed.getFeedSkeleton"] + })) + .send() + .await?; +``` +```go tab="Go" tab-group="language" +body := bytes.NewBufferString(`{"lexicon_ids": ["app.bsky.feed.getFeedSkeleton"]}`) +req, _ := http.NewRequest("DELETE", "http://127.0.0.1:3000/admin/service-entries/1/xrpcs", body) +req.Header.Set("Authorization", "Bearer "+token) +req.Header.Set("Content-Type", "application/json") +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl -X DELETE http://127.0.0.1:3000/admin/service-entries/1/xrpcs \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "lexicon_ids": ["app.bsky.feed.getFeedSkeleton"] }' +``` + +| Field | Type | Required | Description | +| ------------ | -------- | -------- | ---------------------------------------- | +| `lexicon_ids` | string[] | yes | Lexicon NSIDs to unbind from this entry | + +**Response**: `204 No Content` + +## List services for a lexicon + +``` +GET /admin/lexicons/{id}/services +``` + +Returns service entries that grant access to the specified lexicon. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/lexicons/app.bsky.feed.getFeedSkeleton/services", { + headers, +}); +const data: ServiceEntry[] = await response.json(); +``` +```js tab="JavaScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/lexicons/app.bsky.feed.getFeedSkeleton/services", { + headers, +}); +const data = await response.json(); +``` +```rust tab="Rust" tab-group="language" +let response = client + .get("http://127.0.0.1:3000/admin/lexicons/app.bsky.feed.getFeedSkeleton/services") + .bearer_auth(token) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/lexicons/app.bsky.feed.getFeedSkeleton/services", nil) +req.Header.Set("Authorization", "Bearer "+token) +resp, err := http.DefaultClient.Do(req) +``` +```sh tab="cURL" tab-group="language" +curl http://127.0.0.1:3000/admin/lexicons/app.bsky.feed.getFeedSkeleton/services -H "$AUTH" +``` + +**Response**: `200 OK` — returns an array of `ServiceEntry` objects. + +## Sync to PLC directory + +These endpoints publish your service entries to the PLC directory so they appear in the DID document. The sync method depends on the [service identity mode](../../getting-started/service-identity.md). + +### Direct sync (did:plc mode) + +``` +POST /admin/service-entries/sync-plc +``` + +Signs and submits a PLC update operation using the stored rotation key. Only available when the identity mode is `did_plc`. + +```sh tab="cURL" tab-group="language" +curl -X POST http://127.0.0.1:3000/admin/service-entries/sync-plc -H "$AUTH" +``` + +Returns `400 Bad Request` if the identity mode is not `did_plc` or no DID is configured. + +**Response**: `204 No Content` + +### Request PLC token (attach_account mode) + +``` +POST /admin/service-entries/sync-plc/request +``` + +Requests a PLC operation signature token via the attached account's PDS. This sends an email confirmation code to the account holder. Only available when the identity mode is `attach_account`. + +```sh tab="cURL" tab-group="language" +curl -X POST http://127.0.0.1:3000/admin/service-entries/sync-plc/request -H "$AUTH" +``` + +Returns `400 Bad Request` if the identity mode is not `attach_account`. + +**Response**: `204 No Content` + +### Submit PLC token (attach_account mode) + +``` +POST /admin/service-entries/sync-plc/submit +``` + +Submits the email confirmation token to sign and publish the PLC operation via the attached account's PDS. + +```sh tab="cURL" tab-group="language" +curl -X POST http://127.0.0.1:3000/admin/service-entries/sync-plc/submit \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ "token": "123456" }' +``` + +| Field | Type | Required | Description | +| ------- | ------ | -------- | ------------------------------------ | +| `token` | string | yes | Email confirmation code from the PDS | + +Returns `400 Bad Request` if the identity mode is not `attach_account`. + +**Response**: `204 No Content` diff --git a/packages/docs/content/docs/api-reference/admin/service-identity.md b/packages/docs/content/docs/api-reference/admin/service-identity.md new file mode 100644 index 0000000..0e1656e --- /dev/null +++ b/packages/docs/content/docs/api-reference/admin/service-identity.md @@ -0,0 +1,160 @@ +--- +title: "Service Identity" +--- + +Manage the service identity configuration: the DID and identity mode that HappyView uses to identify itself on the AT Protocol network. All endpoints require the `settings:manage` permission. + +```ts tab="TypeScript" tab-group="language" +const TOKEN = "hv_..."; // your API key +const headers = { Authorization: `Bearer ${TOKEN}` }; +``` + +```js tab="JavaScript" tab-group="language" +const TOKEN = "hv_..."; // your API key +const headers = { Authorization: `Bearer ${TOKEN}` }; +``` + +```rust tab="Rust" tab-group="language" +let token = "hv_..."; // your API key +``` + +```go tab="Go" tab-group="language" +token := "hv_..." // your API key +``` + +```sh tab="cURL" tab-group="language" +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## Get service identity + +``` +GET /admin/service-identity +``` + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/service-identity", { + headers, +}); +const data = await response.json(); +``` + +```js tab="JavaScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/service-identity", { + headers, +}); +const data = await response.json(); +``` + +```rust tab="Rust" tab-group="language" +let response = client + .get("http://127.0.0.1:3000/admin/service-identity") + .bearer_auth(token) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` + +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/service-identity", nil) +req.Header.Set("Authorization", "Bearer "+token) +resp, err := http.DefaultClient.Do(req) +``` + +```sh tab="cURL" tab-group="language" +curl http://127.0.0.1:3000/admin/service-identity -H "$AUTH" +``` + +Returns the current service identity configuration, or `null` if no identity has been configured. + +**Response:** + +| Field | Type | Description | +| ---------------------- | ----------- | ------------------------------------------------- | +| `mode` | string | Identity mode (see below) | +| `did` | string/null | The service DID | +| `signing_key_enc` | string/null | Encrypted signing key (present for `did_plc`) | +| `attached_account_did` | string/null | Linked account DID (present for `attach_account`) | +| `setup_complete` | boolean | Whether identity setup has been completed | +| `created_at` | string | ISO 8601 timestamp | +| `updated_at` | string | ISO 8601 timestamp | + +### Identity modes + +| Mode | Description | +| ---------------- | ---------------------------------------------------- | +| `did_web` | HappyView derives a `did:web` from its public URL | +| `did_plc` | HappyView manages its own `did:plc` identity | +| `attach_account` | HappyView uses an existing AT Protocol account's DID | +| `not_exposed` | No service identity is exposed on the network | + +## Update service identity + +``` +PUT /admin/service-identity +``` + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/service-identity", { + method: "PUT", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + mode: "did_web", + }), +}); +``` + +```js tab="JavaScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/service-identity", { + method: "PUT", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + mode: "did_web", + }), +}); +``` + +```rust tab="Rust" tab-group="language" +let response = client + .put("http://127.0.0.1:3000/admin/service-identity") + .bearer_auth(token) + .json(&serde_json::json!({ + "mode": "did_web" + })) + .send() + .await?; +``` + +```go tab="Go" tab-group="language" +body := bytes.NewBufferString(`{"mode": "did_web"}`) +req, _ := http.NewRequest("PUT", "http://127.0.0.1:3000/admin/service-identity", body) +req.Header.Set("Authorization", "Bearer "+token) +req.Header.Set("Content-Type", "application/json") +resp, err := http.DefaultClient.Do(req) +``` + +```sh tab="cURL" tab-group="language" +curl -X PUT http://127.0.0.1:3000/admin/service-identity \ + -H "$AUTH" \ + -H 'Content-Type: application/json' \ + -d '{"mode": "did_web"}' +``` + +**Input:** + +| Field | Type | Required | Description | +| ---------------------- | ------ | -------- | -------------------------------------------------------- | +| `mode` | string | Yes | `did_web`, `did_plc`, `attach_account`, or `not_exposed` | +| `did` | string | No | Service DID (required for `did_plc`) | +| `signing_key_enc` | string | No | Encrypted signing key (for `did_plc`) | +| `rotation_key_enc` | string | No | Encrypted rotation key (for `did_plc`) | +| `attached_account_did` | string | No | Account DID to attach (for `attach_account`) | + +**Response**: `204 No Content` diff --git a/packages/docs/content/docs/api-reference/admin/verification-methods.md b/packages/docs/content/docs/api-reference/admin/verification-methods.md new file mode 100644 index 0000000..123e8a6 --- /dev/null +++ b/packages/docs/content/docs/api-reference/admin/verification-methods.md @@ -0,0 +1,216 @@ +--- +title: "Verification Methods" +--- + +Manage DID verification methods (P-256 keypairs) used for attestation signing and PLC operations. + +```ts tab="TypeScript" tab-group="language" +const TOKEN = "hv_..."; // your API key +const headers = { Authorization: `Bearer ${TOKEN}` }; +``` + +```js tab="JavaScript" tab-group="language" +const TOKEN = "hv_..."; // your API key +const headers = { Authorization: `Bearer ${TOKEN}` }; +``` + +```rust tab="Rust" tab-group="language" +let token = "hv_..."; // your API key +``` + +```go tab="Go" tab-group="language" +token := "hv_..." // your API key +``` + +```sh tab="cURL" tab-group="language" +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## List verification methods + +``` +GET /admin/verification-methods +``` + +Requires `settings:manage` permission. + +```ts tab="TypeScript" tab-group="language" +interface VerificationMethod { + id: string; + fragment_id: string; + key_type: string; + public_key_multibase: string; + created_at: string; +} + +const response = await fetch("http://127.0.0.1:3000/admin/verification-methods", { + headers, +}); +const data: VerificationMethod[] = await response.json(); +``` + +```js tab="JavaScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/verification-methods", { + headers, +}); +const data = await response.json(); +``` + +```rust tab="Rust" tab-group="language" +let response = client + .get("http://127.0.0.1:3000/admin/verification-methods") + .bearer_auth(token) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` + +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/verification-methods", nil) +req.Header.Set("Authorization", "Bearer "+token) +resp, err := http.DefaultClient.Do(req) +``` + +```sh tab="cURL" tab-group="language" +curl http://127.0.0.1:3000/admin/verification-methods -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "fragment_id": "#attestation", + "key_type": "Multikey", + "public_key_multibase": "zDnae...", + "created_at": "2026-06-27T00:00:00Z" + } +] +``` + +## Create a verification method + +``` +POST /admin/verification-methods +``` + +Requires `settings:manage` permission. Generates a new P-256 keypair. The private key is encrypted at rest with AES-256-GCM using `TOKEN_ENCRYPTION_KEY`. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/verification-methods", { + method: "POST", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + fragment_id: "#attestation", + }), +}); +const data: VerificationMethod = await response.json(); +``` + +```js tab="JavaScript" tab-group="language" +const response = await fetch("http://127.0.0.1:3000/admin/verification-methods", { + method: "POST", + headers: { + ...headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + fragment_id: "#attestation", + }), +}); +const data = await response.json(); +``` + +```rust tab="Rust" tab-group="language" +let response = client + .post("http://127.0.0.1:3000/admin/verification-methods") + .bearer_auth(token) + .json(&serde_json::json!({ + "fragment_id": "#attestation" + })) + .send() + .await?; +let data: serde_json::Value = response.json().await?; +``` + +```go tab="Go" tab-group="language" +body := bytes.NewBufferString(`{"fragment_id": "#attestation"}`) +req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/verification-methods", body) +req.Header.Set("Authorization", "Bearer "+token) +req.Header.Set("Content-Type", "application/json") +resp, err := http.DefaultClient.Do(req) +``` + +```sh tab="cURL" tab-group="language" +curl -X POST http://127.0.0.1:3000/admin/verification-methods \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{"fragment_id": "#attestation"}' +``` + +| Field | Type | Required | Description | +| ------------- | ------ | -------- | --------------------------------------------------------------------------------- | +| `fragment_id` | string | yes | DID document fragment identifier, must start with `#` followed by alphanumerics or underscores | + +**Response**: `201 Created` + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "fragment_id": "#attestation", + "key_type": "Multikey", + "public_key_multibase": "zDnae...", + "created_at": "2026-06-27T00:00:00Z" +} +``` + +## Delete a verification method + +``` +DELETE /admin/verification-methods/{fragment_id} +``` + +Requires `settings:manage` permission. + +```ts tab="TypeScript" tab-group="language" +const response = await fetch( + "http://127.0.0.1:3000/admin/verification-methods/attestation", + { method: "DELETE", headers }, +); +``` + +```js tab="JavaScript" tab-group="language" +const response = await fetch( + "http://127.0.0.1:3000/admin/verification-methods/attestation", + { method: "DELETE", headers }, +); +``` + +```rust tab="Rust" tab-group="language" +client + .delete("http://127.0.0.1:3000/admin/verification-methods/attestation") + .bearer_auth(token) + .send() + .await?; +``` + +```go tab="Go" tab-group="language" +req, _ := http.NewRequest("DELETE", + "http://127.0.0.1:3000/admin/verification-methods/attestation", nil) +req.Header.Set("Authorization", "Bearer "+token) +resp, err := http.DefaultClient.Do(req) +``` + +```sh tab="cURL" tab-group="language" +curl -X DELETE http://127.0.0.1:3000/admin/verification-methods/attestation \ + -H "$AUTH" +``` + +The `fragment_id` path parameter is the identifier without the leading `#` (e.g. `attestation` for `#attestation`). + +**Response**: `204 No Content`. Returns `404 Not Found` if no method with that fragment ID exists.