diff --git a/packages/docs/content/docs/api-reference/admin/linked-repos.md b/packages/docs/content/docs/api-reference/admin/linked-repos.md new file mode 100644 index 0000000..d312dde --- /dev/null +++ b/packages/docs/content/docs/api-reference/admin/linked-repos.md @@ -0,0 +1,213 @@ +--- +title: "Linked Repos" +--- + +Admin API endpoints for managing linked repos and their invites. The whole lifecycle is available here, so the feature can be driven headlessly without the dashboard. For a conceptual overview, see [Linked Repos](../../guides/linked-repos.md). + +The repo write operations themselves are not part of this API. The admin API manages grants; [Lua scripts](../lua/linked-repos-api.md) use them. + +## List grants + +```http +GET /admin/linked-repos +``` + +Returns every grant, newest first. Never includes session data or tokens. + +**Permission:** `linked-repos:read` + +**Response:** + +```json +{ + "linked_repos": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "did": "did:plc:abc123", + "handle": "partner.example.com", + "reason": "Mirror published notes", + "scopes": "atproto repo:com.example.note?action=create,update", + "status": "active", + "last_error": null, + "last_refreshed_at": "2026-07-30T12:00:00Z", + "authorized_at": "2026-07-29T09:14:00Z", + "created_by": "did:plc:admin", + "created_at": "2026-07-29T09:12:00Z" + } + ] +} +``` + +`did` is `null` for an open grant that hasn't been authorized yet. `status` is one of `pending`, `active`, or `needs_reauth`. + +## Create a grant + +```http +POST /admin/linked-repos +``` + +**Permission:** `linked-repos:create` + +**Body:** + +| Field | Type | Description | +| -------- | ------ | ------------------------------------------------------------------------------ | +| `handle` | string | Optional. A handle or DID to pin the grant to. Omit for an open grant. | +| `reason` | string | Optional. Why access is being requested; shown to the recipient of an invite. | +| `scopes` | string | Required. Whitespace-separated scope string. | + +```json +{ + "handle": "partner.example.com", + "reason": "Mirror published notes", + "scopes": "repo:com.example.note?action=create,update" +} +``` + +Returns the created grant, in the same shape as the list endpoint. + +Scopes are validated on the way in: a malformed scope or an invalid NSID is rejected with a `400`. `atproto` is added automatically if the string doesn't contain it, so the stored scopes may differ from what you sent. Scopes are immutable afterwards; there is no update endpoint. + +Pinning to a DID that already has a grant returns a `409`. + +## Start an authorization + +```http +POST /admin/linked-repos/{id}/authorize +``` + +Returns a URL to send the browser to. This only works for a grant that names a handle or DID. An open grant has no identity to authorize against, so it returns a `400` telling you to use an invite link instead. + +**Permission:** `linked-repos:create` + +**Response:** + +```json +{ + "authorize_url": "https://pds.example.com/oauth/authorize?..." +} +``` + + +A `400` mentioning cached client metadata is expected occasionally right after creating a grant. Authorization servers cache the client metadata document, so a freshly added scope may not be visible yet. Retrying after a minute usually clears it. The error text distinguishes this from a scope the server genuinely won't accept. + + +## Mint an invite + +```http +POST /admin/linked-repos/{id}/invite +``` + +Mints a single-use invite link for the grant. + +**Permission:** `linked-repos:create` + +**Body:** optional. + +| Field | Type | Description | +| ------------ | ------ | ----------------------------------------------------------------- | +| `expires_in` | number | Optional. Lifetime in seconds, 60 to 2592000 (30 days). Default 7 days. | + +**Response:** + +```json +{ + "invite_url": "https://appview.example.com/auth/linked-repo/start?token=...", + "expires_at": "2026-08-06T09:12:00Z" +} +``` + + +This is the **only** response that ever contains the invite URL, and it cannot be recovered afterwards: only a hash is stored. A headless caller must capture it here. If it's lost, revoke the invite and mint another. + + +## List invites + +```http +GET /admin/linked-repos/{id}/invites +``` + +Returns the grant's outstanding, unexpired invites. Expired invites are not listed. + +**Permission:** `linked-repos:read` + +**Response:** + +```json +{ + "invites": [ + { + "invite_id": "a5e55c20844abe8068d142a815c6730d4e4ae73309c145dc1c5b451d9e7e45c8", + "expires_at": "2026-08-06T09:12:00Z" + } + ] +} +``` + +`invite_id` is the stored SHA-256 of the token. It's safe to display and is what you pass to the revoke endpoint, but it cannot be turned back into a usable link. + +## Revoke an invite + +```http +DELETE /admin/linked-repos/{id}/invites/{invite_id} +``` + +**Permission:** `linked-repos:create` + +Revoking uses the same permission as minting, since they're one lifecycle: whoever can send a link should be able to take it back. + +**Response:** + +```json +{ "revoked": true } +``` + +Returns `404` if no matching invite exists for that grant, which includes an invite that has already been used, revoked, or expired. + +## Delete a grant + +```http +DELETE /admin/linked-repos/{id} +``` + +Deletes the grant and its stored OAuth session together. + +**Permission:** `linked-repos:delete` + +**Response:** + +```json +{ "deleted": true } +``` + +Scopes are immutable, so this is also how you change what a grant can do: delete it and create a new one. Deleting on HappyView's side does not revoke the authorization at the PDS. + +## Headless flow + +Creating a grant and getting it authorized without the dashboard: + +```bash +# 1. Create an open grant +GRANT=$(curl -s -X POST https://appview.example.com/admin/linked-repos \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"reason":"Mirror notes","scopes":"repo:com.example.note?action=create"}' \ + | jq -r .id) + +# 2. Mint an invite, capturing the URL — it can't be read back later +curl -s -X POST "https://appview.example.com/admin/linked-repos/$GRANT/invite" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"expires_in":3600}' | jq -r .invite_url + +# 3. Send that URL to the repo owner. Poll until they've completed it. +curl -s https://appview.example.com/admin/linked-repos \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + | jq --arg id "$GRANT" '.linked_repos[] | select(.id == $id) | .status' +``` + +## See also + +- [Linked Repos guide](../../guides/linked-repos.md): Concepts, scopes, and the invite flow +- [Linked Repos Lua API](../lua/linked-repos-api.md): Writing to a linked repo from a script +- [Permissions](../../guides/permissions.md): The full permission list diff --git a/packages/docs/content/docs/api-reference/lua/linked-repos-api.md b/packages/docs/content/docs/api-reference/lua/linked-repos-api.md new file mode 100644 index 0000000..2969335 --- /dev/null +++ b/packages/docs/content/docs/api-reference/lua/linked-repos-api.md @@ -0,0 +1,226 @@ +--- +title: "Linked Repos API" +--- + +Lua API for writing to repos an admin has linked to this instance. For a conceptual overview, see [Linked Repos](../../guides/linked-repos.md). + +## `linked_repos` table + +The `linked_repos` table is available in **all** script contexts (procedures, queries, record scripts, label scripts, and job scripts). + +### `linked_repos.list()` + +List every linked repo, including ones that aren't usable yet. + +**Returns:** `table` — an array of tables with `id`, `did`, `handle`, `reason`, `status`, and `scopes`. + +```lua +for _, repo in ipairs(linked_repos.list()) do + log(repo.did .. " is " .. repo.status) +end +``` + +### `linked_repos.get(did)` + +Get a handle for a linked repo. + +**Parameters:** + +| Parameter | Type | Description | +| --------- | ------ | -------------------------- | +| `did` | string | The DID of the linked repo | + +**Returns:** a `Repo` handle, or `nil` if no repo with that DID is linked. + +**Raises:** if the grant exists but isn't usable, either it has never been authorized (`pending`) or its session failed to refresh (`needs_reauth`). The error names which. + +```lua +local repo = linked_repos.get("did:plc:abc123") +if not repo then + return { error = "that repo isn't linked" } +end +``` + +## `Repo` handle + +Every method re-reads the grant from the database before acting. A grant deleted or flipped to `needs_reauth` mid-script is refused on the next call; the handle you're holding can't outlive it. + +Errors raise rather than returning `nil, err`, matching `atproto.spaces`. + +**Fields:** + +| Field | Type | Description | +| -------- | ------- | -------------------------------------- | +| `did` | string | The linked repo's DID | +| `handle` | string? | The handle, when one is known | +| `status` | string | `active`, `pending`, or `needs_reauth` | +| `scopes` | string | The grant's scope string | + +### Scope checks + +The typed methods below check the grant's scopes **locally, before any network call**. A missing scope fails immediately with a message naming the grant and the scope it needs, so you never have to work backwards from an opaque `403`. + +`repo:call()` is the exception: it can't be checked generically, so it goes straight to the PDS, which enforces the token's real scope. + +### Local indexing + +`create_record`, `put_record`, and `delete_record` work the same as the [`Record` API](http://localhost:3000/api-reference/lua/record-api). + +`repo:call()` allows the use of arbitrary XRPCs against the repo's PDS. Note that using this instead of the built-in methods (e.g. `repo:call('com.atproto.repo.createRecord'))` instead of `repo:create_record()`) bypasses HappyView's local indexing. The records will still be indexed when they come from Jetstream. + +### `repo:create_record{...}` + +Create a record. + +**Options:** + +| Key | Type | Description | +| ------------ | ------- | ------------------------------- | +| `collection` | string | Required. The collection NSID. | +| `record` | table | Required. The record body. | +| `rkey` | string? | Optional. Generated if omitted. | + +**Returns:** `table` — `{ uri, cid }`. + +**Requires scope:** `repo:?action=create` + +```lua +local result = repo:create_record{ + collection = "com.example.note", + record = { text = "hello", createdAt = now() }, +} +log("wrote " .. result.uri) +``` + +### `repo:put_record{...}` + +Write a record at a known rkey, or creates the record if it doesn't exist. + +**Options:** + +| Key | Type | Description | +| ------------ | ------- | -------------------------------------------- | +| `collection` | string | Required. The collection NSID. | +| `rkey` | string | Required. The record key. | +| `record` | table | Required. The record body. | +| `swap_cid` | string? | Optional. Compare-and-swap against this CID. | + +**Returns:** `table` — `{ uri, cid }`. + +**Requires scope:** both `action=create` and `action=update` — or only `action=update` when `swap_cid` is supplied. + + +`putRecord` is an upsert: it creates the record when `rkey` doesn't already exist. A grant holding only `update` therefore can't call this, because the call might create something the grant was never given permission to create. For `putRecord` to be reliable, the linked repo must be given both `update` **and** `create`. + +Passing `swap_cid` narrows `putRecord` to updates only. The PDS then requires the record to already exist with that CID to guarantee no create can happen, so only `update` is needed. The trade-off is that you must know the current CID, which usually means reading the record first. + + +```lua +-- Needs create + update +repo:put_record{ + collection = "com.example.note", + rkey = "self", + record = { text = "hello" }, +} + +-- Needs only update +repo:put_record{ + collection = "com.example.note", + rkey = "self", + record = { text = "hello" }, + swap_cid = "bafyreib2rxk3rh6kzwq...", +} +``` + +### `repo:delete_record{...}` + +Delete a record. + +**Options:** + +| Key | Type | Description | +| ------------ | ------ | ------------------------------ | +| `collection` | string | Required. The collection NSID. | +| `rkey` | string | Required. The record key. | + +**Returns:** `true`. + +**Requires scope:** `repo:?action=delete` + +```lua +repo:delete_record{ collection = "com.example.note", rkey = "3k2j..." } +``` + +### `repo:upload_blob(bytes, mime)` + +Upload a blob to the linked repo's PDS. + +**Parameters:** + +| Parameter | Type | Description | +| --------- | ------ | -------------------------------- | +| `bytes` | string | The raw bytes. Binary-safe. | +| `mime` | string | The mime type, e.g. `image/png`. | + +**Returns:** `table` — the blob reference, for embedding in a record. + +**Requires scope:** a `blob:` scope matching the mime type, e.g. `blob:image/*` or `blob:*/*`. + +```lua +local blob = repo:upload_blob(bytes, "image/png") +repo:create_record{ + collection = "com.example.photo", + record = { image = blob, alt = "a cat" }, +} +``` + +### `repo:call(nsid[, opts])` + +Call any XRPC method as the linked repo. This is the escape hatch for anything the typed methods don't cover, including `rpc:` scopes. + +**Parameters:** + +| Parameter | Type | Description | +| --------- | ------ | --------------------------------------- | +| `nsid` | string | The XRPC method to call. | +| `opts` | table? | `params` for a GET, `input` for a POST. | + +**Returns:** `table` — the decoded response. + +**Requires scope:** not checked locally. The PDS enforces the token's actual scope. + +```lua +-- GET +local out = repo:call("com.atproto.repo.listRecords", { + params = { repo = repo.did, collection = "com.example.note", limit = 10 }, +}) + +-- POST +repo:call("com.example.doThing", { input = { value = 42 } }) +``` + + +Because `call` isn't scope-checked locally, it can reach operations the typed methods would refuse. This includes `com.atproto.repo.createRecord`. The local checks exist to fail fast with a useful message, not as the security boundary. The real enforcement is the PDS's own scope check on the token. + + +## Error handling + +All methods raise on failure. Use `pcall` if a script should continue past an error: + +```lua +local ok, err = pcall(function() + repo:create_record{ collection = "com.example.note", record = { text = "hi" } } +end) + +if not ok then + log("write failed: " .. tostring(err)) +end +``` + +A grant whose session can no longer be refreshed flips to `needs_reauth` as a side effect of the failed call, so the dashboard reflects the problem even if your script swallows the error. + +## See also + +- [Linked Repos guide](../../guides/linked-repos.md): Concepts, scopes, and the invite flow +- [Linked Repos admin API](../admin/linked-repos.md): Managing grants and invites +- [Lua Scripting](../../guides/lua-scripting.md): Script contexts and triggers diff --git a/packages/docs/content/docs/guides/linked-repos.md b/packages/docs/content/docs/guides/linked-repos.md new file mode 100644 index 0000000..a72d6b6 --- /dev/null +++ b/packages/docs/content/docs/guides/linked-repos.md @@ -0,0 +1,108 @@ +--- +title: "Linked Repos" +--- + +Linked repos give your AppView durable write access to a specific atproto repo. Once an admin links a repo, any Lua script can write to it through the `linked_repos` global. You can think of these repos like service accounts: an account that your AppView may post on behalf of, a partner repo into which you mirror data, or an operator account your instance maintains on its own. + +This is different from writing on behalf of the user making a request. Those writes use the caller's own OAuth session and only work while that session is live. A linked repo belongs to the instance: HappyView stores the session, handles token refreshes in the background, and allows scripts to act as the linked repo rather than the caller. + +## How it works + +1. An admin adds a linked repo from the dashboard (Access > Linked Repos), choosing which scopes it needs. +2. Either the admin runs the OAuth flow directly, or forwards an invite link to someone else with the ability to login to the repo. +3. Scripts call `linked_repos.get(did)` and write through the returned handle. A background worker keeps the session alive so rarely used repos stay working. + +## Adding a linked repo + +In the dashboard, go to **Access > Linked Repos** and choose **Link a repo** to open the side panel. + +The **handle or DID** is optional, but it decides who is allowed to complete the authorization: + +- If a handle or DID is set, the grant is **pinned**: that is the only account that can be connected. +- If a handle or DID is **not set**, the grant is **open**. Whoever follows the invite gets to decide which account is linked. + +In \*_most scenarios_, you should set the identifier to avoid confusion and thwart bad actors that get their hands on the link. + +The **reason** explains why you're asking for access. It's shown to whoever opens the invite link: it's how a user will decide whether to complete the auth. + +You can limit the **scopes** that are requested when a user follows the invite link into the auth flow. It is best practice to use the minimum necessary scopes. + +Scopes are **immutable**. There is no changing what a grant can do after it's been sent. If you make a mistake, you must delete and recreate a new one. + +## Authorizing + +### Inline + +If the grant names a handle or DID, **Authorize** takes you straight to the OAuth flow. Afterward it will return you to the Linked Repos page. Use this for accounts you control. + +### By invite link + +**Copy link** mints a single-use invite and puts the URL in your clipboard. Send it to whoever has credentials for the repo. + +The URL is returned **once** and is unrecoverable afterwards. If you lose it, revoke the invite and mint another. The dashboard shows outstanding invites and their expiry in the **Invites** column, so you can check whether one is still live and revoke it if you sent it to the wrong person. + +An invite is single-use. The invite is retired once a repo is linked, at which point every outstanding invite for that grant is cleared. + +### What the recipient sees + +The recipient of an invite link **is not required** to be added to your HappyView instance, and they never see your dashboard. They get a page naming your instance, your stated reason, and the access being requested in plain language, then a prompt for their handle (or, for a pinned grant, confirmation of which account the link is for). After approving at their own PDS they land on a result page confirming what was linked and reminding them they can revoke it from their own account settings at any time. + +## Grant status + +| Status | Meaning | +| -------------- | ------------------------------------------------------------------------ | +| `pending` | Created but never authorized | +| `active` | Authorized and usable | +| `needs_reauth` | The stored session failed to refresh; the grant must be authorized again | + +A background worker refreshes each active grant's session periodically, so a rarely-used grant doesn't go stale. If a refresh fails, the grant flips to `needs_reauth` and the error is recorded and shown in the dashboard. A script hitting a dead grant triggers the same transition, so failures surface either way. + +## Using a linked repo from a script + +The `linked_repos` global is available in every script context: procedures, queries, record scripts, label scripts, and job scripts. + +```lua +function handle() + local repo = linked_repos.get("did:plc:abc123") + + repo:create_record{ + collection = "com.example.note", + record = { text = "posted by the AppView" }, + } + + return { ok = true } +end +``` + +Scope checks happen locally, before any network call. If a grant lacks the scope an operation needs, the call fails immediately with a message naming the grant and the missing scope, so you never have to work backwards from an opaque 403. + +Every method re-reads the grant from the database. If a grant is revoked or flips to `needs_reauth` while a long-running script is working, the next call it makes is refused. + +See the [Linked Repos Lua API reference](../api-reference/lua/linked-repos-api.md) for the full surface. + + +Any script can use any linked repo. Scripts are already admin-authored code with database, HTTP, and environment access, and linked repos sit at the same trust level. Be deliberate about scripts that write to a linked repo in response to unauthenticated requests. + + +## Permissions + +| Permission | Grants | +| --------------------- | ------------------------------------------------------------ | +| `linked-repos:read` | View grants, their status, and their outstanding invites | +| `linked-repos:create` | Create grants, start authorizations, mint and revoke invites | +| `linked-repos:delete` | Delete a grant and its stored session | + +Revoking an invite uses `linked-repos:create` rather than `linked-repos:delete`, because minting and revoking are part of the same lifecycle: whoever can send a link should be able to delete it. `linked-repos:delete` is reserved for destroying a grant and the session behind it. + +## Removing a linked repo + +Deleting the grant for a linked repo also removes its stored OAuth session. Scripts writing to that repo will fail immediately. + +Deleting the grant on HappyView's side doesn't revoke the authorization at the PDS. The person who granted access can also revoke it from their own account settings, which is what causes a grant to land in `needs_reauth`. + +## See also + +- [Linked Repos admin API](../api-reference/admin/linked-repos.md): Drive the whole lifecycle headlessly +- [Linked Repos Lua API](../api-reference/lua/linked-repos-api.md): The full script surface +- [Lua Scripting](./lua-scripting.md): Script contexts and triggers +- [Permissions](./permissions.md): The full permission list