···2020| `TOKEN_ENCRYPTION_KEY` | no | --- | Base64-encoded 32-byte key for encrypting stored OAuth tokens. **Strongly recommended in production** |
2121| `DEFAULT_RATE_LIMIT_CAPACITY` | no | `100` | Default token bucket capacity used when registering a new API client |
2222| `DEFAULT_RATE_LIMIT_REFILL_RATE` | no | `2.0` | Default token bucket refill rate (tokens/second) for new API clients |
2323+| `ATTESTATION_PRIVATE_KEY` | no | auto-generated | Hex-encoded 32-byte secp256k1 private key for [attestation signing](../guides/features/attestation-signing.md). Auto-generated and persisted to database on first run |
2424+| `ATTESTATION_KEY_ID` | no | `did:web:{host}#attestation` | Key identifier included in attestation signatures. Derived from `PUBLIC_URL` by default |
2525+| `ATTESTATION_SIG_TYPE` | no | app-specific NSID | `$type` value used in attestation signature objects |
2326| `RUST_LOG` | no | `happyview=debug,tower_http=debug` | Log filter (uses `tracing_subscriber::EnvFilter`) |
2427| `APP_NAME` | no | --- | Application name shown on OAuth authorization screens. Overridden by database setting if set via admin API |
2528| `LOGO_URI` | no | --- | URL to application logo for OAuth screens. Overridden by database setting or logo upload |
···11+# Attestation Signing
22+33+HappyView can sign records with an ECDSA (secp256k1) keypair so their origin can be verified later. Lua scripts call `atproto.sign()` to attach an inline signature to a record and `atproto.verify_signature()` to check one. HappyView's implementation follows the [atproto attestation spec](https://tangled.org/strings/did:plc:cbkjy5n7bk3ax2wplmtjofq2/3m3fy2xuahc22).
44+55+## How it works
66+77+1. HappyView loads or generates a secp256k1 keypair on startup
88+2. `atproto.sign(record)` encodes the record to DAG-CBOR, computes its CID, and signs the CID with the private key
99+3. The signature is added to the record's `signatures` array as an inline object
1010+4. `atproto.verify_signature(record, sig, repo_did)` recomputes the CID and verifies the signature
1111+1212+The repo DID is included in the signed data — a signature for one user's record can't be replayed against another's. Any modification to the record invalidates the signature.
1313+1414+## Setup
1515+1616+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.
1717+1818+To use an explicit key instead, set the `ATTESTATION_PRIVATE_KEY` environment variable:
1919+2020+| Variable | Required | Default | Description |
2121+|----------|----------|---------|-------------|
2222+| `ATTESTATION_PRIVATE_KEY` | no | auto-generated | Hex-encoded 32-byte secp256k1 private key |
2323+| `ATTESTATION_KEY_ID` | no | `did:web:{host}#attestation` | Key identifier included in signatures. Derived from `PUBLIC_URL` by default |
2424+| `ATTESTATION_SIG_TYPE` | no | app-specific NSID | The `$type` value used in signature objects |
2525+2626+The key ID defaults to a `did:web` derived from your `PUBLIC_URL`. For example, `PUBLIC_URL=https://happyview.example.com` produces a key ID of `did:web:happyview.example.com#attestation`.
2727+2828+### Priority order
2929+3030+HappyView checks for signing configuration in this order:
3131+3232+1. **Environment variables** — if `ATTESTATION_PRIVATE_KEY` is set, it's used
3333+2. **Database** — if previously generated keys exist in `instance_settings`, they're loaded
3434+3. **Auto-generation** — a new key is generated and persisted to the database
3535+3636+If key loading fails for any reason, signing is disabled and `atproto.sign` / `atproto.verify_signature` will be `nil` in Lua scripts.
3737+3838+## Using in Lua scripts
3939+4040+Available in queries, procedures, and index hooks via the [atproto API](../../reference/lua/atproto-api.md).
4141+4242+### Signing a record
4343+4444+```lua
4545+function handle()
4646+ local r = Record(collection, input)
4747+ r:save()
4848+4949+ local sig = atproto.sign({ text = input.text, createdAt = input.createdAt })
5050+ return { uri = r._uri, cid = r._cid, signature = sig }
5151+end
5252+```
5353+5454+The returned signature object:
5555+5656+```json
5757+{
5858+ "$type": "your.app.attestation",
5959+ "key": "did:web:happyview.example.com#attestation",
6060+ "signature": {
6161+ "$bytes": "base64-encoded-signature"
6262+ }
6363+}
6464+```
6565+6666+### Verifying a signature
6767+6868+```lua
6969+function handle()
7070+ local record = db.get(params.uri)
7171+ if not record then
7272+ return { error = "not found" }
7373+ end
7474+7575+ local sig = record.signatures and record.signatures[1]
7676+ if not sig then
7777+ return { record = record, verified = false }
7878+ end
7979+8080+ local valid = atproto.verify_signature(record, sig, record.did)
8181+ return { record = record, verified = valid }
8282+end
8383+```
8484+8585+### Checking availability
8686+8787+Both functions are `nil` when no signer is configured:
8888+8989+```lua
9090+if atproto.sign then
9191+ record.signature = atproto.sign(record)
9292+end
9393+```
9494+9595+## Signature format
9696+9797+Signatures are stored as objects in the record's `signatures` array:
9898+9999+| Field | Type | Description |
100100+| ----------- | ------ | ------------------------------------ |
101101+| `$type` | string | Signature type NSID |
102102+| `key` | string | Key identifier (DID with fragment) |
103103+| `signature` | table | Contains `$bytes` (base64-encoded) |
104104+105105+## Next steps
106106+107107+- [atproto API reference](../../reference/lua/atproto-api.md#atprotosign) — `atproto.sign` and `atproto.verify_signature` parameter docs
108108+- [Signed Record](../scripting/signed-record.md) — save a record with an attestation signature
109109+- [Verify Signed Record](../scripting/signed-record-verify.md) — fetch a record and verify its signature
···43434444### Procedure globals
45454646-| Global | Type | Description |
4747-| ------------ | ------ | ------------------------------------------------------- |
4848-| `method` | string | The XRPC method name (e.g. `xyz.statusphere.setStatus`) |
4949-| `input` | table | Parsed JSON request body |
5050-| `caller_did` | string | DID of the authenticated user |
5151-| `collection` | string | Target collection NSID |
4646+| Global | Type | Description |
4747+| -------------- | ------- | ------------------------------------------------------- |
4848+| `method` | string | The XRPC method name (e.g. `xyz.statusphere.setStatus`) |
4949+| `input` | table | Parsed JSON request body |
5050+| `params` | table | Query string parameters |
5151+| `caller_did` | string | DID of the authenticated user |
5252+| `collection` | string | Target collection NSID |
5353+| `delegate_did` | string? | DID of the delegated account, if using write delegation |
5454+| `env` | table | Script variables configured in the dashboard |
52555356### Query globals
54575555-| Global | Type | Description |
5656-| ------------ | ------ | ------------------------------------------------ |
5757-| `method` | string | The XRPC method name |
5858-| `params` | table | Query string parameters (all values are strings) |
5959-| `collection` | string | Target collection NSID |
6060-6161-Queries are unauthenticated: there is no `caller_did` or `input`.
5858+| Global | Type | Description |
5959+| ------------ | ------- | ------------------------------------------------ |
6060+| `method` | string | The XRPC method name |
6161+| `params` | table | Query string parameters (all values are strings) |
6262+| `collection` | string | Target collection NSID |
6363+| `caller_did` | string? | DID of the authenticated user (nil if unauthenticated) |
6464+| `env` | table | Script variables configured in the dashboard |
62656366## Utility globals
6467···127130local data = json.decode(resp.body)
128131```
129132133133+## XRPC Lua API
134134+135135+The `xrpc` table lets scripts call other XRPC endpoints — both local and proxied. Available in both queries and procedures.
136136+137137+See the full [XRPC Lua API reference](../reference/lua/xrpc-lua-api.md) for `xrpc.query` and `xrpc.procedure`.
138138+139139+Quick example:
140140+141141+```lua
142142+local resp = xrpc.query("xyz.statusphere.listStatuses", { limit = 5 })
143143+local data = json.decode(resp.body)
144144+```
145145+130146## atproto API
131147132132-The `atproto` table provides atproto utility functions like DID resolution and label queries.
148148+The `atproto` table provides atproto utility functions like DID resolution, label queries, and record signing.
133149134134-See the full [atproto API reference](../reference/lua/atproto-api.md) for `atproto.resolve_service_endpoint`, `atproto.get_labels`, and `atproto.get_labels_batch`.
150150+See the full [atproto API reference](../reference/lua/atproto-api.md) for `atproto.resolve_service_endpoint`, `atproto.get_labels`, `atproto.get_labels_batch`, `atproto.sign`, and `atproto.verify_signature`.
135151136152## JSON API
137153···181197- [Paginated list](scripting/paginated-list.md) — list records with cursor-based pagination and DID filtering
182198- [List or fetch](scripting/list-or-fetch.md) — combined single-record lookup and paginated listing
183199- [Expanded query](scripting/expanded-query.md) — list statuses with user profiles in a single response
200200+- [Verify signed record](scripting/signed-record-verify.md) — fetch a record and verify its attestation signature
184201185202**Procedures:**
186203- [Create a record](scripting/create-record.md) — simple write that saves input as a record
···190207- [Sidecar records](scripting/sidecar-records.md) — create linked records across collections with a shared rkey
191208- [Cascading delete](scripting/cascading-delete.md) — delete a record and all related records
192209- [Complex mutations](scripting/complex-mutations.md) — load, transform, and save a record with multiple field changes
210210+- [Signed record](scripting/signed-record.md) — save a record with an attestation signature
193211194212**Index Hooks:**
195213- [Algolia sync](scripting/algolia-sync.md) — push records to an Algolia search index on create/update/delete
···11+# Query: Verify Signed Record
22+33+Fetch a record and verify its attestation signature.
44+55+**Lexicon type:** query
66+77+```lua
88+function handle()
99+ local record = db.get(params.uri)
1010+ if not record then
1111+ return { error = "not found" }
1212+ end
1313+1414+ local verified = false
1515+ if atproto.verify_signature and record.signature then
1616+ verified = atproto.verify_signature(
1717+ { text = record.text, createdAt = record.createdAt },
1818+ record.signature,
1919+ params.did
2020+ )
2121+ end
2222+2323+ return { record = record, verified = verified }
2424+end
2525+```
2626+2727+## How it works
2828+2929+1. Fetch the record by AT URI.
3030+2. If a signature is present, rebuild the same field table that was signed and verify it with [`atproto.verify_signature()`](../../reference/lua/atproto-api.md#atprotoverify_signature).
3131+3. Return `verified = true` if the signature is valid, `false` if it's missing, invalid, or the signer isn't configured.
3232+3333+## Usage
3434+3535+```sh
3636+curl "http://127.0.0.1:3000/xrpc/xyz.example.getPost?uri=at://did:plc:abc/xyz.example.post/3abc123&did=did:plc:abc"
3737+```
3838+3939+```json
4040+{
4141+ "record": {
4242+ "uri": "at://did:plc:abc/xyz.example.post/3abc123",
4343+ "text": "Hello world",
4444+ "createdAt": "2026-04-30T12:00:00Z"
4545+ },
4646+ "verified": true
4747+}
4848+```
4949+5050+## Use case
5151+5252+Pair this with the [Signed Record](signed-record.md) procedure to create a write-then-verify flow. The query re-derives the CID from the same fields that were originally signed, so any tampering between write and read is caught.
5353+5454+See [Attestation Signing](../features/attestation-signing.md) for setup and configuration.
···11+# Procedure: Signed Record
22+33+Save a record with an attestation signature attached.
44+55+**Lexicon type:** procedure
66+77+```lua
88+function handle()
99+ local r = Record(collection, {
1010+ text = input.text,
1111+ createdAt = now(),
1212+ })
1313+ r:save()
1414+1515+ local sig = nil
1616+ if atproto.sign then
1717+ sig = atproto.sign({ text = input.text, createdAt = r.createdAt })
1818+ end
1919+2020+ return { uri = r._uri, cid = r._cid, signature = sig }
2121+end
2222+```
2323+2424+## How it works
2525+2626+1. Create and save the record.
2727+2. Sign the record fields with [`atproto.sign()`](../../reference/lua/atproto-api.md#atprotosign). The `nil` guard lets the script work without a signer configured.
2828+3. Return the signature alongside the URI.
2929+3030+## Usage
3131+3232+```sh
3333+curl -X POST http://127.0.0.1:3000/xrpc/xyz.example.createPost \
3434+ -H "X-Client-Key: $CLIENT_KEY" \
3535+ -H "Authorization: Bearer $TOKEN" \
3636+ -H "Content-Type: application/json" \
3737+ -d '{ "text": "Hello world" }'
3838+```
3939+4040+```json
4141+{
4242+ "uri": "at://did:plc:abc/xyz.example.post/3abc123",
4343+ "cid": "bafyrei...",
4444+ "signature": {
4545+ "$type": "your.app.attestation",
4646+ "key": "did:web:happyview.example.com#attestation",
4747+ "signature": { "$bytes": "..." }
4848+ }
4949+}
5050+```
5151+5252+## Use case
5353+5454+Attestation signatures let clients verify that a record was processed by your HappyView instance — useful for contributions, moderation decisions, or cross-instance data where provenance matters. The signature covers both the record content and the author's DID, so it can't be replayed across users or tampered with.
5555+5656+See [Attestation Signing](../features/attestation-signing.md) for setup and configuration, or [Verify Signed Record](signed-record-verify.md) for the read-side counterpart.
+77-15
packages/docs/docs/reference/lua/atproto-api.md
···10101111Resolves a DID to its atproto service endpoint URL by fetching the DID document. Supports both `did:plc:*` (via the PLC directory) and `did:web:*` (via `.well-known/did.json`).
12121313-| Parameter | Type | Description |
1414-| --------- | ------ | ------------------------ |
1515-| `did` | string | The DID to resolve |
1313+| Parameter | Type | Description |
1414+| --------- | ------ | ------------------ |
1515+| `did` | string | The DID to resolve |
16161717**Returns:** The service endpoint URL as a string, or `nil` if resolution fails (DID not found, no PDS service in document, network error).
1818···49495050Returns an array of labels for a single AT URI. Merges external labels (from subscribed labelers) with self-labels (from the record's `labels.values[]` field).
51515252-| Parameter | Type | Description |
5353-| --------- | ------ | ------------------------------ |
5454-| `uri` | string | AT URI of the record to query |
5252+| Parameter | Type | Description |
5353+| --------- | ------ | ----------------------------- |
5454+| `uri` | string | AT URI of the record to query |
55555656Each label in the array is a table with:
57575858-| Field | Type | Description |
5959-| ----- | ------ | ---------------------------------------- |
6060-| `src` | string | DID of the labeler (or record author) |
6161-| `uri` | string | AT URI this label applies to |
6262-| `val` | string | Label value (e.g. "nsfw", "!hide") |
6363-| `cts` | string | Timestamp when the label was created |
5858+| Field | Type | Description |
5959+| ----- | ------ | ------------------------------------- |
6060+| `src` | string | DID of the labeler (or record author) |
6161+| `uri` | string | AT URI this label applies to |
6262+| `val` | string | Label value (e.g. "nsfw", "!hide") |
6363+| `cts` | string | Timestamp when the label was created |
64646565Expired labels are automatically filtered out. Returns an empty array if no labels exist.
6666···72727373Batch version of `get_labels`. Takes an array of AT URIs and returns a table keyed by URI, where each value is an array of labels.
74747575-| Parameter | Type | Description |
7676-| --------- | ----- | ------------------------ |
7777-| `uris` | table | Array of AT URI strings |
7575+| Parameter | Type | Description |
7676+| --------- | ----- | ----------------------- |
7777+| `uris` | table | Array of AT URI strings |
78787979**Returns:** A table keyed by URI. Each value is an array of label tables (same shape as `get_labels`). URIs with no labels have an empty array.
8080···105105 end
106106end
107107```
108108+109109+## atproto.sign
110110+111111+```lua
112112+local sig = atproto.sign(record)
113113+```
114114+115115+Signs a record and returns the inline signature object. Only available when an attestation signer is configured — if no signer is configured, `atproto.sign` is `nil`.
116116+117117+| Parameter | Type | Description |
118118+| --------- | ----- | ----------------------- |
119119+| `record` | table | The record data to sign |
120120+121121+**Returns:** A signature table with:
122122+123123+| Field | Type | Description |
124124+| ----------- | ------ | --------------------------------------------------- |
125125+| `key` | string | The signing key ID (e.g. `did:web:example#signing`) |
126126+| `signature` | table | Contains `$bytes` with the signature |
127127+128128+### Examples
129129+130130+```lua
131131+-- Sign a record before returning it
132132+local record = { contributionType = "correction", changes = { name = "Test" } }
133133+local sig = atproto.sign(record)
134134+record.signature = sig
135135+return record
136136+137137+-- Check if signing is available
138138+if atproto.sign then
139139+ local sig = atproto.sign(record)
140140+end
141141+```
142142+143143+## atproto.verify_signature
144144+145145+```lua
146146+local valid = atproto.verify_signature(record, signature, repo_did)
147147+```
148148+149149+Verifies that an inline signature was produced by this HappyView instance. Only available when an attestation signer is configured — if no signer is configured, `atproto.verify_signature` is `nil`.
150150+151151+| Parameter | Type | Description |
152152+| ----------- | ------ | ------------------------------------------ |
153153+| `record` | table | The record data |
154154+| `signature` | table | The signature object from `atproto.sign()` |
155155+| `repo_did` | string | The repo DID |
156156+157157+**Returns:** `true` if the signature is valid, `false` otherwise. Returns `false` on failure rather than raising an error.
158158+159159+### Examples
160160+161161+```lua
162162+-- Verify a signature roundtrip
163163+local record = { contributionType = "correction", changes = { name = "Test" } }
164164+local sig = atproto.sign(record)
165165+local valid = atproto.verify_signature(record, sig, caller_did)
166166+if not valid then
167167+ return { error = "signature verification failed" }
168168+end
169169+```
+19-1
packages/docs/docs/reference/lua/database-api.md
···95959696### SQL dialect
97979898-Write SQL in **SQLite syntax** — HappyView translates it to Postgres at runtime if you're using Postgres. See [Database Setup](../../guides/database/database-setup.md) for details on what gets translated. If you need database-specific SQL that can't be translated, check `db.is_postgres()` at runtime.
9898+Write SQL in **SQLite syntax** — HappyView translates it to Postgres at runtime if you're using Postgres. See [Database Setup](../../guides/database/database-setup.md) for details on what gets translated. If you need database-specific SQL that can't be translated, check `db.backend()` at runtime.
9999100100### Column type mapping
101101···108108| `TEXT` (JSON) | `JSON`, `JSONB` | table |
109109| `TEXT` (ISO 8601) | `TIMESTAMPTZ` | string (ISO 8601) |
110110| Other | Other | string (fallback) |
111111+112112+## db.backend
113113+114114+```lua
115115+local backend = db.backend()
116116+-- "sqlite" or "postgres"
117117+```
118118+119119+Returns `"sqlite"` or `"postgres"`. Useful when you need database-specific SQL that can't be automatically translated.
120120+121121+```lua
122122+if db.backend() == "postgres" then
123123+ db.raw("SELECT * FROM records WHERE record @> $1::jsonb", { json.encode({ status = "active" }) })
124124+else
125125+ -- SQLite fallback
126126+ db.raw("SELECT * FROM records WHERE json_extract(record, '$.status') = $1", { "active" })
127127+end
128128+```
+76
packages/docs/docs/reference/lua/xrpc-lua-api.md
···11+# XRPC Lua API
22+33+The `xrpc` table provides cross-endpoint XRPC calls. Available in queries, procedures, and [index hooks](../../guides/indexing/index-hooks.md).
44+55+## xrpc.query
66+77+```lua
88+local resp = xrpc.query("xyz.statusphere.listStatuses", { -- required: XRPC method name
99+ limit = 10, -- optional: query parameters
1010+})
1111+```
1212+1313+Calls an XRPC query. If the method matches a locally registered query lexicon, it runs locally. Otherwise, the request is proxied to the NSID's authority.
1414+1515+**Returns:** A table with:
1616+1717+| Field | Type | Description |
1818+| -------- | ------- | -------------------- |
1919+| `status` | integer | HTTP status code |
2020+| `body` | string | Response body (JSON) |
2121+2222+The body is a raw JSON string — use `json.decode(resp.body)` to parse it.
2323+2424+### Examples
2525+2626+```lua
2727+-- Call a local query endpoint
2828+local resp = xrpc.query("xyz.statusphere.listStatuses", { limit = 5 })
2929+local data = json.decode(resp.body)
3030+for _, record in ipairs(data.records) do
3131+ log(record.uri)
3232+end
3333+3434+-- Call without parameters
3535+local resp = xrpc.query("com.example.getConfig")
3636+3737+-- Proxy to a remote XRPC endpoint
3838+local resp = xrpc.query("app.bsky.feed.getAuthorFeed", {
3939+ actor = "did:plc:abc123",
4040+ limit = 10,
4141+})
4242+```
4343+4444+## xrpc.procedure
4545+4646+```lua
4747+local resp = xrpc.procedure(
4848+ "xyz.statusphere.setStatus", -- required: XRPC method name
4949+ { status = "hello" }, -- required: request body
5050+ { someParam = "value" } -- optional: query parameters
5151+)
5252+```
5353+5454+Calls an XRPC procedure using the current request's `caller_did` for authentication. If the method matches a locally registered procedure lexicon, it runs locally. Otherwise, the request is proxied.
5555+5656+Requires a `caller_did` — raises an error without one.
5757+5858+**Returns:** A table with the same shape as `xrpc.query` responses (`status` and `body`).
5959+6060+### Examples
6161+6262+```lua
6363+-- Call a local procedure
6464+local resp = xrpc.procedure("xyz.statusphere.setStatus", {
6565+ status = "hello",
6666+ createdAt = now(),
6767+})
6868+6969+if resp.status ~= 200 then
7070+ return { error = "failed: " .. resp.body }
7171+end
7272+7373+-- Parse the response
7474+local result = json.decode(resp.body)
7575+return { uri = result.uri }
7676+```