a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: improve readme

Mary 49c6badd b9d81ecd

+2587 -882
+93 -112
README.md
··· 1 1 # atcute 2 2 3 - a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky, 4 - featuring: 3 + lightweight TypeScript packages for [AT Protocol](https://atproto.com), the protocol powering 4 + Bluesky. 5 5 6 - - an [API client][client] for making typed HTTP requests, with support for lexicons like 7 - [WhiteWind][whitewind] or [Bluemoji][bluemoji] 8 - - an [OAuth client for SPA applications][oauth-browser-client] for authentication use-cases 9 - - a grab bag of utility packages: 10 - - codec libraries for [DASL][dasl] data formats, a strict subset of IPLD specifications, like 11 - CIDv1, DAG-CBOR and CAR, but tailored specifically for atproto 12 - - codec for atproto's timestamp identifiers 13 - - cryptography library for signing and verification of signatures in atproto 14 - - schema validators for DID documents, and verification of did:plc operations 15 - - Bluesky-specific helpers like [a rich text builder][bluesky-richtext-builder] and [a post thread 16 - builder][bluesky-threading] 6 + ## quick start 17 7 18 - [bluemoji]: ./packages/definitions/bluemoji 19 - [bluesky-richtext-builder]: ./packages/bluesky/richtext-builder 20 - [bluesky-threading]: ./packages/bluesky/threading 21 - [client]: ./packages/core/client 22 - [oauth-browser-client]: ./packages/oauth/browser-client 23 - [whitewind]: ./packages/definitions/whitewind 24 - [dasl]: https://dasl.ing/ 25 - [ipld]: https://ipld.io/ 26 - [skyware]: https://skyware.js.org/ 8 + ```sh 9 + npm install @atcute/client @atcute/bluesky 10 + ``` 27 11 28 - --- 12 + ```ts 13 + import { Client, simpleFetchHandler } from '@atcute/client'; 14 + import type {} from '@atcute/bluesky'; 15 + 16 + const client = new Client({ 17 + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }), 18 + }); 29 19 30 - | Packages | 31 - | ---------------------------------------------------------------------------------------------------------------------- | 32 - | **Client packages** | 33 - | [`client`](./packages/clients/client): XRPC HTTP client library | 34 - | [`firehose`](./packages/clients/firehose): XRPC subscriptions client library | 35 - | [`jetstream`](./packages/clients/jetstream): Jetstream client | 36 - | **Server packages** | 37 - | [`xrpc-server`](./packages/servers/xrpc-server): web framework | 38 - | [`xrpc-server-bun`](./packages/servers/xrpc-server-bun): Bun WebSocket adapter | 39 - | [`xrpc-server-cloudflare`](./packages/servers/xrpc-server-cloudflare): Cloudflare Workers WebSocket adapter | 40 - | [`xrpc-server-deno`](./packages/servers/xrpc-server-deno): Deno WebSocket adapter | 41 - | [`xrpc-server-node`](./packages/servers/xrpc-server-node): Node.js WebSocket adapter | 42 - | **OAuth packages** | 43 - | [`oauth-browser-client`](./packages/oauth/browser-client): minimal OAuth browser client implementation | 44 - | **Lexicon packages** | 45 - | [`lex-cli`](./packages/lexicons/lex-cli): CLI tool to generate schema definitions | 46 - | [`lexicon-doc`](./packages/lexicons/lexicon-doc): type definitions and schemas for lexicon documents | 47 - | [`lexicon-resolver`](./packages/lexicons/lexicon-resolver): lexicon authority resolution and schema retrieval | 48 - | [`lexicons`](./packages/lexicons/lexicons): core lexicon types, interfaces, and schema validations | 49 - | **Lexicon definition packages** | 50 - | [`atproto`](./packages/definitions/atproto): `com.atproto.*` schema definitions | 51 - | [`bluemoji`](./packages/definitions/bluemoji): `blue.moji.*` schema definitions | 52 - | [`bluesky`](./packages/definitions/bluesky): `app.bsky.*` and `chat.bsky.*` schema definitions | 53 - | [`frontpage`](./packages/definitions/frontpage): `fyi.unravel.frontpage.*` schema definitions | 54 - | [`leaflet`](./packages/definitions/leaflet): `pub.leaflet.*` schema definitions | 55 - | [`lexicon-community`](./packages/definitions/lexicon-community): `community.lexicon.*` schema definitions | 56 - | [`microcosm`](./packages/definitions/microcosm): `blue.microcosm.*` and `com.bad-example.*` schema definitions | 57 - | [`ozone`](./packages/definitions/ozone): `tools.ozone.*` schema definitions | 58 - | [`pckt`](./packages/definitions/pckt): `blog.pckt.*` schema definitions | 59 - | [`tangled`](./packages/definitions/tangled): `sh.tangled.*` schema definitions | 60 - | [`whitewind`](./packages/definitions/whitewind): `com.whtwnd.*` schema definitions | 61 - | **Identity packages** | 62 - | [`did-plc`](./packages/identity/did-plc): validations, type definitions and schemas for did:plc operations | 63 - | [`identity`](./packages/identity/identity): syntax, type definitions and schemas for handles, DIDs and DID documents | 64 - | [`identity-resolver`](./packages/identity/identity-resolver): handle and DID document resolution | 65 - | [`identity-resolver-node`](./packages/identity/identity-resolver-node): additional identity resolvers for Node.js | 66 - | **Utility packages** | 67 - | [`car`](./packages/utilities/car): DASL CAR codec | 68 - | [`cbor`](./packages/utilities/cbor): DASL dCBOR42 codec | 69 - | [`cid`](./packages/utilities/cid): DASL CID codec | 70 - | [`crypto`](./packages/utilities/crypto): cryptographic utilities | 71 - | [`mst`](./packages/utilities/mst): atproto MST manipulation utilities | 72 - | [`multibase`](./packages/utilities/multibase): multibase utilities | 73 - | [`repo`](./packages/utilities/repo): read AT Protocol repository exports | 74 - | [`tid`](./packages/utilities/tid): atproto timestamp identifier codec | 75 - | [`varint`](./packages/utilities/varint): protobuf-style LEB128 varint codec | 76 - | **Bluesky-specific packages** | 77 - | [`bluesky-moderation`](./packages/bluesky/moderation): interprets Bluesky's content moderation labels | 78 - | [`bluesky-richtext-builder`](./packages/bluesky/richtext-builder): builder pattern for Bluesky's rich text facets | 79 - | [`bluesky-richtext-parser`](./packages/bluesky/richtext-parser): parse Bluesky's (extended) rich text syntax | 80 - | [`bluesky-richtext-segmenter`](./packages/bluesky/richtext-segmenter): segments Bluesky's rich text facets into tokens | 81 - | [`bluesky-threading`](./packages/bluesky/threading): create Bluesky threads containing multiple posts with one write | 20 + const { data } = await client.get('app.bsky.actor.getProfile', { 21 + params: { actor: 'bsky.app' }, 22 + }); 23 + 24 + console.log(data.displayName); 25 + // -> Bluesky 26 + ``` 27 + 28 + for authenticated requests, see the [client docs](./packages/clients/client) or use the 29 + [OAuth browser client](./packages/oauth/browser-client) for web apps. 30 + 31 + ## packages 32 + 33 + | Packages | 34 + | ----------------------------------------------------------------------------------------------------------- | 35 + | **Client packages** | 36 + | [`client`](./packages/clients/client): XRPC HTTP client | 37 + | [`firehose`](./packages/clients/firehose): XRPC subscription client | 38 + | [`jetstream`](./packages/clients/jetstream): Jetstream WebSocket client | 39 + | [`cache`](./packages/clients/cache): normalized cache store | 40 + | **Server packages** | 41 + | [`xrpc-server`](./packages/servers/xrpc-server): XRPC web framework | 42 + | [`xrpc-server-bun`](./packages/servers/xrpc-server-bun): Bun WebSocket adapter | 43 + | [`xrpc-server-cloudflare`](./packages/servers/xrpc-server-cloudflare): Cloudflare Workers WebSocket adapter | 44 + | [`xrpc-server-deno`](./packages/servers/xrpc-server-deno): Deno WebSocket adapter | 45 + | [`xrpc-server-node`](./packages/servers/xrpc-server-node): Node.js WebSocket adapter | 46 + | **OAuth packages** | 47 + | [`oauth-browser-client`](./packages/oauth/browser-client): minimal OAuth client for SPAs | 48 + | **Lexicon packages** | 49 + | [`lex-cli`](./packages/lexicons/lex-cli): generate TypeScript from lexicon schemas | 50 + | [`lexicon-doc`](./packages/lexicons/lexicon-doc): parse and author lexicon documents | 51 + | [`lexicon-resolver`](./packages/lexicons/lexicon-resolver): resolve lexicons from the network | 52 + | [`lexicons`](./packages/lexicons/lexicons): core types and schema validation | 53 + | **Lexicon definition packages** | 54 + | [`atproto`](./packages/definitions/atproto): `com.atproto.*` | 55 + | [`bluemoji`](./packages/definitions/bluemoji): `blue.moji.*` | 56 + | [`bluesky`](./packages/definitions/bluesky): `app.bsky.*`, `chat.bsky.*` | 57 + | [`frontpage`](./packages/definitions/frontpage): `fyi.unravel.frontpage.*` | 58 + | [`leaflet`](./packages/definitions/leaflet): `pub.leaflet.*` | 59 + | [`lexicon-community`](./packages/definitions/lexicon-community): `community.lexicon.*` | 60 + | [`microcosm`](./packages/definitions/microcosm): `blue.microcosm.*`, `com.bad-example.*` | 61 + | [`ozone`](./packages/definitions/ozone): `tools.ozone.*` | 62 + | [`pckt`](./packages/definitions/pckt): `blog.pckt.*` | 63 + | [`tangled`](./packages/definitions/tangled): `sh.tangled.*` | 64 + | [`whitewind`](./packages/definitions/whitewind): `com.whtwnd.*` | 65 + | **Identity packages** | 66 + | [`identity`](./packages/identity/identity): handle, DID and DID document types | 67 + | [`identity-resolver`](./packages/identity/identity-resolver): handle and DID document resolution | 68 + | [`identity-resolver-node`](./packages/identity/identity-resolver-node): Node.js DNS-based handle resolver | 69 + | [`did-plc`](./packages/identity/did-plc): did:plc operation validation | 70 + | **Utility packages** | 71 + | [`car`](./packages/utilities/car): CAR archive codec | 72 + | [`cbor`](./packages/utilities/cbor): deterministic CBOR codec | 73 + | [`cid`](./packages/utilities/cid): content identifier codec | 74 + | [`crypto`](./packages/utilities/crypto): signing and verification | 75 + | [`mst`](./packages/utilities/mst): merkle search tree utilities | 76 + | [`multibase`](./packages/utilities/multibase): base32/base64 encoding | 77 + | [`repo`](./packages/utilities/repo): repository export reader | 78 + | [`tid`](./packages/utilities/tid): timestamp identifier codec | 79 + | [`varint`](./packages/utilities/varint): LEB128 varint codec | 80 + | **Bluesky-specific packages** | 81 + | [`bluesky-moderation`](./packages/bluesky/moderation): content moderation interpretation | 82 + | [`bluesky-richtext-builder`](./packages/bluesky/richtext-builder): rich text facet builder | 83 + | [`bluesky-richtext-parser`](./packages/bluesky/richtext-parser): parse rich text syntax | 84 + | [`bluesky-richtext-segmenter`](./packages/bluesky/richtext-segmenter): segment text by facets | 85 + | [`bluesky-search-parser`](./packages/bluesky/search-parser): search query tokenizer | 86 + | [`bluesky-threading`](./packages/bluesky/threading): atomic thread publishing | 82 87 83 - ## contribution guide 88 + ## contributing 84 89 85 - this monorepo uses [`mise`](https://mise.jdx.dev) to handle versioning, although it doesn't really 86 - matter. Node.js LTS is necessary to use the `internal-dev-env` package for testing the `client` 87 - package with the official PDS distribution, but otherwise you can (and should) use the latest 88 - available version. 90 + this monorepo uses [mise](https://mise.jdx.dev) for runtime versioning and pnpm for package 91 + management. 89 92 90 93 ```sh 91 - # Install all the recommended runtimes 94 + # install runtimes 92 95 mise install 93 96 94 - # Runs all the build scripts 97 + # build all packages 95 98 pnpm run -r build 96 99 97 - # Pull in the latest ATProto/Ozone/Bluesky lexicons, and generate the type declarations 98 - pnpm run pull 100 + # pull latest lexicons and regenerate definitions 101 + pnpm run -r pull 99 102 pnpm run -r generate 100 103 ``` 101 104 102 - ### checking package sizes 105 + ### package size reporting 103 106 104 - to observe the size of packages (both install size and bundled size), there is a `pkg-size-report` 105 - tool doing just that. you can also save the package sizes at a given time and inspect the impact of 106 - changes to the final bundle size. the tool uses `esbuild` to produce a minified bundle to get the 107 - size of each entrypoint. 107 + check bundle sizes with the `pkg-size-report` tool: 108 108 109 - <!-- prettier-ignore-start --> 110 - <!-- Otherwise it wrecks the gfm alertbox ugh --> 111 - 112 - > [!WARNING] 113 - > run `pnpm run -r build` before running the command. otherwise, the command **may not run**, or **give bad measurements**. 114 - 115 - <!-- prettier-ignore-end --> 109 + > [!WARNING] run `pnpm run -r build` first, otherwise measurements may be inaccurate. 116 110 117 111 ```sh 118 - # See the size of packages. 119 - # If package sizes were saved previously, will also show the diff. 120 - pnpm pkg-size-report 121 - 122 - # Save esbuild metafiles and package size information. 123 - pnpm pkg-size-report --save 124 - 125 - # Save just esbuild metafiles. 126 - pnpm pkg-size-report --save-meta 127 - 128 - # Show only the packages whose size have changed. 129 - pnpm pkg-size-report --compare 130 - 131 - # Keep the result bundle produced by esbuild. 132 - # Will be left in /tmp/[...]--[pkgname]--[random] 133 - pnpm pkg-size-report --keep-builds 112 + pnpm pkg-size-report # show sizes (and diff if previously saved) 113 + pnpm pkg-size-report --save # save current sizes for comparison 114 + pnpm pkg-size-report --compare # show only changed packages 134 115 ```
+125 -127
packages/bluesky/moderation/README.md
··· 1 1 # @atcute/bluesky-moderation 2 2 3 - interprets Bluesky's content moderation labels. 3 + interpret Bluesky content moderation labels and user preferences. 4 4 5 - ```ts 6 - import type { XRPC } from '@atcute/client'; 7 - import type { AppBskyActorDefs, AppBskyFeedDefs, AppBskyLabelerDefs, At } from '@atcute/client/lexicons'; 5 + ```sh 6 + npm install @atcute/bluesky-moderation 7 + ``` 8 8 9 + evaluates posts, profiles, lists, and other content against moderation labels, mutes, blocks, and 10 + keyword filters to determine how they should be displayed. 11 + 12 + ## usage 13 + 14 + ### basic flow 15 + 16 + 1. fetch user preferences and labeler definitions 17 + 2. run moderation functions on content 18 + 3. get display restrictions for your UI context 19 + 20 + ```ts 9 21 import { 10 22 DisplayContext, 11 23 getDisplayRestrictions, 12 24 interpretLabelerDefinitions, 13 - interpretMutedWordPreferences, 14 - LabelPreference, 15 25 moderatePost, 16 26 type ModerationPreferences, 17 27 } from '@atcute/bluesky-moderation'; 18 28 19 - declare const rpc: XRPC; 29 + // 1. set up preferences (see "loading preferences" below) 30 + const prefs: ModerationPreferences = { ... }; 31 + const labelDefs = interpretLabelerDefinitions(labelers); 20 32 21 - // first, let's get the user's preferences 22 - const labelerDids = new Set<At.Did>([ 23 - // Bluesky moderation service 24 - 'did:plc:ar7c4by46qjdydhdevvrndac', 25 - ]); 33 + // 2. moderate content 34 + const decision = moderatePost(post, { 35 + viewerDid: 'did:plc:...', 36 + prefs, 37 + labelDefs, 38 + }); 26 39 27 - const modPrefs: ModerationPreferences = { 28 - adultContentEnabled: false, 29 - globalLabelPrefs: {}, 30 - prefsByLabelers: { 31 - 'did:plc:ar7c4by46qjdydhdevvrndac': { 32 - labelPrefs: {}, 33 - }, 34 - }, 35 - keywordFilters: [], 36 - hiddenPosts: [], 37 - temporaryMutes: [], 38 - }; 40 + // 3. get display restrictions for your context 41 + const ui = getDisplayRestrictions(decision, DisplayContext.ContentList); 39 42 40 - { 41 - const { data } = await rpc.get('app.bsky.actor.getPreferences', {}); 43 + if (ui.filters.length > 0) { 44 + // don't show this post in feeds 45 + } 42 46 43 - const labelPrefs: AppBskyActorDefs.ContentLabelPref[] = []; 47 + if (ui.blurs.length > 0) { 48 + // hide behind a content warning 44 49 45 - const globalLabelPrefs = (modPrefs.globalLabelPrefs ??= {}); 46 - const prefsByLabelers = (modPrefs.prefsByLabelers ??= {}); 50 + if (ui.noOverride) { 51 + // don't allow user to reveal 52 + } 53 + } 47 54 48 - for (const pref of data.preferences) { 49 - switch (pref.$type) { 50 - case 'app.bsky.actor.defs#adultContentPref': { 51 - modPrefs.adultContentEnabled = pref.enabled; 52 - break; 53 - } 54 - case 'app.bsky.actor.defs#labelersPref': { 55 - for (const labeler of pref.labelers) { 56 - prefsByLabelers[labeler.did] ??= { labelPrefs: {} }; 57 - labelerDids.add(labeler.did); 58 - } 55 + if (ui.alerts.length > 0 || ui.informs.length > 0) { 56 + // show warning badges 57 + } 58 + ``` 59 59 60 - break; 61 - } 62 - case 'app.bsky.actor.defs#contentLabelPref': { 63 - labelPrefs.push(pref); 64 - break; 65 - } 66 - case 'app.bsky.actor.defs#mutedWordsPref': { 67 - modPrefs.keywordFilters = interpretMutedWordPreferences(pref); 68 - break; 69 - } 70 - case 'app.bsky.actor.defs#hiddenPostsPref': { 71 - modPrefs.hiddenPosts = pref.items as At.CanonicalResourceUri[]; 72 - break; 73 - } 74 - } 75 - } 60 + ### display contexts 76 61 77 - for (const { labelerDid, label, visibility } of labelPrefs) { 78 - let pref: LabelPreference | undefined; 79 - switch (visibility) { 80 - case 'show': 81 - case 'ignore': { 82 - pref = LabelPreference.Ignore; 83 - break; 84 - } 85 - case 'warn': { 86 - pref = LabelPreference.Warn; 87 - break; 88 - } 89 - case 'hide': { 90 - pref = LabelPreference.Hide; 91 - break; 92 - } 93 - } 62 + use different contexts depending on where content appears: 94 63 95 - if (labelerDid === undefined) { 96 - globalLabelPrefs[label] = pref; 97 - } else if (labelerDid in prefsByLabelers) { 98 - const labelerPref = prefsByLabelers[labelerDid]!; 64 + ```ts 65 + // in feeds/lists 66 + getDisplayRestrictions(decision, DisplayContext.ContentList); 99 67 100 - labelerPref.labelPrefs[label] = pref; 101 - } 102 - } 103 - } 68 + // viewing full post 69 + getDisplayRestrictions(decision, DisplayContext.ContentView); 104 70 105 - // grab labeler's definitions 106 - let labelers: AppBskyLabelerDefs.LabelerViewDetailed[] = []; 71 + // media (images/videos) 72 + getDisplayRestrictions(decision, DisplayContext.ProfileMedia); 107 73 108 - { 109 - const { data } = await rpc.get('app.bsky.labeler.getServices', { 110 - params: { 111 - dids: [...labelerDids], 112 - detailed: true, 113 - }, 114 - }); 74 + // profile lists 75 + getDisplayRestrictions(decision, DisplayContext.ProfileList); 115 76 116 - labelers = data.views.filter((view) => view.$type === 'app.bsky.labeler.defs#labelerViewDetailed'); 117 - } 77 + // viewing full profile 78 + getDisplayRestrictions(decision, DisplayContext.ProfileView); 118 79 119 - // interpret the labeler's definitions into something the library can understand 120 - const labelDefs = interpretLabelerDefinitions(labelers); 80 + // profile name/avatar 81 + getDisplayRestrictions(decision, DisplayContext.ProfileName); 82 + ``` 121 83 122 - // then we call the appropriate moderation functions 123 - { 124 - declare const post: AppBskyFeedDefs.PostView; 84 + ### loading preferences 125 85 126 - const mod = moderatePost(post, { 127 - viewerDid: 'did:plc:xyz', 128 - labelDefs, 129 - prefs: modPrefs, 130 - }); 86 + ```ts 87 + import { 88 + interpretLabelerDefinitions, 89 + interpretMutedWordPreferences, 90 + LabelPreference, 91 + type ModerationPreferences, 92 + } from '@atcute/bluesky-moderation'; 131 93 132 - // when displaying the post in feeds... 133 - { 134 - const ui = getDisplayRestrictions(mod, DisplayContext.ContentList); 94 + // fetch user preferences 95 + const { data } = await rpc.get('app.bsky.actor.getPreferences', {}); 135 96 136 - if (ui.filters.length > 0) { 137 - // don't include the post in the feed 138 - } 97 + const prefs: ModerationPreferences = { 98 + adultContentEnabled: false, 99 + globalLabelPrefs: {}, 100 + prefsByLabelers: {}, 101 + keywordFilters: [], 102 + hiddenPosts: [], 103 + temporaryMutes: [], 104 + }; 139 105 140 - if (ui.blurs.length > 0) { 141 - // hide the post behind a cover 106 + for (const pref of data.preferences) { 107 + switch (pref.$type) { 108 + case 'app.bsky.actor.defs#adultContentPref': 109 + prefs.adultContentEnabled = pref.enabled; 110 + break; 142 111 143 - if (ui.noOverride) { 144 - // don't allow the cover to be removed 112 + case 'app.bsky.actor.defs#contentLabelPref': 113 + // map visibility to LabelPreference 114 + const labelPref = 115 + pref.visibility === 'hide' 116 + ? LabelPreference.Hide 117 + : pref.visibility === 'warn' 118 + ? LabelPreference.Warn 119 + : LabelPreference.Ignore; 120 + 121 + if (pref.labelerDid) { 122 + prefs.prefsByLabelers[pref.labelerDid] ??= { labelPrefs: {} }; 123 + prefs.prefsByLabelers[pref.labelerDid].labelPrefs[pref.label] = labelPref; 124 + } else { 125 + prefs.globalLabelPrefs[pref.label] = labelPref; 145 126 } 146 - } 127 + break; 147 128 148 - if (ui.alerts.length > 0 || ui.informs.length > 0) { 149 - // show warning/inform badges in the post 150 - } 129 + case 'app.bsky.actor.defs#mutedWordsPref': 130 + prefs.keywordFilters = interpretMutedWordPreferences(pref); 131 + break; 132 + 133 + case 'app.bsky.actor.defs#hiddenPostsPref': 134 + prefs.hiddenPosts = pref.items; 135 + break; 151 136 } 137 + } 152 138 153 - // when displaying an expanded version of the post... 154 - { 155 - const ui = getDisplayRestrictions(mod, DisplayContext.ContentView); 139 + // fetch labeler definitions 140 + const { data: labelerData } = await rpc.get('app.bsky.labeler.getServices', { 141 + params: { dids: [...labelerDids], detailed: true }, 142 + }); 156 143 157 - // ... 158 - } 144 + const labelDefs = interpretLabelerDefinitions( 145 + labelerData.views.filter((v) => v.$type === 'app.bsky.labeler.defs#labelerViewDetailed'), 146 + ); 147 + ``` 159 148 160 - // when displaying images/videos of a post... 161 - { 162 - const ui = getDisplayRestrictions(mod, DisplayContext.ProfileMedia); 149 + ### moderating different content types 150 + 151 + ```ts 152 + import { 153 + moderateFeedGenerator, 154 + moderateList, 155 + moderateNotification, 156 + moderatePost, 157 + moderateProfile, 158 + } from '@atcute/bluesky-moderation'; 163 159 164 - // ... 165 - } 166 - } 160 + const postDecision = moderatePost(post, opts); 161 + const profileDecision = moderateProfile(profile, opts); 162 + const listDecision = moderateList(list, opts); 163 + const feedDecision = moderateFeedGenerator(feed, opts); 164 + const notifDecision = moderateNotification(notification, opts); 167 165 ```
+171 -10
packages/bluesky/richtext-builder/README.md
··· 1 1 # @atcute/bluesky-richtext-builder 2 2 3 - builder pattern for Bluesky's rich text facets. 3 + fluent builder for constructing Bluesky rich text with facets. 4 + 5 + ```sh 6 + npm install @atcute/bluesky-richtext-builder 7 + ``` 8 + 9 + Bluesky posts support rich text with mentions, links, and hashtags. these are represented as 10 + "facets" - byte ranges with attached features. this package provides a builder that handles the byte 11 + offset calculations for you. 12 + 13 + ## why use a builder? 14 + 15 + Bluesky facets use byte offsets, not character positions. this matters for non-ASCII text: 16 + 17 + ```ts 18 + // "café" is 5 bytes in UTF-8 (c=1, a=1, f=1, é=2) 19 + const text = 'café ☕'; 20 + 21 + // the coffee emoji starts at byte 5, not character 5 22 + ``` 23 + 24 + the builder handles these calculations automatically, so you don't have to think about byte offsets. 25 + 26 + ## usage 27 + 28 + ### basic usage 29 + 30 + ```ts 31 + import RichtextBuilder from '@atcute/bluesky-richtext-builder'; 32 + 33 + const rt = new RichtextBuilder() 34 + .addText('hello, ') 35 + .addMention('@alice', 'did:plc:abc123') 36 + .addText('! check out ') 37 + .addLink('my website', 'https://example.com'); 38 + 39 + console.log(rt.text); 40 + // -> "hello, @alice! check out my website" 41 + 42 + console.log(rt.facets); 43 + // -> [ 44 + // { index: { byteStart: 7, byteEnd: 13 }, features: [{ $type: 'app.bsky.richtext.facet#mention', did: '...' }] }, 45 + // { index: { byteStart: 25, byteEnd: 35 }, features: [{ $type: 'app.bsky.richtext.facet#link', uri: '...' }] } 46 + // ] 47 + ``` 48 + 49 + ### creating a post 50 + 51 + use with `@atcute/client` to create a post: 4 52 5 53 ```ts 54 + import { Client, CredentialManager, ok } from '@atcute/client'; 6 55 import RichtextBuilder from '@atcute/bluesky-richtext-builder'; 7 56 8 - const { text, facets } = new RichtextBuilder() 9 - .addText(`hello, `) 10 - .addMention(`@user`, 'did:plc:ia76kvnndjutgedggx2ibrem') 11 - .addText(`! please visit my`) 12 - .addLink(`website`, 'https://example.com'); 57 + import type {} from '@atcute/bluesky'; 58 + 59 + const manager = new CredentialManager({ service: 'https://bsky.social' }); 60 + const rpc = new Client({ handler: manager }); 61 + 62 + await manager.login({ identifier: 'you.bsky.social', password: 'your-app-password' }); 63 + 64 + const rt = new RichtextBuilder() 65 + .addText('hello ') 66 + .addMention('@bsky.app', 'did:plc:z72i7hdynmk6r22z27h6tvur') 67 + .addText('! ') 68 + .addTag('atproto'); 69 + 70 + await ok( 71 + rpc.post('com.atproto.repo.createRecord', { 72 + input: { 73 + repo: manager.session!.did, 74 + collection: 'app.bsky.feed.post', 75 + record: { 76 + $type: 'app.bsky.feed.post', 77 + text: rt.text, 78 + facets: rt.facets, 79 + createdAt: new Date().toISOString(), 80 + }, 81 + }, 82 + }), 83 + ); 84 + ``` 85 + 86 + ### adding links 87 + 88 + ```ts 89 + const rt = new RichtextBuilder() 90 + .addText('read the ') 91 + .addLink('documentation', 'https://atproto.com/docs') 92 + .addText(' for more info'); 93 + ``` 94 + 95 + the link text can be anything - it doesn't have to be the URL: 96 + 97 + ```ts 98 + const rt = new RichtextBuilder().addLink('click here', 'https://example.com'); 99 + ``` 100 + 101 + ### adding mentions 102 + 103 + mentions require both the display text and the user's DID: 104 + 105 + ```ts 106 + const rt = new RichtextBuilder().addMention('@alice.bsky.social', 'did:plc:abc123'); 107 + ``` 108 + 109 + you'll typically resolve the handle to a DID first: 110 + 111 + ```ts 112 + import { 113 + CompositeHandleResolver, 114 + DohJsonHandleResolver, 115 + WellKnownHandleResolver, 116 + } from '@atcute/identity-resolver'; 117 + 118 + const handleResolver = new CompositeHandleResolver({ 119 + methods: { 120 + dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }), 121 + http: new WellKnownHandleResolver(), 122 + }, 123 + }); 13 124 14 - text; 15 - // ^? `hello, @user! please visit my website` 125 + const handle = 'alice.bsky.social'; 126 + const did = await handleResolver.resolve(handle); 16 127 17 - facets; 18 - // ^? [{ index: { byteStart: 7, byteEnd: 12 }, ... }, { index: { byteStart: 30, byteEnd: 37 }, ... }]; 128 + const rt = new RichtextBuilder().addMention(`@${handle}`, did); 129 + ``` 130 + 131 + ### adding hashtags 132 + 133 + hashtags are added without the `#` prefix - it's added automatically: 134 + 135 + ```ts 136 + const rt = new RichtextBuilder().addText('loving ').addTag('atproto').addText(' development!'); 137 + 138 + // text: "loving #atproto development!" 139 + ``` 140 + 141 + ### custom facet features 142 + 143 + use `addDecoratedText()` for custom facet features: 144 + 145 + ```ts 146 + import type { FacetFeature } from '@atcute/bluesky-richtext-builder'; 147 + 148 + const feature: FacetFeature = { 149 + $type: 'app.bsky.richtext.facet#link', 150 + uri: 'https://example.com', 151 + }; 152 + 153 + const rt = new RichtextBuilder().addDecoratedText('custom link', feature); 154 + ``` 155 + 156 + ### getting the result 157 + 158 + there are multiple ways to get the composed rich text: 159 + 160 + ```ts 161 + const rt = new RichtextBuilder().addText('hello ').addTag('world'); 162 + 163 + // via getters 164 + const text = rt.text; 165 + const facets = rt.facets; 166 + 167 + // via build() method 168 + const { text, facets } = rt.build(); 169 + ``` 170 + 171 + ### cloning the builder 172 + 173 + clone a builder to create variations: 174 + 175 + ```ts 176 + const base = new RichtextBuilder().addText('hello '); 177 + 178 + const withMention = base.clone().addMention('@alice', 'did:plc:abc'); 179 + const withLink = base.clone().addLink('world', 'https://example.com'); 19 180 ```
+197 -55
packages/bluesky/richtext-parser/README.md
··· 1 1 # @atcute/bluesky-richtext-parser 2 2 3 - parse Bluesky's rich text syntax, with added support for emotes, escapes, and Markdown-like links. 3 + tokenizer for parsing Bluesky rich text syntax. 4 + 5 + ```sh 6 + npm install @atcute/bluesky-richtext-parser 7 + ``` 8 + 9 + parses user input text into tokens for mentions, hashtags, links, and text formatting. supports 10 + Bluesky's standard syntax plus Markdown-style formatting extensions. 11 + 12 + ## usage 13 + 14 + ### basic parsing 4 15 5 16 ```ts 6 - const result = tokenize(`hello @bsky.app! check out my [website](https://example.com)`); 17 + import { tokenize } from '@atcute/bluesky-richtext-parser'; 7 18 8 - expect(result).toEqual([ 9 - { 10 - type: 'text', 11 - raw: 'hello ', 12 - text: 'hello ', 13 - }, 14 - { 15 - type: 'mention', 16 - raw: '@bsky.app', 17 - handle: 'bsky.app', 18 - }, 19 - { 20 - type: 'text', 21 - raw: '! check out my ', 22 - text: '! check out my ', 23 - }, 24 - { 25 - type: 'link', 26 - raw: '[website](https://example.com)', 27 - text: 'website', 28 - url: 'https://example.com', 29 - }, 30 - ]); 19 + const tokens = tokenize('hello @alice.bsky.social! check out #atproto'); 20 + 21 + // [ 22 + // { type: 'text', raw: 'hello ', content: 'hello ' }, 23 + // { type: 'mention', raw: '@alice.bsky.social', handle: 'alice.bsky.social' }, 24 + // { type: 'text', raw: '! check out ', content: '! check out ' }, 25 + // { type: 'topic', raw: '#atproto', name: 'atproto' } 26 + // ] 31 27 ``` 32 28 33 - whitespace trimming can be done by using the following regular expression before passing to the 34 - tokenizer, and afterwards for text on Markdown-like links: 29 + ## supported syntax 30 + 31 + ### mentions 35 32 36 33 ```ts 37 - /^\s+|\s+$| +(?=\n)|\n(?=(?: *\n){2}) */g; 34 + tokenize('@alice.bsky.social'); 35 + // -> [{ type: 'mention', handle: 'alice.bsky.social' }] 36 + 37 + tokenize('@alice.bsky.social'); // fullwidth @ also works 38 + // -> [{ type: 'mention', handle: 'alice.bsky.social' }] 38 39 ``` 39 40 40 - autolink trimming can be done like so: 41 + ### hashtags (topics) 41 42 42 43 ```ts 43 - const safeUrlParse = (href: string): URL | null => { 44 - const url = URL.parse(text); 44 + tokenize('#atproto'); 45 + // -> [{ type: 'topic', name: 'atproto' }] 45 46 46 - if (url !== null) { 47 - const protocol = url.protocol; 47 + tokenize('#atproto'); // fullwidth # also works 48 + // -> [{ type: 'topic', name: 'atproto' }] 49 + ``` 50 + 51 + ### auto-linked URLs 52 + 53 + bare URLs are automatically detected: 54 + 55 + ```ts 56 + tokenize('check out https://example.com'); 57 + // -> [ 58 + // { type: 'text', content: 'check out ' }, 59 + // { type: 'autolink', url: 'https://example.com' } 60 + // ] 61 + ``` 62 + 63 + ### markdown links 64 + 65 + ```ts 66 + tokenize('[my website](https://example.com)'); 67 + // -> [{ type: 'link', url: 'https://example.com', children: [{ type: 'text', content: 'my website' }] }] 68 + ``` 69 + 70 + link text can contain nested formatting: 71 + 72 + ```ts 73 + tokenize('[**bold link**](https://example.com)'); 74 + // -> [{ type: 'link', children: [{ type: 'strong', ... }] }] 75 + ``` 76 + 77 + ### text formatting 78 + 79 + ```ts 80 + // bold 81 + tokenize('**bold text**'); 82 + // -> [{ type: 'strong', children: [{ type: 'text', content: 'bold text' }] }] 83 + 84 + // italic 85 + tokenize('*italic text*'); 86 + // -> [{ type: 'emphasis', children: [...] }] 87 + 88 + tokenize('_also italic_'); 89 + // -> [{ type: 'emphasis', children: [...] }] 90 + 91 + // underline 92 + tokenize('__underlined__'); 93 + // -> [{ type: 'underline', children: [...] }] 94 + 95 + // strikethrough 96 + tokenize('~~deleted~~'); 97 + // -> [{ type: 'delete', children: [...] }] 48 98 49 - if (protocol === 'https:' || protocol === 'http:') { 50 - return url; 51 - } 52 - } 99 + // inline code 100 + tokenize('use `npm install`'); 101 + // -> [{ type: 'text', ... }, { type: 'code', content: 'npm install' }] 102 + ``` 53 103 54 - return null; 104 + ### emotes 105 + 106 + ```ts 107 + tokenize('hello :wave:'); 108 + // -> [{ type: 'text', ... }, { type: 'emote', name: 'wave' }] 109 + ``` 110 + 111 + ### escapes 112 + 113 + backslash escapes special characters: 114 + 115 + ```ts 116 + tokenize('not a \\@mention'); 117 + // -> [{ type: 'text', ... }, { type: 'escape', escaped: '@' }, { type: 'text', ... }] 118 + ``` 119 + 120 + ## handling tokens 121 + 122 + process tokens to build facets or render content: 123 + 124 + ```ts 125 + import { tokenize, type Token } from '@atcute/bluesky-richtext-parser'; 126 + import RichtextBuilder from '@atcute/bluesky-richtext-builder'; 127 + 128 + const resolveHandle = async (handle: string): Promise<string | null> => { 129 + // resolve handle to DID 55 130 }; 56 131 57 - const TRIM_HOST_RE = /^www\./; 58 - const PATH_MAX_LENGTH = 16; 132 + const processTokens = async (tokens: Token[]): Promise<RichtextBuilder> => { 133 + const rt = new RichtextBuilder(); 59 134 60 - const toShortUrl = (href: string): string => { 61 - const url = safeUrlParse(href); 135 + for (const token of tokens) { 136 + switch (token.type) { 137 + case 'text': 138 + rt.addText(token.content); 139 + break; 62 140 63 - if (url !== null) { 64 - const host = 65 - (url.username ? url.username + (url.password ? ':' + url.password : '') + '@' : '') + 66 - url.host.replace(TRIM_HOST_RE, ''); 141 + case 'mention': { 142 + const did = await resolveHandle(token.handle); 143 + if (did) { 144 + rt.addMention(token.raw, did); 145 + } else { 146 + rt.addText(token.raw); 147 + } 148 + break; 149 + } 67 150 68 - const path = 69 - (url.pathname === '/' ? '' : url.pathname) + 70 - (url.search.length > 1 ? url.search : '') + 71 - (url.hash.length > 1 ? url.hash : ''); 151 + case 'topic': 152 + rt.addTag(token.name); 153 + break; 72 154 73 - if (path.length > PATH_MAX_LENGTH) { 74 - return host + path.slice(0, PATH_MAX_LENGTH - 1) + '…'; 75 - } 155 + case 'autolink': 156 + rt.addLink(token.url, token.url); 157 + break; 76 158 77 - return host + path; 159 + case 'link': 160 + // flatten children to text 161 + const text = flattenToText(token.children); 162 + rt.addLink(text, token.url); 163 + break; 164 + 165 + case 'escape': 166 + rt.addText(token.escaped); 167 + break; 168 + 169 + // formatting tokens (strong, emphasis, etc.) don't map to facets 170 + // so just extract their text content 171 + case 'strong': 172 + case 'emphasis': 173 + case 'underline': 174 + case 'delete': 175 + rt.addText(flattenToText(token.children)); 176 + break; 177 + 178 + case 'code': 179 + rt.addText(token.content); 180 + break; 181 + 182 + case 'emote': 183 + // handle emotes as needed 184 + rt.addText(token.raw); 185 + break; 186 + } 78 187 } 79 188 80 - return href; 189 + return rt; 190 + }; 191 + 192 + const flattenToText = (tokens: Token[]): string => { 193 + return tokens 194 + .map((t) => { 195 + if ('content' in t) { 196 + return t.content; 197 + } 198 + if ('children' in t) { 199 + return flattenToText(t.children); 200 + } 201 + return t.raw; 202 + }) 203 + .join(''); 81 204 }; 82 205 ``` 206 + 207 + ### token types 208 + 209 + | type | fields | description | 210 + | ----------- | ----------------- | -------------------------------- | 211 + | `text` | `content` | plain text | 212 + | `mention` | `handle` | @mention | 213 + | `topic` | `name` | #hashtag | 214 + | `emote` | `name` | :emote: | 215 + | `autolink` | `url` | bare URL | 216 + | `link` | `url`, `children` | markdown link with nested tokens | 217 + | `strong` | `children` | \*\*bold\*\* | 218 + | `emphasis` | `children` | \_italic\_ | 219 + | `underline` | `children` | \*\*underline\*\* | 220 + | `delete` | `children` | \~~strikethrough~~ | 221 + | `code` | `content` | \`inline code` | 222 + | `escape` | `escaped` | backslash escape | 223 + 224 + all tokens have `raw` containing the original matched text.
+74 -44
packages/bluesky/richtext-segmenter/README.md
··· 1 1 # @atcute/bluesky-richtext-segmenter 2 2 3 - segments Bluesky's rich text facets into tokens. 3 + segments Bluesky rich text into tokens for rendering. 4 + 5 + ```sh 6 + npm install @atcute/bluesky-richtext-segmenter 7 + ``` 8 + 9 + Bluesky posts contain text and facets (byte-range annotations for mentions, links, etc). this 10 + package splits the text into segments, each with its associated features, so you can render them 11 + appropriately. 12 + 13 + ## usage 4 14 5 15 ```ts 6 - const result = segmentize('hello @bsky.app! check out my website', [ 16 + import { segmentize } from '@atcute/bluesky-richtext-segmenter'; 17 + 18 + // text and facets from a post record 19 + const text = 'hello @bsky.app!'; 20 + const facets = [ 7 21 { 8 22 index: { byteStart: 6, byteEnd: 15 }, 9 23 features: [ 10 - { 11 - $type: 'app.bsky.richtext.facet#mention', 12 - did: 'did:plc:z72i7hdynmk6r22z27h6tvur', 13 - }, 24 + { $type: 'app.bsky.richtext.facet#mention', did: 'did:plc:z72i7hdynmk6r22z27h6tvur' }, 14 25 ], 15 26 }, 16 - { 17 - index: { byteStart: 30, byteEnd: 37 }, 18 - features: [ 19 - { 20 - $type: 'app.bsky.richtext.facet#link', 21 - uri: 'https://example.com', 22 - }, 23 - ], 24 - }, 25 - ]); 27 + ]; 26 28 27 - expect(result).toEqual([ 28 - { 29 - text: 'hello ', 30 - features: undefined, 31 - }, 32 - { 33 - text: '@bsky.app', 34 - features: [ 35 - { 36 - $type: 'app.bsky.richtext.facet#mention', 37 - did: 'did:plc:z72i7hdynmk6r22z27h6tvur', 38 - }, 39 - ], 40 - }, 41 - { 42 - text: '! check out my ', 43 - features: undefined, 44 - }, 45 - { 46 - text: 'website', 47 - features: [ 48 - { 49 - $type: 'app.bsky.richtext.facet#link', 50 - uri: 'https://example.com', 51 - }, 52 - ], 53 - }, 54 - ]); 29 + const segments = segmentize(text, facets); 30 + // -> [ 31 + // { text: 'hello ', features: undefined }, 32 + // { text: '@bsky.app', features: [{ $type: '...#mention', did: '...' }] }, 33 + // { text: '!', features: undefined } 34 + // ] 35 + ``` 36 + 37 + ### rendering segments 38 + 39 + each segment contains `text` and optionally `features`. render based on the feature type: 40 + 41 + ```tsx 42 + import { segmentize, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter'; 43 + 44 + const renderSegment = (segment: RichtextSegment, index: number) => { 45 + const { text, features } = segment; 46 + 47 + if (!features) { 48 + return <span key={index}>{text}</span>; 49 + } 50 + 51 + // segments can have multiple features, but typically just one 52 + const feature = features[0]; 53 + 54 + switch (feature.$type) { 55 + case 'app.bsky.richtext.facet#mention': 56 + return ( 57 + <a key={index} href={`/profile/${feature.did}`}> 58 + {text} 59 + </a> 60 + ); 61 + 62 + case 'app.bsky.richtext.facet#link': 63 + return ( 64 + <a key={index} href={feature.uri} target="_blank" rel="noopener noreferrer"> 65 + {text} 66 + </a> 67 + ); 68 + 69 + case 'app.bsky.richtext.facet#tag': 70 + return ( 71 + <a key={index} href={`/search?q=${encodeURIComponent('#' + feature.tag)}`}> 72 + {text} 73 + </a> 74 + ); 75 + 76 + default: 77 + return <span key={index}>{text}</span>; 78 + } 79 + }; 80 + 81 + const RichText = ({ text, facets }: { text: string; facets?: Facet[] }) => { 82 + const segments = segmentize(text, facets); 83 + return <>{segments.map(renderSegment)}</>; 84 + }; 55 85 ```
+17 -9
packages/bluesky/search-parser/README.md
··· 1 1 # @atcute/bluesky-search-parser 2 2 3 - parse Bluesky's search syntax 3 + tokenizer for Bluesky's search query syntax. 4 + 5 + ```sh 6 + npm install @atcute/bluesky-search-parser 7 + ``` 8 + 9 + useful for building search UIs that need to parse and manipulate search queries, such as 10 + highlighting operators or extracting filter values. 4 11 5 12 ```ts 6 - const result = tokenize(`from:me hello "foo bar"`); 13 + import { tokenize } from '@atcute/bluesky-search-parser'; 7 14 8 - expect(result).toEqual([ 9 - { type: 'word', value: 'from:me' }, 10 - { type: 'whitespace', value: ' ' }, 11 - { type: 'word', value: 'hello' }, 12 - { type: 'whitespace', value: ' ' }, 13 - { type: 'quoted', value: '"foo bar"' }, 14 - ]); 15 + const tokens = tokenize(`from:me hello "foo bar"`); 16 + // [ 17 + // { type: 'word', value: 'from:me' }, 18 + // { type: 'whitespace', value: ' ' }, 19 + // { type: 'word', value: 'hello' }, 20 + // { type: 'whitespace', value: ' ' }, 21 + // { type: 'quoted', value: '"foo bar"' }, 22 + // ] 15 23 ```
+97 -12
packages/bluesky/threading/README.md
··· 1 1 # @atcute/bluesky-threading 2 2 3 - create Bluesky threads containing multiple posts with one write. 3 + publish Bluesky threads atomically. 4 + 5 + ```sh 6 + npm install @atcute/bluesky-threading 7 + ``` 8 + 9 + creates multiple posts as a single atomic write using `com.atproto.repo.applyWrites`, so either all 10 + posts succeed or none do. 11 + 12 + ## usage 13 + 14 + ### publishing a thread 4 15 5 16 ```ts 6 17 import { XRPC } from '@atcute/client'; 7 - import { AtpAuth } from '@atcute/client/middlewares/auth'; 8 - 9 18 import RichTextBuilder from '@atcute/bluesky-richtext-builder'; 10 19 import { publishThread } from '@atcute/bluesky-threading'; 11 20 12 - const rpc = new XRPC({ service: 'https://bsky.social' }); 13 - const auth = new AtpAuth(rpc); 14 - 15 - await auth.login({ identifier: '...', password: '...' }); 21 + const rpc = new XRPC({ handler: agent }); 16 22 17 23 await publishThread(rpc, { 18 24 author: 'did:plc:ia76kvnndjutgedggx2ibrem', ··· 20 26 posts: [ 21 27 { 22 28 content: new RichTextBuilder() 23 - .addText('Hello, please visit my website! ') 29 + .addText('First post of my thread! ') 24 30 .addLink('example.com', 'https://example.com'), 25 31 }, 26 32 { 27 - content: { 28 - text: `Here's the second post!`, 33 + content: { text: 'Second post continues the story...' }, 34 + }, 35 + { 36 + content: { text: 'And the thrilling conclusion!' }, 37 + }, 38 + ], 39 + }); 40 + ``` 41 + 42 + ### adding images 43 + 44 + ```ts 45 + await publishThread(rpc, { 46 + author: 'did:plc:ia76kvnndjutgedggx2ibrem', 47 + posts: [ 48 + { 49 + content: { text: 'Check out this photo!' }, 50 + embed: { 51 + media: { 52 + type: 'image', 53 + images: [ 54 + { 55 + blob: imageFile, // Web Blob - auto-uploaded 56 + alt: 'A beautiful sunset', 57 + aspectRatio: { width: 1200, height: 800 }, 58 + }, 59 + ], 60 + }, 29 61 }, 30 62 }, 63 + ], 64 + }); 65 + ``` 66 + 67 + ### quote posting 68 + 69 + ```ts 70 + await publishThread(rpc, { 71 + author: 'did:plc:ia76kvnndjutgedggx2ibrem', 72 + posts: [ 31 73 { 32 - content: { 33 - text: `Third post for good measure.`, 74 + content: { text: 'This is such a great post!' }, 75 + embed: { 76 + record: { 77 + type: 'quote', 78 + uri: 'at://did:plc:.../app.bsky.feed.post/...', 79 + }, 34 80 }, 35 81 }, 36 82 ], 37 83 }); 38 84 ``` 85 + 86 + ### replying to a post 87 + 88 + ```ts 89 + await publishThread(rpc, { 90 + author: 'did:plc:ia76kvnndjutgedggx2ibrem', 91 + reply: 'at://did:plc:.../app.bsky.feed.post/...', // AT-URI of post to reply to 92 + posts: [{ content: { text: 'Great thread! Adding my thoughts...' } }], 93 + }); 94 + ``` 95 + 96 + ### restricting replies with threadgate 97 + 98 + ```ts 99 + await publishThread(rpc, { 100 + author: 'did:plc:ia76kvnndjutgedggx2ibrem', 101 + gate: { 102 + follows: true, // only followers can reply 103 + mentions: true, // or users mentioned in the post 104 + }, 105 + posts: [{ content: { text: 'Only my followers can reply to this thread' } }], 106 + }); 107 + ``` 108 + 109 + ### creating without publishing 110 + 111 + use `createThread` to get the records without publishing - useful for previews or custom handling: 112 + 113 + ```ts 114 + import { createThread } from '@atcute/bluesky-threading'; 115 + 116 + const records = await createThread({ 117 + client: rpc, 118 + author: 'did:plc:ia76kvnndjutgedggx2ibrem', 119 + posts: [{ content: { text: 'Preview this first' } }], 120 + }); 121 + 122 + // inspect records, then publish yourself via com.atproto.repo.applyWrites 123 + ```
+68 -8
packages/clients/cache/README.md
··· 1 1 # @atcute/cache 2 2 3 - > [!WARNING] 4 - > very experimental package 3 + > [!WARNING] 4 + > experimental package - API may change 5 + 6 + normalized cache store for AT Protocol. 7 + 8 + ```sh 9 + npm install @atcute/cache 10 + ``` 11 + 12 + stores entities by their unique keys and automatically deduplicates nested references. when an 13 + entity appears in multiple API responses (e.g., a profile in a post author and in followers), they 14 + share the same object reference in memory. 15 + 16 + ## usage 5 17 6 - normalized cache store for AT Protocol 18 + ### setting up the cache 7 19 8 20 ```ts 9 21 import { NormalizedCache } from '@atcute/cache'; 10 - import { AppBskyActorDefs, AppBskyFeedDefs, AppBskyFeedGetTimeline } from '@atcute/bluesky'; 22 + import { AppBskyActorDefs, AppBskyFeedDefs } from '@atcute/bluesky'; 11 23 12 24 const cache = new NormalizedCache(); 13 25 ··· 21 33 schema: AppBskyActorDefs.profileViewBasicSchema, 22 34 key: (profile) => profile.did, 23 35 }); 36 + ``` 24 37 25 - // normalize API responses 38 + ### normalizing API responses 39 + 40 + ```ts 41 + import { AppBskyFeedGetTimeline } from '@atcute/bluesky'; 42 + 26 43 const response = await rpc.get('app.bsky.feed.getTimeline', { params: {} }); 27 44 45 + // walks the response, extracts entities, and stores them 28 46 const timeline = cache.normalize(AppBskyFeedGetTimeline.mainSchema.output.schema, response.data); 47 + ``` 29 48 30 - // read entities from cache 49 + ### reading from cache 50 + 51 + ```ts 31 52 const post = cache.get(AppBskyFeedDefs.postViewSchema, 'at://did:plc:.../app.bsky.feed.post/...'); 32 53 const profile = cache.get(AppBskyActorDefs.profileViewBasicSchema, 'did:plc:...'); 33 54 34 - // optimistic updates 55 + // check if entity exists 56 + if (cache.has(AppBskyFeedDefs.postViewSchema, postUri)) { 57 + // ... 58 + } 59 + 60 + // get all cached entities of a type 61 + const allPosts = cache.getAll(AppBskyFeedDefs.postViewSchema); 62 + ``` 63 + 64 + ### optimistic updates 65 + 66 + ```ts 67 + // update a post's like count immediately, before the API responds 35 68 cache.update(AppBskyFeedDefs.postViewSchema, postUri, (post) => ({ 36 69 ...post, 37 70 viewer: { ...post.viewer, like: tempLikeUri }, 38 71 likeCount: (post.likeCount ?? 0) + 1, 39 72 })); 73 + ``` 40 74 41 - // subscribe to entity changes 75 + ### subscribing to changes 76 + 77 + ```ts 78 + // subscribe to a specific entity 42 79 const unsubscribe = cache.subscribe(AppBskyFeedDefs.postViewSchema, postUri, (post) => { 43 80 console.log('post changed:', post); 81 + }); 82 + 83 + // subscribe to all entities of a type 84 + const unsubscribeType = cache.subscribeType(AppBskyActorDefs.profileViewBasicSchema, (key, profile) => { 85 + console.log(`profile ${key} changed:`, profile); 86 + }); 87 + 88 + // clean up when done 89 + unsubscribe(); 90 + unsubscribeType(); 91 + ``` 92 + 93 + ### custom merge logic 94 + 95 + ```ts 96 + cache.define({ 97 + schema: AppBskyActorDefs.profileViewBasicSchema, 98 + key: (profile) => profile.did, 99 + // custom merge: prefer existing avatar if new one is missing 100 + merge: (existing, incoming) => ({ 101 + ...incoming, 102 + avatar: incoming.avatar ?? existing.avatar, 103 + }), 44 104 }); 45 105 ```
+297 -92
packages/clients/client/README.md
··· 2 2 3 3 lightweight and cute API client for AT Protocol. 4 4 5 - - **small**, the bare minimum is ~1.1 kB gzipped, with validation at ~3 kB gzipped. 6 - - **optional runtime validation**, by default there's no runtime validation as the server is assumed 7 - to be trusted in returning valid responses, but you can opt-in to validation using the `call()` 8 - method with lexicon schemas. validation code is automatically tree-shaken when not used. 5 + ```sh 6 + npm install @atcute/client 7 + ``` 8 + 9 + ## definition packages 10 + 11 + by default, the client has no type definitions for queries or procedures. 12 + 13 + | package | schemas | 14 + | ------------------------------------------------------------------ | --------------------------------------- | 15 + | [`@atcute/atproto`](../../definitions/atproto) | `com.atproto.*` | 16 + | [`@atcute/bluesky`](../../definitions/bluesky) | `app.bsky.*`, `chat.bsky.*` | 17 + | [`@atcute/ozone`](../../definitions/ozone) | `tools.ozone.*` | 18 + | [`@atcute/bluemoji`](../../definitions/bluemoji) | `blue.moji.*` | 19 + | [`@atcute/frontpage`](../../definitions/frontpage) | `fyi.unravel.frontpage.*` | 20 + | [`@atcute/whitewind`](../../definitions/whitewind) | `com.whtwnd.*` | 21 + | [`@atcute/tangled`](../../definitions/tangled) | `sh.tangled.*` | 22 + | [`@atcute/microcosm`](../../definitions/microcosm) | `blue.microcosm.*`, `com.bad-example.*` | 23 + | [`@atcute/pckt`](../../definitions/pckt) | `blog.pckt.*` | 24 + | [`@atcute/lexicon-community`](../../definitions/lexicon-community) | `community.lexicon.*` | 25 + 26 + ## usage 27 + 28 + the client communicates with AT Protocol services using XRPC, a simple RPC framework over HTTP. 29 + queries are GET requests, procedures are POST requests. 9 30 10 - ```ts 11 - import { Client, CredentialManager, ok, simpleFetchHandler } from '@atcute/client'; 31 + ### making requests 12 32 13 - // import lexicons 33 + ```ts 34 + import { Client, simpleFetchHandler } from '@atcute/client'; 14 35 import type {} from '@atcute/bluesky'; 15 36 16 - // basic usage 17 - { 18 - const handler = simpleFetchHandler({ service: 'https://public.api.bsky.app' }); 19 - const rpc = new Client({ handler }); 37 + // create a client pointing to the Bluesky public API 38 + const rpc = new Client({ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) }); 39 + ``` 20 40 21 - // explicit response handling 22 - { 23 - const { ok, data } = await rpc.get('app.bsky.actor.getProfile', { 24 - params: { 25 - actor: 'bsky.app', 26 - }, 27 - }); 41 + use `get()` for queries and `post()` for procedures. both return a response object with `ok`, 42 + `status`, `headers`, and `data` fields: 28 43 29 - if (!ok) { 30 - switch (data.error) { 31 - case 'InvalidRequest': { 32 - // Account doesn't exist 33 - break; 34 - } 35 - case 'AccountTakedown': { 36 - // Account taken down 37 - break; 38 - } 39 - case 'AccountDeactivated': { 40 - // Account deactivated 41 - break; 42 - } 43 - } 44 - } 44 + ```ts 45 + // queries use get() 46 + const response = await rpc.get('app.bsky.actor.getProfile', { 47 + params: { actor: 'bsky.app' }, 48 + }); 49 + 50 + if (response.ok) { 51 + console.log(response.data.displayName); 52 + // -> "Bluesky" 53 + } 54 + ``` 55 + 56 + ```ts 57 + // procedures use post() 58 + const response = await rpc.post('com.atproto.repo.createRecord', { 59 + input: { 60 + repo: 'did:plc:1234...', 61 + collection: 'app.bsky.feed.post', 62 + record: { 63 + $type: 'app.bsky.feed.post', 64 + text: 'hello world!', 65 + createdAt: new Date().toISOString(), 66 + }, 67 + }, 68 + }); 69 + ``` 70 + 71 + ### handling errors 72 + 73 + responses always include an `ok` field indicating success. for failed requests, `data` contains an 74 + error object with `error` (the error name) and optionally `message` (description): 75 + 76 + ```ts 77 + const response = await rpc.get('app.bsky.actor.getProfile', { 78 + params: { actor: 'nonexistent.invalid' }, 79 + }); 80 + 81 + if (!response.ok) { 82 + console.log(response.data.error); 83 + // -> "InvalidRequest" 84 + console.log(response.data.message); 85 + // -> "Unable to resolve handle" 86 + } 87 + ``` 88 + 89 + the error names are defined in the lexicon schema. you can switch on them for typed error handling: 45 90 46 - if (ok) { 47 - console.log(data.displayName); 48 - // -> "Bluesky" 49 - } 91 + ```ts 92 + if (!response.ok) { 93 + switch (response.data.error) { 94 + case 'InvalidRequest': 95 + // handle or account doesn't exist 96 + break; 97 + case 'AccountTakedown': 98 + // account was taken down 99 + break; 100 + case 'AccountDeactivated': 101 + // account deactivated by user 102 + break; 50 103 } 104 + } 105 + ``` 106 + 107 + ### optimistic requests 108 + 109 + if you prefer throwing on errors instead of checking `response.ok`, use the `ok()` helper: 110 + 111 + ```ts 112 + import { Client, ok, simpleFetchHandler } from '@atcute/client'; 113 + 114 + const rpc = new Client({ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) }); 115 + 116 + // throws ClientResponseError if the request fails 117 + const profile = await ok(rpc.get('app.bsky.actor.getProfile', { params: { actor: 'bsky.app' } })); 118 + 119 + console.log(profile.displayName); 120 + // -> "Bluesky" 121 + ``` 51 122 52 - // optimistic response handling 53 - { 54 - const data = await ok( 55 - rpc.get('app.bsky.actor.getProfile', { 56 - params: { 57 - actor: 'bsky.app', 58 - }, 59 - }), 60 - ); 123 + catch errors with `ClientResponseError`: 61 124 62 - console.log(data.displayName); 63 - // -> "Bluesky" 125 + ```ts 126 + import { ClientResponseError } from '@atcute/client'; 127 + 128 + try { 129 + const profile = await ok(rpc.get('app.bsky.actor.getProfile', { params: { actor: 'invalid' } })); 130 + } catch (err) { 131 + if (err instanceof ClientResponseError) { 132 + console.log(err.error); // error name from server 133 + console.log(err.description); // error message from server 134 + console.log(err.status); // HTTP status code 64 135 } 65 136 } 137 + ``` 66 138 67 - // with runtime validation 139 + ### authenticated requests 140 + 141 + use `CredentialManager` to handle authentication. it manages tokens, automatically refreshes expired 142 + access tokens, and can persist sessions: 143 + 144 + ```ts 145 + import { Client, CredentialManager, ok } from '@atcute/client'; 146 + 147 + const manager = new CredentialManager({ service: 'https://bsky.social' }); 148 + const rpc = new Client({ handler: manager }); 149 + 150 + // sign in with handle/email and password (or app password) 151 + await manager.login({ identifier: 'you.bsky.social', password: 'your-app-password' }); 152 + 153 + // requests are now authenticated 154 + const session = await ok(rpc.get('com.atproto.server.getSession')); 155 + console.log(session.did); 156 + // -> "did:plc:..." 157 + ``` 158 + 159 + save `manager.session` to persist login across app restarts: 160 + 161 + ```ts 162 + // after login, save the session 163 + localStorage.setItem('session', JSON.stringify(manager.session)); 164 + ``` 165 + 166 + ```ts 167 + // later, restore the session 168 + const saved = localStorage.getItem('session'); 169 + if (saved) { 170 + await manager.resume(JSON.parse(saved)); 171 + } 172 + ``` 173 + 174 + use callbacks to keep persisted sessions in sync: 175 + 176 + ```ts 177 + const manager = new CredentialManager({ 178 + service: 'https://bsky.social', 179 + onSessionUpdate(session) { 180 + // called on login, resume, and token refresh 181 + localStorage.setItem('session', JSON.stringify(session)); 182 + }, 183 + onExpired(session) { 184 + // called when refresh token expires and can't be renewed 185 + localStorage.removeItem('session'); 186 + }, 187 + }); 188 + ``` 189 + 190 + ### response formats 191 + 192 + by default, responses are parsed as JSON. for endpoints that return binary data, specify the format 193 + with `as`: 194 + 195 + ```ts 196 + // get response as a Blob 197 + const { data: blob } = await ok( 198 + rpc.get('com.atproto.sync.getBlob', { 199 + params: { did: 'did:plc:...', cid: 'bafyrei...' }, 200 + as: 'blob', 201 + }), 202 + ); 203 + 204 + // get response as Uint8Array 205 + const { data: bytes } = await ok( 206 + rpc.get('com.atproto.sync.getBlob', { 207 + params: { did: 'did:plc:...', cid: 'bafyrei...' }, 208 + as: 'bytes', 209 + }), 210 + ); 211 + 212 + // get response as ReadableStream 213 + const { data: stream } = await ok( 214 + rpc.get('com.atproto.sync.getBlob', { 215 + params: { did: 'did:plc:...', cid: 'bafyrei...' }, 216 + as: 'stream', 217 + }), 218 + ); 219 + 220 + // discard response body 221 + await ok( 222 + rpc.post('com.atproto.repo.deleteRecord', { 223 + input: { repo: 'did:plc:...', collection: '...', rkey: '...' }, 224 + as: null, 225 + }), 226 + ); 227 + ``` 228 + 229 + ### runtime validation 230 + 231 + by default, responses are trusted without validation. for stricter guarantees, use `call()` with the 232 + schema from a definition package: 233 + 234 + ```ts 235 + import { Client, ok, simpleFetchHandler } from '@atcute/client'; 68 236 import { AppBskyActorGetProfile } from '@atcute/bluesky'; 69 237 70 - { 71 - const handler = simpleFetchHandler({ service: 'https://public.api.bsky.app' }); 72 - const rpc = new Client({ handler }); 238 + const rpc = new Client({ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) }); 73 239 74 - const { ok, data } = await rpc.call(AppBskyActorGetProfile, { 75 - params: { 76 - actor: 'bsky.app', 77 - }, 78 - }); 240 + // validates params, input, and output against the schema 241 + const response = await rpc.call(AppBskyActorGetProfile, { 242 + params: { actor: 'bsky.app' }, 243 + }); 79 244 80 - if (ok) { 81 - console.log(data.displayName); 82 - // -> "Bluesky" 245 + if (response.ok) { 246 + // response.data is validated 247 + console.log(response.data.displayName); 248 + } 249 + ``` 250 + 251 + validation errors throw `ClientValidationError`: 252 + 253 + ```ts 254 + import { ClientValidationError } from '@atcute/client'; 255 + 256 + try { 257 + await rpc.call(AppBskyActorGetProfile, { params: { actor: 'invalid!' } }); 258 + } catch (err) { 259 + if (err instanceof ClientValidationError) { 260 + console.log(err.target); // 'params', 'input', or 'output' 261 + console.log(err.message); // validation error details 83 262 } 84 263 } 264 + ``` 85 265 86 - // performing authenticated requests 87 - { 88 - const manager = new CredentialManager({ service: 'https://bsky.social' }); 89 - const rpc = new Client({ handler: manager }); 266 + ### service proxying 267 + 268 + service proxying lets you make authenticated requests through your PDS to other services. the PDS 269 + forwards the request with authorization headers proving it's acting on your behalf. 270 + 271 + ```ts 272 + // must be authenticated via CredentialManager 273 + const manager = new CredentialManager({ service: 'https://bsky.social' }); 274 + await manager.login({ identifier: 'you.bsky.social', password: 'your-app-password' }); 275 + 276 + // create a client that proxies requests through your PDS to the chat service 277 + const chatClient = new Client({ 278 + handler: manager, 279 + proxy: { 280 + did: 'did:web:api.bsky.chat', 281 + serviceId: '#bsky_chat', 282 + }, 283 + }); 284 + 285 + // request goes to your PDS, which forwards it to api.bsky.chat with auth headers 286 + const convos = await ok(chatClient.get('chat.bsky.convo.listConvos')); 287 + ``` 288 + 289 + common service IDs include: 290 + 291 + - `#atproto_pds` - personal data server 292 + - `#atproto_labeler` - labeler service 293 + - `#bsky_chat` - Bluesky chat service 294 + 295 + ### custom fetch handlers 296 + 297 + the `simpleFetchHandler` works for most cases. for advanced scenarios, provide your own handler: 298 + 299 + ```ts 300 + import type { FetchHandler } from '@atcute/client'; 90 301 91 - await manager.login({ identifier: 'example.com', password: 'ofki-yrwl-hmcc-cvau' }); 302 + const customHandler: FetchHandler = async (pathname, init) => { 303 + // pathname is like "/xrpc/app.bsky.actor.getProfile?actor=bsky.app" 304 + const url = new URL(pathname, 'https://public.api.bsky.app'); 92 305 93 - console.log(manager.session); 94 - // -> { refreshJwt: 'eyJhb...', ... } 306 + // add custom headers, logging, retry logic, etc. 307 + console.log(`${init.method?.toUpperCase()} ${url}`); 95 308 96 - const data = await ok( 97 - rpc.get('com.atproto.identity.resolveHandle', { 98 - params: { 99 - handle: 'pfrazee.com', 100 - }, 101 - }), 102 - ); 309 + return fetch(url, init); 310 + }; 103 311 104 - console.log(data.did); 105 - // -> 'did:plc:ragtjsm2j2vknwkz3zp4oxrd' 106 - } 312 + const rpc = new Client({ handler: customHandler }); 107 313 ``` 108 314 109 - by default, the API client ships with no queries or procedures. you can extend the client by 110 - installing one of these definition packages. 315 + or implement `FetchHandlerObject` for stateful handlers (like `CredentialManager` does): 111 316 112 - - [`@atcute/atproto`](../../definitions/atproto): `com.atproto.*` schema definitions 113 - - [`@atcute/bluemoji`](../../definitions/bluemoji): `blue.moji.*` schema definitions 114 - - [`@atcute/bluesky`](../../definitions/bluesky): `app.bsky.*` and `chat.bsky.*` schema definitions 115 - - [`@atcute/frontpage`](../../definitions/frontpage): `fyi.unravel.frontpage.*` schema definitions 116 - - [`@atcute/lexicon-community`](../../definitions/lexicon-community): `community.lexicon.\*` schema 117 - definitions 118 - - [`@atcute/microcosm`](../../definitions/microcosm): `blue.microcosm.*` and `com.bad-example.*` 119 - schema definitions 120 - - [`@atcute/ozone`](../../definitions/ozone): `tools.ozone.*` schema definitions 121 - - [`@atcute/pckt`](../../definitions/pckt): `blog.pckt.*` schema definitions 122 - - [`@atcute/tangled`](../../definitions/tangled): `sh.tangled.*` schema definitions 123 - - [`@atcute/whitewind`](../../definitions/whitewind): `com.whtwnd.*` schema definitions 317 + ```ts 318 + import type { FetchHandlerObject } from '@atcute/client'; 319 + 320 + class MyHandler implements FetchHandlerObject { 321 + async handle(pathname: string, init: RequestInit): Promise<Response> { 322 + // your implementation 323 + return fetch(new URL(pathname, 'https://...'), init); 324 + } 325 + } 326 + 327 + const rpc = new Client({ handler: new MyHandler() }); 328 + ```
+192 -4
packages/clients/firehose/README.md
··· 1 1 # @atcute/firehose 2 2 3 - lightweight and cute XRPC subscription client for AT Protocol. 3 + lightweight XRPC subscription client for AT Protocol. 4 + 5 + ```sh 6 + npm install @atcute/firehose 7 + ``` 8 + 9 + this package provides a generic client for XRPC subscriptions - the WebSocket-based streaming 10 + protocol used by AT Protocol. it handles CBOR frame decoding, automatic reconnection, and optional 11 + schema validation. 12 + 13 + for consuming the Bluesky network firehose specifically, consider using `@atcute/jetstream` instead, 14 + which provides a simpler JSON-based interface. 15 + 16 + ## usage 17 + 18 + ### subscribing to the relay firehose 4 19 5 20 ```ts 6 21 import { FirehoseSubscription } from '@atcute/firehose'; 7 22 import { ComAtprotoSyncSubscribeRepos } from '@atcute/atproto'; 8 23 24 + const subscription = new FirehoseSubscription({ 25 + service: 'wss://bsky.network', 26 + nsid: ComAtprotoSyncSubscribeRepos.mainSchema, 27 + }); 28 + 29 + for await (const message of subscription) { 30 + console.log(message.$type, message.seq); 31 + } 32 + ``` 33 + 34 + the connection opens when you start iterating and closes when you break out of the loop. the 35 + underlying WebSocket automatically reconnects on disconnection. 36 + 37 + ### handling message types 38 + 39 + messages include a `$type` field indicating their type: 40 + 41 + ```ts 42 + for await (const message of subscription) { 43 + switch (message.$type) { 44 + case 'com.atproto.sync.subscribeRepos#commit': { 45 + // repository commit (record creates, updates, deletes) 46 + console.log('commit:', message.repo, message.rev); 47 + break; 48 + } 49 + 50 + case 'com.atproto.sync.subscribeRepos#handle': { 51 + // handle change 52 + console.log('handle:', message.did, message.handle); 53 + break; 54 + } 55 + 56 + case 'com.atproto.sync.subscribeRepos#migrate': { 57 + // account migration 58 + console.log('migrate:', message.did, message.migrateTo); 59 + break; 60 + } 61 + 62 + case 'com.atproto.sync.subscribeRepos#tombstone': { 63 + // account deletion 64 + console.log('tombstone:', message.did); 65 + break; 66 + } 67 + 68 + case 'com.atproto.sync.subscribeRepos#identity': { 69 + // identity update 70 + console.log('identity:', message.did); 71 + break; 72 + } 73 + 74 + case 'com.atproto.sync.subscribeRepos#account': { 75 + // account status change 76 + console.log('account:', message.did, message.active); 77 + break; 78 + } 79 + } 80 + } 81 + ``` 82 + 83 + ### tracking cursor for resumption 84 + 85 + use a function for `params` to provide the current cursor on each connection attempt: 86 + 87 + ```ts 9 88 let cursor: number | undefined; 10 89 90 + // load saved cursor if resuming 91 + const saved = localStorage.getItem('firehose-cursor'); 92 + if (saved) { 93 + cursor = Number(saved); 94 + } 95 + 11 96 const subscription = new FirehoseSubscription({ 12 - service: ['wss://bsky.network'], 97 + service: 'wss://bsky.network', 13 98 nsid: ComAtprotoSyncSubscribeRepos.mainSchema, 99 + // function is called on each connection/reconnection 14 100 params: () => ({ cursor }), 15 101 }); 16 102 17 103 for await (const message of subscription) { 18 - if (message.$type === 'com.atproto.sync.subscribeRepos#commit') { 104 + if ('seq' in message) { 19 105 cursor = message.seq; 20 - console.log('commit:', message.seq, message.repo); 106 + 107 + // periodically save cursor for recovery 108 + if (cursor % 1000 === 0) { 109 + localStorage.setItem('firehose-cursor', String(cursor)); 110 + } 21 111 } 22 112 } 23 113 ``` 114 + 115 + the params function is called on each connection attempt, so when the WebSocket reconnects after a 116 + disconnection, it automatically uses the latest cursor value. 117 + 118 + ### using multiple servers 119 + 120 + pass an array of URLs for automatic failover. the client randomly selects one on each connection: 121 + 122 + ```ts 123 + const subscription = new FirehoseSubscription({ 124 + service: ['wss://bsky.network', 'wss://bsky-relay.example.com'], 125 + nsid: ComAtprotoSyncSubscribeRepos.mainSchema, 126 + }); 127 + ``` 128 + 129 + ### handling errors 130 + 131 + XRPC subscriptions can send error frames. handle them with the `onError` callback: 132 + 133 + ```ts 134 + const subscription = new FirehoseSubscription({ 135 + service: 'wss://bsky.network', 136 + nsid: ComAtprotoSyncSubscribeRepos.mainSchema, 137 + onError(error, message) { 138 + console.error('firehose error:', error, message); 139 + // common errors: 140 + // - "FutureCursor": cursor is ahead of the server 141 + // - "ConsumerTooSlow": client is not consuming messages fast enough 142 + }, 143 + }); 144 + ``` 145 + 146 + ### connection lifecycle callbacks 147 + 148 + handle connection events for logging or UI updates: 149 + 150 + ```ts 151 + const subscription = new FirehoseSubscription({ 152 + service: 'wss://bsky.network', 153 + nsid: ComAtprotoSyncSubscribeRepos.mainSchema, 154 + onConnectionOpen(event) { 155 + console.log('connected to firehose'); 156 + }, 157 + onConnectionClose(event) { 158 + console.log('disconnected:', event.code, event.reason); 159 + }, 160 + onConnectionError(event) { 161 + console.error('connection error:', event.error); 162 + }, 163 + }); 164 + ``` 165 + 166 + ### updating options at runtime 167 + 168 + change options using `updateOptions()`. this triggers a reconnection: 169 + 170 + ```ts 171 + const subscription = new FirehoseSubscription({ 172 + service: 'wss://bsky.network', 173 + nsid: ComAtprotoSyncSubscribeRepos.mainSchema, 174 + }); 175 + 176 + // later, switch to a different service 177 + subscription.updateOptions({ 178 + service: 'wss://different-relay.example.com', 179 + }); 180 + ``` 181 + 182 + ### disabling message validation 183 + 184 + by default, messages are validated against the schema. disable this for better performance if you 185 + trust the server: 186 + 187 + ```ts 188 + const subscription = new FirehoseSubscription({ 189 + service: 'wss://bsky.network', 190 + nsid: ComAtprotoSyncSubscribeRepos.mainSchema, 191 + validateMessages: false, 192 + }); 193 + ``` 194 + 195 + ### WebSocket options 196 + 197 + pass options to the underlying 198 + [partysocket](https://github.com/partykit/partykit/tree/main/packages/partysocket) WebSocket for 199 + custom reconnection behavior: 200 + 201 + ```ts 202 + const subscription = new FirehoseSubscription({ 203 + service: 'wss://bsky.network', 204 + nsid: ComAtprotoSyncSubscribeRepos.mainSchema, 205 + ws: { 206 + maxRetries: 10, 207 + minReconnectionDelay: 1000, 208 + maxReconnectionDelay: 30000, 209 + }, 210 + }); 211 + ```
+222 -13
packages/clients/jetstream/README.md
··· 1 1 # @atcute/jetstream 2 2 3 - a simple Jetstream client 3 + lightweight Jetstream subscriber for AT Protocol. 4 + 5 + ```sh 6 + npm install @atcute/jetstream 7 + ``` 8 + 9 + [Jetstream](https://docs.bsky.app/blog/jetstream) is a streaming service that delivers a filtered 10 + firehose of events from the AT Protocol network over WebSocket. this package provides a simple 11 + client to subscribe to these events. 12 + 13 + ## usage 14 + 15 + ### subscribing to events 16 + 17 + create a subscription and iterate over events with `for await`: 4 18 5 19 ```ts 6 20 import { JetstreamSubscription } from '@atcute/jetstream'; 7 - import { is } from '@atcute/lexicons'; 21 + 22 + const subscription = new JetstreamSubscription({ 23 + url: 'wss://jetstream2.us-east.bsky.network', 24 + }); 25 + 26 + for await (const event of subscription) { 27 + console.log(event.kind, event.did); 28 + } 29 + ``` 30 + 31 + the connection opens when you start iterating and closes when you break out of the loop. the 32 + underlying WebSocket automatically reconnects on disconnection. 33 + 34 + ### filtering by collection 8 35 9 - import { AppBskyFeedPost } from '@atcute/bluesky'; 36 + use `wantedCollections` to receive only events for specific record types: 10 37 38 + ```ts 11 39 const subscription = new JetstreamSubscription({ 12 40 url: 'wss://jetstream2.us-east.bsky.network', 13 - wantedCollections: ['app.bsky.feed.post'], 41 + wantedCollections: ['app.bsky.feed.post', 'app.bsky.feed.like'], 14 42 }); 15 43 16 44 for await (const event of subscription) { 17 45 if (event.kind === 'commit') { 18 - const commit = event.commit; 46 + console.log(event.commit.collection, event.commit.operation); 47 + // -> "app.bsky.feed.post" "create" 48 + } 49 + } 50 + ``` 51 + 52 + ### filtering by account 53 + 54 + use `wantedDids` to receive only events from specific accounts: 19 55 20 - if (commit.collection !== 'app.bsky.feed.post') { 21 - continue; 22 - } 56 + ```ts 57 + const subscription = new JetstreamSubscription({ 58 + url: 'wss://jetstream2.us-east.bsky.network', 59 + wantedDids: ['did:plc:z72i7hdynmk6r22z27h6tvur'], // @bsky.app 60 + }); 61 + ``` 23 62 24 - if (commit.operation === 'create') { 25 - const record = commit.record; 26 - if (!is(AppBskyFeedPost.mainSchema, record)) { 27 - continue; 63 + ### handling event types 64 + 65 + jetstream delivers three kinds of events: 66 + 67 + ```ts 68 + for await (const event of subscription) { 69 + switch (event.kind) { 70 + case 'commit': { 71 + // record was created, updated, or deleted 72 + const { collection, operation, rkey, rev } = event.commit; 73 + 74 + if (operation === 'create' || operation === 'update') { 75 + // record and cid are available on create/update 76 + console.log(event.commit.record); 28 77 } 29 78 30 - console.log(`${record.text}`); 79 + break; 80 + } 81 + 82 + case 'identity': { 83 + // handle or DID document changed 84 + const { did, handle, seq, time } = event.identity; 85 + break; 86 + } 87 + 88 + case 'account': { 89 + // account status changed (activated, deactivated, etc.) 90 + const { did, active, seq, time } = event.account; 91 + break; 31 92 } 32 93 } 33 94 } 34 95 ``` 96 + 97 + ### validating records 98 + 99 + jetstream events include the raw record data. use `is()` from `@atcute/lexicons` to validate and 100 + narrow the type: 101 + 102 + ```ts 103 + import { JetstreamSubscription } from '@atcute/jetstream'; 104 + import { is } from '@atcute/lexicons'; 105 + 106 + import { AppBskyFeedPost } from '@atcute/bluesky'; 107 + 108 + const subscription = new JetstreamSubscription({ 109 + url: 'wss://jetstream2.us-east.bsky.network', 110 + wantedCollections: ['app.bsky.feed.post'], 111 + }); 112 + 113 + for await (const event of subscription) { 114 + if (event.kind !== 'commit') { 115 + continue; 116 + } 117 + 118 + const commit = event.commit; 119 + if (commit.operation !== 'create') { 120 + continue; 121 + } 122 + 123 + // validate the record against the schema 124 + if (!is(AppBskyFeedPost.mainSchema, commit.record)) { 125 + console.warn('invalid record', commit.record); 126 + continue; 127 + } 128 + 129 + // commit.record is now typed as AppBskyFeedPost.$record 130 + console.log(`@${event.did}: ${commit.record.text}`); 131 + } 132 + ``` 133 + 134 + ### resuming from a cursor 135 + 136 + jetstream supports cursors for resuming from a specific point. the cursor is a timestamp in 137 + microseconds: 138 + 139 + ```ts 140 + const subscription = new JetstreamSubscription({ 141 + url: 'wss://jetstream2.us-east.bsky.network', 142 + // resume from a saved cursor 143 + cursor: 1699900000000000, 144 + }); 145 + 146 + // save the cursor periodically to resume later 147 + setInterval(() => { 148 + localStorage.setItem('jetstream-cursor', String(subscription.cursor)); 149 + }, 5_000); 150 + ``` 151 + 152 + when switching between jetstream instances (e.g., when using multiple URLs for failover), the client 153 + automatically rolls back the cursor by 10 seconds to avoid missing events due to clock differences. 154 + 155 + ### using multiple servers 156 + 157 + pass an array of URLs for automatic failover. the client randomly selects one on each connection: 158 + 159 + ```ts 160 + const subscription = new JetstreamSubscription({ 161 + url: [ 162 + 'wss://jetstream1.us-east.bsky.network', 163 + 'wss://jetstream2.us-east.bsky.network', 164 + 'wss://jetstream1.us-west.bsky.network', 165 + 'wss://jetstream2.us-west.bsky.network', 166 + ], 167 + }); 168 + ``` 169 + 170 + ### updating options at runtime 171 + 172 + change filters without reconnecting using `updateOptions()`: 173 + 174 + ```ts 175 + // start with all collections 176 + const subscription = new JetstreamSubscription({ 177 + url: 'wss://jetstream2.us-east.bsky.network', 178 + }); 179 + 180 + // later, filter to only posts 181 + subscription.updateOptions({ 182 + wantedCollections: ['app.bsky.feed.post'], 183 + }); 184 + 185 + // add accounts to filter 186 + subscription.updateOptions({ 187 + wantedDids: ['did:plc:...'], 188 + }); 189 + ``` 190 + 191 + changes to `wantedCollections` and `wantedDids` are sent to the server without reconnecting. other 192 + option changes trigger a reconnection. 193 + 194 + ### connection lifecycle callbacks 195 + 196 + handle connection events for logging or UI updates: 197 + 198 + ```ts 199 + const subscription = new JetstreamSubscription({ 200 + url: 'wss://jetstream2.us-east.bsky.network', 201 + onConnectionOpen(event) { 202 + console.log('connected to jetstream'); 203 + }, 204 + onConnectionClose(event) { 205 + console.log('disconnected from jetstream', event.code, event.reason); 206 + }, 207 + onConnectionError(event) { 208 + console.error('jetstream error', event.error); 209 + }, 210 + }); 211 + ``` 212 + 213 + ### disabling event validation 214 + 215 + by default, jetstream events are validated. disable this for slightly better performance if you 216 + trust the server: 217 + 218 + ```ts 219 + const subscription = new JetstreamSubscription({ 220 + url: 'wss://jetstream2.us-east.bsky.network', 221 + validateEvents: false, 222 + }); 223 + ``` 224 + 225 + note: this only disables validation of the event envelope. you should still validate records using 226 + `is()` from `@atcute/lexicons`. 227 + 228 + ### WebSocket options 229 + 230 + pass options to the underlying 231 + [partysocket](https://github.com/partykit/partykit/tree/main/packages/partysocket) WebSocket for 232 + custom reconnection behavior: 233 + 234 + ```ts 235 + const subscription = new JetstreamSubscription({ 236 + url: 'wss://jetstream2.us-east.bsky.network', 237 + ws: { 238 + maxRetries: 10, 239 + minReconnectionDelay: 1000, 240 + maxReconnectionDelay: 30000, 241 + }, 242 + }); 243 + ```
+4
packages/definitions/atproto/README.md
··· 2 2 3 3 [AT Protocol](https://atproto.com) (com.atproto.\*) schema definitions 4 4 5 + ```sh 6 + npm install @atcute/atproto 7 + ``` 8 + 5 9 ## usage 6 10 7 11 ```ts
+4
packages/definitions/bluemoji/README.md
··· 2 2 3 3 [Bluemoji](https://github.com/aendra-rininsland/bluemoji) (blue.moji.\*) schema definitions 4 4 5 + ```sh 6 + npm install @atcute/bluemoji 7 + ``` 8 + 5 9 ## usage 6 10 7 11 ```ts
+4
packages/definitions/bluesky/README.md
··· 2 2 3 3 [Bluesky](https://bsky.app) (app.bsky.\* and chat.bsky.\*) schema definitions 4 4 5 + ```sh 6 + npm install @atcute/bluesky 7 + ``` 8 + 5 9 ## usage 6 10 7 11 ```ts
+4
packages/definitions/frontpage/README.md
··· 2 2 3 3 [Frontpage](https://frontpage.fyi/) (fyi.unravel.frontpage.\*) schema definitions 4 4 5 + ```sh 6 + npm install @atcute/frontpage 7 + ``` 8 + 5 9 ## usage 6 10 7 11 ```ts
+4
packages/definitions/leaflet/README.md
··· 2 2 3 3 [Leaflet](https://leaflet.pub/) (pub.leaflet.\*) schema definitions 4 4 5 + ```sh 6 + npm install @atcute/leaflet 7 + ``` 8 + 5 9 ## usage 6 10 7 11 ```ts
+4
packages/definitions/lexicon-community/README.md
··· 3 3 [Lexicon Community](https://github.com/lexicon-community/lexicon) (community.lexicon.\*) schema 4 4 definitions 5 5 6 + ```sh 7 + npm install @atcute/lexicon-community 8 + ``` 9 + 6 10 ## usage 7 11 8 12 ```ts
+5 -1
packages/definitions/microcosm/README.md
··· 2 2 3 3 [Microcosm](https://www.microcosm.blue/) (blue.microcosm.\*, com.bad-example.\*) schema definitions 4 4 5 + ```sh 6 + npm install @atcute/microcosm 7 + ``` 8 + 5 9 Microcosm is a collection of services and independent community-run infrastructure for AT Protocol, 6 10 including: 7 11 ··· 78 82 79 83 now all the XRPC operations should be visible in the client 80 84 81 - #### with `@atcute/lex-cli` 85 + ### with `@atcute/lex-cli` 82 86 83 87 when building your own lexicons that reference Microcosm types, configure lex-cli to import from 84 88 this package:
+4
packages/definitions/ozone/README.md
··· 2 2 3 3 [Ozone](https://ozone.tools) (tools.ozone.\*) schema definitions 4 4 5 + ```sh 6 + npm install @atcute/ozone 7 + ``` 8 + 5 9 ## usage 6 10 7 11 ```ts
+4
packages/definitions/pckt/README.md
··· 2 2 3 3 [pckt](https://pckt.blog) (blog.pckt.\*) schema definitions 4 4 5 + ```sh 6 + npm install @atcute/pckt 7 + ``` 8 + 5 9 ## usage 6 10 7 11 ```ts
+4
packages/definitions/tangled/README.md
··· 2 2 3 3 [Tangled](https://tangled.sh/) (sh.tangled.\*) schema definitions 4 4 5 + ```sh 6 + npm install @atcute/tangled 7 + ``` 8 + 5 9 ## usage 6 10 7 11 ```ts
+4
packages/definitions/whitewind/README.md
··· 2 2 3 3 [WhiteWind](https://whtwnd.com) (com.whtwnd.\*) schema definitions 4 4 5 + ```sh 6 + npm install @atcute/whitewind 7 + ``` 8 + 5 9 ## usage 6 10 7 11 ```ts
+40 -4
packages/identity/did-plc/README.md
··· 1 1 # @atcute/did-plc 2 2 3 - validations, type definitions and schemas for did:plc operations 3 + validate and process did:plc operation logs. 4 + 5 + ```sh 6 + npm install @atcute/did-plc 7 + ``` 8 + 9 + did:plc is a self-certifying DID method where the audit log serves as the source of truth. this 10 + package validates that operations are properly signed and chained. 11 + 12 + ## usage 13 + 14 + ### validating audit logs 4 15 5 16 ```ts 6 - import { defs, validateIndexedOperationLog } from '@atcute/did-plc'; 17 + import { defs, processIndexedEntryLog } from '@atcute/did-plc'; 7 18 8 - const did = `did:plc:ragtjsm2j2vknwkz3zp4oxrd`; 19 + const did = 'did:plc:ragtjsm2j2vknwkz3zp4oxrd'; 9 20 10 21 const response = await fetch(`https://plc.directory/${did}/log/audit`); 11 22 const json = await response.json(); 12 23 13 24 const logs = defs.indexedOperationLog.parse(json); 14 - await validateIndexedOperationLog(did, logs); 25 + const { canonical, nullified } = await processIndexedEntryLog(did, logs); 26 + ``` 27 + 28 + ### validating new operations 29 + 30 + before submitting a new operation to plc.directory: 31 + 32 + ```ts 33 + import { validateIncomingOp } from '@atcute/did-plc'; 34 + 35 + // throws if operation exceeds size limits or has invalid structure 36 + validateIncomingOp(operation); 37 + ``` 38 + 39 + ### checking dispute windows 40 + 41 + ```ts 42 + import { isDisputePeriodActive, getDisputeCandidates } from '@atcute/did-plc'; 43 + 44 + // check if an operation can still be disputed (72-hour window) 45 + if (isDisputePeriodActive(operation)) { 46 + // operation is still within the recovery window 47 + } 48 + 49 + // find operations that a key can dispute 50 + const candidates = getDisputeCandidates(canonicalLog, rotationKey); 15 51 ```
+22 -22
packages/identity/identity-resolver-node/README.md
··· 1 1 # @atcute/identity-resolver-node 2 2 3 - additional atproto identity resolvers for Node.js 3 + Node.js handle resolver using native DNS. 4 + 5 + ```sh 6 + npm install @atcute/identity-resolver-node 7 + ``` 8 + 9 + provides `NodeDnsHandleResolver` which resolves handles via DNS TXT records using Node.js's native 10 + `dns` module, avoiding the need for HTTP-based resolution. 11 + 12 + ## usage 4 13 5 14 ```ts 6 - // handle resolution 15 + import { CompositeHandleResolver, WellKnownHandleResolver } from '@atcute/identity-resolver'; 16 + import { NodeDnsHandleResolver } from '@atcute/identity-resolver-node'; 17 + 7 18 const handleResolver = new CompositeHandleResolver({ 8 19 strategy: 'race', 9 20 methods: { ··· 12 23 }, 13 24 }); 14 25 15 - try { 16 - const handle = await didResolver.resolve('bsky.app'); 17 - // ^? 'did:plc:z72i7hdynmk6r22z27h6tvur' 18 - } catch (err) { 19 - if (err instanceof DidNotFoundError) { 20 - // handle returned no did 21 - } 22 - if (err instanceof InvalidResolvedHandleError) { 23 - // handle returned a did, but isn't a valid atproto did 24 - } 25 - if (err instanceof AmbiguousHandleError) { 26 - // handle returned multiple did values 27 - } 28 - if (err instanceof FailedHandleResolutionError) { 29 - // handle resolution had thrown something unexpected (fetch error) 30 - } 26 + const did = await handleResolver.resolve('bsky.app'); 27 + // -> 'did:plc:z72i7hdynmk6r22z27h6tvur' 28 + ``` 31 29 32 - if (err instanceof HandleResolutionError) { 33 - // the errors above extend this class, so you can do a catch-all. 34 - } 35 - } 30 + ### custom nameservers 31 + 32 + ```ts 33 + const resolver = new NodeDnsHandleResolver({ 34 + nameservers: ['8.8.8.8', '8.8.4.4'], 35 + }); 36 36 ```
+213 -40
packages/identity/identity-resolver/README.md
··· 1 1 # @atcute/identity-resolver 2 2 3 - atproto handle and DID document resolution 3 + handle and DID document resolution for AT Protocol. 4 + 5 + ```sh 6 + npm install @atcute/identity-resolver 7 + ``` 8 + 9 + in AT Protocol, handles (like `alice.bsky.social`) need to be resolved to DIDs, and DIDs need to be 10 + resolved to DID documents (which contain the user's PDS location and keys). this package provides 11 + resolvers for both. 12 + 13 + ## usage 14 + 15 + ### resolving handles 16 + 17 + handles can be resolved via DNS TXT records or HTTP well-known endpoints. use the composite resolver 18 + to try both: 4 19 5 20 ```ts 6 - // handle resolution 21 + import { 22 + CompositeHandleResolver, 23 + DohJsonHandleResolver, 24 + WellKnownHandleResolver, 25 + } from '@atcute/identity-resolver'; 26 + 7 27 const handleResolver = new CompositeHandleResolver({ 8 - strategy: 'race', 9 28 methods: { 10 29 dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }), 11 30 http: new WellKnownHandleResolver(), 12 31 }, 13 32 }); 14 33 15 - try { 16 - const handle = await handleResolver.resolve('bsky.app'); 17 - // ^? 'did:plc:z72i7hdynmk6r22z27h6tvur' 18 - } catch (err) { 19 - if (err instanceof DidNotFoundError) { 20 - // handle returned no did 21 - } 22 - if (err instanceof InvalidResolvedHandleError) { 23 - // handle returned a did, but isn't a valid atproto did 24 - } 25 - if (err instanceof AmbiguousHandleError) { 26 - // handle returned multiple did values 27 - } 28 - if (err instanceof FailedHandleResolutionError) { 29 - // handle resolution had thrown something unexpected (fetch error) 30 - } 34 + const did = await handleResolver.resolve('bsky.app'); 35 + // -> "did:plc:z72i7hdynmk6r22z27h6tvur" 36 + ``` 37 + 38 + ### resolution strategies 39 + 40 + the composite resolver supports different strategies for combining DNS and HTTP resolution: 41 + 42 + ```ts 43 + const handleResolver = new CompositeHandleResolver({ 44 + strategy: 'race', // default - first successful response wins 45 + methods: { dns: dnsResolver, http: httpResolver }, 46 + }); 47 + ``` 48 + 49 + available strategies: 50 + 51 + - `race` - returns whichever method succeeds first (default) 52 + - `dns-first` - try DNS first, fall back to HTTP if it fails 53 + - `http-first` - try HTTP first, fall back to DNS if it fails 54 + - `both` - require both methods to agree (throws `AmbiguousHandleError` if they differ) 55 + 56 + ### resolving DID documents 57 + 58 + DID documents can be resolved for did:plc and did:web methods: 31 59 32 - if (err instanceof HandleResolutionError) { 33 - // the errors above extend this class, so you can do a catch-all. 34 - } 35 - } 60 + ```ts 61 + import { 62 + CompositeDidDocumentResolver, 63 + PlcDidDocumentResolver, 64 + WebDidDocumentResolver, 65 + } from '@atcute/identity-resolver'; 36 66 37 - // DID document resolution 38 - const docResolver = new CompositeDidDocumentResolver({ 67 + const didResolver = new CompositeDidDocumentResolver({ 39 68 methods: { 40 69 plc: new PlcDidDocumentResolver(), 41 70 web: new WebDidDocumentResolver(), 42 71 }, 43 72 }); 44 73 74 + const doc = await didResolver.resolve('did:plc:z72i7hdynmk6r22z27h6tvur'); 75 + // -> { '@context': [...], id: 'did:plc:...', service: [...], ... } 76 + ``` 77 + 78 + ### resolving actors 79 + 80 + the `ActorResolver` interface provides a way to resolve an actor identifier (handle or DID) to the 81 + essential info needed to interact with them: their DID, verified handle, and PDS endpoint. 82 + 83 + `LocalActorResolver` implements this by combining handle and DID document resolution locally: 84 + 85 + ```ts 86 + import { LocalActorResolver } from '@atcute/identity-resolver'; 87 + 88 + const actorResolver = new LocalActorResolver({ 89 + handleResolver, 90 + didDocumentResolver: didResolver, 91 + }); 92 + 93 + // resolve from handle 94 + const actor = await actorResolver.resolve('bsky.app'); 95 + // -> { did: "did:plc:...", handle: "bsky.app", pds: "https://..." } 96 + 97 + // resolve from DID 98 + const actor2 = await actorResolver.resolve('did:plc:z72i7hdynmk6r22z27h6tvur'); 99 + // -> { did: "did:plc:...", handle: "bsky.app", pds: "https://..." } 100 + ``` 101 + 102 + the local resolver performs bidirectional verification: it checks that the handle in the DID 103 + document resolves back to the same DID. 104 + 105 + other implementations of `ActorResolver` can get this info from dedicated identity services (like 106 + Slingshot) without needing to fetch and parse full DID documents. 107 + 108 + ### handling errors 109 + 110 + each resolver throws specific error types for different failure cases: 111 + 112 + ```ts 113 + import { 114 + DidNotFoundError, 115 + InvalidResolvedHandleError, 116 + AmbiguousHandleError, 117 + FailedHandleResolutionError, 118 + HandleResolutionError, 119 + } from '@atcute/identity-resolver'; 120 + 45 121 try { 46 - const doc = await docResolver.resolve('did:plc:z72i7hdynmk6r22z27h6tvur'); 47 - // ^? { '@context': [...], id: 'did:plc:z72i7hdynmk6r22z27h6tvur', ... } 122 + const did = await handleResolver.resolve('nonexistent.invalid'); 48 123 } catch (err) { 49 - if (err instanceof DocumentNotFoundError) { 50 - // did returned no document 124 + if (err instanceof DidNotFoundError) { 125 + // handle has no DID record 126 + console.log('handle not found'); 127 + } else if (err instanceof InvalidResolvedHandleError) { 128 + // handle returned an invalid DID format 129 + console.log('invalid DID:', err.did); 130 + } else if (err instanceof AmbiguousHandleError) { 131 + // multiple different DIDs found (with 'both' strategy) 132 + console.log('ambiguous handle'); 133 + } else if (err instanceof FailedHandleResolutionError) { 134 + // network or other unexpected error 135 + console.log('resolution failed:', err.cause); 136 + } else if (err instanceof HandleResolutionError) { 137 + // catch-all for any handle resolution error 51 138 } 52 - if (err instanceof UnsupportedDidMethodError) { 53 - // resolver doesn't support did method (composite resolver) 54 - } 55 - if (err instanceof ImproperDidError) { 56 - // resolver considers did as invalid (atproto did:web) 57 - } 58 - if (err instanceof FailedDocumentResolutionError) { 59 - // document resolution had thrown something unexpected (fetch error) 60 - } 139 + } 140 + ``` 141 + 142 + DID document resolution errors: 143 + 144 + ```ts 145 + import { 146 + DocumentNotFoundError, 147 + UnsupportedDidMethodError, 148 + ImproperDidError, 149 + FailedDocumentResolutionError, 150 + DidDocumentResolutionError, 151 + } from '@atcute/identity-resolver'; 61 152 62 - if (err instanceof HandleResolutionError) { 63 - // the errors above extend this class, so you can do a catch-all. 153 + try { 154 + const doc = await didResolver.resolve('did:example:123'); 155 + } catch (err) { 156 + if (err instanceof DocumentNotFoundError) { 157 + // DID document doesn't exist 158 + } else if (err instanceof UnsupportedDidMethodError) { 159 + // resolver doesn't support this DID method 160 + } else if (err instanceof ImproperDidError) { 161 + // DID format is invalid for this method 162 + } else if (err instanceof FailedDocumentResolutionError) { 163 + // network or other unexpected error 164 + } else if (err instanceof DidDocumentResolutionError) { 165 + // catch-all for any DID resolution error 64 166 } 65 167 } 66 168 ``` 169 + 170 + ### caching and abort signals 171 + 172 + all resolvers accept options for cache control and cancellation: 173 + 174 + ```ts 175 + // skip cache 176 + const did = await handleResolver.resolve('bsky.app', { noCache: true }); 177 + 178 + // with abort signal 179 + const controller = new AbortController(); 180 + const did = await handleResolver.resolve('bsky.app', { signal: controller.signal }); 181 + 182 + // cancel the request 183 + controller.abort(); 184 + ``` 185 + 186 + ### custom fetch function 187 + 188 + all resolvers accept a custom fetch implementation: 189 + 190 + ```ts 191 + const dnsResolver = new DohJsonHandleResolver({ 192 + dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query', 193 + fetch: customFetch, 194 + }); 195 + 196 + const httpResolver = new WellKnownHandleResolver({ 197 + fetch: customFetch, 198 + }); 199 + 200 + const plcResolver = new PlcDidDocumentResolver({ 201 + fetch: customFetch, 202 + }); 203 + ``` 204 + 205 + ### custom PLC directory 206 + 207 + by default, did:plc resolution uses `https://plc.directory`. you can specify a different directory: 208 + 209 + ```ts 210 + const plcResolver = new PlcDidDocumentResolver({ 211 + plcUrl: 'https://plc.wtf', // mirror of plc.directory 212 + }); 213 + ``` 214 + 215 + ## resolver classes 216 + 217 + ### handle resolvers 218 + 219 + | class | description | 220 + | ------------------------- | --------------------------------------------------------------- | 221 + | `DohJsonHandleResolver` | resolves via DNS-over-HTTPS (TXT record at `_atproto.{handle}`) | 222 + | `WellKnownHandleResolver` | resolves via HTTP (`https://{handle}/.well-known/atproto-did`) | 223 + | `XrpcHandleResolver` | resolves via XRPC (`com.atproto.identity.resolveHandle`) | 224 + | `CompositeHandleResolver` | combines DNS and HTTP resolvers | 225 + 226 + ### DID document resolvers 227 + 228 + | class | description | 229 + | ------------------------------ | ----------------------------------------------------- | 230 + | `PlcDidDocumentResolver` | resolves did:plc from PLC directory | 231 + | `WebDidDocumentResolver` | resolves did:web from domain | 232 + | `XrpcDidDocumentResolver` | resolves via XRPC (`com.atproto.identity.resolveDid`) | 233 + | `CompositeDidDocumentResolver` | routes to resolver by DID method | 234 + 235 + ### actor resolvers 236 + 237 + | class | description | 238 + | -------------------- | ------------------------------------------------------------------ | 239 + | `LocalActorResolver` | combines handle and DID resolution with bidirectional verification |
+184 -1
packages/identity/identity/README.md
··· 1 1 # @atcute/identity 2 2 3 - syntax, type definitions and schemas for atproto handles, DIDs and DID documents. 3 + types, schemas, and utilities for working with AT Protocol identities. 4 + 5 + ```sh 6 + npm install @atcute/identity 7 + ``` 8 + 9 + in AT Protocol, users are identified by [DIDs](https://www.w3.org/TR/did-core/) (decentralized 10 + identifiers). this package provides tools for working with DIDs and DID documents - the documents 11 + that describe a user's identity, including their handle, PDS location, and cryptographic keys. 12 + 13 + for resolving handles and DIDs (fetching their documents), see `@atcute/identity-resolver`. 14 + 15 + ## usage 16 + 17 + ### checking DID types 18 + 19 + AT Protocol supports two DID methods: `did:plc` and `did:web`. use the type guards to check which 20 + method a DID uses: 21 + 22 + ```ts 23 + import { isPlcDid, isWebDid, isAtprotoDid } from '@atcute/identity'; 24 + 25 + const did = 'did:plc:z72i7hdynmk6r22z27h6tvur'; 26 + 27 + if (isPlcDid(did)) { 28 + // did:plc identifier 29 + console.log('PLC DID'); 30 + } 31 + 32 + if (isWebDid(did)) { 33 + // did:web identifier (general, includes custom paths) 34 + console.log('Web DID'); 35 + } 36 + 37 + if (isAtprotoDid(did)) { 38 + // either did:plc or atproto-compatible did:web 39 + console.log('AT Protocol DID'); 40 + } 41 + ``` 42 + 43 + `isAtprotoDid` checks for DIDs that are valid in AT Protocol - this excludes did:web identifiers 44 + with custom paths, which atproto doesn't support. 45 + 46 + ### extracting the DID method 47 + 48 + ```ts 49 + import { extractDidMethod } from '@atcute/identity'; 50 + 51 + const method = extractDidMethod('did:plc:z72i7hdynmk6r22z27h6tvur'); 52 + // -> "plc" 53 + ``` 54 + 55 + ### working with did:web 56 + 57 + convert a did:web identifier to its DID document URL: 58 + 59 + ```ts 60 + import { webDidToDocumentUrl, normalizeWebDid } from '@atcute/identity'; 61 + 62 + // simple domain 63 + const url = webDidToDocumentUrl('did:web:example.com'); 64 + // -> URL { href: "https://example.com/.well-known/did.json" } 65 + 66 + // with path 67 + const url2 = webDidToDocumentUrl('did:web:example.com:users:alice'); 68 + // -> URL { href: "https://example.com/users/alice/did.json" } 69 + 70 + // normalize for comparison 71 + const normalized = normalizeWebDid('did:web:EXAMPLE.COM'); 72 + // -> "did:web:example.com" 73 + ``` 74 + 75 + ### reading DID documents 76 + 77 + once you have a DID document (from a resolver or API), extract information using the utility 78 + functions: 79 + 80 + ```ts 81 + import { 82 + getPdsEndpoint, 83 + getAtprotoHandle, 84 + getAtprotoVerificationMaterial, 85 + getLabelerEndpoint, 86 + } from '@atcute/identity'; 87 + import type { DidDocument } from '@atcute/identity'; 88 + 89 + const doc: DidDocument = { 90 + '@context': ['https://www.w3.org/ns/did/v1'], 91 + id: 'did:plc:z72i7hdynmk6r22z27h6tvur', 92 + alsoKnownAs: ['at://bsky.app'], 93 + verificationMethod: [ 94 + { 95 + id: 'did:plc:z72i7hdynmk6r22z27h6tvur#atproto', 96 + type: 'Multikey', 97 + controller: 'did:plc:z72i7hdynmk6r22z27h6tvur', 98 + publicKeyMultibase: 'zDnaek...', 99 + }, 100 + ], 101 + service: [ 102 + { 103 + id: '#atproto_pds', 104 + type: 'AtprotoPersonalDataServer', 105 + serviceEndpoint: 'https://morel.us-east.host.bsky.network', 106 + }, 107 + ], 108 + }; 109 + 110 + // get the user's PDS URL 111 + const pds = getPdsEndpoint(doc); 112 + // -> "https://morel.us-east.host.bsky.network" 113 + 114 + // get the user's handle 115 + const handle = getAtprotoHandle(doc); 116 + // -> "bsky.app" 117 + 118 + // get the signing key material 119 + const key = getAtprotoVerificationMaterial(doc); 120 + // -> { type: "Multikey", publicKeyMultibase: "zDnaek..." } 121 + ``` 122 + 123 + ### service endpoint helpers 124 + 125 + extract specific service endpoints from DID documents: 126 + 127 + ```ts 128 + import { 129 + getPdsEndpoint, 130 + getLabelerEndpoint, 131 + getBlueskyChatEndpoint, 132 + getBlueskyFeedgenEndpoint, 133 + getBlueskyNotificationEndpoint, 134 + getAtprotoServiceEndpoint, 135 + } from '@atcute/identity'; 136 + 137 + // standard helpers for common services 138 + const pds = getPdsEndpoint(doc); // #atproto_pds 139 + const labeler = getLabelerEndpoint(doc); // #atproto_labeler 140 + const chat = getBlueskyChatEndpoint(doc); // #bsky_chat 141 + const feedgen = getBlueskyFeedgenEndpoint(doc); // #bsky_fg 142 + const notif = getBlueskyNotificationEndpoint(doc); // #bsky_notif 143 + 144 + // or use the generic helper for custom services 145 + const custom = getAtprotoServiceEndpoint(doc, { 146 + id: '#my_service', 147 + type: 'MyServiceType', // optional type filter 148 + }); 149 + ``` 150 + 151 + ### validating DID documents 152 + 153 + use the validation schemas to check if a DID document is well-formed: 154 + 155 + ```ts 156 + import { defs } from '@atcute/identity'; 157 + 158 + const result = defs.didDocument.try(unknownData); 159 + if (result.ok) { 160 + // result.value is a validated DidDocument 161 + console.log(result.value.id); 162 + } else { 163 + // validation failed 164 + console.error('invalid DID document'); 165 + } 166 + ``` 167 + 168 + the schema validates: 169 + 170 + - required fields (`@context`, `id`) 171 + - DID string format 172 + - verification method structure and key formats 173 + - service endpoint URLs 174 + - no duplicate entries in arrays 175 + 176 + ### type definitions 177 + 178 + the package exports TypeScript types for DID document structures: 179 + 180 + ```ts 181 + import type { DidDocument, VerificationMethod, Service } from '@atcute/identity'; 182 + 183 + // DidDocument - the full DID document 184 + // VerificationMethod - a verification method entry 185 + // Service - a service endpoint entry 186 + ```
+5 -1
packages/lexicons/lex-cli/README.md
··· 1 1 # @atcute/lex-cli 2 2 3 - command line tool for generating TypeScript schemas out of lexicon documents 3 + generate TypeScript schemas from lexicon documents. 4 + 5 + ```sh 6 + npm install @atcute/lex-cli 7 + ``` 4 8 5 9 ## quick start 6 10
+9 -1
packages/lexicons/lexicon-doc/README.md
··· 1 1 # @atcute/lexicon-doc 2 2 3 - type definitions and schemas for atproto lexicon documents 3 + parse and author atproto lexicon documents. 4 + 5 + ```sh 6 + npm install @atcute/lexicon-doc 7 + ``` 8 + 9 + ## usage 10 + 11 + ### parsing lexicon documents 4 12 5 13 ```ts 6 14 import { findExternalReferences, lexiconDoc } from '@atcute/lexicon-doc';
+50 -34
packages/lexicons/lexicon-resolver/README.md
··· 1 1 # @atcute/lexicon-resolver 2 2 3 - atproto lexicon authority resolution and schema retrieval 3 + resolve lexicon schemas from the AT Protocol network. 4 + 5 + ```sh 6 + npm install @atcute/lexicon-resolver 7 + ``` 8 + 9 + ## usage 10 + 11 + ### resolving lexicon authority 12 + 13 + find which DID is authoritative for an NSID via DNS TXT records: 4 14 5 15 ```ts 6 - // authority resolution 16 + import { DohJsonLexiconAuthorityResolver } from '@atcute/lexicon-resolver'; 17 + 7 18 const authorityResolver = new DohJsonLexiconAuthorityResolver({ 8 19 dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query', 9 20 }); 10 21 11 - try { 12 - const authority = await authorityResolver.resolve('app.bsky.feed.post'); 13 - // ^? 'did:plc:4v4y5r3lwsbtmsxhile2ljac' 14 - } catch (err) { 15 - if (err instanceof AuthorityNotFoundError) { 16 - // nsid returned no did 17 - } 18 - if (err instanceof InvalidResolvedAuthorityError) { 19 - // nsid returned a did, but isn't a valid atproto did 20 - } 21 - if (err instanceof AmbiguousAuthorityError) { 22 - // nsid returned multiple did values 23 - } 24 - if (err instanceof FailedAuthorityResolutionError) { 25 - // nsid resolution had thrown something unexpected (fetch error) 26 - } 22 + const authority = await authorityResolver.resolve('app.bsky.feed.post'); 23 + // -> 'did:plc:4v4y5r3lwsbtmsxhile2ljac' 24 + ``` 25 + 26 + ### fetching lexicon schemas 27 27 28 - if (err instanceof LexiconAuthorityResolutionError) { 29 - // the errors above extend this class, so you can do a catch-all. 30 - } 31 - } 28 + retrieve the lexicon document from an authority's PDS: 32 29 33 - // schema resolution 30 + ```ts 31 + import { 32 + CompositeDidDocumentResolver, 33 + PlcDidDocumentResolver, 34 + WebDidDocumentResolver, 35 + } from '@atcute/identity-resolver'; 36 + import { LexiconSchemaResolver } from '@atcute/lexicon-resolver'; 37 + 34 38 const schemaResolver = new LexiconSchemaResolver({ 35 39 didDocumentResolver: new CompositeDidDocumentResolver({ 36 40 methods: { ··· 40 44 }), 41 45 }); 42 46 47 + const resolved = await schemaResolver.resolve(authority, 'app.bsky.feed.post'); 48 + // -> { uri: string, cid: string, schema: LexiconDoc } 49 + ``` 50 + 51 + ### error handling 52 + 53 + ```ts 54 + import { 55 + AuthorityNotFoundError, 56 + InvalidResolvedAuthorityError, 57 + LexiconAuthorityResolutionError, 58 + InvalidLexiconSchemaError, 59 + LexiconResolutionError, 60 + } from '@atcute/lexicon-resolver'; 61 + 43 62 try { 44 - const resolved = await schemaResolver.resolve(authority, 'app.bsky.feed.post'); 45 - // ^? { uri: string, cid: string, schema: LexiconDoc } 63 + await authorityResolver.resolve(nsid); 46 64 } catch (err) { 47 - if (err instanceof InvalidLexiconSchemaError) { 48 - // lexicon schema is malformed 65 + if (err instanceof LexiconAuthorityResolutionError) { 66 + // authority resolution failed 49 67 } 50 - if (err instanceof InvalidLexiconProofError) { 51 - // lexicon record proof verification failed 52 - } 53 - if (err instanceof FailedLexiconResolutionError) { 54 - // lexicon resolution had thrown something unexpected (fetch error) 55 - } 68 + } 56 69 70 + try { 71 + await schemaResolver.resolve(authority, nsid); 72 + } catch (err) { 57 73 if (err instanceof LexiconResolutionError) { 58 - // the errors above extend this class, so you can do a catch-all. 74 + // schema resolution failed 59 75 } 60 76 } 61 77 ```
+145 -4
packages/lexicons/lexicons/README.md
··· 1 1 # @atcute/lexicons 2 2 3 - AT Protocol core lexicon types, interfaces, and schema validations 3 + core types and syntax validators for AT Protocol. 4 + 5 + ```sh 6 + npm install @atcute/lexicons 7 + ``` 8 + 9 + this package provides syntax validators for AT Protocol's string formats (handles, DIDs, NSIDs, 10 + etc.) and validation functions for use with lexicon schemas from definition packages. 11 + 12 + ## usage 13 + 14 + ### validating syntax 15 + 16 + use the syntax validators to check AT Protocol string formats: 17 + 18 + ```ts 19 + import { 20 + isHandle, 21 + isDid, 22 + isNsid, 23 + isCid, 24 + isTid, 25 + isRecordKey, 26 + isDatetime, 27 + isResourceUri, 28 + isActorIdentifier, 29 + } from '@atcute/lexicons/syntax'; 30 + 31 + // handle format (domain names) 32 + isHandle('alice.bsky.social'); // true 33 + isHandle('invalid'); // false (no TLD) 34 + 35 + // DID format 36 + isDid('did:plc:z72i7hdynmk6r22z27h6tvur'); // true 37 + isDid('did:web:example.com'); // true 38 + isDid('not-a-did'); // false 39 + 40 + // NSID format (namespaced identifiers) 41 + isNsid('app.bsky.feed.post'); // true 42 + isNsid('com.atproto.repo.createRecord'); // true 43 + 44 + // CID format (content identifiers) 45 + isCid('bafyreib2rxk3rybk3aobmv5cjuql3setrnhyfnxq...'); // true 46 + 47 + // TID format (timestamp identifiers) 48 + isTid('3jzfcijpj2z2a'); // true 49 + 50 + // record key format 51 + isRecordKey('3jzfcijpj2z2a'); // true (TID) 52 + isRecordKey('self'); // true (literal "self") 53 + 54 + // datetime format (ISO 8601) 55 + isDatetime('2024-01-15T12:00:00.000Z'); // true 56 + 57 + // AT URI format 58 + isResourceUri('at://did:plc:123/app.bsky.feed.post/abc'); // true 59 + 60 + // actor identifier (handle or DID) 61 + isActorIdentifier('alice.bsky.social'); // true 62 + isActorIdentifier('did:plc:123'); // true 63 + ``` 64 + 65 + ### parsing AT URIs 66 + 67 + parse AT URIs to extract their components: 68 + 69 + ```ts 70 + import { parseResourceUri, parseCanonicalResourceUri } from '@atcute/lexicons/syntax'; 71 + 72 + // parse any AT URI (handle or DID authority) 73 + const uri = parseResourceUri('at://alice.bsky.social/app.bsky.feed.post/123'); 74 + // -> { repo: 'alice.bsky.social', collection: 'app.bsky.feed.post', rkey: '123' } 75 + 76 + // parse canonical AT URI (DID authority only) 77 + const canonical = parseCanonicalResourceUri('at://did:plc:123/app.bsky.feed.post/abc'); 78 + // -> { repo: 'did:plc:123', collection: 'app.bsky.feed.post', rkey: 'abc' } 79 + ``` 80 + 81 + ### branded types 82 + 83 + the syntax module exports branded types for type-safe string handling: 84 + 85 + ```ts 86 + import type { Handle, Did, Nsid, Cid, Tid, RecordKey, Datetime } from '@atcute/lexicons/syntax'; 87 + import { isHandle } from '@atcute/lexicons/syntax'; 88 + 89 + function getProfile(handle: Handle): Promise<Profile> { 90 + // handle is guaranteed to be a valid handle format 91 + } 92 + 93 + const input = 'alice.bsky.social'; 94 + if (isHandle(input)) { 95 + // input is narrowed to Handle type 96 + getProfile(input); 97 + } 98 + ``` 99 + 100 + ### validating records 101 + 102 + validate data against lexicon schemas using `is()`, `safeParse()`, or `parse()`. schemas come from 103 + definition packages like `@atcute/bluesky`: 4 104 5 105 ```ts 6 - import { isHandle, isDid } from '@atcute/lexicons/syntax'; 106 + import { is, safeParse, parse, ValidationError } from '@atcute/lexicons'; 107 + import { AppBskyFeedPost } from '@atcute/bluesky'; 7 108 8 - isHandle('example.com'); 109 + const data: unknown = { 110 + $type: 'app.bsky.feed.post', 111 + text: 'hello world', 112 + createdAt: '2024-01-15T12:00:00.000Z', 113 + }; 9 114 10 - isDid('did:web:example.com'); 115 + // type guard - returns boolean 116 + if (is(AppBskyFeedPost.mainSchema, data)) { 117 + // data is typed as AppBskyFeedPost.$record 118 + console.log(data.text); 119 + } 120 + 121 + // safe parse - returns result object 122 + const result = safeParse(AppBskyFeedPost.mainSchema, data); 123 + if (result.ok) { 124 + console.log(result.value.text); 125 + } else { 126 + console.log(result.message); 127 + console.log(result.issues); 128 + } 129 + 130 + // parse - throws on failure 131 + try { 132 + const post = parse(AppBskyFeedPost.mainSchema, data); 133 + console.log(post.text); 134 + } catch (err) { 135 + if (err instanceof ValidationError) { 136 + console.log(err.message); 137 + console.log(err.issues); 138 + } 139 + } 140 + ``` 141 + 142 + ### IPLD types 143 + 144 + the package exports types for IPLD data structures used in AT Protocol: 145 + 146 + ```ts 147 + import type { Blob, Bytes, CidLink } from '@atcute/lexicons'; 148 + 149 + // Blob - reference to uploaded media (images, videos) 150 + // Bytes - raw binary data (base64 encoded in JSON) 151 + // CidLink - reference to content by CID 11 152 ```
+7 -1
packages/misc/uint8array/README.md
··· 1 1 # @atcute/uint8array 2 2 3 - uint8array utilities 3 + Uint8Array utilities used internally by atcute packages. 4 + 5 + ```sh 6 + npm install @atcute/uint8array 7 + ``` 8 + 9 + this library provides common byte array operations.
+7 -1
packages/misc/util-fetch/README.md
··· 1 1 # @atcute/util-fetch 2 2 3 - random fetch utilities. 3 + fetch response utilities used internally by atcute packages. 4 + 5 + ```sh 6 + npm install @atcute/util-fetch 7 + ``` 8 + 9 + this library provides composable fetch response transformers.
+86 -235
packages/oauth/browser-client/README.md
··· 1 1 # @atcute/oauth-browser-client 2 2 3 - minimal OAuth browser client implementation for AT Protocol. 3 + minimal OAuth browser client for AT Protocol. 4 + 5 + ```sh 6 + npm install @atcute/oauth-browser-client 7 + ``` 8 + 9 + ## client metadata 10 + 11 + your app needs an OAuth client metadata document hosted at a public URL. this tells authorization 12 + servers about your app: 4 13 5 - - **only the bare minimum**: enough code to get authentication reasonably working, with only one 6 - happy path is supported (only ES256 keys for DPoP. PKCE and DPoP-bound PAR is required.) 7 - - **does not use IndexedDB**: makes the library work under Safari's lockdown mode, and has less 8 - maintenance headache overall, but it also means this is "less secure" (it won't be able to use 9 - non-exportable keys as recommended by [DPoP specification][idb-dpop-spec].) 10 - - **not well-tested**: it has been used in personal projects and by friends for quite some time, but 11 - hasn't seen any use outside of that. using the [reference implementation][oauth-atproto-lib] is 12 - recommended if you are unsure about the implications presented here. 14 + ```json 15 + { 16 + "client_id": "https://example.com/oauth-client-metadata.json", 17 + "client_name": "My App", 18 + "client_uri": "https://example.com", 19 + "redirect_uris": ["https://example.com/oauth/callback"], 20 + "scope": "atproto transition:generic", 21 + "grant_types": ["authorization_code", "refresh_token"], 22 + "response_types": ["code"], 23 + "token_endpoint_auth_method": "none", 24 + "application_type": "web", 25 + "dpop_bound_access_tokens": true 26 + } 27 + ``` 13 28 14 - [idb-dpop-spec]: https://datatracker.ietf.org/doc/html/rfc9449#section-2-4 15 - [oauth-atproto-lib]: https://npm.im/@atproto/oauth-client-browser 29 + the `client_id` must be the URL where this document is hosted. see the 30 + [OAuth client metadata spec](https://docs.bsky.app/docs/advanced-guides/oauth-client#client-metadata) 31 + for all available fields. 16 32 17 33 ## usage 18 34 19 - ### setup 35 + ### configuration 20 36 21 - initialize the client by importing and calling `configureOAuth` with the client ID and redirect URL, 22 - along with the resolvers that will be used to resolve and verify account details. this call should 23 - be placed before any other calls you make with this library. 37 + call `configureOAuth` before using any other functions from this library: 24 38 25 39 ```ts 26 - import { configureOAuth, defaultIdentityResolver } from '@atcute/oauth-browser-client'; 40 + import { configureOAuth } from '@atcute/oauth-browser-client'; 27 41 28 42 import { 29 43 CompositeDidDocumentResolver, 44 + LocalActorResolver, 30 45 PlcDidDocumentResolver, 31 46 WebDidDocumentResolver, 32 47 XrpcHandleResolver, ··· 37 52 client_id: 'https://example.com/oauth-client-metadata.json', 38 53 redirect_uri: 'https://example.com/oauth/callback', 39 54 }, 40 - identityResolver: defaultIdentityResolver({ 41 - // AT Protocol handles resolve via DNS TXT record or HTTP well-known endpoints. 42 - // since web apps lack direct DNS access and face CORS restrictions, we're using 43 - // Bluesky's AppView for this example. 44 - // 45 - // NOTE: Bluesky may log handle resolutions and requester info per their privacy 46 - // policy. consider the privacy implications of this arrangement and change this 47 - // setup if unsuitable for your use case. 55 + identityResolver: new LocalActorResolver({ 48 56 handleResolver: new XrpcHandleResolver({ serviceUrl: 'https://public.api.bsky.app' }), 49 - 50 57 didDocumentResolver: new CompositeDidDocumentResolver({ 51 58 methods: { 52 59 plc: new PlcDidDocumentResolver(), ··· 57 64 }); 58 65 ``` 59 66 60 - ### starting an authorization flow 67 + > [!NOTE] 68 + > this example uses Bluesky's AppView for handle resolution since web apps lack direct DNS access. 69 + > Bluesky may log handle resolutions per their privacy policy - consider the implications for your 70 + > use case. 61 71 62 - we can start authorization by calling `createAuthorizationUrl` with the intended account's 63 - identifier or service along with the scope of the authorization, which should either match the one 64 - in your client metadata, or a reduced set of it. 72 + ### starting authorization 65 73 66 74 ```ts 67 75 import { createAuthorizationUrl } from '@atcute/oauth-browser-client'; 68 76 69 77 const authUrl = await createAuthorizationUrl({ 70 78 target: { type: 'account', identifier: 'mary.my.id' }, 71 - // or { type: 'pds', serviceUrl: 'https://bsky.social' } 72 79 scope: 'atproto transition:generic transition:chat.bsky', 73 80 }); 74 81 75 - // recommended to wait for the browser to persist local storage before proceeding 76 - await sleep(200); 77 - 78 - // redirect the user to sign in and authorize the app 82 + await sleep(200); // let browser persist local storage 79 83 window.location.assign(authUrl); 80 - 81 - // if this is on an async function, ideally the function should never ever resolve. 82 - // the only way it should resolve at this point is if the user aborted the authorization 83 - // by returning back to this page (thanks to back-forward page caching) 84 - await new Promise((_resolve, reject) => { 85 - const listener = () => { 86 - reject(new Error(`user aborted the login request`)); 87 - }; 88 - 89 - window.addEventListener('pageshow', listener, { once: true }); 90 - }); 91 84 ``` 92 85 93 86 ### finalizing authorization 94 87 95 - once the user has been redirected to your redirect URL, we can call `finalizeAuthorization` with the 96 - parameters that have been provided. 88 + on your redirect URL, extract the parameters and finalize: 97 89 98 90 ```ts 99 91 import { XRPC } from '@atcute/client'; 100 92 import { OAuthUserAgent, finalizeAuthorization } from '@atcute/oauth-browser-client'; 101 93 102 - // `createAuthorizationUrl` asks for the server to redirect here with the 103 - // parameters assigned in the hash, not the search string. 94 + // server redirects with params in hash, not search string 104 95 const params = new URLSearchParams(location.hash.slice(1)); 105 96 106 - // this is optional, but after retrieving the parameters, we should ideally 107 - // scrub it from history to prevent this authorization state to be replayed, 108 - // just for good measure. 97 + // scrub params from URL to prevent replay 109 98 history.replaceState(null, '', location.pathname + location.search); 110 99 111 - // you'd be given a session object that you can then pass to OAuthUserAgent! 112 - const session = await finalizeAuthorization(params); 113 - 114 - // now you can start making requests! 100 + const { session } = await finalizeAuthorization(params); 115 101 const agent = new OAuthUserAgent(session); 116 - 117 - // pass it onto the XRPC so you can make RPC calls with the PDS. 118 - { 119 - const rpc = new XRPC({ handler: agent }); 120 - 121 - const { data } = await rpc.get('com.atproto.identity.resolveHandle', { 122 - params: { 123 - handle: 'mary.my.id', 124 - }, 125 - }); 126 - } 102 + const rpc = new XRPC({ handler: agent }); 127 103 128 - // or, use it directly! 129 - { 130 - const response = await agent.handle('/xrpc/com.atproto.identity.resolveHandle?handle=mary.my.id'); 131 - } 104 + const { data } = await rpc.get('com.atproto.identity.resolveHandle', { 105 + params: { handle: 'mary.my.id' }, 106 + }); 132 107 ``` 133 108 134 - the `session` object returned by `finalizeAuthorization` should not be stored anywhere else, as it 135 - is already persisted in the internal database. you are expected to keep track of who's signed in and 136 - who was last signed in for your own UI, as the sessions stored by the database is not guaranteed to 137 - be permanent (mostly if they don't come with a refresh token.) 109 + the session is persisted internally - don't store it elsewhere. track signed-in DIDs yourself for 110 + your UI, as sessions without refresh tokens may expire. 138 111 139 - ### resuming existing sessions 140 - 141 - you can resume existing sessions by calling `getSession` with the DID identifier you intend to 142 - resume. 112 + ### resuming sessions 143 113 144 114 ```ts 145 - import { XRPC } from '@atcute/client'; 146 115 import { OAuthUserAgent, getSession } from '@atcute/oauth-browser-client'; 147 116 148 117 const session = await getSession('did:plc:ia76kvnndjutgedggx2ibrem', { allowStale: true }); 149 - 150 118 const agent = new OAuthUserAgent(session); 151 - const rpc = new XRPC({ handler: agent }); 152 119 ``` 153 120 154 - ### removing sessions 155 - 156 - you can manually remove sessions via `deleteStoredSession`, but ideally, you should revoke the token 157 - first before doing so. 121 + ### signing out 158 122 159 123 ```ts 160 124 import { OAuthUserAgent, deleteStoredSession, getSession } from '@atcute/oauth-browser-client'; ··· 164 128 try { 165 129 const session = await getSession(did, { allowStale: true }); 166 130 const agent = new OAuthUserAgent(session); 167 - 168 131 await agent.signOut(); 169 - } catch (err) { 170 - // `signOut` also deletes the session, we only serve as fallback if it fails. 171 - deleteStoredSession(did); 132 + } catch { 133 + deleteStoredSession(did); // fallback if signOut fails 172 134 } 173 135 ``` 174 136 175 - ## confidential client mode (optional) 176 - 177 - by default, `@atcute/oauth-browser-client` operates as a **public client**, resulting in shorter 178 - session lifetimes by authorization servers as it's deemed to be unable to securely store 179 - credentials. 180 - 181 - if you want longer-lived sessions and better security controls, you can enable **confidential client 182 - mode** by setting up a [client assertion backend](client-assertion-backend). 137 + ## confidential client mode 183 138 184 - [client-assertion-backend]: https://github.com/bluesky-social/proposals/tree/main/0010-client-assertion-backend 139 + by default, this library operates as a **public client** with shorter session lifetimes. for 140 + longer-lived sessions, set up a [client assertion backend][client-assertion-backend] to enable 141 + **confidential client mode**. 185 142 186 - ### setup 143 + [client-assertion-backend]: 144 + https://github.com/bluesky-social/proposals/tree/main/0010-client-assertion-backend 187 145 188 - configure the client with a function to fetch client assertions from your backend: 146 + add `fetchClientAssertion` to your config. the backend API is entirely up to you - this is just one 147 + example: 189 148 190 149 ```ts 191 - import { configureOAuth } from '@atcute/oauth-browser-client'; 192 - 193 150 configureOAuth({ 194 151 // ... existing config 195 152 ··· 198 155 199 156 const response = await fetch('https://example.com/api/client-assertion', { 200 157 method: 'POST', 201 - headers: { 202 - dpop: dpop, 203 - 'content-type': 'application/json', 204 - }, 158 + headers: { dpop, 'content-type': 'application/json' }, 205 159 body: JSON.stringify({ jkt, aud }), 206 160 }); 207 161 208 162 const data = await response.json(); 209 - 210 163 return { 211 164 client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 212 165 client_assertion: data.assertion, ··· 215 168 }); 216 169 ``` 217 170 218 - the backend API is completely up to you—there's no standardized spec. design it however works best 219 - for your infrastructure (authentication, request format, error handling, etc.) 220 - 221 - your backend needs to validate the incoming DPoP proof and sign a client assertion JWT with the 222 - following interface: 223 - 224 - ```ts 225 - interface ClientAssertionJwt { 226 - /** your client ID */ 227 - iss: string; 228 - /** also your client ID */ 229 - sub: string; 230 - /** the authorization server receiving this token */ 231 - aud: string; 232 - /** when this token expires */ 233 - exp: number; 234 - /** unique nonce */ 235 - jti: string; 236 - /** asserts that this jkt is allowed */ 237 - cnf: { jkt: string }; 238 - } 239 - ``` 240 - 241 - you're able to use the `jkt` to refuse assertions when necessary (suspicious activity, compromised 242 - code, etc.) 243 - 244 - ### client metadata updates 245 - 246 - your OAuth client metadata document must also be updated for confidential clients: 247 - 248 - ```json 249 - { 250 - "client_id": "https://example.com/oauth-client-metadata.json", 251 - "client_name": "My App", 252 - "redirect_uris": ["https://example.com/oauth/callback"], 253 - "scope": "atproto transition:generic", 254 - "token_endpoint_auth_method": "private_key_jwt", 255 - "token_endpoint_auth_signing_alg": "ES256", 256 - "jwks_uri": "https://example.com/oauth-jwks.json" 257 - } 258 - ``` 259 - 260 - the `jwks_uri` should expose the public keys used to sign client assertions. it should return a JSON 261 - Web Key Set (JWKS) document: 262 - 263 - ```json 264 - { 265 - "keys": [ 266 - { 267 - "kty": "EC", 268 - "crv": "P-256", 269 - "x": "base64url-encoded-x-coordinate", 270 - "y": "base64url-encoded-y-coordinate", 271 - "use": "sig", 272 - "kid": "key-identifier", 273 - "alg": "ES256" 274 - } 275 - ] 276 - } 277 - ``` 278 - 279 - the public keys in the JWKS must correspond to the private keys your backend uses to sign client 280 - assertions. multiple keys can be listed to support key rotation. 171 + your backend validates the DPoP proof and signs a client assertion JWT containing `iss`, `sub` (both 172 + your client ID), `aud` (authorization server), `exp`, `jti` (unique nonce), and `cnf: { jkt }` (the 173 + allowed key thumbprint). 281 174 282 - ## additional guide 175 + update your client metadata for confidential mode - replace `token_endpoint_auth_method` with 176 + `private_key_jwt`, add `token_endpoint_auth_signing_alg: "ES256"`, and add a `jwks_uri` pointing to 177 + your public keys. 283 178 284 - ### configuring your Vite project 179 + ## local development with Vite 285 180 286 - you might want to configure the server options in your Vite config so you'll never end up visiting 287 - your app in `localhost`, which is specifically forbidden by AT Protocol's OAuth, let's change it so 288 - it'll always use `127.0.0.1`: 181 + AT Protocol OAuth forbids `localhost` - use `127.0.0.1` instead: 289 182 290 183 ```ts 291 - /// vite.config.ts 184 + // vite.config.ts 292 185 import { defineConfig } from 'vite'; 186 + import metadata from './public/oauth-client-metadata.json' with { type: 'json' }; 293 187 294 188 const SERVER_HOST = '127.0.0.1'; 295 189 const SERVER_PORT = 12520; 296 190 297 191 export default defineConfig({ 298 - server: { 299 - host: SERVER_HOST, 300 - port: SERVER_PORT, 301 - }, 302 - }); 303 - ``` 304 - 305 - additionally, to make it easier to develop locally and deploy to production, you should consider 306 - adding a plugin that'll inject the necessary values for you through environment variables: 307 - 308 - ```ts 309 - /// vite.config.ts 310 - import metadata from './public/oauth-client-metadata.json' with { type: 'json' }; 311 - 312 - export default defineConfig({ 313 - // ... 314 - 192 + server: { host: SERVER_HOST, port: SERVER_PORT }, 315 193 plugins: [ 316 - // injects OAuth-related environment variables 317 194 { 318 195 config(_conf, { command }) { 319 196 if (command === 'build') { 320 197 process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id; 321 198 process.env.VITE_OAUTH_REDIRECT_URI = metadata.redirect_uris[0]; 322 199 } else { 323 - const redirectUri = (() => { 324 - const url = new URL(metadata.redirect_uris[0]); 325 - return `http://${SERVER_HOST}:${SERVER_PORT}${url.pathname}`; 326 - })(); 327 - 328 - const clientId = 329 - `http://localhost` + 330 - `?redirect_uri=${encodeURIComponent(redirectUri)}` + 200 + const redirectUri = `http://${SERVER_HOST}:${SERVER_PORT}${new URL(metadata.redirect_uris[0]).pathname}`; 201 + process.env.VITE_OAUTH_CLIENT_ID = 202 + `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}` + 331 203 `&scope=${encodeURIComponent(metadata.scope)}`; 332 - 333 - process.env.VITE_DEV_SERVER_PORT = '' + SERVER_PORT; 334 - process.env.VITE_OAUTH_CLIENT_ID = clientId; 335 204 process.env.VITE_OAUTH_REDIRECT_URI = redirectUri; 336 205 } 337 - 338 - process.env.VITE_CLIENT_URI = metadata.client_uri; 339 206 process.env.VITE_OAUTH_SCOPE = metadata.scope; 340 207 }, 341 208 }, ··· 343 210 }); 344 211 ``` 345 212 346 - we'll augment the type declarations to get type-checking on it: 347 - 348 - ```ts 349 - /// src/vite-env.d.ts 350 - 351 - interface ImportMetaEnv { 352 - readonly VITE_DEV_SERVER_PORT?: string; 353 - readonly VITE_CLIENT_URI: string; 354 - readonly VITE_OAUTH_CLIENT_ID: string; 355 - readonly VITE_OAUTH_REDIRECT_URI: string; 356 - readonly VITE_OAUTH_SCOPE: string; 357 - } 358 - 359 - interface ImportMeta { 360 - readonly env: ImportMetaEnv; 361 - } 362 - ``` 363 - 364 - et voilà! you can now use this to configure the client. 213 + then use environment variables in your code: 365 214 366 215 ```ts 367 216 configureOAuth({ ··· 371 220 }, 372 221 // ... 373 222 }); 374 - 375 - // ... later during sign-in process 376 - const authUrl = await createAuthorizationUrl({ 377 - // ... 378 - scope: import.meta.env.VITE_OAUTH_SCOPE, 379 - }); 380 223 ``` 381 224 382 - adjust the code here as necessary, the plugin adds more environment variables than what is actually 383 - needed, you can remove them if you don't think you'd need it. 225 + ## caveats 226 + 227 + - **minimal implementation**: only ES256 DPoP keys, requires PKCE and DPoP-bound PAR 228 + - **no IndexedDB**: works in Safari lockdown mode but can't use non-exportable keys as [recommended 229 + by DPoP spec][dpop-spec] 230 + - **limited testing**: works in personal projects but consider the [reference 231 + implementation][oauth-atproto-lib] for production 232 + 233 + [dpop-spec]: https://datatracker.ietf.org/doc/html/rfc9449#section-2-4 234 + [oauth-atproto-lib]: https://npm.im/@atproto/oauth-client-browser
+4
packages/servers/xrpc-server-bun/README.md
··· 2 2 3 3 Bun WebSocket adapter for `@atcute/xrpc-server`. 4 4 5 + ```sh 6 + npm install @atcute/xrpc-server-bun 7 + ``` 8 + 5 9 ```ts 6 10 import { XRPCRouter } from '@atcute/xrpc-server'; 7 11 import { createBunWebSocket } from '@atcute/xrpc-server-bun';
+4
packages/servers/xrpc-server-cloudflare/README.md
··· 2 2 3 3 Cloudflare Workers WebSocket adapter for `@atcute/xrpc-server`. 4 4 5 + ```sh 6 + npm install @atcute/xrpc-server-cloudflare 7 + ``` 8 + 5 9 ```ts 6 10 import { XRPCRouter } from '@atcute/xrpc-server'; 7 11 import { createCloudflareWebSocket } from '@atcute/xrpc-server-cloudflare';
+4
packages/servers/xrpc-server-deno/README.md
··· 2 2 3 3 Deno WebSocket adapter for `@atcute/xrpc-server`. 4 4 5 + ```sh 6 + npm install @atcute/xrpc-server-deno 7 + ``` 8 + 5 9 ```ts 6 10 import { XRPCRouter } from '@atcute/xrpc-server'; 7 11 import { createDenoWebSocket } from '@atcute/xrpc-server-deno';
+4
packages/servers/xrpc-server-node/README.md
··· 2 2 3 3 Node.js WebSocket adapter for `@atcute/xrpc-server`. 4 4 5 + ```sh 6 + npm install @atcute/xrpc-server-node 7 + ``` 8 + 5 9 ```ts 6 10 import { serve } from '@hono/node-server'; 7 11 import { XRPCRouter } from '@atcute/xrpc-server';
+6 -2
packages/servers/xrpc-server/README.md
··· 1 1 # @atcute/xrpc-server 2 2 3 - a small web framework for handling XRPC operations. 3 + web framework for XRPC servers. 4 + 5 + ```sh 6 + npm install @atcute/xrpc-server 7 + ``` 4 8 5 9 ## quick start 6 10 ··· 49 53 ```ts 50 54 // file: src/index.js 51 55 import { XRPCRouter, json } from '@atcute/xrpc-server'; 52 - import { cors } from '@atucte/xrpc-server/middlewares/cors'; 56 + import { cors } from '@atcute/xrpc-server/middlewares/cors'; 53 57 54 58 import { ComExampleGreet } from './lexicons/index.js'; 55 59
+8 -1
packages/utilities/car/README.md
··· 1 1 # @atcute/car 2 2 3 - lightweight [DASL CAR (content-addressable archives)][dasl-car] codec library for AT Protocol. 3 + content-addressable archive (CAR) reader for AT Protocol. 4 + 5 + ```sh 6 + npm install @atcute/car 7 + ``` 8 + 9 + this library implements DASL's [CAR][dasl-car] format used by AT Protocol to store and transfer 10 + repository data. 4 11 5 12 [dasl-car]: https://dasl.ing/car.html 6 13
+32 -19
packages/utilities/cbor/README.md
··· 1 1 # @atcute/cbor 2 2 3 - lightweight [DASL dCBOR42 (deterministic CBOR with tag 42)][dasl-dcbor42] codec library for AT 4 - Protocol. 3 + deterministic CBOR codec for AT Protocol. 5 4 6 - the specific profile being implemented is [IPLD DAG-CBOR][ipld-dag-cbor], with some additional notes 7 - to keep in mind: 5 + ```sh 6 + npm install @atcute/cbor 7 + ``` 8 8 9 - - `undefined` types are still forbidden, except for when they are in a `map` type, where fields will 10 - be omitted instead, which makes it easier to construct objects to then pass to the encoder. 11 - - `byte` and `link` types are represented by atproto's [lex-json][atproto-data-model] interfaces, 12 - but because these involve string codec and parsing, they are done lazily by `BytesWrapper` and 13 - `CidLinkWrapper` instances. 14 - - use `fromBytes` and `fromCidLink` to convert them to Uint8Array or CID interface respectively, 15 - without hitting the string conversion path. 16 - - use `toBytes` and `toCidLink` for the other direction. 17 - - integers can't exceed JavaScript's safe integer range, no bigint conversions will occur as they 18 - will be thrown instead if encountered. 9 + this library implements DASL's [DRISL][dasl-drisl] format used by AT Protocol for encoding records 10 + and repository data. 11 + 12 + [dasl-drisl]: https://dasl.ing/drisl.html 13 + 14 + ## usage 19 15 20 - [atproto-data-model]: https://atproto.com/specs/data-model 21 - [dasl-dcbor42]: https://dasl.ing/dcbor42.html 22 - [ipld-dag-cbor]: https://ipld.io/specs/codecs/dag-cbor/spec 16 + ### encoding 23 17 24 18 ```ts 25 19 import { encode } from '@atcute/cbor'; ··· 32 26 }; 33 27 34 28 const cbor = encode(record); 35 - // ^? Uint8Array(90) [ ... ] 29 + // -> Uint8Array(90) 30 + ``` 31 + 32 + ### decoding 33 + 34 + ```ts 35 + import { decode } from '@atcute/cbor'; 36 + 37 + const record = decode(cborBytes); 38 + // -> { $type: 'app.bsky.feed.post', ... } 36 39 ``` 37 40 38 - Implementation based on the excellent [`microcbor` library](https://github.com/joeltg/microcbor). 41 + ## notes 42 + 43 + - `undefined` values are omitted from maps (making it easier to construct objects) 44 + - bytes and CID links use lazy wrappers (`BytesWrapper`, `CidLinkWrapper`) compatible with atproto's 45 + [lex-json][atproto-data-model] format 46 + - use `toBytes`/`fromBytes` and `toCidLink`/`fromCidLink` to convert between lex-json and raw types 47 + - integers must be within JavaScript's safe integer range (no bigint support) 48 + 49 + [atproto-data-model]: https://atproto.com/specs/data-model 50 + 51 + based on [`microcbor`](https://github.com/joeltg/microcbor).
+52 -6
packages/utilities/cid/README.md
··· 1 1 # @atcute/cid 2 2 3 - lightweight [DASL CID][dasl-cid] codec library for AT Protocol. 3 + content identifier (CID) codec for AT Protocol. 4 + 5 + ```sh 6 + npm install @atcute/cid 7 + ``` 8 + 9 + this library implements DASL's [CID][dasl-cid] format used by AT Protocol to address resources by 10 + their contents. 4 11 5 12 [dasl-cid]: https://dasl.ing/cid.html 6 13 14 + ## usage 15 + 16 + ### creating CIDs 17 + 7 18 ```ts 8 19 import * as CID from '@atcute/cid'; 9 20 21 + // create a CID from DAG-CBOR data 22 + const cid = await CID.create(0x71, cborBytes); 23 + // -> { version: 1, codec: 113, digest: { ... }, bytes: Uint8Array(36) } 24 + 25 + // create from raw data 26 + const rawCid = await CID.create(0x55, rawBytes); 27 + ``` 28 + 29 + ### parsing CIDs 30 + 31 + ```ts 32 + import * as CID from '@atcute/cid'; 33 + 34 + // parse from base32 string 10 35 const cid = CID.fromString('bafyreihffx5a2e7k5uwrmmgofbvzujc5cmw5h4espouwuxt3liqoflx3ee'); 11 - // ^? { version: 1, codec: 113, digest: { ... }, bytes: Uint8Array(36) } 12 36 13 - // Creating a CID containing CBOR data 14 - const cid = await CID.create(0x71, buffer); 37 + // parse from binary (with 0x00 prefix) 38 + const cid = CID.fromBinary(bytes); 39 + 40 + // parse from raw CID bytes 41 + const cid = CID.decode(cidBytes); 42 + ``` 43 + 44 + ### serializing CIDs 45 + 46 + ```ts 47 + import * as CID from '@atcute/cid'; 48 + 49 + // to base32 string 50 + CID.toString(cid); 51 + // -> "bafyreihffx5a2e7k5uwrmmgofbvzujc5cmw5h4espouwuxt3liqoflx3ee" 15 52 16 - // Serializing CID into string 17 - CID.toString(cid); // -> bafyrei... 53 + // to binary (with 0x00 prefix) 54 + CID.toBinary(cid); 55 + // -> Uint8Array(37) 56 + ``` 57 + 58 + ### comparing CIDs 59 + 60 + ```ts 61 + import * as CID from '@atcute/cid'; 62 + 63 + CID.equals(cidA, cidB); // true if same content hash 18 64 ```
+41 -14
packages/utilities/crypto/README.md
··· 1 1 # @atcute/crypto 2 2 3 - lightweight atproto cryptographic library, supporting its two "blessed" elliptic curve cryptography 4 - systems: 3 + cryptographic utilities for AT Protocol. 5 4 6 - - `p256`: uses WebCrypto API. 7 - - `secp256k1`: uses `node:crypto` on Node.js, [`@noble/secp256k1`][noble-secp256k1] everywhere else 8 - (browsers, Bun, Deno). 5 + ```sh 6 + npm install @atcute/crypto 7 + ``` 8 + 9 + this package provides key generation, signing, and verification for the two elliptic curve systems 10 + used by AT Protocol to certify identity and repository data: 11 + 12 + - `p256`: uses WebCrypto API 13 + - `secp256k1`: uses `node:crypto` on Node.js, [`@noble/secp256k1`][noble-secp256k1] elsewhere 9 14 10 15 [noble-secp256k1]: https://github.com/paulmillr/noble-secp256k1 11 16 17 + ## usage 18 + 19 + ### creating keypairs 20 + 12 21 ```ts 13 - import { Secp256k1PrivateKeyExportable, verifySigWithDidKey } from './index.js'; 22 + import { Secp256k1PrivateKeyExportable, P256PrivateKeyExportable } from '@atcute/crypto'; 14 23 24 + // secp256k1 keypair 15 25 const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); 16 26 17 - // `.sign()` hashes the data and signs it 27 + // p256 keypair 28 + const p256Keypair = await P256PrivateKeyExportable.createKeypair(); 29 + ``` 30 + 31 + ### signing data 32 + 33 + ```ts 34 + // sign() hashes the data and signs it 18 35 const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); 19 36 const sig = await keypair.sign(data); 37 + ``` 20 38 21 - // `.exportPublicKey()` exports the public key in various formats 22 - // e.g. `did:key:zQ3shVRtgqTRHC7Lj4DYScoDgReNpsDp3HBnuKBKt1FSXKQ38` 23 - const didPublicKey = await keypair.exportPublicKey('did'); 39 + ### exporting public keys 40 + 41 + ```ts 42 + // export as did:key format 43 + const didKey = await keypair.exportPublicKey('did'); 44 + // -> "did:key:zQ3shVRtgqTRHC7Lj4DYScoDgReNpsDp3HBnuKBKt1FSXKQ38" 45 + 46 + // export as multibase 47 + const multibase = await keypair.exportPublicKey('multibase'); 48 + ``` 49 + 50 + ### verifying signatures 24 51 25 - // `.verify()` can be used to check if the signature is valid, but to save the 26 - // hassle of figuring out the key type, we can use `verifySigWithDidKey()` 27 - const ok = await verifySigWithDidKey(didPublicKey, sig, data); 52 + ```ts 53 + import { verifySigWithDidKey } from '@atcute/crypto'; 28 54 29 - expect(ok).toBe(true); 55 + // verify using did:key (automatically detects curve type) 56 + const ok = await verifySigWithDidKey(didKey, sig, data); 30 57 ```
+8 -1
packages/utilities/mst/README.md
··· 1 1 # @atcute/mst 2 2 3 - atproto MST (Merkle Search Tree) manipulation utilities 3 + Merkle Search Tree (MST) manipulation utilities for AT Protocol. 4 + 5 + ```sh 6 + npm install @atcute/mst 7 + ``` 4 8 5 9 MST is a key-value tree structure used in atproto repositories to store collections of records. keys 6 10 are sorted lexicographically and organized into nodes based on the leading zero bits in their 7 11 SHA-256 hash. each node uses prefix compression for efficient storage and has a CID (content 8 12 identifier) for content-addressable access. 13 + 14 + this is a low-level utility for building tools that need to work directly with repository 15 + structures. for reading repository exports, see `@atcute/repo` instead. 9 16 10 17 see the [atproto repository specification](https://atproto.com/specs/repository) for more details.
+7 -1
packages/utilities/multibase/README.md
··· 1 1 # @atcute/multibase 2 2 3 - provides various base codecs used in atproto ecosystem 3 + base encoding utilities for AT Protocol. 4 + 5 + ```sh 6 + npm install @atcute/multibase 7 + ``` 8 + 9 + provides various base codecs used in the atproto ecosystem: 4 10 5 11 - base16 6 12 - base32
+9 -1
packages/utilities/repo/README.md
··· 1 1 # @atcute/repo 2 2 3 - read AT Protocol repository exports 3 + read AT Protocol repository exports. 4 + 5 + ```sh 6 + npm install @atcute/repo 7 + ``` 8 + 9 + AT Protocol stores user data in repositories - Merkle tree structures containing records organized 10 + by collection. this package reads repository CAR exports (from `com.atproto.sync.getRepo` or 11 + account exports) and iterates over the records. 4 12 5 13 ## usage 6 14
+37 -5
packages/utilities/tid/README.md
··· 1 1 # @atcute/tid 2 2 3 - atproto timestamp identifier codec library 3 + timestamp identifier (TID) codec for AT Protocol. 4 + 5 + ```sh 6 + npm install @atcute/tid 7 + ``` 8 + 9 + this library implements atproto's TID codec used to generate compact and unique record keys that can 10 + be sorted chronologically. 11 + 12 + ## usage 13 + 14 + ### generating TIDs 4 15 5 16 ```ts 6 17 import * as TID from '@atcute/tid'; 7 18 8 - const tidString = TID.now(); 9 - // ^? "3l25zusnsfctk" 19 + // generate a TID for the current time 20 + const tid = TID.now(); 21 + // -> "3l25zusnsfctk" 10 22 11 - const result = TID.parse(tidString); 12 - // ^? { timestamp: 1724171495793000, clockid: 816 } 23 + // create from specific timestamp (microseconds) and clock ID 24 + const custom = TID.create(1724171495793000, 512); 25 + // -> "3l25zusnsfcta" 26 + ``` 27 + 28 + ### parsing TIDs 29 + 30 + ```ts 31 + import * as TID from '@atcute/tid'; 32 + 33 + const { timestamp, clockid } = TID.parse('3l25zusnsfctk'); 34 + // timestamp: 1724171495793000 (microseconds since epoch) 35 + // clockid: 816 36 + ``` 37 + 38 + ### validating TIDs 39 + 40 + ```ts 41 + import * as TID from '@atcute/tid'; 42 + 43 + TID.validate('3l25zusnsfctk'); // true 44 + TID.validate('invalid'); // false 13 45 ```
+5 -1
packages/utilities/varint/README.md
··· 1 1 # @atcute/varint 2 2 3 - protobuf-style LEB128 varint codec library. 3 + protobuf-style LEB128 varint codec. 4 + 5 + ```sh 6 + npm install @atcute/varint 7 + ``` 4 8 5 9 ```ts 6 10 import { encode } from '@atcute/varint';