+32
-8
README.md
+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
+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
+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
+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
+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
+1
-1
packages/consumer/types.ts
+4
-4
packages/lexicon/README.md
+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
+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
+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
+2
-2
packages/lexicon/src/index.ts
+5
-5
packages/lexicon/src/types/app/cistern/lexicon/item.ts
packages/lexicon/src/types/app/cistern/memo.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
+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
+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
+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
+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