diff --git a/feat/private-bookmark/PLAN.md b/feat/private-bookmark/PLAN.md new file mode 100644 index 0000000..ccca106 --- /dev/null +++ b/feat/private-bookmark/PLAN.md @@ -0,0 +1,279 @@ +# Private Bookmark v1 実装計画 + +## 1. 文書の位置づけ + +本書は、`feat/private-bookmark/Proposal.md` に収録した AT Protocol Proposal 0016 Permissioned Data を前提に、Ritoへ「本人だけが利用できるプライベートブックマーク」を追加するためのv1要件を定義する。 + +Proposal 0016は草案であり、Lexicon、XRPC、認可フロー、用語およびPDS実装は変更される可能性がある。実装時は利用対象PDSの対応状況を確認し、未対応PDSでは機能を有効化しない。 + +## 2. 目的 + +- ブックマークを公開repoへ書かず、所有者本人のpermissioned repoへ保存する。 +- 他のAT Protocolユーザー、公開Relay、Jetstream、公開AppView、Ritoの公開APIから参照できないようにする。 +- 複数端末では、同じDIDで認証することでPDSから同じブックマークを取得できるようにする。 +- Ritoおよびブラウザへ不要な永続コピーを作らず、PDSを唯一の永続保存先にする。 + +## 3. プライバシー境界 + +v1が提供するのはProposal 0016に基づくアクセス制御であり、E2EEではない。 + +プライベートブックマークを読み得る主体は次に限定する。 + +- ブックマーク所有者本人 +- 所有者がOAuthで認可したアプリケーション +- レコードを保持するPDS +- サーバー側OAuthの中継が必要な場合に、処理中の値を一時的に扱うRitoサーバー + +PDS運営者や認可済みアプリケーションからも内容を秘匿することはv1の対象外とする。その要件にはクライアント側暗号化と鍵管理を伴う別設計が必要である。 + +## 4. 確定した設計方針 + +### 4.1 PDSを唯一の永続保存先にする + +- private bookmarkの正本は、所有者のPDSにあるpermissioned repoとする。 +- RitoのPostgreSQL、KV、Redis等へprivate bookmarkレコードや派生検索インデックスを保存しない。 +- ブラウザのIndexedDB、LocalStorage、SessionStorage、Service Worker Cacheへprivate bookmarkを保存しない。 +- UIが取得したデータとページングcursorはタブ内メモリだけに保持する。 +- リロード、ログアウト、セッション失効、DID切り替え時にメモリ上のデータを破棄する。 +- キャッシュ導入はv1の性能を実測した後、別の設計判断として行う。 + +保存禁止の対象には少なくとも次を含む。 + +- subject URL +- タイトル、コメント +- タグ、カテゴリ +- OGP情報 +- レコード値、CIDと値の対応表 +- 全文検索用インデックス + +OAuthセッション、CSRF情報、レート制限情報など、レコード本文を含まない既存の認証・運用データはこの禁止対象に含めない。 + +### 4.2 標準XRPCでPDSを操作する + +permissioned repoの読み書きには、Proposal 0016の標準`com.atproto.space.*` XRPCを使用する。 + +- 一覧取得: `com.atproto.space.listRecords` +- 単一取得: `com.atproto.space.getRecord` +- 作成: `com.atproto.space.createRecord` +- 作成または更新: `com.atproto.space.putRecord` +- 削除: `com.atproto.space.deleteRecord` +- 複数書き込み: `com.atproto.space.applyWrites` + +v1は現在のRitoと同じサーバー管理OAuth経路を使用する。ブラウザの`thisClient`はRitoの同一オリジンにある`/xrpc/com.atproto.space.*`を呼び、Next.js XRPCルートが署名済みDIDを検証してOAuthセッションを復元し、利用者のPDSへ同じ標準XRPCを中継する。中継層はセッション復元、DID検証、CSRF検証、PDS XRPCの転送、レスポンス正規化だけを担い、record値を永続化しない。独自の`blue.rito.*`保存プロトコルへ置き換えず、OAuth tokenをブラウザへ渡さない。 + +### 4.3 公開ブックマーク経路と完全に分離する + +- private bookmarkを公開repoの`blue.rito.feed.bookmark`へ書かない。 +- 公開レコードに`private`フラグを追加する方式は採用しない。 +- private bookmarkを既存の`Bookmark`、`Comment`、`Tag`、`BookmarkTag`テーブルへ保存しない。 +- private bookmarkをJetstream、公開ブックマークindexer、公開集計処理へ入力しない。 +- 次の公開機能へprivate bookmarkを混入させない。 + - プロフィール + - トップページ + - 最新ブックマーク + - タグ・カテゴリ集計 + - ステータス統計 + - `blue.rito.feed.getActorBookmarks`等の公開XRPC + - URLやsubjectから公開ブックマークを検索するAPI +- 公開ブックマーク向けOpenAI分類・モデレーション処理へprivate bookmarkを送らない。 + +## 5. SpaceとLexicon + +v1では所有者ごとにself-onlyの個人Spaceを使用する。 + +- space authority: 所有者のDID +- author DID: 所有者のDID +- skey: 固定値`self` +- policy: `member-list` +- member: 所有者のDIDのみ +- app access: `#open` + +公開repoへの誤書き込みを防ぐため、private bookmark専用のspace typeとrecord collectionを定義する。 + +- space type: `blue.rito.space.bookmark` +- record collection: `blue.rito.private.feed.bookmark` + +space type declarationとrecord collectionはLexiconのmain definitionの種類が異なるため、同じNSIDを共用しない。`appAccess: #open`はSpaceを一般公開する設定ではなく、所有者がOAuthで認可した互換アプリをRito以外にも許可する設定である。利用可能なユーザーは`policy: member-list`により所有者DIDだけに制限する。 + +recordは既存の`blue.rito.feed.bookmark`と同等の利用体験を提供するため、少なくとも次のフィールドを持つ。 + +- `subject` +- `createdAt` +- 多言語のtitle/comment +- tags +- OGP title/description/image + +record collectionの`key`は`tid`とし、bookmark本体のidentityをsubjectから分離する。 + +### 5.1 Record keyとsubject重複の扱い + +新規作成時にTIDを生成してrkeyへ使用する。同じsubjectを持つrecordの複数作成をプロトコルまたはRitoで禁止しない。subjectの重複はPDSの整合性、アクセス制御、record identityを損なわず、異なる保存時点、コメント、タグを持つ別bookmarkとして扱える。 + +編集ではsubjectを変更する場合も同じrkeyへの`putRecord`とし、record URIを維持する。削除はそのrecord URI/rkeyだけを対象にする。 + +v1では重複確認のための全件走査、subject hash、専用index record、PostgreSQL/KV索引を導入しない。取得済み一覧に同じsubjectが存在する場合の任意の警告や表示上のグルーピングは将来追加できるが、保存を拒否する一意制約にはしない。 + +## 6. OAuthと認可 + +本人のrepoだけを対象にするため、v1の読み取り権限は`read_self`を基本とする。 + +必要な権限: + +- `read_self` +- 対象collectionへの`create` +- 対象collectionへの`update` +- 対象collectionへの`delete` +- 初回Space作成に必要な`manage=create` + +v1では次を要求しない。 + +- 他ユーザーrepoを含むSpace全体の`read` +- `com.atproto.space.getDelegationToken` +- `com.atproto.space.getSpaceCredential` +- `com.atproto.space.listRepos` +- `com.atproto.space.registerNotify` +- 常駐syncerによるバックグラウンド同期 + +サーバー側BFFを使用する場合、対象DIDをクエリやbodyから信用してはならない。署名済みCookieおよび復元したOAuthセッションからDIDを確定し、space authority、repo、author DIDがそのDIDと一致することを検証する。 + +## 7. 機能要件 + +### 7.1 Capability確認と初期化 + +- private bookmarkを初めて有効化するときに、既存ログインとは分けて必要な`space:` OAuth scopeの追加認可を要求する。認可サーバーがscopeを拒否した場合はprivate bookmarkをそのアカウントでは利用不能として扱い、既存セッションと公開bookmark機能を維持する。 +- OAuth成功後、既知のSpace URI `at://{ownerDid}/space/blue.rito.space.bookmark/self`を指定して`com.atproto.space.getSpace`を呼び、実際の操作によってcapabilityとSpaceの存在を確認する。汎用的なcapability一覧APIには依存しない。 +- `getSpace`成功時は、返されたURIからowner DID、space type、skeyを検証し、configが`member-list` policyおよび`appAccess: #open`であることを確認する。さらに`com.atproto.simplespace.listMembers`で所有者以外のmemberが存在しないことを確認する。期待と異なる既存Spaceを自動変更または削除しない。 +- `SpaceNotFound`は「PDSは対応しているがSpaceは未作成」と判定し、`com.atproto.simplespace.createSpace`を`type=blue.rito.space.bookmark`、`skey=self`、所有者だけの`member-list`、`appAccess=#open`で呼ぶ。 +- 同時初期化による`SpaceAlreadyExists`は成功相当として`getSpace`を再実行する。 +- XRPCのmethod-not-foundまたは対応する404はPDS未対応、401/403またはscopeエラーは再認可・設定不備、timeout・network error・5xxは一時的な判定不能として区別する。一時障害を「未対応」として永続記録しない。 +- Space確認後に`com.atproto.space.listRecords`を所有者repoと`blue.rito.private.feed.bookmark` collectionへ実行し、permissioned repo読取APIまで利用可能であることを確認する。 +- `listSpaces`は、空のSpaceや未書き込みSpaceを列挙しない実装があり得るため、Space存在判定またはcapability判定の正本にしない。 +- capability判定結果はタブ内メモリにだけ保持し、PDS実装更新後にも再判定できるよう永続的なnegative cacheを作らない。 +- 未対応の場合はprivate bookmark機能を利用不能として明示する。 +- 初期化失敗時に公開ブックマークへ自動フォールバックしてはならない。 + +### 7.2 一覧取得 + +- `listRecords`を使用して所有者本人のprivate bookmarkだけを取得する。採用するLexicon版が値をinlineしない場合は、返されたcollection/rkey/CIDごとに`getRecord`して値を取得する。 +- PDSが返すcursorをそのまま次ページ取得に利用する。 +- 全件を先読みせず、画面に必要なページをオンデマンドで取得する。 +- UI用の正規化はレスポンス受信後にメモリ上で行う。 +- PDS取得に失敗した場合は、古い永続キャッシュを表示せず、再試行可能なエラーを表示する。 + +### 7.3 作成・更新・削除 + +- 書き込みは標準`com.atproto.space.*` XRPCでPDSへ直接適用する。 +- PDSが成功を返した後にUIのタブ内状態を更新する。 +- 失敗時は成功扱いせず、再試行可能なエラーを表示する。 +- private bookmarkの操作に伴って公開repoやRito PostgreSQLへレコードを書かない。 +- CSRF対策が必要な中継経路では、すべての書き込みでCSRFを検証する。 + +### 7.4 表示・編集 + +- private bookmarkの一覧、詳細、編集、削除は認証済み所有者だけが利用できる。 +- private bookmarkのURIを知っているだけでは内容を取得できない。 +- ログアウトまたはDID切り替え後、直前の所有者の内容を画面へ残さない。 +- private bookmarkページを静的生成、ISR、共有サーバーキャッシュ、CDNキャッシュの対象にしない。 + +### 7.5 OGP取得 + +- OGP情報とその取得機能は公開情報として扱い、既存の公開`/api/fetchOgp`をprivate bookmarkからも再利用してよい。 +- OGP取得リクエストへ所有者DID、Space URI、private record URIを含めず、対象URLと所有者をサーバー側で関連付けて保存しない。 +- 取得したOGP情報は作成フォームのタブ内メモリとPDSへ保存するrecordだけに反映し、RitoのPostgreSQL、KV、公開ブックマークテーブルへ保存しない。 +- OGP取得の公開・非公開にかかわらず、公開APIとして必要なSSRF対策、入力検証、応答サイズ制限を適用する。 + +## 8. HTTP・ログ・ブラウザ要件 + +- private bookmarkを含むレスポンスには`Cache-Control: private, no-store`を設定する。 +- private bookmarkを含むレスポンスをNext.js Data Cache、Route Cache、CDN、Service Workerへ保存しない。 +- private recordを扱うXRPCでは、subject、本文、コメント、タグ、OGPをアプリケーションログ、アクセスログ、エラー監視イベントへ出力しない。 +- 公開`/api/fetchOgp`の対象URLは公開情報として扱うが、所有者DID、Space URI、private record URIと関連付けてログまたはメトリクスへ保存しない。 +- URL全体をログへ出す仕組みでは、private record XRPCのquery/bodyを除外またはマスクする。 +- private bookmarkのHTML/Markdown表示には既存の安全なサニタイズ処理を適用する。 +- private bookmarkを含むページで意図しないprefetchを行わない。 + +## 9. 非機能要件 + +- PDSが正本であり、Ritoの再起動やデプロイでprivate bookmarkが失われたり不整合になったりしない。 +- キャッシュがないことを前提に、一覧の初回表示時間、次ページ取得時間、転送量、PDSエラー率を計測できるようにする。ただし計測値へrecord内容を含めない。 +- Proposal 0016の仕様変更へ追従しやすいよう、PDS XRPC呼び出しとUIロジックの境界を分離する。 +- 標準XRPCの型が利用ライブラリに未収録の場合、対象Lexiconから型を生成し、`any`で恒久対応しない。 + +## 10. 受け入れ条件 + +- 所有者はprivate bookmarkを作成、一覧表示、詳細表示、編集、削除できる。 +- 同じDIDで別端末から認証した場合、PDSから同じprivate bookmarkを取得できる。 +- 匿名ユーザーおよび別DIDはprivate bookmarkを取得できない。 +- private bookmarkが公開repo、Jetstream、公開PostgreSQLテーブル、公開API、公開ページへ現れない。 +- private bookmarkの内容がPostgreSQL、KV、Redis、IndexedDB、LocalStorage、SessionStorage、Service Worker Cacheへ残らない。 +- 一覧はPDS cursorでページングでき、タブを再読み込みした場合はPDSから再取得される。 +- private bookmarkを含むHTTPレスポンスが`private, no-store`である。 +- private record XRPCのログ、メトリクス、エラー監視にsubjectやrecord内容が含まれない。 +- PDSが未対応または利用不能なとき、公開保存へ切り替えず安全に失敗する。 +- private bookmarkから既存の公開`/api/fetchOgp`を利用でき、Rito側に所有者との関連付けやOGPキャッシュが作られない。 + +## 11. テスト計画 + +### Unit + +- permissioned recordとUI型の相互変換 +- TID rkey生成 +- 同一subjectを持つ複数recordの作成・個別編集・個別削除 +- cursorの受け渡し +- Space URI、repo、author DIDの所有者検証 +- OAuth scope判定 +- ログ用redaction +- DID切り替え時のメモリ状態破棄 + +### Integration + +- self-only Spaceの作成 +- `listRecords`、`getRecord`、`createRecord`、`putRecord`、`deleteRecord` +- 所有者OAuthでの成功 +- 匿名、別DID、scope不足での拒否 +- PDS未対応、タイムアウト、不正レスポンス時の安全な失敗 +- `SpaceNotFound`からの作成、`SpaceAlreadyExists`競合後の再取得 +- method-not-found、scope不足、一時的な5xxの状態分類 +- BFF利用時のCSRF、session DID固定、`Cache-Control` + +### Regression + +- 公開ブックマークの作成・編集・削除が従来どおり動く。 +- 公開一覧、プロフィール、タグ、カテゴリ、ステータス集計へprivate bookmarkが混入しない。 +- 既存Jetstream indexerがprivate bookmarkを処理しない。 + +### Browser/E2E + +- 作成後に一覧へ表示される。 +- リロード後にPDSから再取得される。 +- 次ページをcursorで取得できる。 +- ログアウトおよびDID切り替え後に内容が残らない。 +- IndexedDB、LocalStorage、SessionStorage、Cache APIにprivate bookmarkが保存されない。 + +## 12. 実装フェーズ + +1. 対象PDSと利用ライブラリのProposal 0016対応状況を確認する。 +2. 確定したNSIDでspace typeおよびprivate bookmark record Lexiconのschemaを定義する。 +3. Lexiconを追加し、frontend/backendの型を生成する。 +4. OAuth permission setへ最小scopeを追加する。 +5. Space capability確認とself-only Space初期化を実装する。 +6. `com.atproto.space.*`を呼ぶ型安全なクライアント層を実装する。 +7. 一覧、cursorページング、作成、編集、削除UIを実装する。 +8. キャッシュ禁止、ログredaction、認可境界をテストする。 +9. PDS未対応時の表示と安全な失敗を検証する。 +10. 実測値を取得し、v1後にキャッシュや検索索引の必要性を再評価する。 + +## 13. v1対象外 + +- PostgreSQL、KV、Redis、ブラウザ永続ストレージへのprivate bookmarkキャッシュ +- オフライン閲覧・オフライン編集 +- Ritoサーバーでの全文検索・カテゴリ集計・推薦 +- OpenAI等の外部サービスによる自動分類・モデレーション +- permissioned Spaceの常駐バックグラウンド同期 +- private bookmarkの共有、グループ利用、他ユーザーへの公開 +- E2EE +- 既存の公開ブックマークをprivate bookmarkへ移行する機能 + +## 14. 実装前に確定する事項 + +- Proposal 0016の実装版Lexiconと本文との差分 diff --git a/feat/private-bookmark/Proposal.md b/feat/private-bookmark/Proposal.md new file mode 100644 index 0000000..8701695 --- /dev/null +++ b/feat/private-bookmark/Proposal.md @@ -0,0 +1,567 @@ +0016 Permissioned Data +This is a proposal, not the final specification. Details, terminology, and behaviors are all likely to change. + +For discussion, head to the community forum. + +For a friendlier introduction to the problem space and deeper analysis of design decisions made along the way, see the permissioned data diary blog posts. + +Current drafts of lexicons can be viewed on the implementation branch in the atproto repo. Note that the implementation itself is still very much a work in progress. + +Introduction +AT Protocol (atproto) is a foundation for building social applications on shared, user-owned data. While commonly referred to as a "protocol", atproto is actually a framework composed of several distinct protocols as well as a schema language. Atproto currently includes a data repository and sync protocol for public broadcast data. + +In the public broadcast protocol, users publish records into a repository on their PDS, and applications crawl those repositories to build views. Records are signed, redistributable, and universally addressable, with authority resting in the DID that publishes a record. + +This document specifies an additional data protocol for permissioned data, or data with an access perimeter. The permissioned data protocol serves modalities such as: + +Personal data: bookmarks, mutes, drafts +Gated content: paid newsletters, subscriber-only posts +Socially shared: private posts, stories +Groups: private forums, communities, group chats +The permissioned data protocol shares the abstract shape of public broadcast. It retains DID-based authority, per-user repositories, lexicon-typed records, and the general flow of applications crawling PDSes to build views. However it has its own repository format, sync mechanism, addressing scheme, and resolution path. Public broadcast is built for open distribution (signed, archival, rebroadcastable) while the permissioned data protocol is built for party-to-party transmission within an access boundary. + +The permissioned data protocol provides access control, not confidentiality. It is not end-to-end encrypted. Services (both PDSes and authorized applications) can read the data they handle, which is required for server-side features such as search, indexing, notifications, aggregation, and moderation. E2EE is a separate concern that may be layered on top by an application and is out of scope in this proposal. + +Relationship to public broadcast +Public broadcast Permissioned data +Unit of data Record in a repo Record in a permissioned repo +Repo scope One repo per user One permissioned repo per (user, space) +Record authority User DID User DID +URI authority User DID Space authority DID +Commit Merkle Search Tree root LtHash set-hash digest +Signature Rebroadcastable, archival Deniable on rebroadcast +Addressing Traditional at:// URI at:// URI with space segment +Access Public Gated by space credential +Terminology +Space: an authorization and sync boundary for a set of permissioned records, identified by an (authority, type, skey) triple. +Permissioned repo: one user's records within one space, with a cryptographic commit, hosted on that user's PDS. +Repo host: a service that stores and serves users' permissioned repos. +Space host: a service that answers for a space as a whole, issuing credentials, enumerating writers, and routing notifications. +Space authority: the DID at the root of a space, which resolves to the space host and the key material for issuing credentials. +Space credential: a token issued by the space authority that grants read access to a space, DPoP-bound to the application it was issued to. +Delegation token: a token issued by a user's PDS that an application exchanges with a space authority for a space credential. +Client attestation: a token signed by an application's own client authentication key, proving the application's identity to a space authority. Required only when a space gates on client app identity. +Syncer: an application that keeps its own copy of a space in sync by pulling from repo hosts. +A PDS fulfills both the roles of a repo host and a space host. However, these roles are discussed separately because they do not necessarily need to be filled by a PDS. A permissioned repo or a space may be hosted by any service that implements the required APIs. + +Spaces +A space is an authorization and sync boundary representing a shared social context. A space may include many different types of records from many users. The space does not colocate records on a single network host. Instead, each user stores their own records for a given space in a permissioned repo on their own repo host. A space is the aggregation of these per-user repos across the network: an application presenting a space pulls each member's repo from its host, assembles the view, and applies access control to requesting users. + +Each space is identified by three values: + +space authority: a DID, the root of authority for the space +space type: an NSID describing the modality of the space +space key (skey): a string distinguishing spaces of the same space type under the same space authority +Reading or syncing a space requires a space credential signed by the declared signing key of the space authority, DPoP-bound to the application holding it. The space authority decides whether to issue one based on the requesting user and client application. The protocol does not define how that decision is made and carries no member list (see Access Control). Spaces scale from a single user's personal data (e.g. bookmarks) to communities of millions of users. + +Addressing +A permissioned record is addressed by an at:// URI with a fixed space segment: + +at://{spaceDid}/space/{spaceType}/{skey}/{authorDid}/{collection}/{rkey} +Component Type Description +spaceDid DID Space authority DID +space literal Fixed marker identifying this as a permissioned space URI +spaceType NSID Space type +skey string Space key +authorDid DID DID of the record's author +collection NSID Record collection +rkey string Record key +Permissioned data reuses the at:// scheme rather than defining its own. The literal space segment sits where a collection NSID appears in a public atproto URI, so a permissioned URI is distinguished from a public one by that marker in the first path segment under the authority DID. The two are never ambiguous, as a public collection is an NSID, which always contains at least two .s, whereas the space marker contains none. + +All segments through rkey are necessary to identify a permissioned record. The leading segments through skey may be used to reference a space: + +Space: at://{spaceDid}/space/{spaceType}/{skey} +Record: at://{spaceDid}/space/{spaceType}/{skey}/{authorDid}/{collection}/{rkey} +Space authority +A space's authority is the DID at the root of the space and the issuer of its credentials. It may be a user's own DID as for personal data such as bookmarks or mutes. Or it may be a dedicated DID which lets a shared space transfer between users independently of any individual account. + +A space authority is resolved through two entries in its DID document: + +a verification method with id #atproto_space: the public key used to verify the space's credentials +a service entry with id #atproto_space_host: the endpoint of the space host +Both entries are optional. When #atproto_space is absent, the space signing key falls back to the account's #atproto signing key. Similarly, when #atproto_space_host is absent, the space host falls back to the account's #atproto_pds service endpoint. An authority MAY instead publish the dedicated entries to point at distinct key material or a distinct host, and MAY set them to the same values as #atproto and #atproto_pds explicitly. + +Space type +A space's type is an NSID that names its modality and resolves to a space type declaration. It identifies the kind of data a space holds before any network resolution, much as a collection NSID does in public atproto. Because a type names a concrete modality, every space is some specific kind of space rather than a generic container. + +The type is also the OAuth consent boundary. Access is granted to a user by type, e.g. "access to your AtmoBoards forums" (see OAuth scopes). + +Space type declarations +A space type NSID resolves to a space type declaration: a Lexicon definition with "type": "space". + +{ + "lexicon": 1, + "id": "com.atmoboards.forum", + "defs": { + "main": { + "type": "space", + "description": "A discussion forum", + "key": "any", + "name": "AtmoBoards Forum", + "name:lang": { "es": "Foro AtmoBoards", "ja": "AtmoBoards 掲示板" }, + "collections": ["com.atmoboards.thread", "com.atmoboards.reply"] + } + } +} +Field Type Required Description +type "space" yes Marks this as a space type declaration. Must be the main definition. +description string no Description of the space type for developers. Not shown to users. +key string yes Specifies the recommended space key type (similar to record key types) +name string (1–64) yes Human-readable name for the space type, shown to users on consent screens. +name:lang map no Localized name values by language code. +collections array of NSID yes Collections clients should expect in a space of this type. +The collections field is the default collection set for a space: scope of this type. However, ultimately any collection may be written to any space and is not constrained at the protocol level by the collections in a space type declaration. + +Space key (skey) +The space key (skey) is a string distinguishing spaces of the same type under the same authority, analogous to a record key (rkey): a slug, a TID, a cryptographic identifier, or a reserved string such as self. Maximum length 512 bytes with the same syntax requirements as an rkey. + +Access control +Reading a space is gated by a space credential issued by the space authority. The authority issues one based on two axes: + +which user is being acted for: established by a delegation token minted by the user's PDS +which application is acting: established by a client attestation signed by the application itself +The delegation token is always required. The client attestation is required only when a space gates on client app identity. An application obtains a credential by getting a delegation token from a user's PDS, then presenting that token (together with its client attestation, if needed) to the space authority in exchange for a credential. The authority decides whether to issue the credential. The protocol does not define the decision procedure (the policies of the PDS's space-management implementation are described under simplespace). + +A space credential is a whole-space capability presented to many independent hosts, so it is DPoP-bound to the application it was issued to rather than being a bearer token. + +Some spaces do not require a client attestation. This can be communicated at the application layer or be detected by simply making a request for a credential without an attestation and seeing whether an error is returned. + +Delegation token +A delegation token proves that an application is acting on a user's behalf when it asks an authority for a space credential. The user's PDS mints it, requested through com.atproto.space.getDelegationToken. It asserts only the delegation from user to application. Whether the user is a member of the space is the authority's determination, and the delegation token says nothing about it. The token is single-use, short-lived (default 60 seconds), and addressed (aud) to the space authority. + +A client app session can only request a delegation token for a space it has been authorized to access. The application must hold a covering space: OAuth scope (specifically a read grant, which confers access to getDelegationToken). + +A delegation token is structurally similar to an atproto service auth token, but it differs in a few ways that make it its own credential class rather than an interchangeable one: + +The typ field in the header is set to atproto-space-delegation+jwt. +It does not include an lxm claim. +It is bound to a target space through the sub claim. +Example JWT header and payload (before base64url encoding and signing): + +{ + "typ": "atproto-space-delegation+jwt", + "alg": "ES256K", // or ES256 + "kid": "#atproto" // Key Identifier - MUST be "#atproto" +} +{ + "iss": "did:example:user_did", // User DID + "sub": "at://did:example:space_did/space/com.example.space_type/space_key", // Space being requested + "aud": "did:example:space_did#atproto_space_host", // Space host (service fragment of the authority DID) + "iat": 1738368000, // Issued-at (unix seconds) + "exp": 1738368060, // iat + 60 (60 seconds) + "jti": "f47ac10b58cc4372a5670e02b2c3d479" // random nonce +} +The delegation token asserts only the user-to-app delegation; it says nothing about which application is acting. App identity is established independently by the client attestation, which the authority verifies itself. The two are presented together but signed by different parties and evaluated independently. + +The signature for the delegation token is computed using the regular JWT process, using the account's signing key. For more details, see the Inter-Service Authentication section of the AT Protocol spec. + +Client attestation +A client attestation is a short-lived, single-use JWT that the application presents to the space authority alongside the delegation token to identify itself, when the space requires it. It is structurally a private_key_jwt client assertion, the same shape an atproto confidential client already presents to its authorization server, but addressed to the space authority rather than to the PDS. + +{ + "typ": "atproto-client-attestation+jwt", + "alg": "ES256", + "kid": "key-1" // Key id from the client's published JWKS +} +{ + "iss": "https://app.example.com/client-metadata.json", // The client_id + "sub": "https://app.example.com/client-metadata.json", // The client_id (== iss) + "aud": "did:example:space_did#atproto_space_host", // Space host being asked for a credential + "iat": 1738368000, // Issued-at (unix seconds) + "exp": 1738368060, // short-lived + "jti": "b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8" // random nonce, replay protection +} +The authority verifies the attestation by resolving iss (the client_id) to the client's client-metadata.json, fetching its published JWKS (jwks or jwks_uri), and verifying the signature against the key in that JWKS identified by the attestation's kid. + +Space credential +A space credential is the token an application presents to a repo host to read a permissioned repo within a space. The authority mints it in exchange for a delegation token, requested through com.atproto.space.getSpaceCredential. It is short-lived (default 2 hours) and signed by the space authority's signing key, so any repo host can verify it against the authority's key without contacting the authority. + +A space credential is multi-use. A single credential is intended to be reused across every repo host serving a repo in the space as well as against a given host for repeated requests, until it expires. + +A space credential resembles a space delegation token, differing in these ways: + +The typ field in the header is set to atproto-space-credential+jwt. +It is signed by the space authority rather than the user. +It has no aud: it is presented to any repo host serving a repo in the space, not to a single recipient. +It carries a cnf claim binding it to a key held by the application. +Example JWT header and payload (before base64url encoding and signing): + +{ + "typ": "atproto-space-credential+jwt", + "alg": "ES256K", // or ES256 + "kid": "#atproto_space" // Key Identifier - "#atproto_space" or "#atproto" +} +{ + "iss": "did:example:space_did", // Space authority DID + "sub": "at://did:example:space_did/space/com.example.space_type/space_key", // Space the credential reads + "cnf": { + "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" // JWK thumbprint of the bound key + }, + "iat": 1738368000, // Issued-at (unix seconds) + "exp": 1738375200, // iat + 7200 (2 hours) + "jti": "9f8e7d6c5b4a3210fedcba9876543210" // random nonce +} +The signature for the space credential is computed using the regular JWT process, using the space authority's signing key. + +DPoP binding +A credential provides access to a whole space. As a bearer token, a credential would become a shared secret: a host given a credential in order to serve one repo could turn around and replay it against every other host in the space, to sync a repo that it should not have necessarily access to. Therefore, each space credential is bound at issuance to a key held by the syncer, and each request carries a proof signed by that key naming the host it is addressed to. + +The construction is DPoP, the same binding atproto OAuth requires on every authenticated request, with a credential from the space authority in place of an access token. + +The application generates a keypair and passes the JWK thumbprint of the public key as the dpopJkt parameter of getSpaceCredential. The authority copies it into the credential's cnf.jkt (RFC 9449 §6.1). Nothing needs to be published or registered. The key may be ephemeral, since losing it costs only a new call to getSpaceCredential. + +The credential is then presented under the DPoP scheme with a proof, exactly as an access token is: + +GET /xrpc/com.atproto.space.getRepo?space=at%3A%2F%2F...&repo=did%3Aplc%3A... HTTP/1.1 +Host: pds.example.com +Authorization: DPoP eyJ0eXAiOiJhdHByb3RvLXNwYWNlLWNyZWRlbnRpYWwrand0... // the space credential +DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2Iiwiandr... // the DPoP proof +A host verifies the proof per RFC 9449: + +verify the signature against the jwk in its own header +verify that the thumbprint of that jwk matches cnf.jkt +verify that ath is the hash of the presented credential +verify that htm and htu match the request as received +verify that iat is recent and jti unseen +Credential flow +┌──────┐ ┌────────────┐ ┌─────────────┐ ┌─────────────────┐ +│ User │ │ User's PDS │ │ Application │ │ Space Authority │ +└───┬──┘ └──────┬─────┘ └──────┬──────┘ └────────┬────────┘ + │ │ │ │ + ├── OAuth consent ───► │ │ + │ ├───── OAuth token ─────► │ + │ │ │ │ + │ ◄─ getDelegationToken ──┤ │ + │ ├── delegation token ───► │ + │ │ │ getSpaceCredential │ + │ │ ├─(token [+ attestation])─►│ + │ │ ◄──── space credential ────┤ +The user authorizes the application via OAuth. +The application calls com.atproto.space.getDelegationToken on the user's PDS, receiving a delegation token. +The application presents the delegation token to the space authority via com.atproto.space.getSpaceCredential, along with the dpopJkt thumbprint of the key to bind the credential to, adding its own client attestation if the space gates on client app identity. The authority verifies what it received and, on authorization, returns a space credential bound to that key. +The application reads the repo from each member's repo host with the credential and a DPoP proof addressed to that host. +An application serving several users of a space does not necessarily need to maintain a space credential for each user. It may obtain its credential using any one user's session. When it loses all OAuth sessions for a space, it can no longer renew the credential and loses access. + +Permissioned repos +A permissioned repo is one account's set of records within one space, generally stored on that user's own PDS. A user has one permissioned repo per space it participates in. Abstractly, a permissioned repo offers a similar interface to a public atproto repository: a key/value mapping where keys are path names (a collection NSID and an rkey) and values are CBOR-encoded records. PDSes expose a CRUD interface for interacting with a user's permissioned repos. + +Each permissioned repo is summarized by a commit. A commit is a short, signed digest that allows a syncer to check whether its copy of a repo matches the source without resyncing it in its entirety. + +Commit digest +The digest in a commit is computed from a set hash over the records a repo currently contains. It is independent of write/delete order, so two repos with the same records always produce the same digest. Adding or removing a record is a single cheap operation, so a repo host maintains the set hash incrementally on each write. + +The construction is LtHash, a homomorphic hash built on a lattice problem (and thus quantum-secure). A record is added to or removed from the digest with a single addition or subtraction rather than a recomputation over the whole repo. + +The state is a fixed 2048-byte buffer, interpreted as 1024 little-endian unsigned 16-bit lanes. + +Each record in the repo maps to one element, the UTF-8 bytes of {collection}/{rkey}/{record_cid}. As with the public repository, the components are currently limited to ASCII, so the encoding (though specified as UTF-8) is a no-op. + +To add an element: + +Expand the element to 2048 bytes with BLAKE3 in XOF (extendable-output) mode. +Read those bytes as 1024 little-endian uint16 lanes. +Add each lane into the corresponding state lane, modulo 65536 (2^16, i.e. with wraparound) +To remove an element, perform the same process, but subtract its lanes instead (modulo 65536). + +Both operations are commutative, so the state depends only on the current set of records, not the order of writes. The empty repo's state is all zeroes. + +The commit's hash is sha256(state), a 32-byte digest of the 2048-byte buffer. The repo host maintains the full state. Only the hash is carried in a commit. + +Commit signature +A user does not sign the digest directly since a signature over the content digest would be a rebroadcastable proof of what the user wrote in a private space. Instead the signature covers only random per-commit bytes, and the digest is bound to those bytes by a symmetric MAC. A reader in the sync flow gets full authenticity and integrity, but a leaked commit is deniable and proves nothing about its contents to a third party. + +Both the signature and the MAC are domain-separated by a single context string, ctx, built once and reused for both. ctx uses the variable-length-vector encoding from TLS 1.3 (§3.4). It is composed of a fixed protocol tag followed by each variable field length-prefixed with a big-endian uint16. + +Note: these length prefixes are big-endian, following the TLS convention for wire encodings. This is the opposite byte order from the little-endian lanes of the commit digest, which follows the LtHash reference construction. The two come from different specs and each keeps its native byte order. + +ctx = "atproto-space-v1" // fixed protocol tag + || uint16be(len(space)) || space // space URI (at://authority/space/type/skey) + || uint16be(len(author)) || author // author DID of the repo + || uint16be(len(rev)) || rev // commit revision (TID) + || uint16be(len(ikm)) || ikm // per-signature nonce (below) +A commit is then produced as follows: + +Generate ikm, 32 fresh random bytes. A new ikm is generated for each reader the commit is served to. +Compute sig = sign(ctx) with the user's signing key. The signed message only contains the space, author DID, revision and ikm, not the current repository hash. +Compute mac = HMAC-SHA256(HKDF-SHA256(ikm, ctx), hash), binding the repository hash to this commit's context. +A reader verifies sig against the user's signing key (authenticity), then recomputes mac and compares (integrity). Because the digest is bound by a symmetric MAC keyed from the public ikm, anyone holding the commit can compute a valid mac for any hash, so a rebroadcast commit cannot prove what the user wrote, only that they signed a (space, author, rev, ikm) context. + +The signed commit (com.atproto.space.defs#signedCommit): + +Field Type Description +ver integer commit format version, currently 1 +hash bytes sha256 of the LtHash state (32 bytes) +ikm bytes per-signature nonce (32 random bytes) +sig bytes sign(ctx) by the user's signing key +mac bytes HMAC-SHA256(HKDF-SHA256(ikm, ctx), hash) +rev string commit revision (TID), also bound into ctx +The ver field is fixed at 1 for this version of the protocol. It corresponds to the version carried in the ctx protocol tag (atproto-space-v1). + +Repo serialization +A permissioned repo may be serialized to a CAR file, the same serialization format used to export a public atproto repository. It is served by com.atproto.space.getRepo and is the transport for full-state recovery. Blobs are not included and are fetched separately via getBlob. + +The CAR header declares two roots, in order: + +the signed commit — the signedCommit block described above +the index — a DRISL (DAG-CBOR) map from "{collection}/{rkey}" to the record's CID, with keys in canonical DAG-CBOR map order (shortest key first, then bytewise) +The record blocks follow the two roots, and MUST appear in the same order as their index entries. + +The serialization carries the information needed to reconstruct and verify the repo, and a consumer can validate it as a stream: + +Verify the commit's signature and MAC. The commit's hash is now trusted. +Fold each index entry's {collection}/{rkey}/{record_cid} into a running set hash as it is read, then compare the result against the commit's hash. This authenticates the whole index without reading a single record. +Verify each record block against its own CID as it streams past. +Sync +Permissioned data sync is functionally similar to public atproto. Applications build views by pulling repos from their hosts. The major difference is that there is no relay to provide a collated firehose of data for the network as permissioned repositories are by their nature non-rebroadcastable. An application pulls directly from each repo host and is responsible for keeping its own copy in sync. + +Incremental sync +A syncer keeps its own copy of a repo and, alongside it, its own running set hash over that copy. This is the same digest that the repo host maintains. When the two digests agree, the syncer knows that their copy is exactly up-to-date with the hosted repo. If there is a disagreement, then the syncer knows their copy is behind or corrupted. + +To advance, a syncer calls com.atproto.space.listRepoOps with a since revision. A repo host keeps an operation log of recent writes to each repo and returns the operations after since. Each entry is { rev (TID), collection, rkey, cid, prev }. The field cid is null for a delete and prev is null for a create. Multiple records may be mutated atomically, and this is captured by entries sharing a rev. The syncer applies each operation to its copy and updates its running set hash accordingly. + +By default listRepoOps inlines each created or updated record's value alongside its operation, so a syncer advances in a single call without a getRecord round-trip per write. Only the current value for a path is inlined. If a record has been updated or deleted by a later operation, the host omits the intermediate (now stale) values. A syncer may set excludeValues to receive only the operation metadata (for example, to drive selective getRecord calls). + +If a given oplog response includes the last available operation that occurred to a repo, then the response must also include the member's current signed commit. The syncer compares the commit's hash against its own running set hash. If they match, the syncer is fully in sync and the commit's signature authenticates that state. If they do not, the syncer has diverged and must fall back to a recovery process. + +Sync is self-healing because its correctness rests on the set hash comparison rather than on receiving every operation. A missed operation is detected on a subsequent sync. A total disjunction in repository history is also recognizable, and is recovered from by syncing the repository contents in their entirety. + +The oplog is a transport optimization rather than a committed data structure. Its contents and history are not guaranteed, and a repo host may compact or drop it, retaining only a backfill window. It is also reset on account migration, as a new repo host begins its oplog afresh and does not inherit the prior host's history. In any such case, a syncer that cannot find its since revision falls back to full-state recovery, which does not depend on the oplog. + +Full-state recovery +In cases in which a syncer cannot proceed incrementally, it must recover by syncing the full state of the repository. + +To do so, a syncer fetches the whole repo as a serialized CAR from com.atproto.space.getRepo. It folds the index into a running set hash and compares that against the signed commit to authenticate the index, then validates each record block against its index CID as it streams past, rebuilding its local copy. A syncer replacing an existing copy diffs the recovered structure against what it holds and keeps only the records it is missing. + +For the narrower case of healing a copy that has only slightly diverged, a syncer may prefer to avoid transferring the whole repo. It can fetch the latest commit through com.atproto.space.getLatestCommit, enumerate the repo's structure (paths → CIDs) with com.atproto.space.listRecords using excludeValues, diff that lightweight listing against its local copy, and fetch just the differing records with com.atproto.space.getRecord. This trades the single getRepo round-trip for a smaller total transfer when most of the repo is already held. + +Blob sync +Blobs referenced by permissioned records are stored on the authoring repo's host and fetched via com.atproto.space.getBlob with the relevant space credential. + +A syncer may discovers which blobs a repo references through com.atproto.space.listBlobs or by discovering blob refs in synced records. + +Write notifications +Write notifications inform syncers that a repo has advanced, so they can pull promptly instead of continuously polling for updates. A notification contains no record data, and states only that a given repo in a given space has reached a new revision. + +A syncer subscribes to notifications by calling com.atproto.space.registerNotify. When called on a space host, this method subscribes to writes for all repos in a space. Generally, syncers should subscribe to the space host for all write notifications from the space. However, it can also be called on particular repo hosts to receive notifications for specific repos.registerNotify is authenticated with a space credential. The service that was registered against should return the expiration time for the registration which may be longer than the expiration window of the space credential. + +A registration is withdrawn with com.atproto.space.unregisterNotify, or simply left to expire. + +When a member writes, their PDS sends a com.atproto.space.notifyWrite to each endpoint registered for that repo. A PDS may not otherwise know which services are syncing the space, which is why the space authority registers itself as a subscriber on each repo host. Members notify the authority, and the authority forwards each notification to the endpoints registered with it for the space. Each notified syncer then pulls the updated repo directly from the relevant repo host. The authority only routes notifications and does not carry record data. + +A repo host does not need an explicit out-of-band registration step from the authority to know where to send these notifications. On the first write into a repo for a shared space (one whose authority is not the account's own DID), the repo host resolves the space authority's #atproto_space_host endpoint and auto-registers it as a subscriber for that repo. Personal-data spaces, where the authority is the account's own DID and the PDS plays both roles, need no such registration. + +Notifications are best-effort and are not required for eventual consistency. If a notification is dropped, the affected repo is caught up by a later write's notification, or by a periodic sweep by the syncer. A sweep may be done over the writer set. com.atproto.space.listRepos returns each repo's current rev, so a syncer can compare those revisions against the revisions it last pulled and re-sync only the repos that have advanced, rather than polling each repo individually. + +The sync boundary (writer set) +Syncing an individual repo requires the syncer to know that the repo exists. To sync a space in full, or to begin syncing a space for the first time, an application requires the set of accounts whose repos hold data in the space. This writer set is retrieved from the space authority via com.atproto.space.listRepos. + +Because it subscribes to updates from every repository in the space, the authority can easily maintain a complete and current record of which repos hold data in the space. Alongside each account, listRepos returns that repo's current rev and hash. + +The writer set is a simple fetch and carries no commit or history. It enumerates accounts that have written at least one record into the space, not the broader set of accounts that are merely allowed to write (which the authority may not even track) nor those that may only read it. Accounts that may read the space are never enumerated at the protocol-level, though applications may choose to enumerate them through records published in the space. + +The writer set is what the authority claims, and is not itself authoritative for any individual repo. It is current as far as the authority's notifications have kept it. A listed account's repo host is the source of truth for whether that repo actually holds data. A syncer treats the writer set as the starting point for discovery and confirms each repo by syncing it directly from its host. + +Space deletion +A space may be deleted by its authority. The authority stops issuing credentials and no longer answers for the space. The authority also deletes its own repo in the space. + +The authority then notifies the syncers registered for the space with com.atproto.space.notifySpaceDeleted, over the same best-effort path as write notifications. + +A syncer should delete every copy of the space's data it holds, both the repos it pulled and any derived state, as it is no longer authorized to retain them. A syncer that misses the notification learns the space is gone on its next credential renewal. The authority should answer getSpaceCredential for a deleted space with an explicit SpaceDeleted error. A renewal that fails for any other reason says nothing about the space, and the syncer retains its copy. + +Members' repo hosts are not notified on deletion. A member's records are the member's own data, and deletion of the space does not entitle the authority to delete all data in it. The records simply become unreadable to everyone but the member's own account. The space's deletion may be surfaced by the application which can aid the user in cleaning up no longer used data. + +OAuth scopes +A user grants an application access to spaces through a space: OAuth scope. The scope identifies a set of spaces by their (authority, spaceType, skey) identifier and states what the grant permits within them. + +space:[?authority=][&skey=][&collection=...][&action=...][&manage=...] +Parameter Position Multiple Default Values +spaceType positional (required) no N/A a space-type NSID, or * for any type +authority query no self a space authority DID, self for the granting user's own DID, or * for any authority +skey query no * a space key (1–512 chars), or * for any key +collection query yes the space type lexicons declared collections a record collection NSID, or * for any +action query yes read, create, update, delete read_self, read, create, update, delete +manage query yes (none) create, update, delete +authority, spaceType, and skey select which spaces the grant covers, matching the first three segments of a space URI. action (and collection) govern operations on the records in those spaces. manage governs operations on the spaces themselves. + +authority defaults to self, the granting user's own DID, so a bare space: grant covers only the user's own spaces of that type. Reaching spaces under other authorities (e.g. a shared forum anchored on an app or another user) requires naming that authority, or authority=* for any. + +Read access +Read access is all-or-nothing at the space boundary. There is no partial, per-record, per-collection, or per-author read grant. + +A read grant confers two things: + +access to the read and sync methods (com.atproto.space.getRecord, listRecords, getBlob, listBlobs, getLatestCommit, getRepo, listRepoOps) on the holder's own PDS, sufficient to read the holder's own repo in the space +access to com.atproto.space.getDelegationToken for that space, which an application exchanges for a space credential to read any repo in the space +read_self is the narrower grant. It confers access to the same read and sync methods, but only for the holder's own repo in the space, and it does not grant getDelegationToken. An application holding only read_self can read its user's own records but cannot reach the rest of the space. read implies read_self. + +A space credential grants whole-space read/sync access directly, so the read and sync methods accept either a covering OAuth scope or a space credential. Write methods accept only an OAuth credential, since a write is attributed to the authoring user. + +Matching +A request is authorized by a grant when its target space matches the grant's (authority, spaceType, skey) (each component equal to the grant's value or covered by its *) and the grant permits the requested operation, per the rules below. An authority of self matches only the space whose authority is the granting user's own DID and is resolved to that DID at grant time. + +Record operations are governed by action: + +read covers every repo in the space and ignores collection since read access is all-or-nothing +read_self covers only the holder's own repo and similarly ignores collection. A read grant also satisfies a read_self request. +create, update, and delete act on a specific record, so they are additionally constrained by collection +Omitting action grants read, create, update, and delete (read is inclusive of read_self). + +Omitting collection defaults it to the collections declared by the space type's declaration. A bare space:com.atmoboards.forum grant therefore permits writing the forum's own record types, the same way a bare repo: scope permits writing the collections it names. An application may narrow this by listing a subset of collections, or widen it with collection=*. When spaceType is * there is no declaration to draw from, so the default is empty and the grant confers no write targets unless provided. + +This default follows the same dynamic-update semantics as a permission set. It is resolved from the declaration as it stands when the grant is evaluated, not frozen at consent time. If the space type's declaration later adds a collection to collections, existing bare grants widen to include it once the change propagates, subject to the caching/rollout window of the authorization server that resolves the declaration. An application that does not want its authorized collection set to move with the declaration should enumerate collections explicitly rather than rely on the default. + +Space management operations are governed by manage, which takes the same create/update/delete verbs applied to the space itself rather than to its records. manage ignores collection. It is omitted by default, so an ordinary record-access grant confers no administrative capability. + +The protocol does not enumerate what each manage verb permits, because space management is implementation-defined (see simplespace). Each space-management implementation maps the verbs onto its own administrative surface. For example, in com.atproto.simplespace, manage=update authorizes com.atproto.simplespace.updateSpace as well as addMember and removeMember. + +manage=create authorizes creating a space of the given spaceType under the given authority. Unlike every other operation, it concerns a space that does not yet exist, so scoping it to a concrete skey is unusual. It is typically granted with skey=* ("this app may create spaces of this type"). + +Examples +space:com.example.bookmarks: the user's own bookmarks space, with read access and write access to the collections its declaration lists. authority defaults to self, so this is the typical grant for personal data. +space:com.atmoboards.forum?authority=*: every com.atmoboards.forum space the user is in under any authority, with read access for the entire space and write access to the collections the forum's declaration lists (com.atmoboards.thread, com.atmoboards.reply). This is the typical grant for a forum client, which reads forums hosted by others. +space:com.atmoboards.forum?authority=*&action=read: the same spaces, read-only. No collection is needed because read is not constrained by collection. +space:com.atmoboards.forum?authority=*&action=read_self: read-only, and only the user's own repo in those forums. Suitable for a personal export or backup tool that should not see other members' posts. +space:com.atmoboards.forum?authority=*&collection=*: read access plus write access to every collection, not only the declared ones. +space:com.atmoboards.forum?authority=did:plc:abc123&skey=default&collection=com.atmoboards.thread&action=create&action=update: create and update com.atmoboards.thread records in the forum keyed default under authority did:plc:abc123. +space:com.atmoboards.forum?authority=*&action=read_self&manage=update&manage=delete: administer the user's forums (update and delete the spaces), with read access, but no record-write access. +space:com.atmoboards.forum?authority=*&manage=update&manage=delete: administer the user's forums (update and delete the spaces), with full read/write access to records in the space. +space:*?authority=did:plc:abc123: read every space under authority did:plc:abc123, any type. +Consent +A space: scope is presented to the user on the OAuth consent screen and requires user-legible text. Each space type resolves to a space type declaration. The consent screen then displays the declaration's name (e.g. "AtmoBoards Forum") in place of the raw NSID. + +If a particular authority DID is specified in a scope, it should be presented to the user as the bidirectionally linked handle associated with the DID. If no handle bidirectionally validates, then the DID itself should be shown. An authority of self refers to the user's own account and needs no such presentation. + +A scope may request wildcard access on both authority and spaceType. This is a very broad grant, and as such the consent screen should present such a scope with a prominent warning. + +Permission sets +Space permissions can also be bundled, usually with more user-friendly verbiage, into a permission set. + +{ + "type": "permission-set", + "title": "AtmoBoards", + "detail": "Read and post in your AtmoBoards forums", + "permissions": [ + { + "type": "permission", + "resource": "space", + "spaceType": "com.atmoboards.forum", + "authority": "*", + "collection": ["com.atmoboards.thread", "com.atmoboards.reply"], + "action": ["read", "create"] + }, + ] +} +Within a permission, "type": "permission" is the entry discriminator, "resource": "space" selects the space resource, and the remaining fields carry the same parameters as the space: scope string: spaceType, authority, skey, collection, and action. + +A set permission must name a concrete space type. In other words, the spaceType parameter may not be a wildcard inside a permission set. The other parameters may still be wildcards, including authority and collection. A cross-type grant (spaceType=*) is expressible only as a standalone space: scope requested directly. + +When expressing space resources in a permission set, the spaceType must follow the Namespace Authority requirements associated with permission sets. However, the collection parameter may be a wildcard or list collections under a different namespace authority than the space and the permission set. + +XRPC API +All protocol XRPC methods for permissioned data are currently defined under the com.atproto.space namespace. + +These methods fall into a few loose groups: + +Repo methods concern an account's permissioned repo within a space, and are implemented by a repo host. +Host methods concern a space as a whole, and are implemented by a space host. +PDS methods are required set of baseline PDS methods that applications can build against. +Syncer methods concern a syncer getting real-time notifications of updates to a space or repos in a space. +This grouping describes kinds of methods, not separate services. A single service (e.g. a PDS) is usually both a repo host for its accounts and a space host for the spaces anchored on it. + +Method Role Type Auth Description +getSpaceCredential host procedure delegation token (+ client attestation) Exchange a delegation token for a space credential. A client attestation is also required when the space gates on client app identity. +listRepos host query space credential List the known repos that hold data in a space, with each repo's current rev and commit hash. +getRecord repo query OAuth / space credential Fetch a single record's value. +listRecords repo query OAuth / space credential List the records in a repo, inlining record values by default. Set excludeValues for a metadata-only listing. +getBlob repo query OAuth / space credential Fetch a blob by CID. +listBlobs repo query OAuth / space credential List the blob CIDs referenced by records in a repo. +getLatestCommit repo query OAuth / space credential The current signed commit for a repo. +getRepo repo query OAuth / space credential Download a whole repo as a serialized CAR for full-state backfill. +listRepoOps repo query OAuth / space credential Primary sync mechanism. A repo's operation log since a given revision, inlining record values by default. Set excludeValues for metadata-only entries. +getDelegationToken pds query OAuth Mint a delegation token for a space. Served by the requesting user's PDS. +createRecord pds procedure OAuth Create a record in the caller's permissioned repo for a space. +putRecord pds procedure OAuth Create or update a record. +deleteRecord pds procedure OAuth Delete a record. +applyWrites pds procedure OAuth Apply a batch of creates, updates, and deletes to one repo atomically. +listSpaces pds query OAuth The spaces the caller holds a repo in. +registerNotify repo/host procedure space credential Register a service to be notified of writes. On the space host, subscribes to the whole space. On a repo host with a repo, subscribes to that repo. +unregisterNotify repo/host procedure space credential Withdraw a registerNotify registration. +notifyWrite syncer/host procedure service auth Notify that a repo advanced. Sent by a repo host to the space host, and forwarded by the space host to registered syncers. +notifySpaceDeleted syncer procedure service auth Notify that a space was deleted and its data should be dropped. Sent by the authority to the syncers registered for the space. +Required PDS space management: simplespace +The protocol does not specify how spaces are created or how an authority decides who may read them. Those are the concern of each space-management implementation, which sits above the protocol and is identified by its own lexicon namespace. + +com.atproto.simplespace is the space-management implementation that every PDS MUST support. It gives applications a baseline that is available on every account's PDS to build against. simplespace spaces are anchored on a user's own DID and governed by an explicit member list (or the public and managing-app policies described below). + +simplespace is neither the only permitted implementation nor a privileged one. It is simply the one that PDSs are required to support. Other space types may define their own management implementations and are full protocol participants, but they are hosted on bespoke space services rather than on the PDS. + +The management procedures are called with an OAuth credential with the relevant manage scope. The read queries require only read access: getSpace accepts an OAuth read_self grant or a space credential, and listMembers accepts a read_self grant. + +Method Type Description +createSpace procedure Create a space (caller becomes the authority) +updateSpace procedure Update config (more details below) +deleteSpace procedure Delete the space (see Space deletion). +getSpace query Describe a space and its configuration. +addMember procedure Add a member (by DID) to view the space. +removeMember procedure Remove a member (by DID). +listMembers query List the current members of a space. +Configuration +simplespace spaces can be further configured along a few dimensions. This configuration is updated through updateSpace and is surfaced through getSpace. + +Field Values Description +policy public | member-list | managing-app How the authority decides whether to authorize a user. +appAccess open union (#open | #allowList) How the authority decides whether to authorize an app. +managingApp service identifier (DID + fragment) Used to route application requests, and as the access check target in managing-app mode. +A user must be authorized by the policy and their app by appAccess for a credential to be minted. A syncing app needs a valid delegation token regardless. + +Both the policy and appAccess fields are open unions at the schema layer. A host MUST reject values it does not implement at createSpace / updateSpace time. + +Policy decides per-user authorization: + +member-list (default): authorize requesters present on the member list. +public: authorize any requester. +managing-app: at mint time, ask managingApp whether to authorize the request, via checkUserAccess below. Enables dynamic policies (e.g. follower-gating) without an app maintaining a list. +App access decides per-app authorization. It is an open union with two current variants: + +#open (default): any application may access the space. No client attestation is required, so public clients work. +#allowList: only the apps named in allowed may access the space. The authority evaluates the list against the attested client_id (the iss of the verified client attestation), so it is enforceable rather than advisory. +The managing app +When a space's policy is managing-app, the space authority defers to the space's managingApp at mint time by calling com.atproto.simplespace.checkUserAccess. + +Unlike the other simplespace methods, checkUserAccess is served by the managingApp, not the PDS. The authority calls it with itself as iss and the managingApp's service identifier as aud, so the app can verify the call genuinely originates from the space's authority. It passes the space, the requesting user, and the requesting client (the attested client_id, if any), and the managing app returns whether to authorize. + +The app evaluates the request against whatever application-layer state it maintains (e.g. follower graphs, paid-subscription status, join approvals) and returns its decision. The authority mints the credential only if the app authorizes and applies appAccess as usual. + +Considerations +This section is non-normative. It discusses how permissioned data interacts with concerns that span the whole protocol. The short answer to most of them is that permissioned data behaves much like public broadcast, because it deliberately keeps the same shape: DID-based authority, per-user repositories, lexicon-typed records, and applications that crawl repos to build views. + +Moderation +Moderation in public broadcast atproto is handled at various levels, including labeling and infrastructure takedowns. Every service in the network is able to, and must, moderate the data it hosts and serves according to its legal requirements, terms of service, and community guidelines. In addition, moderation services may publish public labels that applications and infrastructure providers can then act on. + +Permissioned data keeps this model, with the obvious difference that the data is not open, and therefore a moderation service cannot observe a space it has not been admitted to. A moderation service functions as just another reader. To label content in a space, a moderation service must hold a space credential like any other syncer, which means the space authority has admitted it under the same access control rules. + +A moderation service should not publish public labels for records in permissioned spaces, as this leaks metadata about otherwise-private data. The public com.atproto.label.subscribeLabels endpoint is therefore a poor fit here. Instead, labelers may publish labels as records in a permissioned repo within the relevant space, keeping the labels inside the same access boundary as the content they describe. + +At the infrastructure level, familiar tools and processes apply. Each participant retains authority over their own repo and can delete their own records. A repo host can take down accounts or refuse to serve repos that it hosts. An application can filter records from the views that it serves. + +A space authority has an additional lever with no public analogue. Because reading requires a credential it issues, it can decline to issue one to a user and thereby cut off read access, or stop issuing credentials for the space entirely (see space deletion). Some spaces may also include records with application-level semantics for restricting writes from moderated users. + +Scaling +The scaling story is expected to be similar to the public broadcast protocol. Data is partitioned per user across many hosts, no single node (at the hosting layer) holds an entire space, and applications may choose how much and which data they wish to index. + +The one major difference is the lack of relays. Applications sync permissioned repos directly from PDSes rather than relying on an intermediary service, which places additional load on PDSes. A few things make this more approachable. + +First, the sync protocol itself is significantly lighter-weight than public sync, as it does not carry the overhead of maintaining and transmitting the structural nodes of the MST. + +Second, sync load scales with the number of applications syncing a space, not the number of end users. An application serving many users pulls each repo once and fans it out to them from its own copy, which keeps the number of distinct syncers per repo relatively low. + +Finally, and relatedly, the sync APIs are not open to the public, so resource abuse is much easier to mitigate. Each sync method requires a space credential, which is generally issued only to a closed set of syncers, and can be correlated across requests to enforce rate limits and other controls. + +Account lifecycle +An account's participation in permissioned data is tied to the same DID and signing key as its public atproto identity. The lifecycle events that already exist, migration, key rotation, deactivation, and deletion, therefore all function the same way in the context of permissioned data. + +Migration. Moving a permissioned repo between hosts functions the same as migrating a public repository. The main difference is that a user has many permissioned repos rather than one, so account migration flows will need to enumerate and track all of an account's permissioned repos (via listSpaces) and the blobs associated with them (via listBlobs). + +Deactivation & deletion. Deactivation and deletion function exactly as they do for public broadcast. If an account is deleted, downstream services are expected to delete all public and permissioned data associated with the account. If an account is deactivated, downstream services are expected to stop serving all public and permissioned data associated with it. + +Identity & account events. Applications currently learn about changes to an account's status or identity (such as its signing key and handle) via events on the firehose (com.atproto.sync.subscribeRepos). These same account updates apply to permissioned repos in exactly the same manner. This means an application that syncs only permissioned repos, and no public repositories, still needs to subscribe to a firehose to receive #account and #identity events. Future work may include an additional subscription endpoint that broadcasts only those two event types without the full stream of public repository commits. diff --git a/frontend/lex.config.js b/frontend/lex.config.js index dad8f4b..90c344e 100644 --- a/frontend/lex.config.js +++ b/frontend/lex.config.js @@ -2,6 +2,13 @@ import { defineLexiconConfig } from '@atcute/lex-cli'; export default defineLexiconConfig({ - files: ['../lexicons/**/*.json'], - outdir: 'src/lexicons/', + generate: { + files: [ + '../lexicons/blue/rito/feed/**/*.json', + '../lexicons/blue/rito/preference/**/*.json', + '../lexicons/blue/rito/service/**/*.json', + '../lexicons/blue/rito/private/**/*.json', + ], + outdir: 'src/lexicons/', + }, }); \ No newline at end of file diff --git a/frontend/src/lexicons/index.ts b/frontend/src/lexicons/index.ts index dbe9158..2901622 100644 --- a/frontend/src/lexicons/index.ts +++ b/frontend/src/lexicons/index.ts @@ -2,5 +2,6 @@ export * as BlueRitoFeedBookmark from "./types/blue/rito/feed/bookmark.js"; export * as BlueRitoFeedLike from "./types/blue/rito/feed/like.js"; export * as BlueRitoPreferenceGetPreference from "./types/blue/rito/preference/getPreference.js"; export * as BlueRitoPreferencePutPreference from "./types/blue/rito/preference/putPreference.js"; +export * as BlueRitoPrivateFeedBookmark from "./types/blue/rito/private/feed/bookmark.js"; export * as BlueRitoServiceGetSchema from "./types/blue/rito/service/getSchema.js"; export * as BlueRitoServiceSchema from "./types/blue/rito/service/schema.js"; diff --git a/frontend/src/lexicons/types/blue/rito/feed/bookmark.ts b/frontend/src/lexicons/types/blue/rito/feed/bookmark.ts index fb325dd..c1af2ef 100644 --- a/frontend/src/lexicons/types/blue/rito/feed/bookmark.ts +++ b/frontend/src/lexicons/types/blue/rito/feed/bookmark.ts @@ -1,96 +1,101 @@ -import type {} from "@atcute/lexicons"; -import * as v from "@atcute/lexicons/validations"; -import type {} from "@atcute/lexicons/ambient"; +import type {} from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; +import type {} from '@atcute/lexicons/ambient'; -const _localeSchema = /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.literal("blue.rito.feed.bookmark#locale"), - ), - /** - * URI's comment. It can use GitHub Flavored Markdown. - * @maxLength 100000 - * @maxGraphemes 10000 - */ - comment: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ - /*#__PURE__*/ v.stringLength(0, 100000), - /*#__PURE__*/ v.stringGraphemes(0, 10000), - ]), - ), - /** - * Comments Language - * @maxLength 6 - */ - lang: /*#__PURE__*/ v.literalEnum(["en", "ja"]), - /** - * URI's title - * @maxLength 500 - * @maxGraphemes 50 - */ - title: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ - /*#__PURE__*/ v.stringLength(0, 500), - /*#__PURE__*/ v.stringGraphemes(0, 50), - ]), -}); +const _localeSchema = /*#__PURE__*/ v.object( + { + "$type": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal("blue.rito.feed.bookmark#locale")), + /** + * URI's comment. It can use GitHub Flavored Markdown. + * @maxLength 100000 + * @maxGraphemes 10000 + */ + "comment": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.string(), + [ + /*#__PURE__*/ v.stringLength(0, 100000), + /*#__PURE__*/ v.stringGraphemes(0, 10000) + ] + )), + /** + * Comments Language + * @maxLength 6 + */ + "lang": /*#__PURE__*/ v.literalEnum(["en", "ja"]), + /** + * URI's title + * @maxLength 500 + * @maxGraphemes 50 + */ + "title": /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.string(), + [ + /*#__PURE__*/ v.stringLength(0, 500), + /*#__PURE__*/ v.stringGraphemes(0, 50) + ] + ), + } +); const _mainSchema = /*#__PURE__*/ v.record( - /*#__PURE__*/ v.tidString(), - /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.literal("blue.rito.feed.bookmark"), - /** - * Title and comment in different languages. - * @minLength 1 - */ - get comments() { - return /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.array(localeSchema), [ - /*#__PURE__*/ v.arrayLength(1), - ]); - }, - createdAt: /*#__PURE__*/ v.datetimeString(), - /** - * OGP Description - */ - ogpDescription: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * OGP Image Uri - */ - ogpImage: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), - /** - * OGP Title - */ - ogpTitle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - subject: /*#__PURE__*/ v.genericUriString(), - /** - * Tags describing the uri's description (max 10 tags, 25 charactors) - * @maxLength 10 - */ - tags: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.constrain( - /*#__PURE__*/ v.array( - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ - /*#__PURE__*/ v.stringLength(1, 250), - /*#__PURE__*/ v.stringGraphemes(1, 25), - ]), - ), - [/*#__PURE__*/ v.arrayLength(0, 10)], - ), - ), - }), + /*#__PURE__*/ v.tidString(), + /*#__PURE__*/ v.object( + { + "$type": /*#__PURE__*/ v.literal("blue.rito.feed.bookmark"), + /** + * Title and comment in different languages. + * @minLength 1 + */ + get "comments"() { + return /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(localeSchema), + [/*#__PURE__*/ v.arrayLength(1)] + ) + }, + "createdAt": /*#__PURE__*/ v.datetimeString(), + /** + * OGP Description + */ + "ogpDescription": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * OGP Image Uri + */ + "ogpImage": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + /** + * OGP Title + */ + "ogpTitle": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + "subject": /*#__PURE__*/ v.genericUriString(), + /** + * Tags describing the uri's description (max 10 tags, 25 charactors) + * @maxLength 10 + */ + "tags": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(/*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.string(), + [ + /*#__PURE__*/ v.stringLength(1, 250), + /*#__PURE__*/ v.stringGraphemes(1, 25) + ] + )), + [/*#__PURE__*/ v.arrayLength(0, 10)] + )), + } + ) ); - type locale$schematype = typeof _localeSchema; type main$schematype = typeof _mainSchema; export interface localeSchema extends locale$schematype {} -export interface mainSchema extends main$schematype {} +export interface mainSchema extends main$schematype {} export const localeSchema = _localeSchema as localeSchema; export const mainSchema = _mainSchema as mainSchema; export interface Locale extends v.InferInput {} -export interface Main extends v.InferInput {} -declare module "@atcute/lexicons/ambient" { - interface Records { - "blue.rito.feed.bookmark": mainSchema; - } +export interface Main extends v.InferInput {} +declare module '@atcute/lexicons/ambient' { + interface Records { + "blue.rito.feed.bookmark": mainSchema; + } } diff --git a/frontend/src/lexicons/types/blue/rito/feed/like.ts b/frontend/src/lexicons/types/blue/rito/feed/like.ts index f1bae17..9c68ed0 100644 --- a/frontend/src/lexicons/types/blue/rito/feed/like.ts +++ b/frontend/src/lexicons/types/blue/rito/feed/like.ts @@ -1,26 +1,23 @@ -import type {} from "@atcute/lexicons"; -import * as v from "@atcute/lexicons/validations"; -import type {} from "@atcute/lexicons/ambient"; +import type {} from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; +import type {} from '@atcute/lexicons/ambient'; const _mainSchema = /*#__PURE__*/ v.record( - /*#__PURE__*/ v.tidString(), - /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.literal("blue.rito.feed.like"), - createdAt: /*#__PURE__*/ v.datetimeString(), - subject: /*#__PURE__*/ v.genericUriString(), - }), + /*#__PURE__*/ v.tidString(), + /*#__PURE__*/ v.object({ + "$type": /*#__PURE__*/ v.literal("blue.rito.feed.like"), + "createdAt": /*#__PURE__*/ v.datetimeString(), + "subject": /*#__PURE__*/ v.genericUriString(), + }) ); - type main$schematype = typeof _mainSchema; export interface mainSchema extends main$schematype {} - export const mainSchema = _mainSchema as mainSchema; export interface Main extends v.InferInput {} - -declare module "@atcute/lexicons/ambient" { - interface Records { - "blue.rito.feed.like": mainSchema; - } +declare module '@atcute/lexicons/ambient' { + interface Records { + "blue.rito.feed.like": mainSchema; + } } diff --git a/frontend/src/lexicons/types/blue/rito/preference/getPreference.ts b/frontend/src/lexicons/types/blue/rito/preference/getPreference.ts index b5cda81..7ad3945 100644 --- a/frontend/src/lexicons/types/blue/rito/preference/getPreference.ts +++ b/frontend/src/lexicons/types/blue/rito/preference/getPreference.ts @@ -1,44 +1,42 @@ -import type {} from "@atcute/lexicons"; -import * as v from "@atcute/lexicons/validations"; -import type {} from "@atcute/lexicons/ambient"; +import type {} from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; +import type {} from '@atcute/lexicons/ambient'; const _mainSchema = /*#__PURE__*/ v.query( - "blue.rito.preference.getPreference", - { - params: null, - output: { - type: "lex", - schema: /*#__PURE__*/ v.object({ - /** - * Whether to automatically collect Bluesky posts to Rito bookmarks. - */ - enableAutoGenerateBookmark: /*#__PURE__*/ v.boolean(), - /** - * User's preferred language for Auto Generate Bookmark. - */ - langForAutoGenertateBookmark: /*#__PURE__*/ v.string(), - /** - * Moderation categories that should not be blurred. - */ - unblurModerationCategories: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), - ), - }), - }, - }, + "blue.rito.preference.getPreference", + { + "params": null, + "output": { + "type": "lex", + "schema": /*#__PURE__*/ v.object( + { + /** + * Whether to automatically collect Bluesky posts to Rito bookmarks. + */ + "enableAutoGenerateBookmark": /*#__PURE__*/ v.boolean(), + /** + * User's preferred language for Auto Generate Bookmark. + */ + "langForAutoGenertateBookmark": /*#__PURE__*/ v.string(), + /** + * Moderation categories that should not be blurred. + */ + "unblurModerationCategories": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.array(/*#__PURE__*/ v.string())), + } + ), + } + } ); - type main$schematype = typeof _mainSchema; export interface mainSchema extends main$schematype {} - export const mainSchema = _mainSchema as mainSchema; export interface $params {} -export interface $output extends v.InferXRPCBodyInput {} -declare module "@atcute/lexicons/ambient" { - interface XRPCQueries { - "blue.rito.preference.getPreference": mainSchema; - } +export interface $output extends v.InferXRPCBodyInput {} +declare module '@atcute/lexicons/ambient' { + interface XRPCQueries { + "blue.rito.preference.getPreference": mainSchema; + } } diff --git a/frontend/src/lexicons/types/blue/rito/preference/putPreference.ts b/frontend/src/lexicons/types/blue/rito/preference/putPreference.ts index 4af81de..47c27a3 100644 --- a/frontend/src/lexicons/types/blue/rito/preference/putPreference.ts +++ b/frontend/src/lexicons/types/blue/rito/preference/putPreference.ts @@ -1,49 +1,43 @@ -import type {} from "@atcute/lexicons"; -import * as v from "@atcute/lexicons/validations"; -import type {} from "@atcute/lexicons/ambient"; +import type {} from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; +import type {} from '@atcute/lexicons/ambient'; const _mainSchema = /*#__PURE__*/ v.procedure( - "blue.rito.preference.putPreference", - { - params: null, - input: null, - output: { - type: "lex", - schema: /*#__PURE__*/ v.object({ - /** - * Whether to automatically collect Bluesky posts to Rito bookmarks. - */ - enableAutoGenerateBookmark: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.boolean(), - ), - /** - * User's preferred language for Auto Generate Bookmark. - */ - langForAutoGenertateBookmark: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.string(), - ), - /** - * Moderation categories that should not be blurred. - */ - unblurModerationCategories: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), - ), - }), - }, - }, + "blue.rito.preference.putPreference", + { + "params": null, + "input": null, + "output": { + "type": "lex", + "schema": /*#__PURE__*/ v.object( + { + /** + * Whether to automatically collect Bluesky posts to Rito bookmarks. + */ + "enableAutoGenerateBookmark": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), + /** + * User's preferred language for Auto Generate Bookmark. + */ + "langForAutoGenertateBookmark": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * Moderation categories that should not be blurred. + */ + "unblurModerationCategories": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.array(/*#__PURE__*/ v.string())), + } + ), + } + } ); - type main$schematype = typeof _mainSchema; export interface mainSchema extends main$schematype {} - export const mainSchema = _mainSchema as mainSchema; export interface $params {} -export interface $output extends v.InferXRPCBodyInput {} -declare module "@atcute/lexicons/ambient" { - interface XRPCProcedures { - "blue.rito.preference.putPreference": mainSchema; - } +export interface $output extends v.InferXRPCBodyInput {} +declare module '@atcute/lexicons/ambient' { + interface XRPCProcedures { + "blue.rito.preference.putPreference": mainSchema; + } } diff --git a/frontend/src/lexicons/types/blue/rito/private/feed/bookmark.ts b/frontend/src/lexicons/types/blue/rito/private/feed/bookmark.ts new file mode 100644 index 0000000..c214ff2 --- /dev/null +++ b/frontend/src/lexicons/types/blue/rito/private/feed/bookmark.ts @@ -0,0 +1,101 @@ +import type {} from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; +import type {} from '@atcute/lexicons/ambient'; + +const _localeSchema = /*#__PURE__*/ v.object( + { + "$type": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal("blue.rito.private.feed.bookmark#locale")), + /** + * URI's comment. It can use GitHub Flavored Markdown. + * @maxLength 100000 + * @maxGraphemes 10000 + */ + "comment": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.string(), + [ + /*#__PURE__*/ v.stringLength(0, 100000), + /*#__PURE__*/ v.stringGraphemes(0, 10000) + ] + )), + /** + * Comments Language + * @maxLength 6 + */ + "lang": /*#__PURE__*/ v.literalEnum(["en", "ja"]), + /** + * URI's title + * @maxLength 500 + * @maxGraphemes 50 + */ + "title": /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.string(), + [ + /*#__PURE__*/ v.stringLength(0, 500), + /*#__PURE__*/ v.stringGraphemes(0, 50) + ] + ), + } +); +const _mainSchema = /*#__PURE__*/ v.record( + /*#__PURE__*/ v.tidString(), + /*#__PURE__*/ v.object( + { + "$type": /*#__PURE__*/ v.literal("blue.rito.private.feed.bookmark"), + /** + * Title and comment in different languages. + * @minLength 1 + */ + get "comments"() { + return /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(localeSchema), + [/*#__PURE__*/ v.arrayLength(1)] + ) + }, + "createdAt": /*#__PURE__*/ v.datetimeString(), + /** + * OGP Description + */ + "ogpDescription": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * OGP Image Uri + */ + "ogpImage": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + /** + * OGP Title + */ + "ogpTitle": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + "subject": /*#__PURE__*/ v.genericUriString(), + /** + * Tags describing the uri's description (max 10 tags, 25 charactors) + * @maxLength 10 + */ + "tags": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(/*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.string(), + [ + /*#__PURE__*/ v.stringLength(1, 250), + /*#__PURE__*/ v.stringGraphemes(1, 25) + ] + )), + [/*#__PURE__*/ v.arrayLength(0, 10)] + )), + } + ) +); +type locale$schematype = typeof _localeSchema; +type main$schematype = typeof _mainSchema; + +export interface localeSchema extends locale$schematype {} + +export interface mainSchema extends main$schematype {} +export const localeSchema = _localeSchema as localeSchema; +export const mainSchema = _mainSchema as mainSchema; + +export interface Locale extends v.InferInput {} + +export interface Main extends v.InferInput {} +declare module '@atcute/lexicons/ambient' { + interface Records { + "blue.rito.private.feed.bookmark": mainSchema; + } +} diff --git a/frontend/src/lexicons/types/blue/rito/service/getSchema.ts b/frontend/src/lexicons/types/blue/rito/service/getSchema.ts index 0712478..5abea6d 100644 --- a/frontend/src/lexicons/types/blue/rito/service/getSchema.ts +++ b/frontend/src/lexicons/types/blue/rito/service/getSchema.ts @@ -1,91 +1,91 @@ -import type {} from "@atcute/lexicons"; -import * as v from "@atcute/lexicons/validations"; -import type {} from "@atcute/lexicons/ambient"; +import type {} from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; +import type {} from '@atcute/lexicons/ambient'; const _langsSchema = /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.literal("blue.rito.service.getSchema#langs"), - ), - comment: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - lang: /*#__PURE__*/ v.string(), - moderation: /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), - title: /*#__PURE__*/ v.string(), + "$type": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.literal("blue.rito.service.getSchema#langs")), + "comment": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + "lang": /*#__PURE__*/ v.string(), + "moderation": /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), + "title": /*#__PURE__*/ v.string(), }); -const _mainSchema = /*#__PURE__*/ v.procedure("blue.rito.service.getSchema", { - params: null, - input: { - type: "lex", - schema: /*#__PURE__*/ v.object({ - nsid: /*#__PURE__*/ v.string(), - }), - }, - output: { - type: "lex", - schema: /*#__PURE__*/ v.object({ - /** - * Comments with titles, content, and moderation in multiple languages. - * @minLength 1 - */ - get comments() { - return /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.array(langsSchema), [ - /*#__PURE__*/ v.arrayLength(1), - ]); - }, - /** - * Moderation result for OGP title and description - */ - moderations: /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), - /** - * Namespace ID of the service or application (e.g., 'uk.skyblur.post'). - */ - nsid: /*#__PURE__*/ v.string(), - /** - * The Open Graph Protocol (OGP) description for the bookmark. - */ - ogpDescription: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * The Open Graph Protocol (OGP) image URL for the bookmark. - */ - ogpImage: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), - /** - * The Open Graph Protocol (OGP) title for the bookmark. - */ - ogpTitle: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), - /** - * The schema URL pattern associated with this NSID (e.g., 'https://skyblur.uk/post/{did}/{rkey}'). - */ - schema: /*#__PURE__*/ v.string(), - /** - * This field contains tags. If registered by the owner, it may include 'Verified'. - */ - tags: /*#__PURE__*/ v.optional( - /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), - ), - /** - * If this comment registed by owner, this field should be true. - */ - verified: /*#__PURE__*/ v.boolean(), - }), - }, -}); - +const _mainSchema = /*#__PURE__*/ v.procedure( + "blue.rito.service.getSchema", + { + "params": null, + "input": { + "type": "lex", + "schema": /*#__PURE__*/ v.object({ "nsid": /*#__PURE__*/ v.string(), }), + }, + "output": { + "type": "lex", + "schema": /*#__PURE__*/ v.object( + { + /** + * Comments with titles, content, and moderation in multiple languages. + * @minLength 1 + */ + get "comments"() { + return /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array(langsSchema), + [/*#__PURE__*/ v.arrayLength(1)] + ) + }, + /** + * Moderation result for OGP title and description + */ + "moderations": /*#__PURE__*/ v.array(/*#__PURE__*/ v.string()), + /** + * Namespace ID of the service or application (e.g., 'uk.skyblur.post'). + */ + "nsid": /*#__PURE__*/ v.string(), + /** + * The Open Graph Protocol (OGP) description for the bookmark. + */ + "ogpDescription": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * The Open Graph Protocol (OGP) image URL for the bookmark. + */ + "ogpImage": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.genericUriString()), + /** + * The Open Graph Protocol (OGP) title for the bookmark. + */ + "ogpTitle": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), + /** + * The schema URL pattern associated with this NSID (e.g., 'https://skyblur.uk/post/{did}/{rkey}'). + */ + "schema": /*#__PURE__*/ v.string(), + /** + * This field contains tags. If registered by the owner, it may include 'Verified'. + */ + "tags": /*#__PURE__*/ v.optional(/*#__PURE__*/ v.array(/*#__PURE__*/ v.string())), + /** + * If this comment registed by owner, this field should be true. + */ + "verified": /*#__PURE__*/ v.boolean(), + } + ), + } + } +); type langs$schematype = typeof _langsSchema; type main$schematype = typeof _mainSchema; export interface langsSchema extends langs$schematype {} -export interface mainSchema extends main$schematype {} +export interface mainSchema extends main$schematype {} export const langsSchema = _langsSchema as langsSchema; export const mainSchema = _mainSchema as mainSchema; export interface Langs extends v.InferInput {} export interface $params {} -export interface $input extends v.InferXRPCBodyInput {} -export interface $output extends v.InferXRPCBodyInput {} -declare module "@atcute/lexicons/ambient" { - interface XRPCProcedures { - "blue.rito.service.getSchema": mainSchema; - } +export interface $input extends v.InferXRPCBodyInput {} + +export interface $output extends v.InferXRPCBodyInput {} +declare module '@atcute/lexicons/ambient' { + interface XRPCProcedures { + "blue.rito.service.getSchema": mainSchema; + } } diff --git a/frontend/src/lexicons/types/blue/rito/service/schema.ts b/frontend/src/lexicons/types/blue/rito/service/schema.ts index 55a94fe..141cdce 100644 --- a/frontend/src/lexicons/types/blue/rito/service/schema.ts +++ b/frontend/src/lexicons/types/blue/rito/service/schema.ts @@ -1,28 +1,27 @@ -import type {} from "@atcute/lexicons"; -import * as v from "@atcute/lexicons/validations"; -import type {} from "@atcute/lexicons/ambient"; +import type {} from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; +import type {} from '@atcute/lexicons/ambient'; const _mainSchema = /*#__PURE__*/ v.record( - /*#__PURE__*/ v.nsidString(), - /*#__PURE__*/ v.object({ - $type: /*#__PURE__*/ v.literal("blue.rito.service.schema"), - /** - * The AppView URL for the NSID. For example, if the NSID is uk.skyblur.post, the URL should be https://skyblur.uk/post/{did}/{rkey} - */ - schema: /*#__PURE__*/ v.genericUriString(), - }), + /*#__PURE__*/ v.nsidString(), + /*#__PURE__*/ v.object( + { + "$type": /*#__PURE__*/ v.literal("blue.rito.service.schema"), + /** + * The AppView URL for the NSID. For example, if the NSID is uk.skyblur.post, the URL should be https://skyblur.uk/post/{did}/{rkey} + */ + "schema": /*#__PURE__*/ v.genericUriString(), + } + ) ); - type main$schematype = typeof _mainSchema; export interface mainSchema extends main$schematype {} - export const mainSchema = _mainSchema as mainSchema; export interface Main extends v.InferInput {} - -declare module "@atcute/lexicons/ambient" { - interface Records { - "blue.rito.service.schema": mainSchema; - } +declare module '@atcute/lexicons/ambient' { + interface Records { + "blue.rito.service.schema": mainSchema; + } } diff --git a/lexicons/blue/rito/private/feed/bookmark.json b/lexicons/blue/rito/private/feed/bookmark.json new file mode 100644 index 0000000..019bf5f --- /dev/null +++ b/lexicons/blue/rito/private/feed/bookmark.json @@ -0,0 +1,93 @@ +{ + "lexicon": 1, + "id": "blue.rito.private.feed.bookmark", + "defs": { + "locale": { + "type": "object", + "required": [ + "lang", + "title" + ], + "properties": { + "lang": { + "type": "string", + "enum": [ + "ja", + "en" + ], + "maxLength": 6, + "description": "Comments Language" + }, + "title": { + "type": "string", + "description": "URI's title", + "maxGraphemes": 50, + "maxLength": 500 + }, + "comment": { + "type": "string", + "description": "URI's comment. It can use GitHub Flavored Markdown.", + "maxGraphemes": 10000, + "maxLength": 100000 + } + } + }, + "main": { + "type": "record", + "description": "Records a private bookmark within a permissioned repo.", + "key": "tid", + "record": { + "type": "object", + "required": [ + "comments", + "subject", + "createdAt" + ], + "properties": { + "subject": { + "type": "string", + "format": "uri" + }, + "comments": { + "type": "array", + "description": "Title and comment in different languages.", + "minLength": 1, + "items": { + "type": "ref", + "ref": "blue.rito.private.feed.bookmark#locale" + } + }, + "tags": { + "type": "array", + "description": "Tags describing the uri's description (max 10 tags, 25 charactors)", + "maxLength": 10, + "items": { + "type": "string", + "minGraphemes": 1, + "maxGraphemes": 25, + "minLength": 1, + "maxLength": 250 + } + }, + "ogpTitle": { + "type": "string", + "description": "OGP Title" + }, + "ogpDescription": { + "type": "string", + "description": "OGP Description" + }, + "ogpImage": { + "type": "string", + "format": "uri", + "description": "OGP Image Uri" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } +} diff --git a/lexicons/blue/rito/space/bookmark.json b/lexicons/blue/rito/space/bookmark.json new file mode 100644 index 0000000..125ead0 --- /dev/null +++ b/lexicons/blue/rito/space/bookmark.json @@ -0,0 +1,18 @@ +{ + "lexicon": 1, + "id": "blue.rito.space.bookmark", + "defs": { + "main": { + "type": "space", + "description": "A private bookmark space for personal bookmark management.", + "key": "literal:self", + "name": "Rito Private Bookmarks", + "name:lang": { + "ja": "リトプライベートブックマーク" + }, + "collections": [ + "blue.rito.private.feed.bookmark" + ] + } + } +}