Encrypted, ephemeral, private memos on atproto
3
fork

Configure Feed

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

at main 234 lines 6.3 kB view raw
1import { 2 produceRequirements, 3 type XRPCProcedures, 4 type XRPCQueries, 5} from "@cistern/shared"; 6import { decryptText, generateKeys } from "@cistern/crypto"; 7import { generateRandomName } from "@puregarlic/randimal"; 8import { 9 is, 10 isResourceUri, 11 parse, 12 type RecordKey, 13 type ResourceUri, 14} from "@atcute/lexicons"; 15import { JetstreamSubscription } from "@atcute/jetstream"; 16import type { Did } from "@atcute/lexicons/syntax"; 17import type { Client } from "@atcute/client"; 18import { AppCisternMemo, type AppCisternPubkey } from "@cistern/lexicon"; 19import type { 20 ConsumerOptions, 21 ConsumerParams, 22 DecryptedMemo, 23 LocalKeyPair, 24} from "./types.ts"; 25 26/** 27 * Client for generating keys and decoding Cistern memos. 28 */ 29export class Consumer { 30 /** DID of the user this consumer acts on behalf of */ 31 did: Did; 32 33 /** `@atcute/client` instance with credential manager */ 34 rpc: Client<XRPCQueries, XRPCProcedures>; 35 36 /** Private key used for decrypting and the AT URI of its associated public key */ 37 keypair?: LocalKeyPair; 38 39 constructor(params: ConsumerParams) { 40 this.did = params.miniDoc.did; 41 this.keypair = params.options.keypair 42 ? { 43 privateKey: Uint8Array.fromBase64(params.options.keypair.privateKey), 44 publicKey: params.options.keypair.publicKey as ResourceUri, 45 } 46 : undefined; 47 this.rpc = params.rpc; 48 } 49 50 /** 51 * Generates a key pair, uploading the public key to PDS and returning the pair. 52 */ 53 async generateKeyPair(): Promise<LocalKeyPair> { 54 if (this.keypair) { 55 throw new Error("client already has a key pair"); 56 } 57 58 const keys = generateKeys(); 59 const name = await generateRandomName(); 60 61 const record: AppCisternPubkey.Main = { 62 $type: "app.cistern.pubkey", 63 name, 64 algorithm: "x_wing", 65 content: { $bytes: keys.publicKey.toBase64() }, 66 createdAt: new Date().toISOString(), 67 }; 68 const res = await this.rpc.post("com.atproto.repo.createRecord", { 69 input: { 70 collection: "app.cistern.pubkey", 71 repo: this.did, 72 record, 73 }, 74 }); 75 76 if (!res.ok) { 77 throw new Error( 78 `failed to save public key: ${res.status} ${res.data.error}`, 79 ); 80 } 81 82 const keypair = { 83 privateKey: keys.secretKey, 84 publicKey: res.data.uri, 85 }; 86 87 this.keypair = keypair; 88 89 return keypair; 90 } 91 92 /** 93 * Asynchronously iterate through memos in the user's PDS 94 */ 95 async *listMemos(): AsyncGenerator< 96 DecryptedMemo, 97 void, 98 undefined 99 > { 100 if (!this.keypair) { 101 throw new Error("no key pair set; generate a key before listing memos"); 102 } 103 104 let cursor: string | undefined; 105 106 while (true) { 107 const res = await this.rpc.get("com.atproto.repo.listRecords", { 108 params: { 109 collection: "app.cistern.memo", 110 repo: this.did, 111 cursor, 112 }, 113 }); 114 115 if (!res.ok) { 116 throw new Error( 117 `failed to list memos: ${res.status} ${res.data.error}`, 118 ); 119 } 120 121 cursor = res.data.cursor; 122 123 for (const record of res.data.records) { 124 const memo = parse(AppCisternMemo.mainSchema, record.value); 125 126 if (memo.pubkey !== this.keypair.publicKey) continue; 127 128 const decrypted = decryptText(this.keypair.privateKey, { 129 nonce: memo.nonce.$bytes, 130 cipherText: memo.ciphertext.$bytes, 131 content: memo.payload.$bytes, 132 hash: memo.contentHash.$bytes, 133 length: memo.contentLength, 134 }); 135 136 yield { 137 key: record.uri.split("/").pop() as RecordKey, 138 tid: memo.tid, 139 text: decrypted, 140 }; 141 } 142 143 if (!cursor) return; 144 } 145 } 146 147 /** 148 * Subscribes to the Jetstreams for the user's memos. Pass `"stop"` into `subscription.next(...)` to cancel 149 * @todo Allow specifying Jetstream endpoint 150 */ 151 async *subscribeToMemos(): AsyncGenerator< 152 DecryptedMemo, 153 void, 154 "stop" | undefined 155 > { 156 if (!this.keypair) { 157 throw new Error("no key pair set; generate a key before subscribing"); 158 } 159 160 const subscription = new JetstreamSubscription({ 161 url: "wss://jetstream2.us-east.bsky.network", 162 wantedCollections: ["app.cistern.memo"], 163 wantedDids: [this.did], 164 }); 165 166 for await (const event of subscription) { 167 if (event.kind === "commit" && event.commit.operation === "create") { 168 const record = event.commit.record; 169 170 if (!is(AppCisternMemo.mainSchema, record)) { 171 continue; 172 } 173 174 if (record.pubkey !== this.keypair.publicKey) { 175 continue; 176 } 177 178 const decrypted = decryptText(this.keypair.privateKey, { 179 nonce: record.nonce.$bytes, 180 cipherText: record.ciphertext.$bytes, 181 content: record.payload.$bytes, 182 hash: record.contentHash.$bytes, 183 length: record.contentLength, 184 }); 185 186 const command = yield { 187 key: event.commit.rkey, 188 tid: record.tid, 189 text: decrypted, 190 }; 191 192 if (command === "stop") return; 193 } 194 } 195 } 196 197 /** 198 * Deletes a memo from the user's PDS by record key. 199 */ 200 async deleteMemo(key: RecordKey) { 201 const res = await this.rpc.post("com.atproto.repo.deleteRecord", { 202 input: { 203 collection: "app.cistern.memo", 204 repo: this.did, 205 rkey: key, 206 }, 207 }); 208 209 if (!res.ok) { 210 throw new Error( 211 `failed to delete memo ${key}: ${res.status} ${res.data.error}`, 212 ); 213 } 214 } 215} 216 217/** 218 * Creates a `Consumer` instance with all necessary requirements. This is the recommended way to construct a `Consumer`. 219 * 220 * @description Resolves the user's DID using Slingshot, instantiates an `@atcute/client` instance, creates an initial session, and then returns a new Consumer. 221 * @param {ConsumerOptions} options - Information for constructing the underlying XRPC client 222 * @returns {Promise<Consumer>} A Cistern consumer client with an authorized session 223 */ 224export async function createConsumer( 225 options: ConsumerOptions, 226): Promise<Consumer> { 227 const reqs = await produceRequirements(options); 228 229 if (options.keypair && !isResourceUri(options.keypair.publicKey)) { 230 throw new Error("provided public key is not a valid AT URI"); 231 } 232 233 return new Consumer(reqs); 234}