Encrypted, ephemeral, private memos on atproto

Compare changes

Choose any two refs to compare.

+6
.letta/settings.json
··· 1 + { 2 + "localSharedBlockIds": { 3 + "project": "block-19ad1e80-37cd-4413-8f73-e1dddbb89119", 4 + "skills": "block-25b184a7-e851-42d4-bf5a-9422337e7803" 5 + } 6 + }
+3
.letta/settings.local.json
··· 1 + { 2 + "lastAgent": "agent-2470875d-24da-4bab-8670-293b3cbcf3cf" 3 + }
+1
.tangled/workflows/test.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 + branch: ["*"] 3 4 4 5 dependencies: 5 6 nixpkgs:
+98 -12
README.md
··· 1 1 # Cistern 2 2 3 - Cistern is an attempt at implementing a private, personal quick-capture system 4 - on AT Protocol. Cistern "items" are encrypted, so that they are only readable by 5 - the holder of the correct secret keyโ€”stored off-protocol. These items are 6 - intended to be ephemeral, and to be deleted after they've been read. 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 six 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 + ### `@cistern/mcp` 7 46 8 - The intention is for Cistern to bridge the gap between where ideas are had and 9 - where they can be stored long-term. For example, let's say you have an idea 10 - while at a restaurant. You create an item using your phone, and once you're back 11 - at your desk and you open Obsidian, that item is automatically pulled from your 12 - PDS, decrypted, and deleted from your PDS. If your notebook was open at the time 13 - you created your item, the Cistern Obsidian plugin would have been notified of 14 - the new item via the Jetstream, and so you would find your memo waiting for you 15 - once you got home. 47 + Model Context Protocol server that exposes Cistern as MCP tools for AI 48 + assistants. Supports stdio transport for local integrations (Claude Desktop) and 49 + HTTP transport for remote deployments. Automatically generates and persists 50 + keypairs in Deno KV. 51 + 52 + ## Security Model 53 + 54 + Private keys never leave the consumer device. Public keys are stored in the PDS 55 + as records, while private keys remain off-protocol. Only the holder of the 56 + matching private key can decrypt memos encrypted with the corresponding public 57 + key. 58 + 59 + ## Quick Start 60 + 61 + ### Using the MCP Server with Claude Desktop 62 + 63 + 1. Generate an [app password](https://bsky.app/settings/app-passwords) for your Bluesky account 64 + 2. Add to Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`): 65 + 66 + ```json 67 + { 68 + "mcpServers": { 69 + "cistern": { 70 + "command": "deno", 71 + "args": ["task", "--cwd", "/path/to/cistern/packages/mcp", "stdio"], 72 + "env": { 73 + "CISTERN_MCP_HANDLE": "yourname.bsky.social", 74 + "CISTERN_MCP_APP_PASSWORD": "xxxx-xxxx-xxxx-xxxx" 75 + } 76 + } 77 + } 78 + } 79 + ``` 80 + 81 + 3. Restart Claude Desktop 82 + 4. Create memos using the Cistern Producer from any device 83 + 5. Ask Claude to retrieve and process your memos 84 + 85 + See [`packages/mcp/README.md`](./packages/mcp/README.md) for detailed usage. 86 + 87 + ## Testing 88 + 89 + Run all unit tests: 90 + 91 + ```bash 92 + deno test --allow-env 93 + ``` 94 + 95 + Run end-to-end tests (requires AT Protocol credentials): 96 + 97 + ```bash 98 + CISTERN_HANDLE="your.bsky.social" \ 99 + CISTERN_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" \ 100 + deno test --allow-env --allow-net e2e.test.ts 101 + ```
+6 -1
deno.jsonc
··· 1 1 { 2 2 "workspace": [ 3 3 "packages/*" 4 - ] 4 + ], 5 + "unstable": ["kv"], 6 + "imports": { 7 + "@std/expect": "jsr:@std/expect@^1.0.17", 8 + "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2" 9 + } 5 10 }
+1555 -10
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 + "jsr:@hono/hono@^4.10.5": "4.10.5", 5 + "jsr:@logtape/logtape@^1.2.0": "1.2.0", 4 6 "jsr:@noble/ciphers@^2.0.1": "2.0.1", 5 7 "jsr:@noble/curves@2.0": "2.0.1", 6 8 "jsr:@noble/hashes@2": "2.0.1", 7 9 "jsr:@noble/hashes@2.0": "2.0.1", 8 10 "jsr:@noble/hashes@^2.0.1": "2.0.1", 9 11 "jsr:@noble/post-quantum@~0.5.2": "0.5.2", 10 - "jsr:@std/assert@^1.0.14": "1.0.14", 12 + "jsr:@puregarlic/randimal@^1.1.1": "1.1.1", 13 + "jsr:@std/assert@^1.0.14": "1.0.15", 14 + "jsr:@std/cli@^1.0.23": "1.0.23", 11 15 "jsr:@std/expect@^1.0.17": "1.0.17", 12 - "jsr:@std/internal@^1.0.10": "1.0.10", 16 + "jsr:@std/internal@^1.0.10": "1.0.12", 17 + "jsr:@std/internal@^1.0.12": "1.0.12", 13 18 "npm:@atcute/atproto@^3.1.9": "3.1.9", 14 19 "npm:@atcute/client@^4.0.5": "4.0.5", 20 + "npm:@atcute/jetstream@^1.1.2": "1.1.2", 21 + "npm:@atcute/lex-cli@*": "2.3.1", 15 22 "npm:@atcute/lex-cli@^2.3.1": "2.3.1", 16 23 "npm:@atcute/lexicons@^1.2.2": "1.2.2", 17 - "npm:@atproto/lexicon@~0.5.1": "0.5.1" 24 + "npm:@atcute/tid@^1.0.3": "1.0.3", 25 + "npm:@atproto/lexicon@~0.5.1": "0.5.1", 26 + "npm:@modelcontextprotocol/inspector@*": "0.15.0_@types+node@24.2.0", 27 + "npm:@modelcontextprotocol/sdk@^1.21.1": "1.21.1_ajv@8.17.1_express@5.1.0_zod@3.25.76", 28 + "npm:@types/node@*": "24.2.0", 29 + "npm:fetch-to-node@^2.1.0": "2.1.0", 30 + "npm:zod@^3.25.76": "3.25.76" 18 31 }, 19 32 "jsr": { 33 + "@hono/hono@4.10.5": { 34 + "integrity": "13dbf2a528feb8189ad13394b213f0cf5f83b0ba4b2fadd0549993426db9ad2d" 35 + }, 36 + "@logtape/logtape@1.2.0": { 37 + "integrity": "8e1d3af5c91966cc5689cfb17081a36bccfdff28ff6314769185661f5147e74d" 38 + }, 20 39 "@noble/ciphers@2.0.1": { 21 40 "integrity": "1d28df773a29684c85844d27eefbb7cad3e4ce62849b63dae3024baf66cf769f" 22 41 }, ··· 36 55 "jsr:@noble/hashes@2.0" 37 56 ] 38 57 }, 39 - "@std/assert@1.0.14": { 40 - "integrity": "68d0d4a43b365abc927f45a9b85c639ea18a9fab96ad92281e493e4ed84abaa4", 58 + "@puregarlic/randimal@1.1.1": { 59 + "integrity": "4e1fa61982cf2f610e9ad851d0fd0ff7bc3bb7b7a3c6cccae59f5ae2e68a7e47" 60 + }, 61 + "@std/assert@1.0.15": { 62 + "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b", 41 63 "dependencies": [ 42 - "jsr:@std/internal" 64 + "jsr:@std/internal@^1.0.12" 65 + ] 66 + }, 67 + "@std/cli@1.0.23": { 68 + "integrity": "bf95b7a9425ba2af1ae5a6359daf58c508f2decf711a76ed2993cd352498ccca", 69 + "dependencies": [ 70 + "jsr:@std/internal@^1.0.12" 43 71 ] 44 72 }, 45 73 "@std/expect@1.0.17": { 46 74 "integrity": "316b47dd65c33e3151344eb3267bf42efba17d1415425f07ed96185d67fc04d9", 47 75 "dependencies": [ 48 76 "jsr:@std/assert", 49 - "jsr:@std/internal" 77 + "jsr:@std/internal@^1.0.10" 50 78 ] 51 79 }, 52 80 "@std/internal@1.0.10": { 53 81 "integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7" 82 + }, 83 + "@std/internal@1.0.12": { 84 + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 54 85 } 55 86 }, 56 87 "npm": { ··· 74 105 "@badrap/valita" 75 106 ] 76 107 }, 108 + "@atcute/jetstream@1.1.2": { 109 + "integrity": "sha512-u6p/h2xppp7LE6W/9xErAJ6frfN60s8adZuCKtfAaaBBiiYbb1CfpzN8Uc+2qtJZNorqGvuuDb5572Jmh7yHBQ==", 110 + "dependencies": [ 111 + "@atcute/lexicons", 112 + "@badrap/valita", 113 + "@mary-ext/event-iterator", 114 + "@mary-ext/simple-event-emitter", 115 + "partysocket", 116 + "type-fest", 117 + "yocto-queue" 118 + ] 119 + }, 77 120 "@atcute/lex-cli@2.3.1": { 78 121 "integrity": "sha512-HrHD91CFSFd/p0UFe3akFA1HXiboQwd5LbYiU0srKdLxGX+NLTX/EdCdhbLV6M7LsXdmxk7PB6BMcprsX4rbvg==", 79 122 "dependencies": [ ··· 99 142 "esm-env" 100 143 ] 101 144 }, 145 + "@atcute/tid@1.0.3": { 146 + "integrity": "sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w==" 147 + }, 102 148 "@atproto/common-web@0.4.3": { 103 149 "integrity": "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==", 104 150 "dependencies": [ ··· 124 170 "@badrap/valita@0.4.6": { 125 171 "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==" 126 172 }, 173 + "@cspotcode/source-map-support@0.8.1": { 174 + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", 175 + "dependencies": [ 176 + "@jridgewell/trace-mapping" 177 + ] 178 + }, 179 + "@floating-ui/core@1.7.2": { 180 + "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", 181 + "dependencies": [ 182 + "@floating-ui/utils" 183 + ] 184 + }, 185 + "@floating-ui/dom@1.7.2": { 186 + "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", 187 + "dependencies": [ 188 + "@floating-ui/core", 189 + "@floating-ui/utils" 190 + ] 191 + }, 192 + "@floating-ui/react-dom@2.1.4_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 193 + "integrity": "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==", 194 + "dependencies": [ 195 + "@floating-ui/dom", 196 + "react", 197 + "react-dom" 198 + ] 199 + }, 200 + "@floating-ui/utils@0.2.10": { 201 + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" 202 + }, 203 + "@jridgewell/resolve-uri@3.1.2": { 204 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" 205 + }, 206 + "@jridgewell/sourcemap-codec@1.5.4": { 207 + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==" 208 + }, 209 + "@jridgewell/trace-mapping@0.3.9": { 210 + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", 211 + "dependencies": [ 212 + "@jridgewell/resolve-uri", 213 + "@jridgewell/sourcemap-codec" 214 + ] 215 + }, 216 + "@mary-ext/event-iterator@1.0.0": { 217 + "integrity": "sha512-l6gCPsWJ8aRCe/s7/oCmero70kDHgIK5m4uJvYgwEYTqVxoBOIXbKr5tnkLqUHEg6mNduB4IWvms3h70Hp9ADQ==", 218 + "dependencies": [ 219 + "yocto-queue" 220 + ] 221 + }, 222 + "@mary-ext/simple-event-emitter@1.0.0": { 223 + "integrity": "sha512-meA/zJZKIN1RVBNEYIbjufkUrW7/tRjHH60FjolpG1ixJKo76TB208qefQLNdOVDA7uIG0CGEDuhmMirtHKLAg==" 224 + }, 225 + "@modelcontextprotocol/inspector-cli@0.15.0": { 226 + "integrity": "sha512-mZxRqxYub6qFi3oypLI63yCm9TAxlTO8asE9FeAU4+HFlvKxQrujcfpckcWjqGKhZ0uVH1YUE+VwDx70nz+I5w==", 227 + "dependencies": [ 228 + "@modelcontextprotocol/sdk", 229 + "commander", 230 + "spawn-rx" 231 + ], 232 + "bin": true 233 + }, 234 + "@modelcontextprotocol/inspector-client@0.15.0_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 235 + "integrity": "sha512-zIKxvp5HX1yE+kPOhI42/TVNuM9/RYEizdVmlpov7H38Mg9DeN9DptHYrsVLy8ZEJD1XFAu/eLl+ZtS3ceANNg==", 236 + "dependencies": [ 237 + "@modelcontextprotocol/sdk", 238 + "@radix-ui/react-checkbox", 239 + "@radix-ui/react-dialog", 240 + "@radix-ui/react-icons", 241 + "@radix-ui/react-label", 242 + "@radix-ui/react-popover", 243 + "@radix-ui/react-select", 244 + "@radix-ui/react-slot", 245 + "@radix-ui/react-tabs", 246 + "@radix-ui/react-toast", 247 + "@radix-ui/react-tooltip", 248 + "ajv@6.12.6", 249 + "class-variance-authority", 250 + "clsx", 251 + "cmdk", 252 + "lucide-react", 253 + "pkce-challenge@4.1.0", 254 + "prismjs", 255 + "react", 256 + "react-dom", 257 + "react-simple-code-editor", 258 + "serve-handler", 259 + "tailwind-merge", 260 + "tailwindcss-animate", 261 + "zod" 262 + ], 263 + "bin": true 264 + }, 265 + "@modelcontextprotocol/inspector-server@0.15.0": { 266 + "integrity": "sha512-x1qtDEUeSHURtBH1/WN30NX7O/Imb3u2IoY+T2YCf4mGiB24eo4hEudiZmnuKSDGwDs4BAj2keiFeL3/EwkH9w==", 267 + "dependencies": [ 268 + "@modelcontextprotocol/sdk", 269 + "cors", 270 + "express", 271 + "ws", 272 + "zod" 273 + ], 274 + "bin": true 275 + }, 276 + "@modelcontextprotocol/inspector@0.15.0_@types+node@24.2.0": { 277 + "integrity": "sha512-PN1R7InR48Y6wU8s/vHWc0KOYAjlYQkgCpjUQsNFB078ebdv+empkMI6d1Gg+UIRx8mTrwtbBgv0A6ookGG+0w==", 278 + "dependencies": [ 279 + "@modelcontextprotocol/inspector-cli", 280 + "@modelcontextprotocol/inspector-client", 281 + "@modelcontextprotocol/inspector-server", 282 + "@modelcontextprotocol/sdk", 283 + "concurrently", 284 + "open", 285 + "shell-quote", 286 + "spawn-rx", 287 + "ts-node", 288 + "zod" 289 + ], 290 + "bin": true 291 + }, 292 + "@modelcontextprotocol/sdk@1.21.1_ajv@8.17.1_express@5.1.0_zod@3.25.76": { 293 + "integrity": "sha512-UyLFcJLDvUuZbGnaQqXFT32CpPpGj7VS19roLut6gkQVhb439xUzYWbsUvdI3ZPL+2hnFosuugtYWE0Mcs1rmQ==", 294 + "dependencies": [ 295 + "ajv@8.17.1", 296 + "ajv-formats", 297 + "content-type", 298 + "cors", 299 + "cross-spawn", 300 + "eventsource", 301 + "eventsource-parser", 302 + "express", 303 + "express-rate-limit", 304 + "pkce-challenge@5.0.0", 305 + "raw-body", 306 + "zod", 307 + "zod-to-json-schema" 308 + ] 309 + }, 127 310 "@optique/core@0.6.2": { 128 311 "integrity": "sha512-HTxIHJ8xLOSZotiU6Zc5BCJv+SJ8DMYmuiQM+7tjF7RolJn/pdZNe7M78G3+DgXL9lIf82l8aGcilmgVYRQnGQ==" 129 312 }, ··· 133 316 "@optique/core" 134 317 ] 135 318 }, 319 + "@radix-ui/number@1.1.1": { 320 + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" 321 + }, 322 + "@radix-ui/primitive@1.1.2": { 323 + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==" 324 + }, 325 + "@radix-ui/react-arrow@1.1.7_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 326 + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", 327 + "dependencies": [ 328 + "@radix-ui/react-primitive", 329 + "react", 330 + "react-dom" 331 + ] 332 + }, 333 + "@radix-ui/react-checkbox@1.3.2_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 334 + "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", 335 + "dependencies": [ 336 + "@radix-ui/primitive", 337 + "@radix-ui/react-compose-refs", 338 + "@radix-ui/react-context", 339 + "@radix-ui/react-presence", 340 + "@radix-ui/react-primitive", 341 + "@radix-ui/react-use-controllable-state", 342 + "@radix-ui/react-use-previous", 343 + "@radix-ui/react-use-size", 344 + "react", 345 + "react-dom" 346 + ] 347 + }, 348 + "@radix-ui/react-collection@1.1.7_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 349 + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", 350 + "dependencies": [ 351 + "@radix-ui/react-compose-refs", 352 + "@radix-ui/react-context", 353 + "@radix-ui/react-primitive", 354 + "@radix-ui/react-slot", 355 + "react", 356 + "react-dom" 357 + ] 358 + }, 359 + "@radix-ui/react-compose-refs@1.1.2_react@18.3.1": { 360 + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", 361 + "dependencies": [ 362 + "react" 363 + ] 364 + }, 365 + "@radix-ui/react-context@1.1.2_react@18.3.1": { 366 + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", 367 + "dependencies": [ 368 + "react" 369 + ] 370 + }, 371 + "@radix-ui/react-dialog@1.1.14_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 372 + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", 373 + "dependencies": [ 374 + "@radix-ui/primitive", 375 + "@radix-ui/react-compose-refs", 376 + "@radix-ui/react-context", 377 + "@radix-ui/react-dismissable-layer", 378 + "@radix-ui/react-focus-guards", 379 + "@radix-ui/react-focus-scope", 380 + "@radix-ui/react-id", 381 + "@radix-ui/react-portal", 382 + "@radix-ui/react-presence", 383 + "@radix-ui/react-primitive", 384 + "@radix-ui/react-slot", 385 + "@radix-ui/react-use-controllable-state", 386 + "aria-hidden", 387 + "react", 388 + "react-dom", 389 + "react-remove-scroll" 390 + ] 391 + }, 392 + "@radix-ui/react-direction@1.1.1_react@18.3.1": { 393 + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", 394 + "dependencies": [ 395 + "react" 396 + ] 397 + }, 398 + "@radix-ui/react-dismissable-layer@1.1.10_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 399 + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", 400 + "dependencies": [ 401 + "@radix-ui/primitive", 402 + "@radix-ui/react-compose-refs", 403 + "@radix-ui/react-primitive", 404 + "@radix-ui/react-use-callback-ref", 405 + "@radix-ui/react-use-escape-keydown", 406 + "react", 407 + "react-dom" 408 + ] 409 + }, 410 + "@radix-ui/react-focus-guards@1.1.2_react@18.3.1": { 411 + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", 412 + "dependencies": [ 413 + "react" 414 + ] 415 + }, 416 + "@radix-ui/react-focus-scope@1.1.7_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 417 + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", 418 + "dependencies": [ 419 + "@radix-ui/react-compose-refs", 420 + "@radix-ui/react-primitive", 421 + "@radix-ui/react-use-callback-ref", 422 + "react", 423 + "react-dom" 424 + ] 425 + }, 426 + "@radix-ui/react-icons@1.3.2_react@18.3.1": { 427 + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", 428 + "dependencies": [ 429 + "react" 430 + ] 431 + }, 432 + "@radix-ui/react-id@1.1.1_react@18.3.1": { 433 + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", 434 + "dependencies": [ 435 + "@radix-ui/react-use-layout-effect", 436 + "react" 437 + ] 438 + }, 439 + "@radix-ui/react-label@2.1.7_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 440 + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", 441 + "dependencies": [ 442 + "@radix-ui/react-primitive", 443 + "react", 444 + "react-dom" 445 + ] 446 + }, 447 + "@radix-ui/react-popover@1.1.14_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 448 + "integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==", 449 + "dependencies": [ 450 + "@radix-ui/primitive", 451 + "@radix-ui/react-compose-refs", 452 + "@radix-ui/react-context", 453 + "@radix-ui/react-dismissable-layer", 454 + "@radix-ui/react-focus-guards", 455 + "@radix-ui/react-focus-scope", 456 + "@radix-ui/react-id", 457 + "@radix-ui/react-popper", 458 + "@radix-ui/react-portal", 459 + "@radix-ui/react-presence", 460 + "@radix-ui/react-primitive", 461 + "@radix-ui/react-slot", 462 + "@radix-ui/react-use-controllable-state", 463 + "aria-hidden", 464 + "react", 465 + "react-dom", 466 + "react-remove-scroll" 467 + ] 468 + }, 469 + "@radix-ui/react-popper@1.2.7_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 470 + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", 471 + "dependencies": [ 472 + "@floating-ui/react-dom", 473 + "@radix-ui/react-arrow", 474 + "@radix-ui/react-compose-refs", 475 + "@radix-ui/react-context", 476 + "@radix-ui/react-primitive", 477 + "@radix-ui/react-use-callback-ref", 478 + "@radix-ui/react-use-layout-effect", 479 + "@radix-ui/react-use-rect", 480 + "@radix-ui/react-use-size", 481 + "@radix-ui/rect", 482 + "react", 483 + "react-dom" 484 + ] 485 + }, 486 + "@radix-ui/react-portal@1.1.9_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 487 + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", 488 + "dependencies": [ 489 + "@radix-ui/react-primitive", 490 + "@radix-ui/react-use-layout-effect", 491 + "react", 492 + "react-dom" 493 + ] 494 + }, 495 + "@radix-ui/react-presence@1.1.4_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 496 + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", 497 + "dependencies": [ 498 + "@radix-ui/react-compose-refs", 499 + "@radix-ui/react-use-layout-effect", 500 + "react", 501 + "react-dom" 502 + ] 503 + }, 504 + "@radix-ui/react-primitive@2.1.3_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 505 + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", 506 + "dependencies": [ 507 + "@radix-ui/react-slot", 508 + "react", 509 + "react-dom" 510 + ] 511 + }, 512 + "@radix-ui/react-roving-focus@1.1.10_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 513 + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", 514 + "dependencies": [ 515 + "@radix-ui/primitive", 516 + "@radix-ui/react-collection", 517 + "@radix-ui/react-compose-refs", 518 + "@radix-ui/react-context", 519 + "@radix-ui/react-direction", 520 + "@radix-ui/react-id", 521 + "@radix-ui/react-primitive", 522 + "@radix-ui/react-use-callback-ref", 523 + "@radix-ui/react-use-controllable-state", 524 + "react", 525 + "react-dom" 526 + ] 527 + }, 528 + "@radix-ui/react-select@2.2.5_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 529 + "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==", 530 + "dependencies": [ 531 + "@radix-ui/number", 532 + "@radix-ui/primitive", 533 + "@radix-ui/react-collection", 534 + "@radix-ui/react-compose-refs", 535 + "@radix-ui/react-context", 536 + "@radix-ui/react-direction", 537 + "@radix-ui/react-dismissable-layer", 538 + "@radix-ui/react-focus-guards", 539 + "@radix-ui/react-focus-scope", 540 + "@radix-ui/react-id", 541 + "@radix-ui/react-popper", 542 + "@radix-ui/react-portal", 543 + "@radix-ui/react-primitive", 544 + "@radix-ui/react-slot", 545 + "@radix-ui/react-use-callback-ref", 546 + "@radix-ui/react-use-controllable-state", 547 + "@radix-ui/react-use-layout-effect", 548 + "@radix-ui/react-use-previous", 549 + "@radix-ui/react-visually-hidden", 550 + "aria-hidden", 551 + "react", 552 + "react-dom", 553 + "react-remove-scroll" 554 + ] 555 + }, 556 + "@radix-ui/react-slot@1.2.3_react@18.3.1": { 557 + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", 558 + "dependencies": [ 559 + "@radix-ui/react-compose-refs", 560 + "react" 561 + ] 562 + }, 563 + "@radix-ui/react-tabs@1.1.12_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 564 + "integrity": "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==", 565 + "dependencies": [ 566 + "@radix-ui/primitive", 567 + "@radix-ui/react-context", 568 + "@radix-ui/react-direction", 569 + "@radix-ui/react-id", 570 + "@radix-ui/react-presence", 571 + "@radix-ui/react-primitive", 572 + "@radix-ui/react-roving-focus", 573 + "@radix-ui/react-use-controllable-state", 574 + "react", 575 + "react-dom" 576 + ] 577 + }, 578 + "@radix-ui/react-toast@1.2.14_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 579 + "integrity": "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg==", 580 + "dependencies": [ 581 + "@radix-ui/primitive", 582 + "@radix-ui/react-collection", 583 + "@radix-ui/react-compose-refs", 584 + "@radix-ui/react-context", 585 + "@radix-ui/react-dismissable-layer", 586 + "@radix-ui/react-portal", 587 + "@radix-ui/react-presence", 588 + "@radix-ui/react-primitive", 589 + "@radix-ui/react-use-callback-ref", 590 + "@radix-ui/react-use-controllable-state", 591 + "@radix-ui/react-use-layout-effect", 592 + "@radix-ui/react-visually-hidden", 593 + "react", 594 + "react-dom" 595 + ] 596 + }, 597 + "@radix-ui/react-tooltip@1.2.7_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 598 + "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", 599 + "dependencies": [ 600 + "@radix-ui/primitive", 601 + "@radix-ui/react-compose-refs", 602 + "@radix-ui/react-context", 603 + "@radix-ui/react-dismissable-layer", 604 + "@radix-ui/react-id", 605 + "@radix-ui/react-popper", 606 + "@radix-ui/react-portal", 607 + "@radix-ui/react-presence", 608 + "@radix-ui/react-primitive", 609 + "@radix-ui/react-slot", 610 + "@radix-ui/react-use-controllable-state", 611 + "@radix-ui/react-visually-hidden", 612 + "react", 613 + "react-dom" 614 + ] 615 + }, 616 + "@radix-ui/react-use-callback-ref@1.1.1_react@18.3.1": { 617 + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", 618 + "dependencies": [ 619 + "react" 620 + ] 621 + }, 622 + "@radix-ui/react-use-controllable-state@1.2.2_react@18.3.1": { 623 + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", 624 + "dependencies": [ 625 + "@radix-ui/react-use-effect-event", 626 + "@radix-ui/react-use-layout-effect", 627 + "react" 628 + ] 629 + }, 630 + "@radix-ui/react-use-effect-event@0.0.2_react@18.3.1": { 631 + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", 632 + "dependencies": [ 633 + "@radix-ui/react-use-layout-effect", 634 + "react" 635 + ] 636 + }, 637 + "@radix-ui/react-use-escape-keydown@1.1.1_react@18.3.1": { 638 + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", 639 + "dependencies": [ 640 + "@radix-ui/react-use-callback-ref", 641 + "react" 642 + ] 643 + }, 644 + "@radix-ui/react-use-layout-effect@1.1.1_react@18.3.1": { 645 + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", 646 + "dependencies": [ 647 + "react" 648 + ] 649 + }, 650 + "@radix-ui/react-use-previous@1.1.1_react@18.3.1": { 651 + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", 652 + "dependencies": [ 653 + "react" 654 + ] 655 + }, 656 + "@radix-ui/react-use-rect@1.1.1_react@18.3.1": { 657 + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", 658 + "dependencies": [ 659 + "@radix-ui/rect", 660 + "react" 661 + ] 662 + }, 663 + "@radix-ui/react-use-size@1.1.1_react@18.3.1": { 664 + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", 665 + "dependencies": [ 666 + "@radix-ui/react-use-layout-effect", 667 + "react" 668 + ] 669 + }, 670 + "@radix-ui/react-visually-hidden@1.2.3_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 671 + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", 672 + "dependencies": [ 673 + "@radix-ui/react-primitive", 674 + "react", 675 + "react-dom" 676 + ] 677 + }, 678 + "@radix-ui/rect@1.1.1": { 679 + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" 680 + }, 136 681 "@standard-schema/spec@1.0.0": { 137 682 "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" 138 683 }, 684 + "@tsconfig/node10@1.0.11": { 685 + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==" 686 + }, 687 + "@tsconfig/node12@1.0.11": { 688 + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==" 689 + }, 690 + "@tsconfig/node14@1.0.3": { 691 + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==" 692 + }, 693 + "@tsconfig/node16@1.0.4": { 694 + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==" 695 + }, 696 + "@types/node@24.2.0": { 697 + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", 698 + "dependencies": [ 699 + "undici-types" 700 + ] 701 + }, 702 + "accepts@2.0.0": { 703 + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", 704 + "dependencies": [ 705 + "mime-types@3.0.1", 706 + "negotiator" 707 + ] 708 + }, 709 + "acorn-walk@8.3.4": { 710 + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", 711 + "dependencies": [ 712 + "acorn" 713 + ] 714 + }, 715 + "acorn@8.15.0": { 716 + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", 717 + "bin": true 718 + }, 719 + "ajv-formats@3.0.1_ajv@8.17.1": { 720 + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", 721 + "dependencies": [ 722 + "ajv@8.17.1" 723 + ], 724 + "optionalPeers": [ 725 + "ajv@8.17.1" 726 + ] 727 + }, 728 + "ajv@6.12.6": { 729 + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", 730 + "dependencies": [ 731 + "fast-deep-equal", 732 + "fast-json-stable-stringify", 733 + "json-schema-traverse@0.4.1", 734 + "uri-js" 735 + ] 736 + }, 737 + "ajv@8.17.1": { 738 + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", 739 + "dependencies": [ 740 + "fast-deep-equal", 741 + "fast-uri", 742 + "json-schema-traverse@1.0.0", 743 + "require-from-string" 744 + ] 745 + }, 746 + "ansi-regex@5.0.1": { 747 + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" 748 + }, 749 + "ansi-styles@4.3.0": { 750 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 751 + "dependencies": [ 752 + "color-convert" 753 + ] 754 + }, 755 + "arg@4.1.3": { 756 + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" 757 + }, 758 + "aria-hidden@1.2.6": { 759 + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", 760 + "dependencies": [ 761 + "tslib" 762 + ] 763 + }, 764 + "balanced-match@1.0.2": { 765 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" 766 + }, 767 + "body-parser@2.2.0": { 768 + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", 769 + "dependencies": [ 770 + "bytes@3.1.2", 771 + "content-type", 772 + "debug", 773 + "http-errors", 774 + "iconv-lite@0.6.3", 775 + "on-finished", 776 + "qs", 777 + "raw-body", 778 + "type-is" 779 + ] 780 + }, 781 + "brace-expansion@1.1.12": { 782 + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", 783 + "dependencies": [ 784 + "balanced-match", 785 + "concat-map" 786 + ] 787 + }, 788 + "bundle-name@4.1.0": { 789 + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", 790 + "dependencies": [ 791 + "run-applescript" 792 + ] 793 + }, 794 + "bytes@3.0.0": { 795 + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==" 796 + }, 797 + "bytes@3.1.2": { 798 + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" 799 + }, 800 + "call-bind-apply-helpers@1.0.2": { 801 + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", 802 + "dependencies": [ 803 + "es-errors", 804 + "function-bind" 805 + ] 806 + }, 807 + "call-bound@1.0.4": { 808 + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", 809 + "dependencies": [ 810 + "call-bind-apply-helpers", 811 + "get-intrinsic" 812 + ] 813 + }, 814 + "chalk@4.1.2": { 815 + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 816 + "dependencies": [ 817 + "ansi-styles", 818 + "supports-color@7.2.0" 819 + ] 820 + }, 821 + "class-variance-authority@0.7.1": { 822 + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", 823 + "dependencies": [ 824 + "clsx" 825 + ] 826 + }, 827 + "cliui@8.0.1": { 828 + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", 829 + "dependencies": [ 830 + "string-width", 831 + "strip-ansi", 832 + "wrap-ansi" 833 + ] 834 + }, 835 + "clsx@2.1.1": { 836 + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" 837 + }, 838 + "cmdk@1.1.1_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 839 + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", 840 + "dependencies": [ 841 + "@radix-ui/react-compose-refs", 842 + "@radix-ui/react-dialog", 843 + "@radix-ui/react-id", 844 + "@radix-ui/react-primitive", 845 + "react", 846 + "react-dom" 847 + ] 848 + }, 849 + "color-convert@2.0.1": { 850 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 851 + "dependencies": [ 852 + "color-name" 853 + ] 854 + }, 855 + "color-name@1.1.4": { 856 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" 857 + }, 858 + "commander@13.1.0": { 859 + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==" 860 + }, 861 + "concat-map@0.0.1": { 862 + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" 863 + }, 864 + "concurrently@9.2.0": { 865 + "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", 866 + "dependencies": [ 867 + "chalk", 868 + "lodash", 869 + "rxjs", 870 + "shell-quote", 871 + "supports-color@8.1.1", 872 + "tree-kill", 873 + "yargs" 874 + ], 875 + "bin": true 876 + }, 877 + "content-disposition@0.5.2": { 878 + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==" 879 + }, 880 + "content-disposition@1.0.0": { 881 + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", 882 + "dependencies": [ 883 + "safe-buffer" 884 + ] 885 + }, 886 + "content-type@1.0.5": { 887 + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" 888 + }, 889 + "cookie-signature@1.2.2": { 890 + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" 891 + }, 892 + "cookie@0.7.2": { 893 + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" 894 + }, 895 + "cors@2.8.5": { 896 + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", 897 + "dependencies": [ 898 + "object-assign", 899 + "vary" 900 + ] 901 + }, 902 + "create-require@1.1.1": { 903 + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" 904 + }, 905 + "cross-spawn@7.0.6": { 906 + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 907 + "dependencies": [ 908 + "path-key", 909 + "shebang-command", 910 + "which" 911 + ] 912 + }, 913 + "debug@4.4.3": { 914 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 915 + "dependencies": [ 916 + "ms" 917 + ] 918 + }, 919 + "default-browser-id@5.0.0": { 920 + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==" 921 + }, 922 + "default-browser@5.2.1": { 923 + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", 924 + "dependencies": [ 925 + "bundle-name", 926 + "default-browser-id" 927 + ] 928 + }, 929 + "define-lazy-prop@3.0.0": { 930 + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==" 931 + }, 932 + "depd@2.0.0": { 933 + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" 934 + }, 935 + "detect-node-es@1.1.0": { 936 + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" 937 + }, 938 + "diff@4.0.2": { 939 + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==" 940 + }, 941 + "dunder-proto@1.0.1": { 942 + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", 943 + "dependencies": [ 944 + "call-bind-apply-helpers", 945 + "es-errors", 946 + "gopd" 947 + ] 948 + }, 949 + "ee-first@1.1.1": { 950 + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" 951 + }, 952 + "emoji-regex@8.0.0": { 953 + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" 954 + }, 955 + "encodeurl@2.0.0": { 956 + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" 957 + }, 958 + "es-define-property@1.0.1": { 959 + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" 960 + }, 961 + "es-errors@1.3.0": { 962 + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" 963 + }, 964 + "es-object-atoms@1.1.1": { 965 + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", 966 + "dependencies": [ 967 + "es-errors" 968 + ] 969 + }, 970 + "escalade@3.2.0": { 971 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" 972 + }, 973 + "escape-html@1.0.3": { 974 + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" 975 + }, 139 976 "esm-env@1.2.2": { 140 977 "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" 141 978 }, 979 + "etag@1.8.1": { 980 + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" 981 + }, 982 + "event-target-polyfill@0.0.4": { 983 + "integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==" 984 + }, 985 + "eventsource-parser@3.0.6": { 986 + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==" 987 + }, 988 + "eventsource@3.0.7": { 989 + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", 990 + "dependencies": [ 991 + "eventsource-parser" 992 + ] 993 + }, 994 + "express-rate-limit@7.5.1_express@5.1.0": { 995 + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", 996 + "dependencies": [ 997 + "express" 998 + ] 999 + }, 1000 + "express@5.1.0": { 1001 + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", 1002 + "dependencies": [ 1003 + "accepts", 1004 + "body-parser", 1005 + "content-disposition@1.0.0", 1006 + "content-type", 1007 + "cookie", 1008 + "cookie-signature", 1009 + "debug", 1010 + "encodeurl", 1011 + "escape-html", 1012 + "etag", 1013 + "finalhandler", 1014 + "fresh", 1015 + "http-errors", 1016 + "merge-descriptors", 1017 + "mime-types@3.0.1", 1018 + "on-finished", 1019 + "once", 1020 + "parseurl", 1021 + "proxy-addr", 1022 + "qs", 1023 + "range-parser@1.2.1", 1024 + "router", 1025 + "send", 1026 + "serve-static", 1027 + "statuses", 1028 + "type-is", 1029 + "vary" 1030 + ] 1031 + }, 1032 + "fast-deep-equal@3.1.3": { 1033 + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" 1034 + }, 1035 + "fast-json-stable-stringify@2.1.0": { 1036 + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" 1037 + }, 1038 + "fast-uri@3.1.0": { 1039 + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==" 1040 + }, 1041 + "fetch-to-node@2.1.0": { 1042 + "integrity": "sha512-Wq05j6LE1GrWpT2t1YbCkyFY6xKRJq3hx/oRJdWEJpZlik3g25MmdJS6RFm49iiMJw6zpZuBOrgihOgy2jGyAA==" 1043 + }, 1044 + "finalhandler@2.1.0": { 1045 + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", 1046 + "dependencies": [ 1047 + "debug", 1048 + "encodeurl", 1049 + "escape-html", 1050 + "on-finished", 1051 + "parseurl", 1052 + "statuses" 1053 + ] 1054 + }, 1055 + "forwarded@0.2.0": { 1056 + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" 1057 + }, 1058 + "fresh@2.0.0": { 1059 + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" 1060 + }, 1061 + "function-bind@1.1.2": { 1062 + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" 1063 + }, 1064 + "get-caller-file@2.0.5": { 1065 + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" 1066 + }, 1067 + "get-intrinsic@1.3.0": { 1068 + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", 1069 + "dependencies": [ 1070 + "call-bind-apply-helpers", 1071 + "es-define-property", 1072 + "es-errors", 1073 + "es-object-atoms", 1074 + "function-bind", 1075 + "get-proto", 1076 + "gopd", 1077 + "has-symbols", 1078 + "hasown", 1079 + "math-intrinsics" 1080 + ] 1081 + }, 1082 + "get-nonce@1.0.1": { 1083 + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" 1084 + }, 1085 + "get-proto@1.0.1": { 1086 + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", 1087 + "dependencies": [ 1088 + "dunder-proto", 1089 + "es-object-atoms" 1090 + ] 1091 + }, 1092 + "gopd@1.2.0": { 1093 + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" 1094 + }, 142 1095 "graphemer@1.4.0": { 143 1096 "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 144 1097 }, 1098 + "has-flag@4.0.0": { 1099 + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" 1100 + }, 1101 + "has-symbols@1.1.0": { 1102 + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" 1103 + }, 1104 + "hasown@2.0.2": { 1105 + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 1106 + "dependencies": [ 1107 + "function-bind" 1108 + ] 1109 + }, 1110 + "http-errors@2.0.0": { 1111 + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", 1112 + "dependencies": [ 1113 + "depd", 1114 + "inherits", 1115 + "setprototypeof", 1116 + "statuses", 1117 + "toidentifier" 1118 + ] 1119 + }, 1120 + "iconv-lite@0.6.3": { 1121 + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", 1122 + "dependencies": [ 1123 + "safer-buffer" 1124 + ] 1125 + }, 1126 + "iconv-lite@0.7.0": { 1127 + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", 1128 + "dependencies": [ 1129 + "safer-buffer" 1130 + ] 1131 + }, 1132 + "inherits@2.0.4": { 1133 + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 1134 + }, 1135 + "ipaddr.js@1.9.1": { 1136 + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" 1137 + }, 1138 + "is-docker@3.0.0": { 1139 + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", 1140 + "bin": true 1141 + }, 1142 + "is-fullwidth-code-point@3.0.0": { 1143 + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" 1144 + }, 1145 + "is-inside-container@1.0.0": { 1146 + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", 1147 + "dependencies": [ 1148 + "is-docker" 1149 + ], 1150 + "bin": true 1151 + }, 1152 + "is-promise@4.0.0": { 1153 + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" 1154 + }, 1155 + "is-wsl@3.1.0": { 1156 + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", 1157 + "dependencies": [ 1158 + "is-inside-container" 1159 + ] 1160 + }, 1161 + "isexe@2.0.0": { 1162 + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" 1163 + }, 145 1164 "iso-datestring-validator@2.2.2": { 146 1165 "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" 147 1166 }, 1167 + "js-tokens@4.0.0": { 1168 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 1169 + }, 1170 + "json-schema-traverse@0.4.1": { 1171 + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" 1172 + }, 1173 + "json-schema-traverse@1.0.0": { 1174 + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" 1175 + }, 1176 + "lodash@4.17.21": { 1177 + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 1178 + }, 1179 + "loose-envify@1.4.0": { 1180 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 1181 + "dependencies": [ 1182 + "js-tokens" 1183 + ], 1184 + "bin": true 1185 + }, 1186 + "lucide-react@0.523.0_react@18.3.1": { 1187 + "integrity": "sha512-rUjQoy7egZT9XYVXBK1je9ckBnNp7qzRZOhLQx5RcEp2dCGlXo+mv6vf7Am4LimEcFBJIIZzSGfgTqc9QCrPSw==", 1188 + "dependencies": [ 1189 + "react" 1190 + ] 1191 + }, 1192 + "make-error@1.3.6": { 1193 + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" 1194 + }, 1195 + "math-intrinsics@1.1.0": { 1196 + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" 1197 + }, 1198 + "media-typer@1.1.0": { 1199 + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" 1200 + }, 1201 + "merge-descriptors@2.0.0": { 1202 + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" 1203 + }, 1204 + "mime-db@1.33.0": { 1205 + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==" 1206 + }, 1207 + "mime-db@1.54.0": { 1208 + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" 1209 + }, 1210 + "mime-types@2.1.18": { 1211 + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", 1212 + "dependencies": [ 1213 + "mime-db@1.33.0" 1214 + ] 1215 + }, 1216 + "mime-types@3.0.1": { 1217 + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", 1218 + "dependencies": [ 1219 + "mime-db@1.54.0" 1220 + ] 1221 + }, 1222 + "minimatch@3.1.2": { 1223 + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", 1224 + "dependencies": [ 1225 + "brace-expansion" 1226 + ] 1227 + }, 1228 + "ms@2.1.3": { 1229 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 1230 + }, 148 1231 "multiformats@9.9.0": { 149 1232 "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" 150 1233 }, 1234 + "negotiator@1.0.0": { 1235 + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" 1236 + }, 1237 + "object-assign@4.1.1": { 1238 + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" 1239 + }, 1240 + "object-inspect@1.13.4": { 1241 + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" 1242 + }, 1243 + "on-finished@2.4.1": { 1244 + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", 1245 + "dependencies": [ 1246 + "ee-first" 1247 + ] 1248 + }, 1249 + "once@1.4.0": { 1250 + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 1251 + "dependencies": [ 1252 + "wrappy" 1253 + ] 1254 + }, 1255 + "open@10.1.2": { 1256 + "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", 1257 + "dependencies": [ 1258 + "default-browser", 1259 + "define-lazy-prop", 1260 + "is-inside-container", 1261 + "is-wsl" 1262 + ] 1263 + }, 1264 + "parseurl@1.3.3": { 1265 + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" 1266 + }, 1267 + "partysocket@1.1.6": { 1268 + "integrity": "sha512-LkEk8N9hMDDsDT0iDK0zuwUDFVrVMUXFXCeN3850Ng8wtjPqPBeJlwdeY6ROlJSEh3tPoTTasXoSBYH76y118w==", 1269 + "dependencies": [ 1270 + "event-target-polyfill" 1271 + ] 1272 + }, 1273 + "path-is-inside@1.0.2": { 1274 + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==" 1275 + }, 1276 + "path-key@3.1.1": { 1277 + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" 1278 + }, 1279 + "path-to-regexp@3.3.0": { 1280 + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==" 1281 + }, 1282 + "path-to-regexp@8.3.0": { 1283 + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==" 1284 + }, 151 1285 "picocolors@1.1.1": { 152 1286 "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" 153 1287 }, 1288 + "pkce-challenge@4.1.0": { 1289 + "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==" 1290 + }, 1291 + "pkce-challenge@5.0.0": { 1292 + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==" 1293 + }, 154 1294 "prettier@3.6.2": { 155 1295 "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", 156 1296 "bin": true 157 1297 }, 1298 + "prismjs@1.30.0": { 1299 + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==" 1300 + }, 1301 + "proxy-addr@2.0.7": { 1302 + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", 1303 + "dependencies": [ 1304 + "forwarded", 1305 + "ipaddr.js" 1306 + ] 1307 + }, 1308 + "punycode@2.3.1": { 1309 + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" 1310 + }, 1311 + "qs@6.14.0": { 1312 + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", 1313 + "dependencies": [ 1314 + "side-channel" 1315 + ] 1316 + }, 1317 + "range-parser@1.2.0": { 1318 + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==" 1319 + }, 1320 + "range-parser@1.2.1": { 1321 + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" 1322 + }, 1323 + "raw-body@3.0.1": { 1324 + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", 1325 + "dependencies": [ 1326 + "bytes@3.1.2", 1327 + "http-errors", 1328 + "iconv-lite@0.7.0", 1329 + "unpipe" 1330 + ] 1331 + }, 1332 + "react-dom@18.3.1_react@18.3.1": { 1333 + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", 1334 + "dependencies": [ 1335 + "loose-envify", 1336 + "react", 1337 + "scheduler" 1338 + ] 1339 + }, 1340 + "react-remove-scroll-bar@2.3.8_react@18.3.1": { 1341 + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", 1342 + "dependencies": [ 1343 + "react", 1344 + "react-style-singleton", 1345 + "tslib" 1346 + ] 1347 + }, 1348 + "react-remove-scroll@2.7.1_react@18.3.1": { 1349 + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", 1350 + "dependencies": [ 1351 + "react", 1352 + "react-remove-scroll-bar", 1353 + "react-style-singleton", 1354 + "tslib", 1355 + "use-callback-ref", 1356 + "use-sidecar" 1357 + ] 1358 + }, 1359 + "react-simple-code-editor@0.14.1_react@18.3.1_react-dom@18.3.1__react@18.3.1": { 1360 + "integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==", 1361 + "dependencies": [ 1362 + "react", 1363 + "react-dom" 1364 + ] 1365 + }, 1366 + "react-style-singleton@2.2.3_react@18.3.1": { 1367 + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", 1368 + "dependencies": [ 1369 + "get-nonce", 1370 + "react", 1371 + "tslib" 1372 + ] 1373 + }, 1374 + "react@18.3.1": { 1375 + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", 1376 + "dependencies": [ 1377 + "loose-envify" 1378 + ] 1379 + }, 1380 + "require-directory@2.1.1": { 1381 + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" 1382 + }, 1383 + "require-from-string@2.0.2": { 1384 + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" 1385 + }, 1386 + "router@2.2.0": { 1387 + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", 1388 + "dependencies": [ 1389 + "debug", 1390 + "depd", 1391 + "is-promise", 1392 + "parseurl", 1393 + "path-to-regexp@8.3.0" 1394 + ] 1395 + }, 1396 + "run-applescript@7.0.0": { 1397 + "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==" 1398 + }, 1399 + "rxjs@7.8.2": { 1400 + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", 1401 + "dependencies": [ 1402 + "tslib" 1403 + ] 1404 + }, 1405 + "safe-buffer@5.2.1": { 1406 + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" 1407 + }, 1408 + "safer-buffer@2.1.2": { 1409 + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 1410 + }, 1411 + "scheduler@0.23.2": { 1412 + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", 1413 + "dependencies": [ 1414 + "loose-envify" 1415 + ] 1416 + }, 1417 + "send@1.2.0": { 1418 + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", 1419 + "dependencies": [ 1420 + "debug", 1421 + "encodeurl", 1422 + "escape-html", 1423 + "etag", 1424 + "fresh", 1425 + "http-errors", 1426 + "mime-types@3.0.1", 1427 + "ms", 1428 + "on-finished", 1429 + "range-parser@1.2.1", 1430 + "statuses" 1431 + ] 1432 + }, 1433 + "serve-handler@6.1.6": { 1434 + "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", 1435 + "dependencies": [ 1436 + "bytes@3.0.0", 1437 + "content-disposition@0.5.2", 1438 + "mime-types@2.1.18", 1439 + "minimatch", 1440 + "path-is-inside", 1441 + "path-to-regexp@3.3.0", 1442 + "range-parser@1.2.0" 1443 + ] 1444 + }, 1445 + "serve-static@2.2.0": { 1446 + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", 1447 + "dependencies": [ 1448 + "encodeurl", 1449 + "escape-html", 1450 + "parseurl", 1451 + "send" 1452 + ] 1453 + }, 1454 + "setprototypeof@1.2.0": { 1455 + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" 1456 + }, 1457 + "shebang-command@2.0.0": { 1458 + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 1459 + "dependencies": [ 1460 + "shebang-regex" 1461 + ] 1462 + }, 1463 + "shebang-regex@3.0.0": { 1464 + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" 1465 + }, 1466 + "shell-quote@1.8.3": { 1467 + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==" 1468 + }, 1469 + "side-channel-list@1.0.0": { 1470 + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", 1471 + "dependencies": [ 1472 + "es-errors", 1473 + "object-inspect" 1474 + ] 1475 + }, 1476 + "side-channel-map@1.0.1": { 1477 + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", 1478 + "dependencies": [ 1479 + "call-bound", 1480 + "es-errors", 1481 + "get-intrinsic", 1482 + "object-inspect" 1483 + ] 1484 + }, 1485 + "side-channel-weakmap@1.0.2": { 1486 + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", 1487 + "dependencies": [ 1488 + "call-bound", 1489 + "es-errors", 1490 + "get-intrinsic", 1491 + "object-inspect", 1492 + "side-channel-map" 1493 + ] 1494 + }, 1495 + "side-channel@1.1.0": { 1496 + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", 1497 + "dependencies": [ 1498 + "es-errors", 1499 + "object-inspect", 1500 + "side-channel-list", 1501 + "side-channel-map", 1502 + "side-channel-weakmap" 1503 + ] 1504 + }, 1505 + "spawn-rx@5.1.2": { 1506 + "integrity": "sha512-/y7tJKALVZ1lPzeZZB9jYnmtrL7d0N2zkorii5a7r7dhHkWIuLTzZpZzMJLK1dmYRgX/NCc4iarTO3F7BS2c/A==", 1507 + "dependencies": [ 1508 + "debug", 1509 + "rxjs" 1510 + ] 1511 + }, 1512 + "statuses@2.0.1": { 1513 + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" 1514 + }, 1515 + "string-width@4.2.3": { 1516 + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 1517 + "dependencies": [ 1518 + "emoji-regex", 1519 + "is-fullwidth-code-point", 1520 + "strip-ansi" 1521 + ] 1522 + }, 1523 + "strip-ansi@6.0.1": { 1524 + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 1525 + "dependencies": [ 1526 + "ansi-regex" 1527 + ] 1528 + }, 1529 + "supports-color@7.2.0": { 1530 + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 1531 + "dependencies": [ 1532 + "has-flag" 1533 + ] 1534 + }, 1535 + "supports-color@8.1.1": { 1536 + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", 1537 + "dependencies": [ 1538 + "has-flag" 1539 + ] 1540 + }, 1541 + "tailwind-merge@2.6.0": { 1542 + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==" 1543 + }, 1544 + "tailwindcss-animate@1.0.7_tailwindcss@4.1.11": { 1545 + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", 1546 + "dependencies": [ 1547 + "tailwindcss" 1548 + ] 1549 + }, 1550 + "tailwindcss@4.1.11": { 1551 + "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==" 1552 + }, 1553 + "toidentifier@1.0.1": { 1554 + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" 1555 + }, 1556 + "tree-kill@1.2.2": { 1557 + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", 1558 + "bin": true 1559 + }, 1560 + "ts-node@10.9.2_@types+node@24.2.0_typescript@5.8.3": { 1561 + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", 1562 + "dependencies": [ 1563 + "@cspotcode/source-map-support", 1564 + "@tsconfig/node10", 1565 + "@tsconfig/node12", 1566 + "@tsconfig/node14", 1567 + "@tsconfig/node16", 1568 + "@types/node", 1569 + "acorn", 1570 + "acorn-walk", 1571 + "arg", 1572 + "create-require", 1573 + "diff", 1574 + "make-error", 1575 + "typescript", 1576 + "v8-compile-cache-lib", 1577 + "yn" 1578 + ], 1579 + "bin": true 1580 + }, 1581 + "tslib@2.8.1": { 1582 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" 1583 + }, 1584 + "type-fest@4.41.0": { 1585 + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==" 1586 + }, 1587 + "type-is@2.0.1": { 1588 + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", 1589 + "dependencies": [ 1590 + "content-type", 1591 + "media-typer", 1592 + "mime-types@3.0.1" 1593 + ] 1594 + }, 1595 + "typescript@5.8.3": { 1596 + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", 1597 + "bin": true 1598 + }, 158 1599 "uint8arrays@3.0.0": { 159 1600 "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 160 1601 "dependencies": [ 161 1602 "multiformats" 162 1603 ] 163 1604 }, 1605 + "undici-types@7.10.0": { 1606 + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" 1607 + }, 1608 + "unpipe@1.0.0": { 1609 + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" 1610 + }, 1611 + "uri-js@4.4.1": { 1612 + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", 1613 + "dependencies": [ 1614 + "punycode" 1615 + ] 1616 + }, 1617 + "use-callback-ref@1.3.3_react@18.3.1": { 1618 + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", 1619 + "dependencies": [ 1620 + "react", 1621 + "tslib" 1622 + ] 1623 + }, 1624 + "use-sidecar@1.1.3_react@18.3.1": { 1625 + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", 1626 + "dependencies": [ 1627 + "detect-node-es", 1628 + "react", 1629 + "tslib" 1630 + ] 1631 + }, 1632 + "v8-compile-cache-lib@3.0.1": { 1633 + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==" 1634 + }, 1635 + "vary@1.1.2": { 1636 + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" 1637 + }, 1638 + "which@2.0.2": { 1639 + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 1640 + "dependencies": [ 1641 + "isexe" 1642 + ], 1643 + "bin": true 1644 + }, 1645 + "wrap-ansi@7.0.0": { 1646 + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 1647 + "dependencies": [ 1648 + "ansi-styles", 1649 + "string-width", 1650 + "strip-ansi" 1651 + ] 1652 + }, 1653 + "wrappy@1.0.2": { 1654 + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" 1655 + }, 1656 + "ws@8.18.3": { 1657 + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==" 1658 + }, 1659 + "y18n@5.0.8": { 1660 + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" 1661 + }, 1662 + "yargs-parser@21.1.1": { 1663 + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" 1664 + }, 1665 + "yargs@17.7.2": { 1666 + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", 1667 + "dependencies": [ 1668 + "cliui", 1669 + "escalade", 1670 + "get-caller-file", 1671 + "require-directory", 1672 + "string-width", 1673 + "y18n", 1674 + "yargs-parser" 1675 + ] 1676 + }, 1677 + "yn@3.1.1": { 1678 + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==" 1679 + }, 1680 + "yocto-queue@1.2.1": { 1681 + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==" 1682 + }, 1683 + "zod-to-json-schema@3.24.6_zod@3.25.76": { 1684 + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", 1685 + "dependencies": [ 1686 + "zod" 1687 + ] 1688 + }, 164 1689 "zod@3.25.76": { 165 1690 "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 166 1691 } 167 1692 }, 168 1693 "workspace": { 1694 + "dependencies": [ 1695 + "jsr:@std/expect@^1.0.17", 1696 + "npm:@atcute/lexicons@^1.2.2" 1697 + ], 169 1698 "members": { 170 1699 "packages/consumer": { 171 1700 "dependencies": [ 1701 + "jsr:@puregarlic/randimal@^1.1.1", 1702 + "jsr:@std/expect@^1.0.17", 172 1703 "npm:@atcute/client@^4.0.5", 173 - "npm:@atcute/lexicons@^1.2.2" 1704 + "npm:@atcute/jetstream@^1.1.2", 1705 + "npm:@atcute/lexicons@^1.2.2", 1706 + "npm:@atcute/tid@^1.0.3" 174 1707 ] 175 1708 }, 176 1709 "packages/crypto": { ··· 188 1721 "npm:@atproto/lexicon@~0.5.1" 189 1722 ] 190 1723 }, 1724 + "packages/mcp": { 1725 + "dependencies": [ 1726 + "jsr:@hono/hono@^4.10.5", 1727 + "jsr:@logtape/logtape@^1.2.0", 1728 + "jsr:@std/cli@^1.0.23", 1729 + "npm:@modelcontextprotocol/sdk@^1.21.1", 1730 + "npm:fetch-to-node@^2.1.0", 1731 + "npm:zod@^3.25.76" 1732 + ] 1733 + }, 191 1734 "packages/producer": { 192 1735 "dependencies": [ 193 - "npm:@atcute/atproto@^3.1.9", 1736 + "jsr:@std/expect@^1.0.17", 194 1737 "npm:@atcute/client@^4.0.5", 195 - "npm:@atcute/lexicons@^1.2.2" 1738 + "npm:@atcute/lexicons@^1.2.2", 1739 + "npm:@atcute/tid@^1.0.3" 196 1740 ] 197 1741 }, 198 1742 "packages/shared": { 199 1743 "dependencies": [ 1744 + "npm:@atcute/atproto@^3.1.9", 200 1745 "npm:@atcute/client@^4.0.5", 201 1746 "npm:@atcute/lexicons@^1.2.2" 202 1747 ]
+248
e2e.test.ts
··· 1 + import { expect } from "@std/expect"; 2 + import { createConsumer } from "@cistern/consumer"; 3 + import { createProducer } from "@cistern/producer"; 4 + import type { Handle } from "@atcute/lexicons"; 5 + 6 + /** 7 + * End-to-end integration test for Cistern 8 + * 9 + * This test requires the following environment variables: 10 + * - CISTERN_HANDLE: Your Bluesky handle (e.g., "user.bsky.social") 11 + * - CISTERN_APP_PASSWORD: Your app password 12 + * 13 + * To run this test: 14 + * ```bash 15 + * CISTERN_HANDLE="your.handle" CISTERN_APP_PASSWORD="your-app-password" deno test --allow-env --allow-net e2e.test.ts 16 + * ``` 17 + */ 18 + 19 + const SKIP_E2E = !Deno.env.get("CISTERN_HANDLE") || 20 + !Deno.env.get("CISTERN_APP_PASSWORD"); 21 + 22 + Deno.test({ 23 + name: "E2E: Full encryption workflow", 24 + ignore: SKIP_E2E, 25 + async fn(t) { 26 + const handle = Deno.env.get("CISTERN_HANDLE") as Handle; 27 + const appPassword = Deno.env.get("CISTERN_APP_PASSWORD")!; 28 + 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 () => { 36 + consumer = await createConsumer({ 37 + handle, 38 + appPassword, 39 + }); 40 + 41 + expect(consumer.did).toBeDefined(); 42 + expect(consumer.rpc).toBeDefined(); 43 + }); 44 + 45 + await t.step("Generate keypair", async () => { 46 + keypair = await consumer.generateKeyPair(); 47 + 48 + expect(keypair.privateKey).toBeInstanceOf(Uint8Array); 49 + expect(keypair.publicKey).toBeDefined(); 50 + expect(keypair.publicKey).toContain("app.cistern.pubkey"); 51 + }); 52 + 53 + try { 54 + await t.step("Create producer with public key", async () => { 55 + const publicKeyRkey = keypair.publicKey.split("/").pop()!; 56 + producer = await createProducer({ 57 + handle, 58 + appPassword, 59 + publicKey: publicKeyRkey, 60 + }); 61 + 62 + expect(producer.publicKey).toBeDefined(); 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 () => { 104 + const keys = []; 105 + for await (const key of producer.listPublicKeys()) { 106 + keys.push(key); 107 + } 108 + 109 + expect(keys.length).toBeGreaterThan(0); 110 + 111 + const ourKey = keys.find((key) => key.uri === keypair.publicKey); 112 + expect(ourKey).toBeDefined(); 113 + expect(ourKey!.uri).toEqual(keypair.publicKey); 114 + }); 115 + } finally { 116 + await t.step("Cleanup: Delete test keypair", async () => { 117 + const publicKeyRkey = keypair.publicKey.split("/").pop()!; 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 + }, 125 + }); 126 + 127 + expect(res.ok).toBe(true); 128 + }); 129 + } 130 + }, 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; 138 + const appPassword = Deno.env.get("CISTERN_APP_PASSWORD")!; 139 + 140 + let consumer: Awaited<ReturnType<typeof createConsumer>>; 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({ 148 + handle, 149 + appPassword, 150 + }); 151 + 152 + keypair = await consumer.generateKeyPair(); 153 + 154 + expect(keypair.privateKey).toBeInstanceOf(Uint8Array); 155 + expect(keypair.publicKey).toBeDefined(); 156 + }); 157 + 158 + try { 159 + await t.step("Create producer", async () => { 160 + const publicKeyRkey = keypair.publicKey.split("/").pop()!; 161 + producer = await createProducer({ 162 + handle, 163 + appPassword, 164 + publicKey: publicKeyRkey, 165 + }); 166 + 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 { 220 + await t.step("Cleanup: Delete test keypair", async () => { 221 + const publicKeyRkey = keypair.publicKey.split("/").pop()!; 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 + }, 229 + }); 230 + 231 + expect(res.ok).toBe(true); 232 + }); 233 + } 234 + }, 235 + }); 236 + 237 + if (SKIP_E2E) { 238 + console.log(` 239 + โš ๏ธ E2E tests skipped - missing environment variables 240 + 241 + To run E2E tests, set the following environment variables: 242 + CISTERN_HANDLE="your.bsky.social" 243 + CISTERN_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx" 244 + 245 + Then run: 246 + deno test --allow-env --allow-net e2e.test.ts 247 + `); 248 + }
+52 -5
packages/consumer/README.md
··· 1 - # `@cistern/consumer` 1 + # @cistern/consumer 2 + 3 + Consumer client for retrieving, decrypting, and deleting Cistern memos. 4 + 5 + ## Usage 6 + 7 + ### Generate Keypair 8 + 9 + ```typescript 10 + import { createConsumer, serializeKey } from "@cistern/consumer"; 11 + 12 + const consumer = await createConsumer({ 13 + handle: "user.bsky.social", 14 + appPassword: "xxxx-xxxx-xxxx-xxxx", 15 + }); 16 + 17 + const keypair = await consumer.generateKeyPair(); 18 + 19 + console.log(`Public key URI: ${keypair.publicKey}`); 20 + console.log(`Private key: ${serializeKey(keypair.privateKey)}`); 21 + ``` 22 + 23 + ### Use Existing Keypair 24 + 25 + ```typescript 26 + import { createConsumer } from "@cistern/consumer"; 27 + 28 + const consumer = await createConsumer({ 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 + ``` 2 37 3 - The Consumer module is responsible for the following: 38 + ### List Memos (Polling) 4 39 5 - - Generating key pairs 6 - - Retrieving and decrypting items 7 - - Subscribing to Jetstream to monitor for items 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 + ```
+234
packages/consumer/client.ts
··· 1 + import { 2 + produceRequirements, 3 + type XRPCProcedures, 4 + type XRPCQueries, 5 + } from "@cistern/shared"; 6 + import { decryptText, generateKeys } from "@cistern/crypto"; 7 + import { generateRandomName } from "@puregarlic/randimal"; 8 + import { 9 + is, 10 + isResourceUri, 11 + parse, 12 + type RecordKey, 13 + type ResourceUri, 14 + } from "@atcute/lexicons"; 15 + import { JetstreamSubscription } from "@atcute/jetstream"; 16 + import type { Did } from "@atcute/lexicons/syntax"; 17 + import type { Client } from "@atcute/client"; 18 + import { AppCisternMemo, type AppCisternPubkey } from "@cistern/lexicon"; 19 + import type { 20 + ConsumerOptions, 21 + ConsumerParams, 22 + DecryptedMemo, 23 + LocalKeyPair, 24 + } from "./types.ts"; 25 + 26 + /** 27 + * Client for generating keys and decoding Cistern memos. 28 + */ 29 + export class Consumer { 30 + /** DID of the user this consumer acts on behalf of */ 31 + did: Did; 32 + 33 + /** `@atcute/client` instance with credential manager */ 34 + rpc: Client<XRPCQueries, XRPCProcedures>; 35 + 36 + /** Private key used for decrypting and the AT URI of its associated public key */ 37 + keypair?: LocalKeyPair; 38 + 39 + constructor(params: ConsumerParams) { 40 + this.did = params.miniDoc.did; 41 + this.keypair = params.options.keypair 42 + ? { 43 + privateKey: Uint8Array.fromBase64(params.options.keypair.privateKey), 44 + publicKey: params.options.keypair.publicKey as ResourceUri, 45 + } 46 + : undefined; 47 + this.rpc = params.rpc; 48 + } 49 + 50 + /** 51 + * Generates a key pair, uploading the public key to PDS and returning the pair. 52 + */ 53 + async generateKeyPair(): Promise<LocalKeyPair> { 54 + if (this.keypair) { 55 + throw new Error("client already has a key pair"); 56 + } 57 + 58 + const keys = generateKeys(); 59 + const name = await generateRandomName(); 60 + 61 + const record: AppCisternPubkey.Main = { 62 + $type: "app.cistern.pubkey", 63 + name, 64 + algorithm: "x_wing", 65 + content: { $bytes: keys.publicKey.toBase64() }, 66 + createdAt: new Date().toISOString(), 67 + }; 68 + const res = await this.rpc.post("com.atproto.repo.createRecord", { 69 + input: { 70 + collection: "app.cistern.pubkey", 71 + repo: this.did, 72 + record, 73 + }, 74 + }); 75 + 76 + if (!res.ok) { 77 + throw new Error( 78 + `failed to save public key: ${res.status} ${res.data.error}`, 79 + ); 80 + } 81 + 82 + const keypair = { 83 + privateKey: keys.secretKey, 84 + publicKey: res.data.uri, 85 + }; 86 + 87 + this.keypair = keypair; 88 + 89 + return keypair; 90 + } 91 + 92 + /** 93 + * Asynchronously iterate through memos in the user's PDS 94 + */ 95 + async *listMemos(): AsyncGenerator< 96 + DecryptedMemo, 97 + void, 98 + undefined 99 + > { 100 + if (!this.keypair) { 101 + throw new Error("no key pair set; generate a key before listing memos"); 102 + } 103 + 104 + let cursor: string | undefined; 105 + 106 + while (true) { 107 + const res = await this.rpc.get("com.atproto.repo.listRecords", { 108 + params: { 109 + collection: "app.cistern.memo", 110 + repo: this.did, 111 + cursor, 112 + }, 113 + }); 114 + 115 + if (!res.ok) { 116 + throw new Error( 117 + `failed to list memos: ${res.status} ${res.data.error}`, 118 + ); 119 + } 120 + 121 + cursor = res.data.cursor; 122 + 123 + for (const record of res.data.records) { 124 + const memo = parse(AppCisternMemo.mainSchema, record.value); 125 + 126 + if (memo.pubkey !== this.keypair.publicKey) continue; 127 + 128 + const decrypted = decryptText(this.keypair.privateKey, { 129 + nonce: memo.nonce.$bytes, 130 + cipherText: memo.ciphertext.$bytes, 131 + content: memo.payload.$bytes, 132 + hash: memo.contentHash.$bytes, 133 + length: memo.contentLength, 134 + }); 135 + 136 + yield { 137 + key: record.uri.split("/").pop() as RecordKey, 138 + tid: memo.tid, 139 + text: decrypted, 140 + }; 141 + } 142 + 143 + if (!cursor) return; 144 + } 145 + } 146 + 147 + /** 148 + * Subscribes to the Jetstreams for the user's memos. Pass `"stop"` into `subscription.next(...)` to cancel 149 + * @todo Allow specifying Jetstream endpoint 150 + */ 151 + async *subscribeToMemos(): AsyncGenerator< 152 + DecryptedMemo, 153 + void, 154 + "stop" | undefined 155 + > { 156 + if (!this.keypair) { 157 + throw new Error("no key pair set; generate a key before subscribing"); 158 + } 159 + 160 + const subscription = new JetstreamSubscription({ 161 + url: "wss://jetstream2.us-east.bsky.network", 162 + wantedCollections: ["app.cistern.memo"], 163 + wantedDids: [this.did], 164 + }); 165 + 166 + for await (const event of subscription) { 167 + if (event.kind === "commit" && event.commit.operation === "create") { 168 + const record = event.commit.record; 169 + 170 + if (!is(AppCisternMemo.mainSchema, record)) { 171 + continue; 172 + } 173 + 174 + if (record.pubkey !== this.keypair.publicKey) { 175 + continue; 176 + } 177 + 178 + const decrypted = decryptText(this.keypair.privateKey, { 179 + nonce: record.nonce.$bytes, 180 + cipherText: record.ciphertext.$bytes, 181 + content: record.payload.$bytes, 182 + hash: record.contentHash.$bytes, 183 + length: record.contentLength, 184 + }); 185 + 186 + const command = yield { 187 + key: event.commit.rkey, 188 + tid: record.tid, 189 + text: decrypted, 190 + }; 191 + 192 + if (command === "stop") return; 193 + } 194 + } 195 + } 196 + 197 + /** 198 + * Deletes a memo from the user's PDS by record key. 199 + */ 200 + async deleteMemo(key: RecordKey) { 201 + const res = await this.rpc.post("com.atproto.repo.deleteRecord", { 202 + input: { 203 + collection: "app.cistern.memo", 204 + repo: this.did, 205 + rkey: key, 206 + }, 207 + }); 208 + 209 + if (!res.ok) { 210 + throw new Error( 211 + `failed to delete memo ${key}: ${res.status} ${res.data.error}`, 212 + ); 213 + } 214 + } 215 + } 216 + 217 + /** 218 + * Creates a `Consumer` instance with all necessary requirements. This is the recommended way to construct a `Consumer`. 219 + * 220 + * @description Resolves the user's DID using Slingshot, instantiates an `@atcute/client` instance, creates an initial session, and then returns a new Consumer. 221 + * @param {ConsumerOptions} options - Information for constructing the underlying XRPC client 222 + * @returns {Promise<Consumer>} A Cistern consumer client with an authorized session 223 + */ 224 + export async function createConsumer( 225 + options: ConsumerOptions, 226 + ): Promise<Consumer> { 227 + const reqs = await produceRequirements(options); 228 + 229 + if (options.keypair && !isResourceUri(options.keypair.publicKey)) { 230 + throw new Error("provided public key is not a valid AT URI"); 231 + } 232 + 233 + return new Consumer(reqs); 234 + }
+10 -1
packages/consumer/deno.jsonc
··· 1 1 { 2 2 "name": "@cistern/consumer", 3 + "version": "1.0.3", 4 + "license": "MIT", 3 5 "exports": { 4 6 ".": "./mod.ts" 5 7 }, 8 + "publish": { 9 + "exclude": ["*.test.ts"] 10 + }, 6 11 "imports": { 7 12 "@atcute/client": "npm:@atcute/client@^4.0.5", 8 - "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2" 13 + "@atcute/jetstream": "npm:@atcute/jetstream@^1.1.2", 14 + "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2", 15 + "@atcute/tid": "npm:@atcute/tid@^1.0.3", 16 + "@puregarlic/randimal": "jsr:@puregarlic/randimal@^1.1.1", 17 + "@std/expect": "jsr:@std/expect@^1.0.17" 9 18 } 10 19 }
+479
packages/consumer/mod.test.ts
··· 1 + import { expect } from "@std/expect"; 2 + import { Consumer } from "./mod.ts"; 3 + import { encryptText, generateKeys } from "@cistern/crypto"; 4 + import type { ConsumerParams } from "./types.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 { AppCisternMemo } from "@cistern/lexicon"; 9 + import type { XRPCProcedures, XRPCQueries } from "@cistern/shared"; 10 + 11 + // Helper to create a mock Consumer instance 12 + function createMockConsumer( 13 + overrides?: Partial<ConsumerParams>, 14 + ): Consumer { 15 + const mockParams: ConsumerParams = { 16 + miniDoc: { 17 + did: "did:plc:test123" as Did, 18 + handle: "test.bsky.social" as Handle, 19 + pds: "https://test.pds.example", 20 + signing_key: "test-key", 21 + }, 22 + manager: {} as CredentialManager, 23 + rpc: createMockRpcClient(), 24 + options: { 25 + handle: "test.bsky.social" as Handle, 26 + appPassword: "test-password", 27 + }, 28 + ...overrides, 29 + }; 30 + 31 + return new Consumer(mockParams); 32 + } 33 + 34 + // Helper to create a mock RPC client 35 + function createMockRpcClient(): Client<XRPCQueries, XRPCProcedures> { 36 + return { 37 + get: () => { 38 + throw new Error("Mock RPC get not implemented"); 39 + }, 40 + post: () => { 41 + throw new Error("Mock RPC post not implemented"); 42 + }, 43 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 44 + } 45 + 46 + Deno.test({ 47 + name: "Consumer constructor initializes with provided params", 48 + fn() { 49 + const consumer = createMockConsumer(); 50 + 51 + expect(consumer.did).toEqual("did:plc:test123"); 52 + expect(consumer.keypair).toBeUndefined(); 53 + expect(consumer.rpc).toBeDefined(); 54 + }, 55 + }); 56 + 57 + Deno.test({ 58 + name: "Consumer constructor initializes with existing keypair", 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({ 66 + options: { 67 + handle: "test.bsky.social" as Handle, 68 + appPassword: "test-password", 69 + keypair: mockKeypair, 70 + }, 71 + }); 72 + 73 + expect(consumer.keypair).toBeDefined(); 74 + expect(consumer.keypair?.publicKey).toEqual(mockKeypair.publicKey); 75 + expect(consumer.keypair?.privateKey).toBeInstanceOf(Uint8Array); 76 + }, 77 + }); 78 + 79 + Deno.test({ 80 + name: "generateKeyPair creates and uploads a new keypair", 81 + async fn() { 82 + let capturedRecord: unknown; 83 + let capturedCollection: string | undefined; 84 + 85 + const mockRpc = { 86 + post: (endpoint: string, params: { input: unknown }) => { 87 + if (endpoint === "com.atproto.repo.createRecord") { 88 + const input = params.input as { 89 + collection: string; 90 + record: unknown; 91 + }; 92 + capturedCollection = input.collection; 93 + capturedRecord = input.record; 94 + 95 + return Promise.resolve({ 96 + ok: true, 97 + data: { 98 + uri: "at://did:plc:test/app.cistern.pubkey/generated123", 99 + }, 100 + }); 101 + } 102 + return Promise.resolve({ ok: false, status: 500, data: {} }); 103 + }, 104 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 105 + 106 + const consumer = createMockConsumer({ rpc: mockRpc }); 107 + const keypair = await consumer.generateKeyPair(); 108 + 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 + }, 122 + }); 123 + 124 + Deno.test({ 125 + name: "generateKeyPair throws when consumer already has a keypair", 126 + async fn() { 127 + const consumer = createMockConsumer({ 128 + options: { 129 + handle: "test.bsky.social" as Handle, 130 + appPassword: "test-password", 131 + keypair: { 132 + privateKey: new Uint8Array(32).toBase64(), 133 + publicKey: 134 + "at://did:plc:test/app.cistern.pubkey/existing" as ResourceUri, 135 + }, 136 + }, 137 + }); 138 + 139 + await expect(consumer.generateKeyPair()).rejects.toThrow( 140 + "client already has a key pair", 141 + ); 142 + }, 143 + }); 144 + 145 + Deno.test({ 146 + name: "generateKeyPair throws when upload fails", 147 + async fn() { 148 + const mockRpc = { 149 + post: () => 150 + Promise.resolve({ 151 + ok: false, 152 + status: 500, 153 + data: { error: "Internal Server Error" }, 154 + }), 155 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 156 + 157 + const consumer = createMockConsumer({ rpc: mockRpc }); 158 + 159 + await expect(consumer.generateKeyPair()).rejects.toThrow( 160 + "failed to save public key", 161 + ); 162 + }, 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 + 185 + const mockRpc = { 186 + get: (endpoint: string) => { 187 + if (endpoint === "com.atproto.repo.listRecords") { 188 + return Promise.resolve({ 189 + ok: true, 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, 208 + }, 209 + }); 210 + } 211 + return Promise.resolve({ ok: false, status: 500, data: {} }); 212 + }, 213 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 214 + 215 + const consumer = createMockConsumer({ 216 + rpc: mockRpc, 217 + options: { 218 + handle: "test.bsky.social" as Handle, 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 + 246 + const mockRpc = { 247 + get: (endpoint: string) => { 248 + if (endpoint === "com.atproto.repo.listRecords") { 249 + return Promise.resolve({ 250 + ok: true, 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, 270 + }, 271 + }); 272 + } 273 + return Promise.resolve({ ok: false, status: 500, data: {} }); 274 + }, 275 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 276 + 277 + const consumer = createMockConsumer({ 278 + rpc: mockRpc, 279 + options: { 280 + handle: "test.bsky.social" as Handle, 281 + appPassword: "test-password", 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(); 308 + const tid2 = now(); 309 + 310 + let callCount = 0; 311 + const mockRpc = { 312 + get: (endpoint: string, _params?: { params?: { cursor?: string } }) => { 313 + if (endpoint === "com.atproto.repo.listRecords") { 314 + callCount++; 315 + 316 + if (callCount === 1) { 317 + return Promise.resolve({ 318 + ok: true, 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", 337 + }, 338 + }); 339 + } else { 340 + return Promise.resolve({ 341 + ok: true, 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, 360 + }, 361 + }); 362 + } 363 + } 364 + return Promise.resolve({ ok: false, status: 500, data: {} }); 365 + }, 366 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 367 + 368 + const consumer = createMockConsumer({ 369 + rpc: mockRpc, 370 + options: { 371 + handle: "test.bsky.social" as Handle, 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: () => 397 + Promise.resolve({ 398 + ok: false, 399 + status: 401, 400 + data: { error: "Unauthorized" }, 401 + }), 402 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 403 + 404 + const consumer = createMockConsumer({ 405 + rpc: mockRpc, 406 + options: { 407 + handle: "test.bsky.social" as Handle, 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 + ); 430 + }, 431 + }); 432 + 433 + Deno.test({ 434 + name: "deleteMemo successfully deletes a memo", 435 + async fn() { 436 + let deletedRkey: string | undefined; 437 + 438 + const mockRpc = { 439 + post: (endpoint: string, params: { input: unknown }) => { 440 + if (endpoint === "com.atproto.repo.deleteRecord") { 441 + const input = params.input as { rkey: string }; 442 + deletedRkey = input.rkey; 443 + 444 + return Promise.resolve({ 445 + ok: true, 446 + data: {}, 447 + }); 448 + } 449 + return Promise.resolve({ ok: false, status: 500, data: {} }); 450 + }, 451 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 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: () => 466 + Promise.resolve({ 467 + ok: false, 468 + status: 404, 469 + data: { error: "Not Found" }, 470 + }), 471 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 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 + });
+2 -56
packages/consumer/mod.ts
··· 1 - import type { Client, CredentialManager } from "@atcute/client"; 2 - import { produceRequirements } from "@cistern/shared"; 3 - import type { Did } from "@atcute/lexicons/syntax"; 4 - import type { ConsumerOptions, ConsumerParams, LocalKeyPair } from "./types.ts"; 5 - 6 - export async function createConsumer( 7 - options: ConsumerOptions, 8 - ): Promise<Consumer> { 9 - const reqs = await produceRequirements(options); 10 - 11 - return new Consumer(reqs); 12 - } 13 - 14 - /** 15 - * Client for generating keys and decoding Cistern items. 16 - */ 17 - export class Consumer { 18 - did: Did; 19 - keypair?: LocalKeyPair; 20 - rpc: Client; 21 - manager: CredentialManager; 22 - 23 - constructor(params: ConsumerParams) { 24 - this.did = params.miniDoc.did; 25 - this.keypair = params.options.keypair; 26 - this.rpc = params.rpc; 27 - this.manager = params.manager; 28 - } 29 - 30 - /** 31 - * Generates a key pair, uploading the public key to PDS and returning the pair. 32 - * @todo Generate key pair 33 - * @todo Store private key in local registry 34 - * @todo Upload public key to PDS 35 - */ 36 - async generateKeyPair() {} 37 - 38 - /** 39 - * Returns an async iterator that returns pages of the user's items from their PDS. 40 - * @todo List items from repo 41 - */ 42 - async listItems() {} 43 - 44 - /** 45 - * Subscribes to the Jetstreams for the user's items. 46 - * @todo Add `@atcute/jetstream` dependency 47 - * @todo Return an async iterator 48 - */ 49 - async getItemSubscription() {} 50 - 51 - /** 52 - * Deletes an item from the user's PDS by record key. 53 - * @todo Delete item from PDS 54 - */ 55 - async deleteItem() {} 56 - } 1 + export * from "./client.ts"; 2 + export * from "./types.ts";
+36 -4
packages/consumer/types.ts
··· 1 1 import type { BaseClientOptions, ClientRequirements } from "@cistern/shared"; 2 - import type { AppCisternLexiconPubkey } from "@cistern/lexicon"; 2 + import type { RecordKey, ResourceUri, Tid } from "@atcute/lexicons"; 3 3 4 - export interface LocalKeyPair { 4 + /** 5 + * A locally-stored key pair suitable for storage 6 + */ 7 + export interface InputLocalKeyPair { 8 + /** An X-Wing private key, encoded in base64 */ 5 9 privateKey: string; 6 - publicKey: AppCisternLexiconPubkey.Main; 10 + 11 + /** An AT URI to the `app.cistern.pubkey` record derived from this private key */ 12 + publicKey: string; 7 13 } 8 14 15 + /** 16 + * InputLocalKeyPair, with `privateKey` decoded to a Uint8Array 17 + */ 18 + export interface LocalKeyPair { 19 + /** An X-Wing private key in raw byte format */ 20 + privateKey: Uint8Array; 21 + 22 + /** An AT URI to the `app.cistern.pubkey` record derived from this private key */ 23 + publicKey: ResourceUri; 24 + } 25 + 26 + /** Credentials and optional keypair for creating a Consumer client */ 9 27 export interface ConsumerOptions extends BaseClientOptions { 10 - keypair?: LocalKeyPair; 28 + /** Optional input keypair. If you do not provide this here, you will need to generate one after the client is instantiated */ 29 + keypair?: InputLocalKeyPair; 11 30 } 12 31 32 + /** Asynchronously-acquired parameters required to construct a Client. `createConsumer` will translate from `ConsumerOptions` to `ConsumerParams` for you */ 13 33 export type ConsumerParams = ClientRequirements<ConsumerOptions>; 34 + 35 + /** A simplified, encrypted memo */ 36 + export interface DecryptedMemo { 37 + /** Record key of this memo */ 38 + key: RecordKey; 39 + 40 + /** TID for when the memo was created */ 41 + tid: Tid; 42 + 43 + /** The original, decrypted contents of the memo */ 44 + text: string; 45 + }
+11
packages/crypto/README.md
··· 1 + # @cistern/crypto 2 + 3 + Post-quantum cryptographic primitives for Cistern. 4 + 5 + ## Algorithm 6 + 7 + **`x_wing-xchacha20_poly1305-sha3_512`** 8 + 9 + - **X-Wing KEM**: Post-quantum hybrid key encapsulation (ML-KEM-768 + X25519) 10 + - **XChaCha20-Poly1305**: Authenticated encryption 11 + - **SHA3-512**: Content integrity verification
+5
packages/crypto/deno.jsonc
··· 1 1 { 2 2 "name": "@cistern/crypto", 3 + "version": "1.0.0", 4 + "license": "MIT", 3 5 "exports": { 4 6 ".": "./mod.ts" 7 + }, 8 + "publish": { 9 + "exclude": ["*.test.ts"] 5 10 }, 6 11 "imports": { 7 12 "@noble/ciphers": "jsr:@noble/ciphers@^2.0.1",
+3 -1
packages/crypto/src/encrypt.ts
··· 8 8 publicKey: Uint8Array, 9 9 text: string, 10 10 ): EncryptedPayload { 11 - const { cipherText, sharedSecret } = XWing.encapsulate(publicKey); 11 + // Create a copy of the public key to prevent mutation by XWing.encapsulate 12 + const publicKeyCopy = new Uint8Array(publicKey); 13 + const { cipherText, sharedSecret } = XWing.encapsulate(publicKeyCopy); 12 14 const nonce = randomBytes(24); 13 15 const contentBytes = new TextEncoder().encode(text); 14 16 const cipher = xchacha20poly1305(sharedSecret, nonce);
+51
packages/crypto/src/integration.test.ts
··· 1 + import { expect } from "@std/expect"; 2 + import { generateKeys } from "./keys.ts"; 3 + import { encryptText } from "./encrypt.ts"; 4 + import { decryptText } from "./decrypt.ts"; 5 + 6 + Deno.test({ 7 + name: "encrypts and decrypts multiple messages with the same keypair", 8 + fn() { 9 + const keys = generateKeys(); 10 + 11 + const text1 = "First message"; 12 + const text2 = "Second message"; 13 + const text3 = "Third message"; 14 + 15 + const encrypted1 = encryptText(keys.publicKey, text1); 16 + const encrypted2 = encryptText(keys.publicKey, text2); 17 + const encrypted3 = encryptText(keys.publicKey, text3); 18 + 19 + expect(encrypted1.content.length).toBeGreaterThan(0); 20 + expect(encrypted2.content.length).toBeGreaterThan(0); 21 + expect(encrypted3.content.length).toBeGreaterThan(0); 22 + 23 + const decrypted1 = decryptText(keys.secretKey, encrypted1); 24 + const decrypted2 = decryptText(keys.secretKey, encrypted2); 25 + const decrypted3 = decryptText(keys.secretKey, encrypted3); 26 + 27 + expect(decrypted1).toEqual(text1); 28 + expect(decrypted2).toEqual(text2); 29 + expect(decrypted3).toEqual(text3); 30 + }, 31 + }); 32 + 33 + Deno.test({ 34 + name: "encrypts messages with reused public key reference", 35 + fn() { 36 + const keys = generateKeys(); 37 + const publicKey = keys.publicKey; 38 + 39 + const encrypted1 = encryptText(publicKey, "Message 1"); 40 + const encrypted2 = encryptText(publicKey, "Message 2"); 41 + const encrypted3 = encryptText(publicKey, "Message 3"); 42 + 43 + expect(encrypted1.cipherText).not.toEqual(encrypted2.cipherText); 44 + expect(encrypted2.cipherText).not.toEqual(encrypted3.cipherText); 45 + expect(encrypted1.cipherText).not.toEqual(encrypted3.cipherText); 46 + 47 + expect(decryptText(keys.secretKey, encrypted1)).toEqual("Message 1"); 48 + expect(decryptText(keys.secretKey, encrypted2)).toEqual("Message 2"); 49 + expect(decryptText(keys.secretKey, encrypted3)).toEqual("Message 3"); 50 + }, 51 + });
+10
packages/lexicon/README.md
··· 1 + # @cistern/lexicon 2 + 3 + AT Protocol lexicon definitions and TypeScript types for Cistern records. 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 |
+5
packages/lexicon/deno.jsonc
··· 1 1 { 2 2 "name": "@cistern/lexicon", 3 + "version": "1.0.0", 4 + "license": "MIT", 3 5 "exports": { 4 6 ".": "./mod.ts" 7 + }, 8 + "publish": { 9 + "exclude": ["*.test.ts"] 5 10 }, 6 11 "tasks": { 7 12 "generate": "deno run --allow-env --allow-sys --allow-read --allow-write npm:@atcute/lex-cli generate -c lex.config.ts"
-63
packages/lexicon/lexicons/app/cistern/lexicon/item.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": { 7 - "type": "record", 8 - "description": "An encrypted memo", 9 - "record": { 10 - "type": "object", 11 - "required": [ 12 - "tid", 13 - "ciphertext", 14 - "nonce", 15 - "algorithm", 16 - "pubkey", 17 - "payload", 18 - "contentLength", 19 - "contentHash" 20 - ], 21 - "properties": { 22 - "tid": { 23 - "type": "string", 24 - "description": "TID representing when this item was created", 25 - "format": "tid" 26 - }, 27 - "ciphertext": { 28 - "type": "string", 29 - "description": "Encapsulated shared ciphertext", 30 - "maxLength": 2000 31 - }, 32 - "nonce": { 33 - "type": "string", 34 - "description": "Base64-encoded nonce used for content encryption", 35 - "maxLength": 32 36 - }, 37 - "algorithm": { 38 - "type": "string", 39 - "description": "Algorithm used for encryption, in <kem>-<cipher>-<hash> format.", 40 - "knownValues": ["x_wing-xchacha20_poly1305-sha3_512"] 41 - }, 42 - "pubkey": { 43 - "type": "string", 44 - "description": "URI to the public key used to encrypt this item", 45 - "format": "at-uri" 46 - }, 47 - "payload": { 48 - "type": "string", 49 - "description": "Base64-encoded encrypted item contents" 50 - }, 51 - "contentLength": { 52 - "type": "integer", 53 - "description": "Original content length in bytes" 54 - }, 55 - "contentHash": { 56 - "type": "string", 57 - "description": "Base64-encoded hash of the decrypted contents. Verify this before accepting the decrypted message. The algorithm is identified under `algorithm`" 58 - } 59 - } 60 - } 61 - } 62 - } 63 - }
-35
packages/lexicon/lexicons/app/cistern/lexicon/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"], 12 - "properties": { 13 - "name": { 14 - "type": "string", 15 - "minGraphemes": 1, 16 - "description": "A memorable name for this public key. Avoid using revealing names, such as \"Graham's Macbook\"" 17 - }, 18 - "algorithm": { 19 - "type": "string", 20 - "knownValues": ["x_wing"], 21 - "description": "KEM algorithm used to generate this key" 22 - }, 23 - "content": { 24 - "type": "string", 25 - "description": "Contents of the public key, encoded in base64" 26 - }, 27 - "createdAt": { 28 - "type": "string", 29 - "format": "datetime" 30 - } 31 - } 32 - } 33 - } 34 - } 35 - }
+61
packages/lexicon/lexicons/app/cistern/memo.json
··· 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": { 7 + "type": "record", 8 + "description": "An encrypted memo", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "tid", 13 + "ciphertext", 14 + "nonce", 15 + "algorithm", 16 + "pubkey", 17 + "payload", 18 + "contentLength", 19 + "contentHash" 20 + ], 21 + "properties": { 22 + "tid": { 23 + "type": "string", 24 + "description": "TID representing when this memo was created", 25 + "format": "tid" 26 + }, 27 + "ciphertext": { 28 + "type": "bytes", 29 + "description": "Encapsulated shared ciphertext" 30 + }, 31 + "nonce": { 32 + "type": "bytes", 33 + "description": "Nonce used for content encryption" 34 + }, 35 + "algorithm": { 36 + "type": "string", 37 + "description": "Algorithm used for encryption, in <kem>-<cipher>-<hash> format.", 38 + "knownValues": ["x_wing-xchacha20_poly1305-sha3_512"] 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", 51 + "description": "Original content length in bytes" 52 + }, 53 + "contentHash": { 54 + "type": "bytes", 55 + "description": "Hash of the decrypted contents. Verify this before accepting the decrypted message. The algorithm is identified under `algorithm`" 56 + } 57 + } 58 + } 59 + } 60 + } 61 + }
+35
packages/lexicon/lexicons/app/cistern/pubkey.json
··· 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"], 12 + "properties": { 13 + "name": { 14 + "type": "string", 15 + "minGraphemes": 1, 16 + "description": "A memorable name for this public key. Avoid using revealing names, such as \"Graham's Macbook\"" 17 + }, 18 + "algorithm": { 19 + "type": "string", 20 + "knownValues": ["x_wing"], 21 + "description": "KEM algorithm used to generate this key" 22 + }, 23 + "content": { 24 + "type": "bytes", 25 + "description": "Contents of the public key" 26 + }, 27 + "createdAt": { 28 + "type": "string", 29 + "format": "datetime" 30 + } 31 + } 32 + } 33 + } 34 + } 35 + }
+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";
-64
packages/lexicon/src/types/app/cistern/lexicon/item.ts
··· 1 - import type {} from "@atcute/lexicons"; 2 - import * as v from "@atcute/lexicons/validations"; 3 - import type {} from "@atcute/lexicons/ambient"; 4 - 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 - */ 12 - algorithm: /*#__PURE__*/ v.string< 13 - "x_wing-xchacha20_poly1305-sha3_512" | (string & {}) 14 - >(), 15 - /** 16 - * Encapsulated shared ciphertext 17 - * @maxLength 2000 18 - */ 19 - ciphertext: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 20 - /*#__PURE__*/ v.stringLength(0, 2000), 21 - ]), 22 - /** 23 - * Base64-encoded hash of the decrypted contents. Verify this before accepting the decrypted message. The algorithm is identified under `algorithm` 24 - */ 25 - contentHash: /*#__PURE__*/ v.string(), 26 - /** 27 - * Original content length in bytes 28 - */ 29 - contentLength: /*#__PURE__*/ v.integer(), 30 - /** 31 - * Base64-encoded nonce used for content encryption 32 - * @maxLength 32 33 - */ 34 - nonce: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 35 - /*#__PURE__*/ v.stringLength(0, 32), 36 - ]), 37 - /** 38 - * Base64-encoded encrypted item contents 39 - */ 40 - payload: /*#__PURE__*/ v.string(), 41 - /** 42 - * URI to the public key used to encrypt this item 43 - */ 44 - pubkey: /*#__PURE__*/ v.resourceUriString(), 45 - /** 46 - * TID representing when this item was created 47 - */ 48 - tid: /*#__PURE__*/ v.tidString(), 49 - }), 50 - ); 51 - 52 - type main$schematype = typeof _mainSchema; 53 - 54 - export interface mainSchema extends main$schematype {} 55 - 56 - export const mainSchema = _mainSchema as mainSchema; 57 - 58 - export interface Main extends v.InferInput<typeof mainSchema> {} 59 - 60 - declare module "@atcute/lexicons/ambient" { 61 - interface Records { 62 - "app.cistern.lexicon.item": mainSchema; 63 - } 64 - }
-40
packages/lexicon/src/types/app/cistern/lexicon/pubkey.ts
··· 1 - import type {} from "@atcute/lexicons"; 2 - import * as v from "@atcute/lexicons/validations"; 3 - import type {} from "@atcute/lexicons/ambient"; 4 - 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 - */ 12 - algorithm: /*#__PURE__*/ v.string<"x_wing" | (string & {})>(), 13 - /** 14 - * Contents of the public key, encoded in base64 15 - */ 16 - content: /*#__PURE__*/ v.string(), 17 - createdAt: /*#__PURE__*/ v.datetimeString(), 18 - /** 19 - * A memorable name for this public key. Avoid using revealing names, such as "Graham's Macbook" 20 - * @minGraphemes 1 21 - */ 22 - name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 23 - /*#__PURE__*/ v.stringGraphemes(1), 24 - ]), 25 - }), 26 - ); 27 - 28 - type main$schematype = typeof _mainSchema; 29 - 30 - export interface mainSchema extends main$schematype {} 31 - 32 - export const mainSchema = _mainSchema as mainSchema; 33 - 34 - export interface Main extends v.InferInput<typeof mainSchema> {} 35 - 36 - declare module "@atcute/lexicons/ambient" { 37 - interface Records { 38 - "app.cistern.lexicon.pubkey": mainSchema; 39 - } 40 - }
+51
packages/lexicon/src/types/app/cistern/memo.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + 4 + const _mainSchema = /*#__PURE__*/ v.record( 5 + /*#__PURE__*/ v.string(), 6 + /*#__PURE__*/ v.object({ 7 + $type: /*#__PURE__*/ v.literal("app.cistern.memo"), 8 + /** 9 + * Algorithm used for encryption, in <kem>-<cipher>-<hash> format. 10 + */ 11 + algorithm: /*#__PURE__*/ v.string< 12 + "x_wing-xchacha20_poly1305-sha3_512" | (string & {}) 13 + >(), 14 + /** 15 + * Encapsulated shared ciphertext 16 + */ 17 + ciphertext: /*#__PURE__*/ v.bytes(), 18 + /** 19 + * Hash of the decrypted contents. Verify this before accepting the decrypted message. The algorithm is identified under `algorithm` 20 + */ 21 + contentHash: /*#__PURE__*/ v.bytes(), 22 + /** 23 + * Original content length in bytes 24 + */ 25 + contentLength: /*#__PURE__*/ v.integer(), 26 + /** 27 + * Nonce used for content encryption 28 + */ 29 + nonce: /*#__PURE__*/ v.bytes(), 30 + /** 31 + * Encrypted memo contents 32 + */ 33 + payload: /*#__PURE__*/ v.bytes(), 34 + /** 35 + * URI to the public key used to encrypt this memo 36 + */ 37 + pubkey: /*#__PURE__*/ v.resourceUriString(), 38 + /** 39 + * TID representing when this memo was created 40 + */ 41 + tid: /*#__PURE__*/ v.tidString(), 42 + }), 43 + ); 44 + 45 + type main$schematype = typeof _mainSchema; 46 + 47 + export interface mainSchema extends main$schematype {} 48 + 49 + export const mainSchema = _mainSchema as mainSchema; 50 + 51 + export interface Main extends v.InferInput<typeof mainSchema> {}
+33
packages/lexicon/src/types/app/cistern/pubkey.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + 4 + const _mainSchema = /*#__PURE__*/ v.record( 5 + /*#__PURE__*/ v.string(), 6 + /*#__PURE__*/ v.object({ 7 + $type: /*#__PURE__*/ v.literal("app.cistern.pubkey"), 8 + /** 9 + * KEM algorithm used to generate this key 10 + */ 11 + algorithm: /*#__PURE__*/ v.string<"x_wing" | (string & {})>(), 12 + /** 13 + * Contents of the public key 14 + */ 15 + content: /*#__PURE__*/ v.bytes(), 16 + createdAt: /*#__PURE__*/ v.datetimeString(), 17 + /** 18 + * A memorable name for this public key. Avoid using revealing names, such as "Graham's Macbook" 19 + * @minGraphemes 1 20 + */ 21 + name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 22 + /*#__PURE__*/ v.stringGraphemes(1), 23 + ]), 24 + }), 25 + ); 26 + 27 + type main$schematype = typeof _mainSchema; 28 + 29 + export interface mainSchema extends main$schematype {} 30 + 31 + export const mainSchema = _mainSchema as mainSchema; 32 + 33 + export interface Main extends v.InferInput<typeof mainSchema> {}
+2
packages/mcp/.gitignore
··· 1 + .env 2 + cistern-mcp.db*
+182
packages/mcp/README.md
··· 1 + # @cistern/mcp 2 + 3 + Model Context Protocol (MCP) server for Cistern, enabling AI assistants to retrieve and manage encrypted memos. 4 + 5 + ## Features 6 + 7 + - **Dual Transport Support**: stdio for local integrations (Claude Desktop) and HTTP for remote deployments 8 + - **Automatic Keypair Management**: Generates and persists keypairs in Deno KV on first launch 9 + - **Two MCP Tools**: 10 + - `next_memo`: Retrieve the next outstanding memo 11 + - `delete_memo`: Delete a memo after handling it 12 + 13 + ## Installation 14 + 15 + ### Prerequisites 16 + 17 + - Deno 2.0+ 18 + - AT Protocol account with app password 19 + - Bluesky handle (e.g., `yourname.bsky.social`) 20 + 21 + ### Environment Variables 22 + 23 + **Required:** 24 + ```bash 25 + CISTERN_MCP_HANDLE=yourname.bsky.social 26 + CISTERN_MCP_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx 27 + ``` 28 + 29 + **Optional (for existing keypair):** 30 + ```bash 31 + CISTERN_MCP_PRIVATE_KEY=base64-encoded-private-key 32 + CISTERN_MCP_PUBLIC_KEY_URI=at://did:plc:abc.../app.cistern.pubkey/xyz 33 + ``` 34 + 35 + **Required for HTTP mode:** 36 + ```bash 37 + CISTERN_MCP_BEARER_TOKEN=your-secret-bearer-token 38 + ``` 39 + 40 + ## Usage 41 + 42 + ### stdio Mode (Claude Desktop) 43 + 44 + Run the server in stdio mode for local integrations: 45 + 46 + ```bash 47 + cd packages/mcp 48 + deno task stdio 49 + ``` 50 + 51 + Add to Claude Desktop configuration (`~/Library/Application Support/Claude/claude_desktop_config.json`): 52 + 53 + ```json 54 + { 55 + "mcpServers": { 56 + "cistern": { 57 + "command": "deno", 58 + "args": [ 59 + "task", 60 + "--cwd", 61 + "/path/to/cistern/packages/mcp", 62 + "stdio" 63 + ], 64 + "env": { 65 + "CISTERN_MCP_HANDLE": "yourname.bsky.social", 66 + "CISTERN_MCP_APP_PASSWORD": "xxxx-xxxx-xxxx-xxxx" 67 + } 68 + } 69 + } 70 + } 71 + ``` 72 + 73 + ### HTTP Mode (Remote Deployment) 74 + 75 + Run the server in HTTP mode for remote access: 76 + 77 + ```bash 78 + cd packages/mcp 79 + deno task http 80 + ``` 81 + 82 + The server listens on port 8000 by default. Configure your MCP client to connect via HTTP: 83 + 84 + ```json 85 + { 86 + "url": "http://localhost:8000/mcp", 87 + "headers": { 88 + "Authorization": "Bearer your-secret-bearer-token" 89 + } 90 + } 91 + ``` 92 + 93 + ## Keypair Management 94 + 95 + On first launch without `CISTERN_MCP_PRIVATE_KEY` and `CISTERN_MCP_PUBLIC_KEY_URI`, the server will: 96 + 97 + 1. Check Deno KV for a stored keypair (keyed by handle) 98 + 2. If not found, generate a new X-Wing keypair 99 + 3. Upload the public key to your PDS as an `app.cistern.pubkey` record 100 + 4. Store the keypair in Deno KV at `./cistern-mcp.db` 101 + 5. Log the public key URI for reference 102 + 103 + The keypair persists across restarts and is isolated per handle. 104 + 105 + ### Example First Launch Log 106 + 107 + ``` 108 + [cistern:mcp] starting in stdio mode 109 + [cistern:mcp] no keypair found; generating new keypair for yourname.bsky.social 110 + [cistern:mcp] generated new keypair with public key URI: at://did:plc:abc123.../app.cistern.pubkey/xyz789 111 + [cistern:mcp] stored keypair for yourname.bsky.social 112 + ``` 113 + 114 + ### Example Subsequent Launch Log 115 + 116 + ``` 117 + [cistern:mcp] starting in stdio mode 118 + [cistern:mcp] using stored keypair for yourname.bsky.social 119 + ``` 120 + 121 + ## MCP Tools 122 + 123 + ### `next_memo` 124 + 125 + Retrieves the next outstanding memo from your PDS. 126 + 127 + **Output:** 128 + ```json 129 + { 130 + "key": "3kbxyz789abc", 131 + "tid": "3kbxyz789abc", 132 + "text": "Remember to buy milk" 133 + } 134 + ``` 135 + 136 + Returns `"no memos remaining"` when all memos have been retrieved. 137 + 138 + ### `delete_memo` 139 + 140 + Deletes a memo by record key after it has been handled. 141 + 142 + **Input:** 143 + ```json 144 + { 145 + "key": "3kbxyz789abc" 146 + } 147 + ``` 148 + 149 + **Output:** 150 + ```json 151 + { 152 + "success": true 153 + } 154 + ``` 155 + 156 + ## Development 157 + 158 + ### Testing with MCP Inspector 159 + 160 + ```bash 161 + deno task stdio:inspect 162 + ``` 163 + 164 + This launches the MCP Inspector UI for interactive testing of the stdio server. 165 + 166 + ### Logs 167 + 168 + The server uses LogTape for structured logging: 169 + 170 + - **`[cistern:mcp]`**: Server lifecycle, keypair operations 171 + - **`[cistern:http]`**: HTTP request/response logs (HTTP mode only) 172 + 173 + ## Security 174 + 175 + - **Bearer Authentication**: Required for HTTP mode 176 + - **Private Keys**: Never transmitted; stored locally in Deno KV 177 + - **Session Isolation**: Each HTTP session gets its own Consumer instance 178 + - **CORS**: Configured for MCP protocol headers 179 + 180 + ## Limitations 181 + 182 + - **No Keypair Deletion**: The Consumer SDK doesn't currently support deleting public keys from the PDS. If you want to use a different keypair, you can either set `CISTERN_MCP_PRIVATE_KEY` and `CISTERN_MCP_PUBLIC_KEY_URI` environment variables, or delete the `cistern-mcp.db` SQLite files to force regeneration. You'll need to manually delete the old public key record from your PDS using a tool like [pdsls.dev](https://pdsls.dev).
+29
packages/mcp/deno.jsonc
··· 1 + { 2 + "name": "@cistern/mcp", 3 + "version": "1.0.0", 4 + "license": "MIT", 5 + "exports": { 6 + ".": "./index.ts" 7 + }, 8 + "tasks": { 9 + "inspector": "npx @modelcontextprotocol/inspector", 10 + "http": "deno -P --allow-net --env-file ./index.ts --http", 11 + "stdio": "deno -P --env-file ./index.ts", 12 + "stdio:inspect": "npx @modelcontextprotocol/inspector deno task stdio" 13 + }, 14 + "permissions": { 15 + "default": { 16 + "env": true, 17 + "read": ["./cistern-mcp.db"], 18 + "write": ["./cistern-mcp.db"] 19 + } 20 + }, 21 + "imports": { 22 + "hono": "jsr:@hono/hono@^4.10.5", 23 + "@logtape/logtape": "jsr:@logtape/logtape@^1.2.0", 24 + "@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.21.1", 25 + "@std/cli": "jsr:@std/cli@^1.0.23", 26 + "fetch-to-node": "npm:fetch-to-node@^2.1.0", 27 + "zod": "npm:zod@^3.25.76" 28 + } 29 + }
+29
packages/mcp/env.ts
··· 1 + import { getLogger } from "@logtape/logtape"; 2 + import type { ConsumerOptions } from "@cistern/consumer"; 3 + 4 + export function collectOptions(): ConsumerOptions { 5 + const logger = getLogger(["cistern", "mcp"]); 6 + const handle = Deno.env.get("CISTERN_MCP_HANDLE"); 7 + const appPassword = Deno.env.get("CISTERN_MCP_APP_PASSWORD"); 8 + 9 + if (!handle || !appPassword) { 10 + logger.error( 11 + "CISTERN_MCP_HANDLE or CISTERN_MCP_APP_PASSWORD are not set in the environment", 12 + ); 13 + return Deno.exit(1); 14 + } 15 + 16 + const privateKey = Deno.env.get("CISTERN_MCP_PRIVATE_KEY"); 17 + const publicKeyUri = Deno.env.get("CISTERN_MCP_PUBLIC_KEY_URI"); 18 + 19 + return { 20 + appPassword, 21 + handle, 22 + keypair: privateKey && publicKeyUri 23 + ? { 24 + privateKey, 25 + publicKey: publicKeyUri, 26 + } 27 + : undefined, 28 + }; 29 + }
+135
packages/mcp/hono.ts
··· 1 + import { Hono } from "hono"; 2 + import { cors } from "hono/cors"; 3 + import { bearerAuth } from "hono/bearer-auth"; 4 + import { getLogger, withContext } from "@logtape/logtape"; 5 + import { toFetchResponse, toReqRes } from "fetch-to-node"; 6 + import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; 7 + import { collectOptions } from "./env.ts"; 8 + import { createServer } from "./server.ts"; 9 + 10 + export function createApp() { 11 + const app = new Hono(); 12 + const logger = getLogger(["cistern", "http"]); 13 + const sessions = new Map<string, StreamableHTTPServerTransport>(); 14 + 15 + const token = Deno.env.get("CISTERN_MCP_BEARER_TOKEN"); 16 + 17 + if (!token) { 18 + logger.error("http mode requires CISTERN_MCP_BEARER_TOKEN to be set"); 19 + return Deno.exit(1); 20 + } 21 + 22 + app.all( 23 + "/mcp", 24 + cors({ 25 + origin: "*", 26 + allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], 27 + allowHeaders: [ 28 + "Content-Type", 29 + "Authorization", 30 + "Mcp-Session-Id", 31 + "Mcp-Protocol-Version", 32 + ], 33 + exposeHeaders: ["Mcp-Session-Id"], 34 + }), 35 + ); 36 + 37 + app.use("/mcp", bearerAuth({ token })); 38 + app.use("*", async (c, next) => { 39 + const requestId = crypto.randomUUID(); 40 + const startTime = Date.now(); 41 + 42 + await withContext({ 43 + requestId, 44 + method: c.req.method, 45 + url: c.req.url, 46 + userAgent: c.req.header("User-Agent"), 47 + ipAddress: c.req.header("CF-Connecting-IP") || 48 + c.req.header("X-Forwarded-For"), 49 + }, async () => { 50 + logger.info("{method} request started", { 51 + method: c.req.method, 52 + url: c.req.url, 53 + requestId, 54 + }); 55 + 56 + await next(); 57 + 58 + const duration = Date.now() - startTime; 59 + 60 + logger.info("{status} request completed in {duration}ms", { 61 + status: c.res.status, 62 + duration, 63 + requestId, 64 + }); 65 + }); 66 + }); 67 + 68 + app.onError((err, c) => { 69 + logger.error("request error {error}", { 70 + error: { 71 + name: err.name, 72 + message: err.message, 73 + stack: err.stack, 74 + }, 75 + method: c.req.method, 76 + url: c.req.url, 77 + }); 78 + 79 + return c.json({ error: "internal server error" }, 500); 80 + }); 81 + 82 + app.post("/mcp", async (ctx) => { 83 + const sessionId = ctx.req.header("mcp-session-id") ?? crypto.randomUUID(); 84 + let session = sessions.get(sessionId); 85 + 86 + if (session) { 87 + logger.info("resuming session {sessionId}", { sessionId }); 88 + } else { 89 + logger.info("creating new session {sessionId}", { sessionId }); 90 + 91 + const options = collectOptions(); 92 + const server = await createServer(options); 93 + 94 + session = new StreamableHTTPServerTransport({ 95 + sessionIdGenerator: () => sessionId, 96 + }); 97 + 98 + session.onclose = () => { 99 + logger.info("closing session {sessionId}", { sessionId }); 100 + }; 101 + 102 + await server.connect(session); 103 + 104 + sessions.set(sessionId, session); 105 + } 106 + 107 + const { req, res } = toReqRes(ctx.req.raw); 108 + 109 + await session.handleRequest(req, res); 110 + 111 + return await toFetchResponse(res); 112 + }); 113 + 114 + app.on(["GET", "DELETE"], "/mcp", async (ctx) => { 115 + const sessionId = ctx.req.header("mcp-session-id") ?? ""; 116 + const session = sessions.get(sessionId); 117 + 118 + if (!session) { 119 + logger.info("{method} invalid session {sessionId}", { 120 + method: ctx.req.method, 121 + sessionId, 122 + }); 123 + 124 + return ctx.json({ error: "invalid or missing session" }, 401); 125 + } 126 + 127 + const { req, res } = toReqRes(ctx.req.raw); 128 + 129 + await session.handleRequest(req, res); 130 + 131 + return await toFetchResponse(res); 132 + }); 133 + 134 + return app; 135 + }
+62
packages/mcp/index.ts
··· 1 + import { parseArgs } from "@std/cli"; 2 + import { AsyncLocalStorage } from "node:async_hooks"; 3 + import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; 4 + import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 + 6 + import { createServer } from "./server.ts"; 7 + import { createApp } from "./hono.ts"; 8 + import { collectOptions } from "./env.ts"; 9 + 10 + async function main() { 11 + await configure({ 12 + sinks: { console: getConsoleSink() }, 13 + loggers: [ 14 + { 15 + category: ["cistern", "mcp"], 16 + lowestLevel: "trace", 17 + sinks: ["console"], 18 + }, 19 + { 20 + category: ["cistern", "http"], 21 + lowestLevel: "info", 22 + sinks: ["console"], 23 + }, 24 + ], 25 + contextLocalStorage: new AsyncLocalStorage(), 26 + }); 27 + 28 + const logger = getLogger(["cistern", "mcp"]); 29 + const args = parseArgs(Deno.args, { 30 + boolean: ["http"], 31 + }); 32 + 33 + if (!args.http) { 34 + logger.info("starting in stdio mode"); 35 + 36 + const options = collectOptions(); 37 + const server = await createServer(options); 38 + const transport = new StdioServerTransport(); 39 + 40 + await server.connect(transport); 41 + } else { 42 + logger.info("starting in streamable HTTP mode"); 43 + 44 + // Validate environment before starting the server 45 + collectOptions(); 46 + 47 + const app = createApp(); 48 + 49 + Deno.serve( 50 + { 51 + onListen(addr) { 52 + logger.info("http server listening at {hostname} on port {port}", { 53 + ...addr, 54 + }); 55 + }, 56 + }, 57 + app.fetch, 58 + ); 59 + } 60 + } 61 + 62 + await main();
+45
packages/mcp/kv.ts
··· 1 + import type { InputLocalKeyPair } from "@cistern/consumer"; 2 + import { getLogger } from "@logtape/logtape"; 3 + 4 + const KV_PATH = "./cistern-mcp.db"; 5 + 6 + let kv: Deno.Kv | undefined; 7 + 8 + async function getKv(): Promise<Deno.Kv> { 9 + if (!kv) { 10 + kv = await Deno.openKv(KV_PATH); 11 + } 12 + return kv; 13 + } 14 + 15 + export async function getStoredKeypair( 16 + handle: string, 17 + ): Promise<InputLocalKeyPair | null> { 18 + const logger = getLogger(["cistern", "mcp"]); 19 + const db = await getKv(); 20 + const result = await db.get<InputLocalKeyPair>([ 21 + "cistern", 22 + "keypairs", 23 + handle, 24 + ]); 25 + 26 + if (result.value) { 27 + logger.debug("found stored keypair for {handle}", { handle }); 28 + return result.value; 29 + } 30 + 31 + logger.debug("no stored keypair found for {handle}", { handle }); 32 + return null; 33 + } 34 + 35 + export async function storeKeypair( 36 + handle: string, 37 + keypair: InputLocalKeyPair, 38 + ): Promise<void> { 39 + const logger = getLogger(["cistern", "mcp"]); 40 + const db = await getKv(); 41 + 42 + await db.set(["cistern", "keypairs", handle], keypair); 43 + 44 + logger.info("stored keypair for {handle}", { handle }); 45 + }
+126
packages/mcp/server.ts
··· 1 + import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 + import { getLogger } from "@logtape/logtape"; 3 + import { z } from "zod"; 4 + import type { 5 + Consumer, 6 + ConsumerOptions, 7 + DecryptedMemo, 8 + } from "@cistern/consumer"; 9 + import { createConsumer } from "@cistern/consumer"; 10 + import { serializeKey } from "@cistern/crypto"; 11 + import { getStoredKeypair, storeKeypair } from "./kv.ts"; 12 + 13 + export async function createServer(options: ConsumerOptions) { 14 + const logger = getLogger(["cistern", "mcp"]); 15 + 16 + if (!options.keypair) { 17 + const storedKeypair = await getStoredKeypair(options.handle); 18 + if (storedKeypair) { 19 + logger.info("using stored keypair for {handle}", { 20 + handle: options.handle, 21 + }); 22 + options.keypair = storedKeypair; 23 + } 24 + } else { 25 + logger.info("using keypair from environment variables"); 26 + } 27 + 28 + const consumer = await createConsumer(options); 29 + 30 + if (!consumer.keypair) { 31 + logger.info("no keypair found; generating new keypair for {handle}", { 32 + handle: options.handle, 33 + }); 34 + 35 + const keypair = await consumer.generateKeyPair(); 36 + 37 + logger.info("generated new keypair with public key URI: {publicKey}", { 38 + publicKey: keypair.publicKey, 39 + }); 40 + 41 + await storeKeypair(options.handle, { 42 + privateKey: serializeKey(keypair.privateKey), 43 + publicKey: keypair.publicKey, 44 + }); 45 + } 46 + 47 + return _createServerWithConsumer(consumer); 48 + } 49 + 50 + function _createServerWithConsumer(consumer: Consumer) { 51 + const logger = getLogger("cistern-mcp"); 52 + const server = new McpServer({ 53 + name: "cistern-mcp", 54 + version: "1.0.0", 55 + }); 56 + 57 + let iterator: 58 + | AsyncGenerator<DecryptedMemo, void, "stop" | undefined> 59 + | undefined; 60 + 61 + server.registerTool( 62 + "next_memo", 63 + { 64 + title: "Next memo", 65 + description: "Retrieve the next outstanding memo", 66 + outputSchema: { key: z.string(), tid: z.string(), text: z.string() }, 67 + }, 68 + async () => { 69 + if (!iterator) { 70 + logger.trace("creating iterator"); 71 + iterator ??= consumer.listMemos(); 72 + } 73 + 74 + const res = await iterator.next(); 75 + 76 + if (res.done) { 77 + logger.trace("iterator done; cleaning up"); 78 + iterator = undefined; 79 + } 80 + 81 + return { 82 + content: [{ 83 + type: "text", 84 + text: res.value?.text 85 + ? `key: ${res.value.key}, text: ${res.value.text}` 86 + : "no memos remaining", 87 + }], 88 + structuredContent: { 89 + key: res.value?.key ?? "", 90 + tid: res.value?.tid ?? "", 91 + text: res.value?.text ?? "no memos remaining", 92 + }, 93 + }; 94 + }, 95 + ); 96 + 97 + server.registerTool( 98 + "delete_memo", 99 + { 100 + title: "Delete memo", 101 + description: 102 + "Delete a memo by record key, after it has been handled as instructed by the user", 103 + inputSchema: { key: z.string() }, 104 + outputSchema: { success: z.boolean() }, 105 + }, 106 + async ({ key }) => { 107 + try { 108 + await consumer.deleteMemo(key); 109 + 110 + return { 111 + content: [{ type: "text", text: "delete successful" }], 112 + structuredContent: { success: true }, 113 + }; 114 + } catch (error) { 115 + logger.error("failed to delete memo: {error}", { error }); 116 + 117 + return { 118 + content: [{ type: "text", text: "delete unsuccessful" }], 119 + structuredContent: { success: false }, 120 + }; 121 + } 122 + }, 123 + ); 124 + 125 + return server; 126 + }
+34
packages/producer/README.md
··· 1 + # @cistern/producer 2 + 3 + Producer client for creating and encrypting Cistern memos. 4 + 5 + ## Usage 6 + 7 + ```typescript 8 + import { createProducer } from "@cistern/producer"; 9 + 10 + const producer = await createProducer({ 11 + handle: "user.bsky.social", 12 + appPassword: "xxxx-xxxx-xxxx-xxxx", 13 + }); 14 + 15 + for await (const pubkey of producer.listPublicKeys()) { 16 + console.log(`${pubkey.name}: ${pubkey.uri}`); 17 + } 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: 25 + 26 + ```typescript 27 + const producer = await createProducer({ 28 + handle: "user.bsky.social", 29 + appPassword: "xxxx-xxxx-xxxx-xxxx", 30 + publicKey: "3jzfcijpj2z", 31 + }); 32 + 33 + const memoUri = await producer.createMemo("Hello, world!"); 34 + ```
+167
packages/producer/client.ts
··· 1 + import { produceRequirements } from "@cistern/shared"; 2 + import { encryptText } from "@cistern/crypto"; 3 + import type { 4 + ProducerOptions, 5 + ProducerParams, 6 + PublicKeyOption, 7 + } from "./types.ts"; 8 + import { type Did, parse, type ResourceUri } from "@atcute/lexicons"; 9 + import type { Client } from "@atcute/client"; 10 + import { now } from "@atcute/tid"; 11 + import { type AppCisternMemo, AppCisternPubkey } from "@cistern/lexicon"; 12 + 13 + /** 14 + * Creates a `Producer` instance with all necessary requirements. This is the recommended way to construct a `Producer`. 15 + * 16 + * @description Resolves the user's DID using Slingshot, instantiates an `@atcute/client` instance, creates an initial session, and returns a new `Producer`. If a pubkey record key is provided, it will be resolved and set as the active key. 17 + * @param {ProducerOptions} options - Information for constructing the underlying XRPC client 18 + * @returns {Promise<Producer>} A Cistern producer client with an authorized session 19 + */ 20 + export async function createProducer( 21 + { publicKey: rkey, ...opts }: ProducerOptions, 22 + ): Promise<Producer> { 23 + const reqs = await produceRequirements(opts); 24 + 25 + let publicKey: PublicKeyOption | undefined; 26 + if (rkey) { 27 + const res = await reqs.rpc.get("com.atproto.repo.getRecord", { 28 + params: { 29 + repo: reqs.miniDoc.did, 30 + rkey, 31 + collection: "app.cistern.pubkey", 32 + }, 33 + }); 34 + 35 + if (!res.ok) { 36 + throw new Error( 37 + `invalid public key record ID ${publicKey}, got: ${res.status} ${res.data.error}`, 38 + ); 39 + } 40 + 41 + const record = parse(AppCisternPubkey.mainSchema, res.data.value); 42 + 43 + publicKey = { 44 + uri: res.data.uri, 45 + name: record.name, 46 + content: record.content.$bytes, 47 + }; 48 + } 49 + 50 + return new Producer({ 51 + ...reqs, 52 + publicKey, 53 + }); 54 + } 55 + 56 + /** 57 + * A client for encrypting and creating Cistern memos. 58 + */ 59 + export class Producer { 60 + /** DID of the user this producer acts on behalf of */ 61 + did: Did; 62 + 63 + /** `@atcute/client` instance with credential manager */ 64 + rpc: Client; 65 + 66 + /** Partial public key record, used for encrypting items */ 67 + publicKey?: PublicKeyOption; 68 + 69 + constructor(params: ProducerParams) { 70 + this.did = params.miniDoc.did; 71 + this.rpc = params.rpc; 72 + this.publicKey = params.publicKey; 73 + } 74 + 75 + /** 76 + * Creates a memo and saves it as a record in the user's PDS. 77 + * @param {string} text - The contents of the memo you wish to create 78 + */ 79 + async createMemo(text: string): Promise<ResourceUri> { 80 + if (!this.publicKey) { 81 + throw new Error( 82 + "no public key set; select a public key before creating a memo", 83 + ); 84 + } 85 + 86 + const payload = encryptText( 87 + Uint8Array.fromBase64(this.publicKey.content), 88 + text, 89 + ); 90 + const record: AppCisternMemo.Main = { 91 + $type: "app.cistern.memo", 92 + tid: now(), 93 + algorithm: "x_wing-xchacha20_poly1305-sha3_512", 94 + ciphertext: { $bytes: payload.cipherText }, 95 + contentHash: { $bytes: payload.hash }, 96 + contentLength: payload.length, 97 + nonce: { $bytes: payload.nonce }, 98 + payload: { $bytes: payload.content }, 99 + pubkey: this.publicKey.uri, 100 + }; 101 + 102 + const res = await this.rpc.post("com.atproto.repo.createRecord", { 103 + input: { 104 + collection: "app.cistern.memo", 105 + repo: this.did, 106 + record, 107 + }, 108 + }); 109 + 110 + if (!res.ok) { 111 + throw new Error( 112 + `failed to create new memo: ${res.status} ${res.data.error}`, 113 + ); 114 + } 115 + 116 + return res.data.uri; 117 + } 118 + 119 + /** 120 + * Lists public keys registered in the user's PDS 121 + */ 122 + async *listPublicKeys(): AsyncGenerator< 123 + PublicKeyOption, 124 + void, 125 + void 126 + > { 127 + let cursor: string | undefined; 128 + 129 + while (true) { 130 + const res = await this.rpc.get("com.atproto.repo.listRecords", { 131 + params: { 132 + collection: "app.cistern.pubkey", 133 + repo: this.did, 134 + cursor, 135 + }, 136 + }); 137 + 138 + if (!res.ok) { 139 + throw new Error( 140 + `failed to list public keys: ${res.status} ${res.data.error}`, 141 + ); 142 + } 143 + 144 + cursor = res.data.cursor; 145 + 146 + for (const record of res.data.records) { 147 + const memo = parse(AppCisternPubkey.mainSchema, record.value); 148 + 149 + yield { 150 + uri: record.uri, 151 + content: memo.content.$bytes, 152 + name: memo.name, 153 + }; 154 + } 155 + 156 + if (!cursor) return; 157 + } 158 + } 159 + 160 + /** 161 + * Sets a public key as the main encryption key. This is not necessary to use if you instantiated the client with a public key. 162 + * @param {PublicKeyOption} key - The key you want to use for encryption 163 + */ 164 + selectPublicKey(key: PublicKeyOption) { 165 + this.publicKey = key; 166 + } 167 + }
+8 -2
packages/producer/deno.jsonc
··· 1 1 { 2 2 "name": "@cistern/producer", 3 + "version": "1.0.2", 4 + "license": "MIT", 3 5 "exports": { 4 6 ".": "./mod.ts" 7 + }, 8 + "publish": { 9 + "exclude": ["*.test.ts"] 5 10 }, 6 11 "imports": { 7 - "@atcute/atproto": "npm:@atcute/atproto@^3.1.9", 8 12 "@atcute/client": "npm:@atcute/client@^4.0.5", 9 - "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2" 13 + "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2", 14 + "@atcute/tid": "npm:@atcute/tid@^1.0.3", 15 + "@std/expect": "jsr:@std/expect@^1.0.17" 10 16 } 11 17 }
+345
packages/producer/mod.test.ts
··· 1 + import { expect } from "@std/expect"; 2 + import { Producer } from "./mod.ts"; 3 + import { generateKeys } from "@cistern/crypto"; 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 + import type { XRPCProcedures, XRPCQueries } from "@cistern/shared"; 9 + 10 + // Helper to create a mock Producer instance 11 + function createMockProducer( 12 + overrides?: Partial<ProducerParams>, 13 + ): Producer { 14 + const mockParams: ProducerParams = { 15 + miniDoc: { 16 + did: "did:plc:test123" as Did, 17 + handle: "test.bsky.social" as Handle, 18 + pds: "https://test.pds.example", 19 + signing_key: "test-key", 20 + }, 21 + manager: {} as CredentialManager, 22 + rpc: createMockRpcClient(), 23 + options: { 24 + handle: "test.bsky.social" as Handle, 25 + appPassword: "test-password", 26 + }, 27 + ...overrides, 28 + }; 29 + 30 + return new Producer(mockParams); 31 + } 32 + 33 + // Helper to create a mock RPC client 34 + function createMockRpcClient(): Client<XRPCQueries, XRPCProcedures> { 35 + return { 36 + get: () => { 37 + throw new Error("Mock RPC get not implemented"); 38 + }, 39 + post: () => { 40 + throw new Error("Mock RPC post not implemented"); 41 + }, 42 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 43 + } 44 + 45 + Deno.test({ 46 + name: "Producer constructor initializes with provided params", 47 + fn() { 48 + const producer = createMockProducer(); 49 + 50 + expect(producer.did).toEqual("did:plc:test123"); 51 + expect(producer.publicKey).toBeUndefined(); 52 + expect(producer.rpc).toBeDefined(); 53 + }, 54 + }); 55 + 56 + Deno.test({ 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 + }; 64 + 65 + const producer = createMockProducer({ 66 + publicKey: mockPublicKey, 67 + }); 68 + 69 + expect(producer.publicKey).toBeDefined(); 70 + expect(producer.publicKey?.uri).toEqual(mockPublicKey.uri); 71 + expect(producer.publicKey?.name).toEqual("Test Key"); 72 + }, 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; 80 + let capturedCollection: string | undefined; 81 + 82 + const mockRpc = { 83 + post: (endpoint: string, params: { input: unknown }) => { 84 + if (endpoint === "com.atproto.repo.createRecord") { 85 + const input = params.input as { 86 + collection: string; 87 + record: unknown; 88 + }; 89 + capturedCollection = input.collection; 90 + capturedRecord = input.record; 91 + 92 + return Promise.resolve({ 93 + ok: true, 94 + data: { 95 + uri: "at://did:plc:test/app.cistern.memo/memo123" as ResourceUri, 96 + }, 97 + }); 98 + } 99 + return Promise.resolve({ ok: false, status: 500, data: {} }); 100 + }, 101 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 102 + 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 = { 139 + post: () => 140 + Promise.resolve({ 141 + ok: false, 142 + status: 500, 143 + data: { error: "Internal Server Error" }, 144 + }), 145 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 146 + 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 + }); 161 + 162 + Deno.test({ 163 + name: "listPublicKeys yields public keys from PDS", 164 + async fn() { 165 + const mockRpc = { 166 + get: (endpoint: string) => { 167 + if (endpoint === "com.atproto.repo.listRecords") { 168 + return Promise.resolve({ 169 + ok: true, 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, 194 + }, 195 + }); 196 + } 197 + return Promise.resolve({ ok: false, status: 500, data: {} }); 198 + }, 199 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 200 + 201 + const producer = createMockProducer({ rpc: mockRpc }); 202 + 203 + const keys = []; 204 + for await (const key of producer.listPublicKeys()) { 205 + keys.push(key); 206 + } 207 + 208 + expect(keys).toHaveLength(2); 209 + expect(keys[0].name).toEqual("Key 1"); 210 + expect(keys[1].name).toEqual("Key 2"); 211 + }, 212 + }); 213 + 214 + Deno.test({ 215 + name: "listPublicKeys handles pagination", 216 + async fn() { 217 + let callCount = 0; 218 + const mockRpc = { 219 + get: (endpoint: string, _params?: { params?: { cursor?: string } }) => { 220 + if (endpoint === "com.atproto.repo.listRecords") { 221 + callCount++; 222 + 223 + if (callCount === 1) { 224 + return Promise.resolve({ 225 + ok: true, 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", 240 + }, 241 + }); 242 + } else { 243 + return Promise.resolve({ 244 + ok: true, 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, 259 + }, 260 + }); 261 + } 262 + } 263 + return Promise.resolve({ ok: false, status: 500, data: {} }); 264 + }, 265 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 266 + 267 + const producer = createMockProducer({ rpc: mockRpc }); 268 + 269 + const keys = []; 270 + for await (const key of producer.listPublicKeys()) { 271 + keys.push(key); 272 + } 273 + 274 + expect(keys).toHaveLength(2); 275 + expect(keys[0].name).toEqual("Key 1"); 276 + expect(keys[1].name).toEqual("Key 2"); 277 + expect(callCount).toEqual(2); 278 + }, 279 + }); 280 + 281 + Deno.test({ 282 + name: "listPublicKeys throws when request fails", 283 + async fn() { 284 + const mockRpc = { 285 + get: () => 286 + Promise.resolve({ 287 + ok: false, 288 + status: 401, 289 + data: { error: "Unauthorized" }, 290 + }), 291 + } as unknown as Client<XRPCQueries, XRPCProcedures>; 292 + 293 + const producer = createMockProducer({ rpc: mockRpc }); 294 + 295 + const iterator = producer.listPublicKeys(); 296 + await expect(iterator.next()).rejects.toThrow("failed to list public keys"); 297 + }, 298 + }); 299 + 300 + Deno.test({ 301 + name: "selectPublicKey sets the active public key", 302 + fn() { 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 + }; 310 + 311 + expect(producer.publicKey).toBeUndefined(); 312 + 313 + producer.selectPublicKey(mockPublicKey); 314 + 315 + expect(producer.publicKey).toBeDefined(); 316 + expect(producer.publicKey?.uri).toEqual(mockPublicKey.uri); 317 + expect(producer.publicKey?.name).toEqual("Selected Key"); 318 + }, 319 + }); 320 + 321 + Deno.test({ 322 + name: "selectPublicKey can change the active key", 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 + }, 330 + }); 331 + 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 + }; 339 + 340 + producer.selectPublicKey(newKey); 341 + 342 + expect(producer.publicKey?.name).toEqual("New Key"); 343 + expect(producer.publicKey?.uri).toEqual(newKey.uri); 344 + }, 345 + });
+2 -79
packages/producer/mod.ts
··· 1 - import { produceRequirements } from "@cistern/shared"; 2 - import type { 3 - LocalPublicKey, 4 - ProducerOptions, 5 - ProducerParams, 6 - } from "./types.ts"; 7 - import { type Did, is } from "@atcute/lexicons"; 8 - import type { Client, CredentialManager } from "@atcute/client"; 9 - import { AppCisternLexiconPubkey } from "@cistern/lexicon"; 10 - 11 - import type {} from "@atcute/atproto"; 12 - 13 - export async function createProducer( 14 - { publicKey, ...opts }: ProducerOptions, 15 - ): Promise<Producer> { 16 - const reqs = await produceRequirements(opts); 17 - 18 - let record: AppCisternLexiconPubkey.Main | undefined; 19 - if (typeof publicKey === "string") { 20 - // Fetch record contents from PDS 21 - const res = await reqs.rpc.get("com.atproto.repo.getRecord", { 22 - params: { 23 - repo: reqs.miniDoc.did, 24 - rkey: publicKey, 25 - collection: "app.cistern.lexicon.pubkey", 26 - }, 27 - }); 28 - 29 - if (!res.ok) { 30 - throw new Error( 31 - `invalid public key record ID ${publicKey}, got: ${res.status} ${res.data.error}`, 32 - ); 33 - } 34 - 35 - record = res.data.value as AppCisternLexiconPubkey.Main; 36 - } else if (is(AppCisternLexiconPubkey.mainSchema, publicKey)) { 37 - record = publicKey; 38 - } else if (publicKey) { 39 - throw new Error( 40 - "provided public key does not validate against lexicon schema", 41 - ); 42 - } 43 - 44 - return new Producer({ ...reqs, options: { ...opts, publicKey: record } }); 45 - } 46 - 47 - export class Producer { 48 - did: Did; 49 - rpc: Client; 50 - manager: CredentialManager; 51 - publicKey?: LocalPublicKey; 52 - 53 - constructor(params: ProducerParams) { 54 - this.did = params.miniDoc.did; 55 - this.rpc = params.rpc; 56 - this.manager = params.manager; 57 - this.publicKey = params.options.publicKey; 58 - } 59 - 60 - /** 61 - * Creates an item and saves it as a record in the user's PDS 62 - * @todo Error if there is no selected public key 63 - * @todo Construct valid item 64 - * @todo Save item to PDS 65 - */ 66 - async createItem() {} 67 - 68 - /** 69 - * Lists public keys registered in the user's PDS 70 - * @todo List public keys 71 - */ 72 - async listPublicKeys() {} 73 - 74 - /** 75 - * Sets a public key as the main encryption key 76 - * @todo Replace local key with provided key 77 - */ 78 - selectPublicKey() {} 79 - } 1 + export * from "./client.ts"; 2 + export * from "./types.ts";
+19 -8
packages/producer/types.ts
··· 1 1 import type { BaseClientOptions, ClientRequirements } from "@cistern/shared"; 2 - import type { AppCisternLexiconPubkey } from "@cistern/lexicon"; 3 - import type { RecordKey } from "@atcute/lexicons"; 2 + import type { ResourceUri } from "@atcute/lexicons"; 4 3 5 - export type LocalPublicKey = AppCisternLexiconPubkey.Main; 6 - 4 + /** Credentials and an optional public key, used for deriving `ProducerParams` */ 7 5 export interface ProducerOptions extends BaseClientOptions { 8 - publicKey?: RecordKey | LocalPublicKey; 6 + /** An optional record key to a Cistern public key. Assumed to be within the specified user's PDS, and retrieved before instantiation. You can omit this value if you intend to select a public key later */ 7 + publicKey?: string; 9 8 } 10 9 10 + /** Required parameters for constructing a `Producer`. These are automatically created for you in `createProducer` */ 11 11 export type ProducerParams = ClientRequirements<ProducerOptions> & { 12 - options: { 13 - publicKey?: AppCisternLexiconPubkey.Main; 14 - }; 12 + /** Optional public key and its contents */ 13 + publicKey?: PublicKeyOption; 15 14 }; 15 + 16 + /** A simplified public key, suitable for local storage */ 17 + export interface PublicKeyOption { 18 + /** Full AT-URI of this key */ 19 + uri: ResourceUri; 20 + 21 + /** Generated friendly name for this public key */ 22 + name: string; 23 + 24 + /** The contents of this public key, encoded in base64 */ 25 + content: string; 26 + }
+6
packages/shared/README.md
··· 1 + # @cistern/shared 2 + 3 + Shared authentication utilities for Cistern producer and consumer packages. 4 + 5 + Provides DID resolution via Slingshot and authenticated RPC client creation for 6 + AT Protocol operations.
+6
packages/shared/deno.jsonc
··· 1 1 { 2 2 "name": "@cistern/shared", 3 + "version": "1.0.2", 4 + "license": "MIT", 3 5 "exports": { 4 6 ".": "./mod.ts" 5 7 }, 8 + "publish": { 9 + "exclude": ["*.test.ts"] 10 + }, 6 11 "imports": { 12 + "@atcute/atproto": "npm:@atcute/atproto@^3.1.9", 7 13 "@atcute/client": "npm:@atcute/client@^4.0.5", 8 14 "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2" 9 15 }
+5
packages/shared/produce-requirements.ts
··· 1 1 import { Client, CredentialManager } from "@atcute/client"; 2 2 import { resolveMiniDoc } from "./resolve-did.ts"; 3 3 import type { BaseClientOptions, ClientRequirements } from "./types.ts"; 4 + import { isHandle } from "@atcute/lexicons/syntax"; 4 5 5 6 export async function produceRequirements<Options extends BaseClientOptions>( 6 7 options: Options, 7 8 ): Promise<ClientRequirements<Options>> { 9 + if (!isHandle(options.handle)) { 10 + throw new Error("provided handle is not valid"); 11 + } 12 + 8 13 const miniDoc = await resolveMiniDoc(options.handle); 9 14 const manager = new CredentialManager({ service: miniDoc.pds }); 10 15 const rpc = new Client({ handler: manager });
+1 -1
packages/shared/resolve-did.ts
··· 9 9 slingshotUrl ?? "https://slingshot.microcosm.blue", 10 10 ); 11 11 12 - url.searchParams.set("handle", handle); 12 + url.searchParams.set("identifier", handle); 13 13 14 14 const result = await fetch(url.toString()); 15 15
+18 -2
packages/shared/types.ts
··· 1 1 import type { Did, Handle } from "@atcute/lexicons"; 2 2 import type { Client, CredentialManager } from "@atcute/client"; 3 + import type { 4 + ComAtprotoRepoCreateRecord, 5 + ComAtprotoRepoDeleteRecord, 6 + ComAtprotoRepoGetRecord, 7 + ComAtprotoRepoListRecords, 8 + } from "@atcute/atproto"; 3 9 4 10 export interface MiniDoc { 5 11 did: Did; ··· 9 15 } 10 16 11 17 export interface BaseClientOptions { 12 - handle: Handle; 18 + handle: string; 13 19 appPassword: string; 14 20 } 15 21 22 + export interface XRPCQueries { 23 + "com.atproto.repo.getRecord": ComAtprotoRepoGetRecord.mainSchema; 24 + "com.atproto.repo.listRecords": ComAtprotoRepoListRecords.mainSchema; 25 + } 26 + 27 + export interface XRPCProcedures { 28 + "com.atproto.repo.createRecord": ComAtprotoRepoCreateRecord.mainSchema; 29 + "com.atproto.repo.deleteRecord": ComAtprotoRepoDeleteRecord.mainSchema; 30 + } 31 + 16 32 export interface ClientRequirements<Options extends BaseClientOptions> { 17 33 miniDoc: MiniDoc; 18 34 manager: CredentialManager; 19 - rpc: Client; 35 + rpc: Client<XRPCQueries, XRPCProcedures>; 20 36 options: Options; 21 37 }