Encrypted, ephemeral, private memos on atproto

refactor: rename items to memos, flatten lexicon names

graham.systems 668022c5 804583d7

verified
Changed files
+256 -244
packages
consumer
lexicon
lexicons
app
src
types
app
cistern
producer
+32 -8
README.md
··· 1 # Cistern 2 3 - Cistern is a private, end-to-end encrypted quick-capture system built on AT Protocol. Items are encrypted using post-quantum cryptography and stored temporarily in your Personal Data Server (PDS), then automatically retrieved and deleted after consumption. 4 5 - The system bridges the gap between where ideas are captured and where they are stored long-term. Create an encrypted item on your phone, and it automatically appears in your desktop application, decrypted and ready to use. 6 7 ## Architecture 8 9 Cistern is a Deno monorepo consisting of five packages: 10 11 ### `@cistern/crypto` 12 - Core cryptographic operations using post-quantum algorithms. Implements X-Wing key encapsulation with XChaCha20-Poly1305 authenticated encryption and SHA3-512 integrity verification. Handles keypair generation, encryption, and decryption. 13 14 ### `@cistern/lexicon` 15 - AT Protocol schema definitions for Cistern record types. Defines `app.cistern.lexicon.pubkey` (public key records) and `app.cistern.lexicon.item` (encrypted item records). Includes code generation from JSON schemas. 16 17 ### `@cistern/shared` 18 - Common utilities and authentication logic. Handles DID resolution via Slingshot service and creates authenticated RPC clients using app passwords. 19 20 ### `@cistern/producer` 21 - Creates and encrypts items for storage. Manages public key selection, encrypts plaintext content, and uploads encrypted items to the PDS as AT Protocol records. 22 23 ### `@cistern/consumer` 24 - Retrieves, decrypts, and deletes items. Generates keypairs, manages private keys locally, retrieves items via polling or real-time streaming (Jetstream), and handles item deletion after consumption. 25 26 ## Security Model 27 28 - Private keys never leave the consumer device. Public keys are stored in the PDS as records, while private keys remain off-protocol. Only the holder of the matching private key can decrypt items encrypted with the corresponding public key. 29 30 ## Testing 31 32 Run all unit tests: 33 ```bash 34 deno test --allow-env 35 ``` 36 37 Run end-to-end tests (requires AT Protocol credentials): 38 ```bash 39 CISTERN_HANDLE="your.bsky.social" \ 40 CISTERN_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" \
··· 1 # Cistern 2 3 + Cistern is a private, end-to-end encrypted quick-capture system built on AT 4 + Protocol. Memos are encrypted using post-quantum cryptography and stored 5 + temporarily in your Personal Data Server (PDS), then automatically retrieved and 6 + deleted after consumption. 7 8 + The system bridges the gap between where ideas are captured and where they are 9 + stored long-term. Create an encrypted memo on your phone, and it automatically 10 + appears in your desktop application, decrypted and ready to use. 11 12 ## Architecture 13 14 Cistern is a Deno monorepo consisting of five packages: 15 16 ### `@cistern/crypto` 17 + 18 + Core cryptographic operations using post-quantum algorithms. Implements X-Wing 19 + key encapsulation with XChaCha20-Poly1305 authenticated encryption and SHA3-512 20 + integrity verification. Handles keypair generation, encryption, and decryption. 21 22 ### `@cistern/lexicon` 23 + 24 + AT Protocol schema definitions for Cistern record types. Defines 25 + `app.cistern.pubkey` (public key records) and `app.cistern.memo` (encrypted memo 26 + records). Includes code generation from JSON schemas. 27 28 ### `@cistern/shared` 29 + 30 + Common utilities and authentication logic. Handles DID resolution via Slingshot 31 + service and creates authenticated RPC clients using app passwords. 32 33 ### `@cistern/producer` 34 + 35 + Creates and encrypts memos for storage. Manages public key selection, encrypts 36 + plaintext content, and uploads encrypted memos to the PDS as AT Protocol 37 + records. 38 39 ### `@cistern/consumer` 40 + 41 + Retrieves, decrypts, and deletes memos. Generates keypairs, manages private keys 42 + locally, retrieves memos via polling or real-time streaming (Jetstream), and 43 + handles memo deletion after consumption. 44 45 ## Security Model 46 47 + Private keys never leave the consumer device. Public keys are stored in the PDS 48 + as records, while private keys remain off-protocol. Only the holder of the 49 + matching private key can decrypt memos encrypted with the corresponding public 50 + key. 51 52 ## Testing 53 54 Run all unit tests: 55 + 56 ```bash 57 deno test --allow-env 58 ``` 59 60 Run end-to-end tests (requires AT Protocol credentials): 61 + 62 ```bash 63 CISTERN_HANDLE="your.bsky.social" \ 64 CISTERN_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" \
+52 -52
e2e.test.ts
··· 29 let consumer: Awaited<ReturnType<typeof createConsumer>>; 30 let producer: Awaited<ReturnType<typeof createProducer>>; 31 let keypair: Awaited<ReturnType<typeof consumer.generateKeyPair>>; 32 - let itemUri: string; 33 let testMessage: string; 34 35 await t.step("Create consumer", async () => { ··· 47 48 expect(keypair.privateKey).toBeInstanceOf(Uint8Array); 49 expect(keypair.publicKey).toBeDefined(); 50 - expect(keypair.publicKey).toContain("app.cistern.lexicon.pubkey"); 51 }); 52 53 try { ··· 63 expect(producer.publicKey?.uri).toEqual(keypair.publicKey); 64 }); 65 66 - await t.step("Create encrypted item", async () => { 67 testMessage = `E2E Test - ${new Date().toISOString()}`; 68 - itemUri = await producer.createItem(testMessage); 69 70 - expect(itemUri).toBeDefined(); 71 - expect(itemUri).toContain("app.cistern.lexicon.item"); 72 }); 73 74 - await t.step("List and decrypt items", async () => { 75 - const items = []; 76 - for await (const item of consumer.listItems()) { 77 - items.push(item); 78 } 79 80 - expect(items.length).toBeGreaterThan(0); 81 82 - const ourItem = items.find((item) => item.text === testMessage); 83 - expect(ourItem).toBeDefined(); 84 - expect(ourItem!.text).toEqual(testMessage); 85 }); 86 87 - await t.step("Delete item", async () => { 88 - const itemRkey = itemUri.split("/").pop()!; 89 - await consumer.deleteItem(itemRkey); 90 91 // Verify deletion 92 - const itemsAfterDelete = []; 93 - for await (const item of consumer.listItems()) { 94 - itemsAfterDelete.push(item); 95 } 96 97 - const deletedItem = itemsAfterDelete.find( 98 - (item) => item.text === testMessage, 99 ); 100 - expect(deletedItem).toBeUndefined(); 101 }); 102 103 await t.step("List public keys", async () => { ··· 118 119 const res = await consumer.rpc.post("com.atproto.repo.deleteRecord", { 120 input: { 121 - collection: "app.cistern.lexicon.pubkey", 122 repo: consumer.did, 123 rkey: publicKeyRkey, 124 }, ··· 131 }); 132 133 Deno.test({ 134 - name: "E2E: Multiple items with same keypair", 135 ignore: SKIP_E2E, 136 async fn(t) { 137 const handle = Deno.env.get("CISTERN_HANDLE") as Handle; ··· 141 let producer: Awaited<ReturnType<typeof createProducer>>; 142 let keypair: Awaited<ReturnType<typeof consumer.generateKeyPair>>; 143 let messages: string[]; 144 - let itemUris: string[]; 145 146 await t.step("Create consumer and generate keypair", async () => { 147 consumer = await createConsumer({ ··· 167 expect(producer.publicKey?.uri).toEqual(keypair.publicKey); 168 }); 169 170 - await t.step("Create multiple encrypted items", async () => { 171 messages = [ 172 - `E2E Item 1 - ${new Date().toISOString()}`, 173 - `E2E Item 2 - ${new Date().toISOString()}`, 174 - `E2E Item 3 - ${new Date().toISOString()}`, 175 ]; 176 177 - itemUris = []; 178 for (const message of messages) { 179 - const uri = await producer.createItem(message); 180 - itemUris.push(uri); 181 } 182 183 - expect(itemUris).toHaveLength(3); 184 }); 185 186 - await t.step("Decrypt all items", async () => { 187 - const items = []; 188 - for await (const item of consumer.listItems()) { 189 - items.push(item); 190 } 191 192 - expect(items.length).toBeGreaterThanOrEqual(3); 193 194 // Verify all test messages are present 195 for (const message of messages) { 196 - const item = items.find((i) => i.text === message); 197 - expect(item).toBeDefined(); 198 - expect(item!.text).toEqual(message); 199 } 200 }); 201 202 - await t.step("Cleanup: Delete test items", async () => { 203 - for (const uri of itemUris) { 204 const rkey = uri.split("/").pop()!; 205 - await consumer.deleteItem(rkey); 206 } 207 208 - // Verify all items deleted 209 - const remainingItems = []; 210 - for await (const item of consumer.listItems()) { 211 - remainingItems.push(item); 212 } 213 214 for (const message of messages) { 215 - const item = remainingItems.find((i) => i.text === message); 216 - expect(item).toBeUndefined(); 217 } 218 }); 219 } finally { ··· 222 223 const res = await consumer.rpc.post("com.atproto.repo.deleteRecord", { 224 input: { 225 - collection: "app.cistern.lexicon.pubkey", 226 repo: consumer.did, 227 rkey: publicKeyRkey, 228 },
··· 29 let consumer: Awaited<ReturnType<typeof createConsumer>>; 30 let producer: Awaited<ReturnType<typeof createProducer>>; 31 let keypair: Awaited<ReturnType<typeof consumer.generateKeyPair>>; 32 + let memoUri: string; 33 let testMessage: string; 34 35 await t.step("Create consumer", async () => { ··· 47 48 expect(keypair.privateKey).toBeInstanceOf(Uint8Array); 49 expect(keypair.publicKey).toBeDefined(); 50 + expect(keypair.publicKey).toContain("app.cistern.pubkey"); 51 }); 52 53 try { ··· 63 expect(producer.publicKey?.uri).toEqual(keypair.publicKey); 64 }); 65 66 + await t.step("Create encrypted memo", async () => { 67 testMessage = `E2E Test - ${new Date().toISOString()}`; 68 + memoUri = await producer.createMemo(testMessage); 69 70 + expect(memoUri).toBeDefined(); 71 + expect(memoUri).toContain("app.cistern.memo"); 72 }); 73 74 + await t.step("List and decrypt memos", async () => { 75 + const memos = []; 76 + for await (const memo of consumer.listMemos()) { 77 + memos.push(memo); 78 } 79 80 + expect(memos.length).toBeGreaterThan(0); 81 82 + const ourMemo = memos.find((memo) => memo.text === testMessage); 83 + expect(ourMemo).toBeDefined(); 84 + expect(ourMemo!.text).toEqual(testMessage); 85 }); 86 87 + await t.step("Delete memo", async () => { 88 + const memoRkey = memoUri.split("/").pop()!; 89 + await consumer.deleteMemo(memoRkey); 90 91 // Verify deletion 92 + const memosAfterDelete = []; 93 + for await (const memo of consumer.listMemos()) { 94 + memosAfterDelete.push(memo); 95 } 96 97 + const deletedMemo = memosAfterDelete.find( 98 + (memo) => memo.text === testMessage, 99 ); 100 + expect(deletedMemo).toBeUndefined(); 101 }); 102 103 await t.step("List public keys", async () => { ··· 118 119 const res = await consumer.rpc.post("com.atproto.repo.deleteRecord", { 120 input: { 121 + collection: "app.cistern.pubkey", 122 repo: consumer.did, 123 rkey: publicKeyRkey, 124 }, ··· 131 }); 132 133 Deno.test({ 134 + name: "E2E: Multiple memos with same keypair", 135 ignore: SKIP_E2E, 136 async fn(t) { 137 const handle = Deno.env.get("CISTERN_HANDLE") as Handle; ··· 141 let producer: Awaited<ReturnType<typeof createProducer>>; 142 let keypair: Awaited<ReturnType<typeof consumer.generateKeyPair>>; 143 let messages: string[]; 144 + let memoUris: string[]; 145 146 await t.step("Create consumer and generate keypair", async () => { 147 consumer = await createConsumer({ ··· 167 expect(producer.publicKey?.uri).toEqual(keypair.publicKey); 168 }); 169 170 + await t.step("Create multiple encrypted memos", async () => { 171 messages = [ 172 + `E2E Memo 1 - ${new Date().toISOString()}`, 173 + `E2E Memo 2 - ${new Date().toISOString()}`, 174 + `E2E Memo 3 - ${new Date().toISOString()}`, 175 ]; 176 177 + memoUris = []; 178 for (const message of messages) { 179 + const uri = await producer.createMemo(message); 180 + memoUris.push(uri); 181 } 182 183 + expect(memoUris).toHaveLength(3); 184 }); 185 186 + await t.step("Decrypt all memos", async () => { 187 + const memos = []; 188 + for await (const memo of consumer.listMemos()) { 189 + memos.push(memo); 190 } 191 192 + expect(memos.length).toBeGreaterThanOrEqual(3); 193 194 // Verify all test messages are present 195 for (const message of messages) { 196 + const memo = memos.find((m) => m.text === message); 197 + expect(memo).toBeDefined(); 198 + expect(memo!.text).toEqual(message); 199 } 200 }); 201 202 + await t.step("Cleanup: Delete test memos", async () => { 203 + for (const uri of memoUris) { 204 const rkey = uri.split("/").pop()!; 205 + await consumer.deleteMemo(rkey); 206 } 207 208 + // Verify all memos deleted 209 + const remainingMemos = []; 210 + for await (const memo of consumer.listMemos()) { 211 + remainingMemos.push(memo); 212 } 213 214 for (const message of messages) { 215 + const memo = remainingMemos.find((m) => m.text === message); 216 + expect(memo).toBeUndefined(); 217 } 218 }); 219 } finally { ··· 222 223 const res = await consumer.rpc.post("com.atproto.repo.deleteRecord", { 224 input: { 225 + collection: "app.cistern.pubkey", 226 repo: consumer.did, 227 rkey: publicKeyRkey, 228 },
+10 -10
packages/consumer/README.md
··· 1 # @cistern/consumer 2 3 - Consumer client for retrieving, decrypting, and deleting Cistern items. 4 5 ## Usage 6 ··· 29 handle: "user.bsky.social", 30 appPassword: "xxxx-xxxx-xxxx-xxxx", 31 keypair: { 32 - publicKey: "at://did:plc:abc123/app.cistern.lexicon.pubkey/3jzfcijpj2z", 33 privateKey: "base64-encoded-private-key", 34 }, 35 }); 36 ``` 37 38 - ### List Items (Polling) 39 40 ```typescript 41 - for await (const item of consumer.listItems()) { 42 - console.log(`[${item.tid}] ${item.text}`); 43 - await consumer.deleteItem(item.tid); 44 } 45 ``` 46 47 - ### Subscribe to Items (Real-time) 48 49 ```typescript 50 - for await (const item of consumer.subscribeToItems()) { 51 - console.log(`[${item.tid}] ${item.text}`); 52 - await consumer.deleteItem(item.tid); 53 } 54 ```
··· 1 # @cistern/consumer 2 3 + Consumer client for retrieving, decrypting, and deleting Cistern memos. 4 5 ## Usage 6 ··· 29 handle: "user.bsky.social", 30 appPassword: "xxxx-xxxx-xxxx-xxxx", 31 keypair: { 32 + publicKey: "at://did:plc:abc123/app.cistern.pubkey/3jzfcijpj2z", 33 privateKey: "base64-encoded-private-key", 34 }, 35 }); 36 ``` 37 38 + ### List Memos (Polling) 39 40 ```typescript 41 + for await (const memo of consumer.listMemos()) { 42 + console.log(`[${memo.tid}] ${memo.text}`); 43 + await consumer.deleteMemo(memo.tid); 44 } 45 ``` 46 47 + ### Subscribe to Memos (Real-time) 48 49 ```typescript 50 + for await (const memo of consumer.subscribeToMemos()) { 51 + console.log(`[${memo.tid}] ${memo.text}`); 52 + await consumer.deleteMemo(memo.tid); 53 } 54 ```
+64 -69
packages/consumer/mod.test.ts
··· 5 import type { Client, CredentialManager } from "@atcute/client"; 6 import type { Did, Handle, ResourceUri } from "@atcute/lexicons"; 7 import { now } from "@atcute/tid"; 8 - import type { AppCisternLexiconItem } from "@cistern/lexicon"; 9 10 // Helper to create a mock Consumer instance 11 function createMockConsumer( ··· 59 fn() { 60 const mockKeypair = { 61 privateKey: new Uint8Array(32).toBase64(), 62 - publicKey: 63 - "at://did:plc:test/app.cistern.lexicon.pubkey/abc123" as ResourceUri, 64 }; 65 66 const consumer = createMockConsumer({ ··· 96 return Promise.resolve({ 97 ok: true, 98 data: { 99 - uri: "at://did:plc:test/app.cistern.lexicon.pubkey/generated123", 100 }, 101 }); 102 } ··· 110 expect(keypair).toBeDefined(); 111 expect(keypair.privateKey).toBeInstanceOf(Uint8Array); 112 expect(keypair.publicKey).toEqual( 113 - "at://did:plc:test/app.cistern.lexicon.pubkey/generated123", 114 ); 115 expect(consumer.keypair).toEqual(keypair); 116 117 - expect(capturedCollection).toEqual("app.cistern.lexicon.pubkey"); 118 expect(capturedRecord).toMatchObject({ 119 - $type: "app.cistern.lexicon.pubkey", 120 algorithm: "x_wing", 121 }); 122 }, ··· 132 keypair: { 133 privateKey: new Uint8Array(32).toBase64(), 134 publicKey: 135 - "at://did:plc:test/app.cistern.lexicon.pubkey/existing" as ResourceUri, 136 }, 137 }, 138 }); ··· 164 }); 165 166 Deno.test({ 167 - name: "listItems throws when no keypair is set", 168 async fn() { 169 const consumer = createMockConsumer(); 170 171 - const iterator = consumer.listItems(); 172 await expect(iterator.next()).rejects.toThrow( 173 - "no key pair set; generate a key before listing items", 174 ); 175 }, 176 }); 177 178 Deno.test({ 179 - name: "listItems decrypts and yields items", 180 async fn() { 181 const keys = generateKeys(); 182 - const testText = "Test item content"; 183 const encrypted = encryptText(keys.publicKey, testText); 184 const testTid = now(); 185 ··· 191 data: { 192 records: [ 193 { 194 - uri: "at://did:plc:test/app.cistern.lexicon.item/item1", 195 value: { 196 - $type: "app.cistern.lexicon.item", 197 tid: testTid, 198 ciphertext: { $bytes: encrypted.cipherText }, 199 nonce: { $bytes: encrypted.nonce }, 200 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 201 - pubkey: "at://did:plc:test/app.cistern.lexicon.pubkey/key1", 202 payload: { $bytes: encrypted.content }, 203 contentLength: encrypted.length, 204 contentHash: { $bytes: encrypted.hash }, 205 - } as AppCisternLexiconItem.Main, 206 }, 207 ], 208 cursor: undefined, ··· 220 appPassword: "test-password", 221 keypair: { 222 privateKey: keys.secretKey.toBase64(), 223 - publicKey: 224 - "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, 225 }, 226 }, 227 }); 228 229 - const items = []; 230 - for await (const item of consumer.listItems()) { 231 - items.push(item); 232 } 233 234 - expect(items).toHaveLength(1); 235 - expect(items[0].text).toEqual(testText); 236 - expect(items[0].tid).toEqual(testTid); 237 }, 238 }); 239 240 Deno.test({ 241 - name: "listItems skips items with mismatched public key", 242 async fn() { 243 const keys = generateKeys(); 244 - const testText = "Test item content"; 245 const encrypted = encryptText(keys.publicKey, testText); 246 const testTid = now(); 247 ··· 253 data: { 254 records: [ 255 { 256 - uri: "at://did:plc:test/app.cistern.lexicon.item/item1", 257 value: { 258 - $type: "app.cistern.lexicon.item", 259 tid: testTid, 260 ciphertext: { $bytes: encrypted.cipherText }, 261 nonce: { $bytes: encrypted.nonce }, 262 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 263 pubkey: 264 - "at://did:plc:test/app.cistern.lexicon.pubkey/different-key", 265 payload: { $bytes: encrypted.content }, 266 contentLength: encrypted.length, 267 contentHash: { $bytes: encrypted.hash }, 268 - } as AppCisternLexiconItem.Main, 269 }, 270 ], 271 cursor: undefined, ··· 284 keypair: { 285 privateKey: keys.secretKey.toBase64(), 286 publicKey: 287 - "at://did:plc:test/app.cistern.lexicon.pubkey/my-key" as ResourceUri, 288 }, 289 }, 290 }); 291 292 - const items = []; 293 - for await (const item of consumer.listItems()) { 294 - items.push(item); 295 } 296 297 - expect(items).toHaveLength(0); 298 }, 299 }); 300 301 Deno.test({ 302 - name: "listItems handles pagination", 303 async fn() { 304 const keys = generateKeys(); 305 - const text1 = "First item"; 306 - const text2 = "Second item"; 307 const encrypted1 = encryptText(keys.publicKey, text1); 308 const encrypted2 = encryptText(keys.publicKey, text2); 309 const tid1 = now(); ··· 321 data: { 322 records: [ 323 { 324 - uri: "at://did:plc:test/app.cistern.lexicon.item/item1", 325 value: { 326 - $type: "app.cistern.lexicon.item", 327 tid: tid1, 328 ciphertext: { $bytes: encrypted1.cipherText }, 329 nonce: { $bytes: encrypted1.nonce }, 330 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 331 - pubkey: 332 - "at://did:plc:test/app.cistern.lexicon.pubkey/key1", 333 payload: { $bytes: encrypted1.content }, 334 contentLength: encrypted1.length, 335 contentHash: { $bytes: encrypted1.hash }, 336 - } as AppCisternLexiconItem.Main, 337 }, 338 ], 339 cursor: "next-page", ··· 345 data: { 346 records: [ 347 { 348 - uri: "at://did:plc:test/app.cistern.lexicon.item/item2", 349 value: { 350 - $type: "app.cistern.lexicon.item", 351 tid: tid2, 352 ciphertext: { $bytes: encrypted2.cipherText }, 353 nonce: { $bytes: encrypted2.nonce }, 354 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 355 - pubkey: 356 - "at://did:plc:test/app.cistern.lexicon.pubkey/key1", 357 payload: { $bytes: encrypted2.content }, 358 contentLength: encrypted2.length, 359 contentHash: { $bytes: encrypted2.hash }, 360 - } as AppCisternLexiconItem.Main, 361 }, 362 ], 363 cursor: undefined, ··· 376 appPassword: "test-password", 377 keypair: { 378 privateKey: keys.secretKey.toBase64(), 379 - publicKey: 380 - "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, 381 }, 382 }, 383 }); 384 385 - const items = []; 386 - for await (const item of consumer.listItems()) { 387 - items.push(item); 388 } 389 390 - expect(items).toHaveLength(2); 391 - expect(items[0].text).toEqual(text1); 392 - expect(items[1].text).toEqual(text2); 393 expect(callCount).toEqual(2); 394 }, 395 }); 396 397 Deno.test({ 398 - name: "listItems throws when list request fails", 399 async fn() { 400 const mockRpc = { 401 get: () => ··· 413 appPassword: "test-password", 414 keypair: { 415 privateKey: new Uint8Array(32).toBase64(), 416 - publicKey: "at://did:plc:test/app.cistern.lexicon.pubkey/key1", 417 }, 418 }, 419 }); 420 421 - const iterator = consumer.listItems(); 422 - await expect(iterator.next()).rejects.toThrow("failed to list items"); 423 }, 424 }); 425 426 Deno.test({ 427 - name: "subscribeToItems throws when no keypair is set", 428 async fn() { 429 const consumer = createMockConsumer(); 430 431 - const iterator = consumer.subscribeToItems(); 432 await expect(iterator.next()).rejects.toThrow( 433 "no key pair set; generate a key before subscribing", 434 ); ··· 436 }); 437 438 Deno.test({ 439 - name: "deleteItem successfully deletes an item", 440 async fn() { 441 let deletedRkey: string | undefined; 442 ··· 457 458 const consumer = createMockConsumer({ rpc: mockRpc }); 459 460 - await consumer.deleteItem("item123"); 461 462 - expect(deletedRkey).toEqual("item123"); 463 }, 464 }); 465 466 Deno.test({ 467 - name: "deleteItem throws when delete request fails", 468 async fn() { 469 const mockRpc = { 470 post: () => ··· 477 478 const consumer = createMockConsumer({ rpc: mockRpc }); 479 480 - await expect(consumer.deleteItem("item123")).rejects.toThrow( 481 - "failed to delete item item123", 482 ); 483 }, 484 });
··· 5 import type { Client, CredentialManager } from "@atcute/client"; 6 import type { Did, Handle, ResourceUri } from "@atcute/lexicons"; 7 import { now } from "@atcute/tid"; 8 + import type { AppCisternMemo } from "@cistern/lexicon"; 9 10 // Helper to create a mock Consumer instance 11 function createMockConsumer( ··· 59 fn() { 60 const mockKeypair = { 61 privateKey: new Uint8Array(32).toBase64(), 62 + publicKey: "at://did:plc:test/app.cistern.pubkey/abc123" as ResourceUri, 63 }; 64 65 const consumer = createMockConsumer({ ··· 95 return Promise.resolve({ 96 ok: true, 97 data: { 98 + uri: "at://did:plc:test/app.cistern.pubkey/generated123", 99 }, 100 }); 101 } ··· 109 expect(keypair).toBeDefined(); 110 expect(keypair.privateKey).toBeInstanceOf(Uint8Array); 111 expect(keypair.publicKey).toEqual( 112 + "at://did:plc:test/app.cistern.pubkey/generated123", 113 ); 114 expect(consumer.keypair).toEqual(keypair); 115 116 + expect(capturedCollection).toEqual("app.cistern.pubkey"); 117 expect(capturedRecord).toMatchObject({ 118 + $type: "app.cistern.pubkey", 119 algorithm: "x_wing", 120 }); 121 }, ··· 131 keypair: { 132 privateKey: new Uint8Array(32).toBase64(), 133 publicKey: 134 + "at://did:plc:test/app.cistern.pubkey/existing" as ResourceUri, 135 }, 136 }, 137 }); ··· 163 }); 164 165 Deno.test({ 166 + name: "listMemos throws when no keypair is set", 167 async fn() { 168 const consumer = createMockConsumer(); 169 170 + const iterator = consumer.listMemos(); 171 await expect(iterator.next()).rejects.toThrow( 172 + "no key pair set; generate a key before listing memos", 173 ); 174 }, 175 }); 176 177 Deno.test({ 178 + name: "listMemos decrypts and yields memos", 179 async fn() { 180 const keys = generateKeys(); 181 + const testText = "Test memo content"; 182 const encrypted = encryptText(keys.publicKey, testText); 183 const testTid = now(); 184 ··· 190 data: { 191 records: [ 192 { 193 + uri: "at://did:plc:test/app.cistern.memo/memo1", 194 value: { 195 + $type: "app.cistern.memo", 196 tid: testTid, 197 ciphertext: { $bytes: encrypted.cipherText }, 198 nonce: { $bytes: encrypted.nonce }, 199 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 200 + pubkey: "at://did:plc:test/app.cistern.pubkey/key1", 201 payload: { $bytes: encrypted.content }, 202 contentLength: encrypted.length, 203 contentHash: { $bytes: encrypted.hash }, 204 + } as AppCisternMemo.Main, 205 }, 206 ], 207 cursor: undefined, ··· 219 appPassword: "test-password", 220 keypair: { 221 privateKey: keys.secretKey.toBase64(), 222 + publicKey: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, 223 }, 224 }, 225 }); 226 227 + const memos = []; 228 + for await (const memo of consumer.listMemos()) { 229 + memos.push(memo); 230 } 231 232 + expect(memos).toHaveLength(1); 233 + expect(memos[0].text).toEqual(testText); 234 + expect(memos[0].tid).toEqual(testTid); 235 }, 236 }); 237 238 Deno.test({ 239 + name: "listmemos skips memos with mismatched public key", 240 async fn() { 241 const keys = generateKeys(); 242 + const testText = "Test memo content"; 243 const encrypted = encryptText(keys.publicKey, testText); 244 const testTid = now(); 245 ··· 251 data: { 252 records: [ 253 { 254 + uri: "at://did:plc:test/app.cistern.memo/memo1", 255 value: { 256 + $type: "app.cistern.memo", 257 tid: testTid, 258 ciphertext: { $bytes: encrypted.cipherText }, 259 nonce: { $bytes: encrypted.nonce }, 260 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 261 pubkey: 262 + "at://did:plc:test/app.cistern.pubkey/different-key", 263 payload: { $bytes: encrypted.content }, 264 contentLength: encrypted.length, 265 contentHash: { $bytes: encrypted.hash }, 266 + } as AppCisternMemo.Main, 267 }, 268 ], 269 cursor: undefined, ··· 282 keypair: { 283 privateKey: keys.secretKey.toBase64(), 284 publicKey: 285 + "at://did:plc:test/app.cistern.pubkey/my-key" as ResourceUri, 286 }, 287 }, 288 }); 289 290 + const memos = []; 291 + for await (const memo of consumer.listMemos()) { 292 + memos.push(memo); 293 } 294 295 + expect(memos).toHaveLength(0); 296 }, 297 }); 298 299 Deno.test({ 300 + name: "listMemos handles pagination", 301 async fn() { 302 const keys = generateKeys(); 303 + const text1 = "First memo"; 304 + const text2 = "Second memo"; 305 const encrypted1 = encryptText(keys.publicKey, text1); 306 const encrypted2 = encryptText(keys.publicKey, text2); 307 const tid1 = now(); ··· 319 data: { 320 records: [ 321 { 322 + uri: "at://did:plc:test/app.cistern.memo/memo1", 323 value: { 324 + $type: "app.cistern.memo", 325 tid: tid1, 326 ciphertext: { $bytes: encrypted1.cipherText }, 327 nonce: { $bytes: encrypted1.nonce }, 328 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 329 + pubkey: "at://did:plc:test/app.cistern.pubkey/key1", 330 payload: { $bytes: encrypted1.content }, 331 contentLength: encrypted1.length, 332 contentHash: { $bytes: encrypted1.hash }, 333 + } as AppCisternMemo.Main, 334 }, 335 ], 336 cursor: "next-page", ··· 342 data: { 343 records: [ 344 { 345 + uri: "at://did:plc:test/app.cistern.memo/memo2", 346 value: { 347 + $type: "app.cistern.memo", 348 tid: tid2, 349 ciphertext: { $bytes: encrypted2.cipherText }, 350 nonce: { $bytes: encrypted2.nonce }, 351 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 352 + pubkey: "at://did:plc:test/app.cistern.pubkey/key1", 353 payload: { $bytes: encrypted2.content }, 354 contentLength: encrypted2.length, 355 contentHash: { $bytes: encrypted2.hash }, 356 + } as AppCisternMemo.Main, 357 }, 358 ], 359 cursor: undefined, ··· 372 appPassword: "test-password", 373 keypair: { 374 privateKey: keys.secretKey.toBase64(), 375 + publicKey: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, 376 }, 377 }, 378 }); 379 380 + const memos = []; 381 + for await (const memo of consumer.listMemos()) { 382 + memos.push(memo); 383 } 384 385 + expect(memos).toHaveLength(2); 386 + expect(memos[0].text).toEqual(text1); 387 + expect(memos[1].text).toEqual(text2); 388 expect(callCount).toEqual(2); 389 }, 390 }); 391 392 Deno.test({ 393 + name: "listMemos throws when list request fails", 394 async fn() { 395 const mockRpc = { 396 get: () => ··· 408 appPassword: "test-password", 409 keypair: { 410 privateKey: new Uint8Array(32).toBase64(), 411 + publicKey: "at://did:plc:test/app.cistern.pubkey/key1", 412 }, 413 }, 414 }); 415 416 + const iterator = consumer.listMemos(); 417 + await expect(iterator.next()).rejects.toThrow("failed to list memos"); 418 }, 419 }); 420 421 Deno.test({ 422 + name: "subscribeToMemos throws when no keypair is set", 423 async fn() { 424 const consumer = createMockConsumer(); 425 426 + const iterator = consumer.subscribeToMemos(); 427 await expect(iterator.next()).rejects.toThrow( 428 "no key pair set; generate a key before subscribing", 429 ); ··· 431 }); 432 433 Deno.test({ 434 + name: "deleteMemo successfully deletes a memo", 435 async fn() { 436 let deletedRkey: string | undefined; 437 ··· 452 453 const consumer = createMockConsumer({ rpc: mockRpc }); 454 455 + await consumer.deleteMemo("memo123"); 456 457 + expect(deletedRkey).toEqual("memo123"); 458 }, 459 }); 460 461 Deno.test({ 462 + name: "deleteMemo throws when delete request fails", 463 async fn() { 464 const mockRpc = { 465 post: () => ··· 472 473 const consumer = createMockConsumer({ rpc: mockRpc }); 474 475 + await expect(consumer.deleteMemo("memo123")).rejects.toThrow( 476 + "failed to delete memo memo123", 477 ); 478 }, 479 });
+29 -32
packages/consumer/mod.ts
··· 5 import { JetstreamSubscription } from "@atcute/jetstream"; 6 import type { Did } from "@atcute/lexicons/syntax"; 7 import type { Client, CredentialManager } from "@atcute/client"; 8 - import { 9 - AppCisternLexiconItem, 10 - type AppCisternLexiconPubkey, 11 - } from "@cistern/lexicon"; 12 import type { 13 ConsumerOptions, 14 ConsumerParams, 15 - DecryptedItem, 16 LocalKeyPair, 17 } from "./types.ts"; 18 ··· 27 } 28 29 /** 30 - * Client for generating keys and decoding Cistern items. 31 */ 32 export class Consumer { 33 did: Did; ··· 58 const keys = generateKeys(); 59 const name = await generateRandomName(); 60 61 - const record: AppCisternLexiconPubkey.Main = { 62 - $type: "app.cistern.lexicon.pubkey", 63 name, 64 algorithm: "x_wing", 65 content: { $bytes: keys.publicKey.toBase64() }, ··· 67 }; 68 const res = await this.rpc.post("com.atproto.repo.createRecord", { 69 input: { 70 - collection: "app.cistern.lexicon.pubkey", 71 repo: this.did, 72 record, 73 }, ··· 90 } 91 92 /** 93 - * Asynchronously iterate through items in the user's PDS 94 */ 95 - async *listItems(): AsyncGenerator< 96 - DecryptedItem, 97 void, 98 undefined 99 > { 100 if (!this.keypair) { 101 - throw new Error("no key pair set; generate a key before listing items"); 102 } 103 104 let cursor: string | undefined; ··· 106 while (true) { 107 const res = await this.rpc.get("com.atproto.repo.listRecords", { 108 params: { 109 - collection: "app.cistern.lexicon.item", 110 repo: this.did, 111 cursor, 112 }, ··· 114 115 if (!res.ok) { 116 throw new Error( 117 - `failed to list items: ${res.status} ${res.data.error}`, 118 ); 119 } 120 121 cursor = res.data.cursor; 122 123 for (const record of res.data.records) { 124 - const item = parse(AppCisternLexiconItem.mainSchema, record.value); 125 126 - if (item.pubkey !== this.keypair.publicKey) continue; 127 128 const decrypted = decryptText(this.keypair.privateKey, { 129 - nonce: item.nonce.$bytes, 130 - cipherText: item.ciphertext.$bytes, 131 - content: item.payload.$bytes, 132 - hash: item.contentHash.$bytes, 133 - length: item.contentLength, 134 }); 135 136 yield { 137 - tid: item.tid, 138 text: decrypted, 139 }; 140 } ··· 144 } 145 146 /** 147 - * Subscribes to the Jetstreams for the user's items. Pass `"stop"` into `subscription.next(...)` to cancel 148 * @todo Allow specifying Jetstream endpoint 149 */ 150 - async *subscribeToItems(): AsyncGenerator< 151 - DecryptedItem, 152 void, 153 "stop" | undefined 154 > { ··· 158 159 const subscription = new JetstreamSubscription({ 160 url: "wss://jetstream2.us-east.bsky.network", 161 - wantedCollections: ["app.cistern.lexicon.item"], 162 wantedDids: [this.did], 163 }); 164 ··· 166 if (event.kind === "commit" && event.commit.operation === "create") { 167 const record = event.commit.record; 168 169 - if (!is(AppCisternLexiconItem.mainSchema, record)) { 170 continue; 171 } 172 ··· 190 } 191 192 /** 193 - * Deletes an item from the user's PDS by record key. 194 */ 195 - async deleteItem(key: RecordKey) { 196 const res = await this.rpc.post("com.atproto.repo.deleteRecord", { 197 input: { 198 - collection: "app.cistern.lexicon.item", 199 repo: this.did, 200 rkey: key, 201 }, ··· 203 204 if (!res.ok) { 205 throw new Error( 206 - `failed to delete item ${key}: ${res.status} ${res.data.error}`, 207 ); 208 } 209 }
··· 5 import { JetstreamSubscription } from "@atcute/jetstream"; 6 import type { Did } from "@atcute/lexicons/syntax"; 7 import type { Client, CredentialManager } from "@atcute/client"; 8 + import { AppCisternMemo, type AppCisternPubkey } from "@cistern/lexicon"; 9 import type { 10 ConsumerOptions, 11 ConsumerParams, 12 + DecryptedMemo, 13 LocalKeyPair, 14 } from "./types.ts"; 15 ··· 24 } 25 26 /** 27 + * Client for generating keys and decoding Cistern memos. 28 */ 29 export class Consumer { 30 did: Did; ··· 55 const keys = generateKeys(); 56 const name = await generateRandomName(); 57 58 + const record: AppCisternPubkey.Main = { 59 + $type: "app.cistern.pubkey", 60 name, 61 algorithm: "x_wing", 62 content: { $bytes: keys.publicKey.toBase64() }, ··· 64 }; 65 const res = await this.rpc.post("com.atproto.repo.createRecord", { 66 input: { 67 + collection: "app.cistern.pubkey", 68 repo: this.did, 69 record, 70 }, ··· 87 } 88 89 /** 90 + * Asynchronously iterate through memos in the user's PDS 91 */ 92 + async *listMemos(): AsyncGenerator< 93 + DecryptedMemo, 94 void, 95 undefined 96 > { 97 if (!this.keypair) { 98 + throw new Error("no key pair set; generate a key before listing memos"); 99 } 100 101 let cursor: string | undefined; ··· 103 while (true) { 104 const res = await this.rpc.get("com.atproto.repo.listRecords", { 105 params: { 106 + collection: "app.cistern.memo", 107 repo: this.did, 108 cursor, 109 }, ··· 111 112 if (!res.ok) { 113 throw new Error( 114 + `failed to list memos: ${res.status} ${res.data.error}`, 115 ); 116 } 117 118 cursor = res.data.cursor; 119 120 for (const record of res.data.records) { 121 + const memo = parse(AppCisternMemo.mainSchema, record.value); 122 123 + if (memo.pubkey !== this.keypair.publicKey) continue; 124 125 const decrypted = decryptText(this.keypair.privateKey, { 126 + nonce: memo.nonce.$bytes, 127 + cipherText: memo.ciphertext.$bytes, 128 + content: memo.payload.$bytes, 129 + hash: memo.contentHash.$bytes, 130 + length: memo.contentLength, 131 }); 132 133 yield { 134 + tid: memo.tid, 135 text: decrypted, 136 }; 137 } ··· 141 } 142 143 /** 144 + * Subscribes to the Jetstreams for the user's memos. Pass `"stop"` into `subscription.next(...)` to cancel 145 * @todo Allow specifying Jetstream endpoint 146 */ 147 + async *subscribeToMemos(): AsyncGenerator< 148 + DecryptedMemo, 149 void, 150 "stop" | undefined 151 > { ··· 155 156 const subscription = new JetstreamSubscription({ 157 url: "wss://jetstream2.us-east.bsky.network", 158 + wantedCollections: ["app.cistern.memo"], 159 wantedDids: [this.did], 160 }); 161 ··· 163 if (event.kind === "commit" && event.commit.operation === "create") { 164 const record = event.commit.record; 165 166 + if (!is(AppCisternMemo.mainSchema, record)) { 167 continue; 168 } 169 ··· 187 } 188 189 /** 190 + * Deletes a memo from the user's PDS by record key. 191 */ 192 + async deleteMemo(key: RecordKey) { 193 const res = await this.rpc.post("com.atproto.repo.deleteRecord", { 194 input: { 195 + collection: "app.cistern.memo", 196 repo: this.did, 197 rkey: key, 198 }, ··· 200 201 if (!res.ok) { 202 throw new Error( 203 + `failed to delete memo ${key}: ${res.status} ${res.data.error}`, 204 ); 205 } 206 }
+1 -1
packages/consumer/types.ts
··· 17 18 export type ConsumerParams = ClientRequirements<ConsumerOptions>; 19 20 - export interface DecryptedItem { 21 tid: Tid; 22 text: string; 23 }
··· 17 18 export type ConsumerParams = ClientRequirements<ConsumerOptions>; 19 20 + export interface DecryptedMemo { 21 tid: Tid; 22 text: string; 23 }
+4 -4
packages/lexicon/README.md
··· 4 5 ## Record Types 6 7 - | Collection | Description | 8 - |------------|-------------| 9 - | `app.cistern.lexicon.pubkey` | Public key records with human-readable names, referenced by items via AT-URI | 10 - | `app.cistern.lexicon.item` | Encrypted item records containing ciphertext, nonce, algorithm metadata, and public key reference |
··· 4 5 ## Record Types 6 7 + | Collection | Description | 8 + | -------------------- | ------------------------------------------------------------------------------------------------- | 9 + | `app.cistern.pubkey` | Public key records with human-readable names, referenced by memos via AT-URI | 10 + | `app.cistern.memo` | Encrypted memo records containing ciphertext, nonce, algorithm metadata, and public key reference |
+4 -4
packages/lexicon/lexicons/app/cistern/lexicon/item.json packages/lexicon/lexicons/app/cistern/memo.json
··· 1 { 2 "lexicon": 1, 3 - "id": "app.cistern.lexicon.item", 4 "description": "An encrypted memo intended to be accessed and deleted later.", 5 "defs": { 6 "main": { ··· 21 "properties": { 22 "tid": { 23 "type": "string", 24 - "description": "TID representing when this item was created", 25 "format": "tid" 26 }, 27 "ciphertext": { ··· 39 }, 40 "pubkey": { 41 "type": "string", 42 - "description": "URI to the public key used to encrypt this item", 43 "format": "at-uri" 44 }, 45 "payload": { 46 "type": "bytes", 47 - "description": "Encrypted item contents" 48 }, 49 "contentLength": { 50 "type": "integer",
··· 1 { 2 "lexicon": 1, 3 + "id": "app.cistern.memo", 4 "description": "An encrypted memo intended to be accessed and deleted later.", 5 "defs": { 6 "main": { ··· 21 "properties": { 22 "tid": { 23 "type": "string", 24 + "description": "TID representing when this memo was created", 25 "format": "tid" 26 }, 27 "ciphertext": { ··· 39 }, 40 "pubkey": { 41 "type": "string", 42 + "description": "URI to the public key used to encrypt this memo", 43 "format": "at-uri" 44 }, 45 "payload": { 46 "type": "bytes", 47 + "description": "Encrypted memo contents" 48 }, 49 "contentLength": { 50 "type": "integer",
+3 -3
packages/lexicon/lexicons/app/cistern/lexicon/pubkey.json packages/lexicon/lexicons/app/cistern/pubkey.json
··· 1 { 2 "lexicon": 1, 3 - "id": "app.cistern.lexicon.pubkey", 4 - "description": "A public key used to encrypt Cistern items", 5 "defs": { 6 "main": { 7 "type": "record", 8 - "description": "A public key used to encrypt Cistern items", 9 "record": { 10 "type": "object", 11 "required": ["name", "algorithm", "content", "createdAt"],
··· 1 { 2 "lexicon": 1, 3 + "id": "app.cistern.pubkey", 4 + "description": "A public key used to encrypt Cistern memos", 5 "defs": { 6 "main": { 7 "type": "record", 8 + "description": "A public key used to encrypt Cistern memos", 9 "record": { 10 "type": "object", 11 "required": ["name", "algorithm", "content", "createdAt"],
+2 -2
packages/lexicon/src/index.ts
··· 1 - export * as AppCisternLexiconItem from "./types/app/cistern/lexicon/item.ts"; 2 - export * as AppCisternLexiconPubkey from "./types/app/cistern/lexicon/pubkey.ts";
··· 1 + export * as AppCisternMemo from "./types/app/cistern/memo.ts"; 2 + export * as AppCisternPubkey from "./types/app/cistern/pubkey.ts";
+5 -5
packages/lexicon/src/types/app/cistern/lexicon/item.ts packages/lexicon/src/types/app/cistern/memo.ts
··· 5 const _mainSchema = /*#__PURE__*/ v.record( 6 /*#__PURE__*/ v.string(), 7 /*#__PURE__*/ v.object({ 8 - $type: /*#__PURE__*/ v.literal("app.cistern.lexicon.item"), 9 /** 10 * Algorithm used for encryption, in <kem>-<cipher>-<hash> format. 11 */ ··· 29 */ 30 nonce: /*#__PURE__*/ v.bytes(), 31 /** 32 - * Encrypted item contents 33 */ 34 payload: /*#__PURE__*/ v.bytes(), 35 /** 36 - * URI to the public key used to encrypt this item 37 */ 38 pubkey: /*#__PURE__*/ v.resourceUriString(), 39 /** 40 - * TID representing when this item was created 41 */ 42 tid: /*#__PURE__*/ v.tidString(), 43 }), ··· 53 54 declare module "@atcute/lexicons/ambient" { 55 interface Records { 56 - "app.cistern.lexicon.item": mainSchema; 57 } 58 }
··· 5 const _mainSchema = /*#__PURE__*/ v.record( 6 /*#__PURE__*/ v.string(), 7 /*#__PURE__*/ v.object({ 8 + $type: /*#__PURE__*/ v.literal("app.cistern.memo"), 9 /** 10 * Algorithm used for encryption, in <kem>-<cipher>-<hash> format. 11 */ ··· 29 */ 30 nonce: /*#__PURE__*/ v.bytes(), 31 /** 32 + * Encrypted memo contents 33 */ 34 payload: /*#__PURE__*/ v.bytes(), 35 /** 36 + * URI to the public key used to encrypt this memo 37 */ 38 pubkey: /*#__PURE__*/ v.resourceUriString(), 39 /** 40 + * TID representing when this memo was created 41 */ 42 tid: /*#__PURE__*/ v.tidString(), 43 }), ··· 53 54 declare module "@atcute/lexicons/ambient" { 55 interface Records { 56 + "app.cistern.memo": mainSchema; 57 } 58 }
+2 -2
packages/lexicon/src/types/app/cistern/lexicon/pubkey.ts packages/lexicon/src/types/app/cistern/pubkey.ts
··· 5 const _mainSchema = /*#__PURE__*/ v.record( 6 /*#__PURE__*/ v.string(), 7 /*#__PURE__*/ v.object({ 8 - $type: /*#__PURE__*/ v.literal("app.cistern.lexicon.pubkey"), 9 /** 10 * KEM algorithm used to generate this key 11 */ ··· 35 36 declare module "@atcute/lexicons/ambient" { 37 interface Records { 38 - "app.cistern.lexicon.pubkey": mainSchema; 39 } 40 }
··· 5 const _mainSchema = /*#__PURE__*/ v.record( 6 /*#__PURE__*/ v.string(), 7 /*#__PURE__*/ v.object({ 8 + $type: /*#__PURE__*/ v.literal("app.cistern.pubkey"), 9 /** 10 * KEM algorithm used to generate this key 11 */ ··· 35 36 declare module "@atcute/lexicons/ambient" { 37 interface Records { 38 + "app.cistern.pubkey": mainSchema; 39 } 40 }
+3 -3
packages/producer/README.md
··· 1 # @cistern/producer 2 3 - Producer client for creating and encrypting Cistern items. 4 5 ## Usage 6 ··· 18 19 producer.selectPublicKey(pubkey); 20 21 - const itemUri = await producer.createItem("Hello, world!"); 22 ``` 23 24 Or, if you already have a public key record ID: ··· 30 publicKey: "3jzfcijpj2z", 31 }); 32 33 - const itemUri = await producer.createItem("Hello, world!"); 34 ```
··· 1 # @cistern/producer 2 3 + Producer client for creating and encrypting Cistern memos. 4 5 ## Usage 6 ··· 18 19 producer.selectPublicKey(pubkey); 20 21 + const memoUri = await producer.createMemo("Hello, world!"); 22 ``` 23 24 Or, if you already have a public key record ID: ··· 30 publicKey: "3jzfcijpj2z", 31 }); 32 33 + const memoUri = await producer.createMemo("Hello, world!"); 34 ```
+31 -32
packages/producer/mod.test.ts
··· 4 import type { ProducerParams, PublicKeyOption } from "./types.ts"; 5 import type { Client, CredentialManager } from "@atcute/client"; 6 import type { Did, Handle, ResourceUri } from "@atcute/lexicons"; 7 - import type { AppCisternLexiconPubkey } from "@cistern/lexicon"; 8 9 // Helper to create a mock Producer instance 10 function createMockProducer( ··· 57 name: "Producer constructor initializes with existing public key", 58 fn() { 59 const mockPublicKey: PublicKeyOption = { 60 - uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, 61 name: "Test Key", 62 content: new Uint8Array(32).toBase64(), 63 }; ··· 73 }); 74 75 Deno.test({ 76 - name: "createItem successfully creates and uploads an encrypted item", 77 async fn() { 78 const keys = generateKeys(); 79 let capturedRecord: unknown; ··· 92 return Promise.resolve({ 93 ok: true, 94 data: { 95 - uri: 96 - "at://did:plc:test/app.cistern.lexicon.item/item123" as ResourceUri, 97 }, 98 }); 99 } ··· 104 const producer = createMockProducer({ 105 rpc: mockRpc, 106 publicKey: { 107 - uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, 108 name: "Test Key", 109 content: keys.publicKey.toBase64(), 110 }, 111 }); 112 113 - const uri = await producer.createItem("Test message"); 114 115 - expect(uri).toEqual("at://did:plc:test/app.cistern.lexicon.item/item123"); 116 - expect(capturedCollection).toEqual("app.cistern.lexicon.item"); 117 expect(capturedRecord).toMatchObject({ 118 - $type: "app.cistern.lexicon.item", 119 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 120 }); 121 }, 122 }); 123 124 Deno.test({ 125 - name: "createItem throws when no public key is set", 126 async fn() { 127 const producer = createMockProducer(); 128 129 - await expect(producer.createItem("Test message")).rejects.toThrow( 130 - "no public key set; select a public key before creating an item", 131 ); 132 }, 133 }); 134 135 Deno.test({ 136 - name: "createItem throws when upload fails", 137 async fn() { 138 const keys = generateKeys(); 139 const mockRpc = { ··· 148 const producer = createMockProducer({ 149 rpc: mockRpc, 150 publicKey: { 151 - uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, 152 name: "Test Key", 153 content: keys.publicKey.toBase64(), 154 }, 155 }); 156 157 - await expect(producer.createItem("Test message")).rejects.toThrow( 158 - "failed to create new item", 159 ); 160 }, 161 }); ··· 171 data: { 172 records: [ 173 { 174 - uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key1", 175 value: { 176 - $type: "app.cistern.lexicon.pubkey", 177 name: "Key 1", 178 algorithm: "x_wing", 179 content: { $bytes: new Uint8Array(32).toBase64() }, 180 createdAt: new Date().toISOString(), 181 - } as AppCisternLexiconPubkey.Main, 182 }, 183 { 184 - uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key2", 185 value: { 186 - $type: "app.cistern.lexicon.pubkey", 187 name: "Key 2", 188 algorithm: "x_wing", 189 content: { $bytes: new Uint8Array(32).toBase64() }, 190 createdAt: new Date().toISOString(), 191 - } as AppCisternLexiconPubkey.Main, 192 }, 193 ], 194 cursor: undefined, ··· 227 data: { 228 records: [ 229 { 230 - uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key1", 231 value: { 232 - $type: "app.cistern.lexicon.pubkey", 233 name: "Key 1", 234 algorithm: "x_wing", 235 content: { $bytes: new Uint8Array(32).toBase64() }, 236 createdAt: new Date().toISOString(), 237 - } as AppCisternLexiconPubkey.Main, 238 }, 239 ], 240 cursor: "next-page", ··· 246 data: { 247 records: [ 248 { 249 - uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key2", 250 value: { 251 - $type: "app.cistern.lexicon.pubkey", 252 name: "Key 2", 253 algorithm: "x_wing", 254 content: { $bytes: new Uint8Array(32).toBase64() }, 255 createdAt: new Date().toISOString(), 256 - } as AppCisternLexiconPubkey.Main, 257 }, 258 ], 259 cursor: undefined, ··· 304 const producer = createMockProducer(); 305 306 const mockPublicKey: PublicKeyOption = { 307 - uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, 308 name: "Selected Key", 309 content: new Uint8Array(32).toBase64(), 310 }; ··· 324 fn() { 325 const producer = createMockProducer({ 326 publicKey: { 327 - uri: "at://did:plc:test/app.cistern.lexicon.pubkey/old" as ResourceUri, 328 name: "Old Key", 329 content: new Uint8Array(32).toBase64(), 330 }, ··· 333 expect(producer.publicKey?.name).toEqual("Old Key"); 334 335 const newKey: PublicKeyOption = { 336 - uri: "at://did:plc:test/app.cistern.lexicon.pubkey/new" as ResourceUri, 337 name: "New Key", 338 content: new Uint8Array(32).toBase64(), 339 };
··· 4 import type { ProducerParams, PublicKeyOption } from "./types.ts"; 5 import type { Client, CredentialManager } from "@atcute/client"; 6 import type { Did, Handle, ResourceUri } from "@atcute/lexicons"; 7 + import type { AppCisternPubkey } from "@cistern/lexicon"; 8 9 // Helper to create a mock Producer instance 10 function createMockProducer( ··· 57 name: "Producer constructor initializes with existing public key", 58 fn() { 59 const mockPublicKey: PublicKeyOption = { 60 + uri: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, 61 name: "Test Key", 62 content: new Uint8Array(32).toBase64(), 63 }; ··· 73 }); 74 75 Deno.test({ 76 + name: "createMemo successfully creates and uploads an encrypted memo", 77 async fn() { 78 const keys = generateKeys(); 79 let capturedRecord: unknown; ··· 92 return Promise.resolve({ 93 ok: true, 94 data: { 95 + uri: "at://did:plc:test/app.cistern.memo/memo123" as ResourceUri, 96 }, 97 }); 98 } ··· 103 const producer = createMockProducer({ 104 rpc: mockRpc, 105 publicKey: { 106 + uri: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, 107 name: "Test Key", 108 content: keys.publicKey.toBase64(), 109 }, 110 }); 111 112 + const uri = await producer.createMemo("Test message"); 113 114 + expect(uri).toEqual("at://did:plc:test/app.cistern.memo/memo123"); 115 + expect(capturedCollection).toEqual("app.cistern.memo"); 116 expect(capturedRecord).toMatchObject({ 117 + $type: "app.cistern.memo", 118 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 119 }); 120 }, 121 }); 122 123 Deno.test({ 124 + name: "createMemo throws when no public key is set", 125 async fn() { 126 const producer = createMockProducer(); 127 128 + await expect(producer.createMemo("Test message")).rejects.toThrow( 129 + "no public key set; select a public key before creating a memo", 130 ); 131 }, 132 }); 133 134 Deno.test({ 135 + name: "createMemo throws when upload fails", 136 async fn() { 137 const keys = generateKeys(); 138 const mockRpc = { ··· 147 const producer = createMockProducer({ 148 rpc: mockRpc, 149 publicKey: { 150 + uri: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, 151 name: "Test Key", 152 content: keys.publicKey.toBase64(), 153 }, 154 }); 155 156 + await expect(producer.createMemo("Test message")).rejects.toThrow( 157 + "failed to create new memo", 158 ); 159 }, 160 }); ··· 170 data: { 171 records: [ 172 { 173 + uri: "at://did:plc:test/app.cistern.pubkey/key1", 174 value: { 175 + $type: "app.cistern.pubkey", 176 name: "Key 1", 177 algorithm: "x_wing", 178 content: { $bytes: new Uint8Array(32).toBase64() }, 179 createdAt: new Date().toISOString(), 180 + } as AppCisternPubkey.Main, 181 }, 182 { 183 + uri: "at://did:plc:test/app.cistern.pubkey/key2", 184 value: { 185 + $type: "app.cistern.pubkey", 186 name: "Key 2", 187 algorithm: "x_wing", 188 content: { $bytes: new Uint8Array(32).toBase64() }, 189 createdAt: new Date().toISOString(), 190 + } as AppCisternPubkey.Main, 191 }, 192 ], 193 cursor: undefined, ··· 226 data: { 227 records: [ 228 { 229 + uri: "at://did:plc:test/app.cistern.pubkey/key1", 230 value: { 231 + $type: "app.cistern.pubkey", 232 name: "Key 1", 233 algorithm: "x_wing", 234 content: { $bytes: new Uint8Array(32).toBase64() }, 235 createdAt: new Date().toISOString(), 236 + } as AppCisternPubkey.Main, 237 }, 238 ], 239 cursor: "next-page", ··· 245 data: { 246 records: [ 247 { 248 + uri: "at://did:plc:test/app.cistern.pubkey/key2", 249 value: { 250 + $type: "app.cistern.pubkey", 251 name: "Key 2", 252 algorithm: "x_wing", 253 content: { $bytes: new Uint8Array(32).toBase64() }, 254 createdAt: new Date().toISOString(), 255 + } as AppCisternPubkey.Main, 256 }, 257 ], 258 cursor: undefined, ··· 303 const producer = createMockProducer(); 304 305 const mockPublicKey: PublicKeyOption = { 306 + uri: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, 307 name: "Selected Key", 308 content: new Uint8Array(32).toBase64(), 309 }; ··· 323 fn() { 324 const producer = createMockProducer({ 325 publicKey: { 326 + uri: "at://did:plc:test/app.cistern.pubkey/old" as ResourceUri, 327 name: "Old Key", 328 content: new Uint8Array(32).toBase64(), 329 }, ··· 332 expect(producer.publicKey?.name).toEqual("Old Key"); 333 334 const newKey: PublicKeyOption = { 335 + uri: "at://did:plc:test/app.cistern.pubkey/new" as ResourceUri, 336 name: "New Key", 337 content: new Uint8Array(32).toBase64(), 338 };
+14 -17
packages/producer/mod.ts
··· 8 import { type Did, parse, type ResourceUri } from "@atcute/lexicons"; 9 import type { Client, CredentialManager } from "@atcute/client"; 10 import { now } from "@atcute/tid"; 11 - import { 12 - type AppCisternLexiconItem, 13 - AppCisternLexiconPubkey, 14 - } from "@cistern/lexicon"; 15 16 import type {} from "@atcute/atproto"; 17 ··· 26 params: { 27 repo: reqs.miniDoc.did, 28 rkey, 29 - collection: "app.cistern.lexicon.pubkey", 30 }, 31 }); 32 ··· 36 ); 37 } 38 39 - const record = parse(AppCisternLexiconPubkey.mainSchema, res.data.value); 40 41 publicKey = { 42 uri: res.data.uri, ··· 65 } 66 67 /** 68 - * Creates an item and saves it as a record in the user's PDS 69 */ 70 - async createItem(text: string): Promise<ResourceUri> { 71 if (!this.publicKey) { 72 throw new Error( 73 - "no public key set; select a public key before creating an item", 74 ); 75 } 76 ··· 78 Uint8Array.fromBase64(this.publicKey.content), 79 text, 80 ); 81 - const record: AppCisternLexiconItem.Main = { 82 - $type: "app.cistern.lexicon.item", 83 tid: now(), 84 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 85 ciphertext: { $bytes: payload.cipherText }, ··· 92 93 const res = await this.rpc.post("com.atproto.repo.createRecord", { 94 input: { 95 - collection: "app.cistern.lexicon.item", 96 repo: this.did, 97 record, 98 }, ··· 100 101 if (!res.ok) { 102 throw new Error( 103 - `failed to create new item: ${res.status} ${res.data.error}`, 104 ); 105 } 106 ··· 120 while (true) { 121 const res = await this.rpc.get("com.atproto.repo.listRecords", { 122 params: { 123 - collection: "app.cistern.lexicon.pubkey", 124 repo: this.did, 125 cursor, 126 }, ··· 135 cursor = res.data.cursor; 136 137 for (const record of res.data.records) { 138 - const item = parse(AppCisternLexiconPubkey.mainSchema, record.value); 139 140 yield { 141 uri: record.uri, 142 - content: item.content.$bytes, 143 - name: item.name, 144 }; 145 } 146
··· 8 import { type Did, parse, type ResourceUri } from "@atcute/lexicons"; 9 import type { Client, CredentialManager } from "@atcute/client"; 10 import { now } from "@atcute/tid"; 11 + import { type AppCisternMemo, AppCisternPubkey } from "@cistern/lexicon"; 12 13 import type {} from "@atcute/atproto"; 14 ··· 23 params: { 24 repo: reqs.miniDoc.did, 25 rkey, 26 + collection: "app.cistern.pubkey", 27 }, 28 }); 29 ··· 33 ); 34 } 35 36 + const record = parse(AppCisternPubkey.mainSchema, res.data.value); 37 38 publicKey = { 39 uri: res.data.uri, ··· 62 } 63 64 /** 65 + * Creates a memo and saves it as a record in the user's PDS 66 */ 67 + async createMemo(text: string): Promise<ResourceUri> { 68 if (!this.publicKey) { 69 throw new Error( 70 + "no public key set; select a public key before creating a memo", 71 ); 72 } 73 ··· 75 Uint8Array.fromBase64(this.publicKey.content), 76 text, 77 ); 78 + const record: AppCisternMemo.Main = { 79 + $type: "app.cistern.memo", 80 tid: now(), 81 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 82 ciphertext: { $bytes: payload.cipherText }, ··· 89 90 const res = await this.rpc.post("com.atproto.repo.createRecord", { 91 input: { 92 + collection: "app.cistern.memo", 93 repo: this.did, 94 record, 95 }, ··· 97 98 if (!res.ok) { 99 throw new Error( 100 + `failed to create new memo: ${res.status} ${res.data.error}`, 101 ); 102 } 103 ··· 117 while (true) { 118 const res = await this.rpc.get("com.atproto.repo.listRecords", { 119 params: { 120 + collection: "app.cistern.pubkey", 121 repo: this.did, 122 cursor, 123 }, ··· 132 cursor = res.data.cursor; 133 134 for (const record of res.data.records) { 135 + const memo = parse(AppCisternPubkey.mainSchema, record.value); 136 137 yield { 138 uri: record.uri, 139 + content: memo.content.$bytes, 140 + name: memo.name, 141 }; 142 } 143