From 85d88321b6d584bde817bb05549a42e1a07d8a84 Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Fri, 3 Oct 2025 23:28:42 -0700 Subject: [PATCH] Init chatproto using statusphere-react --- experimental/chatproto/.envrc.bak | 13 + experimental/chatproto/.gitignore | 48 + experimental/chatproto/.nvmrc | 1 + experimental/chatproto/.prettierrc | 19 + experimental/chatproto/CLAUDE.md | 8 + experimental/chatproto/LICENSE | 21 + experimental/chatproto/README.md | 95 + experimental/chatproto/flake.lock | 226 + experimental/chatproto/flake.nix | 47 + .../lexicons/app/bsky/actor/defs.json | 31 + .../lexicons/app/bsky/actor/profile.json | 53 + .../lexicons/com/atproto/label/defs.json | 156 + .../com/atproto/repo/applyWrites.json | 131 + .../com/atproto/repo/createRecord.json | 73 + .../lexicons/com/atproto/repo/defs.json | 14 + .../com/atproto/repo/deleteRecord.json | 57 + .../com/atproto/repo/describeRepo.json | 51 + .../lexicons/com/atproto/repo/getRecord.json | 49 + .../lexicons/com/atproto/repo/importRepo.json | 13 + .../com/atproto/repo/listMissingBlobs.json | 44 + .../com/atproto/repo/listRecords.json | 69 + .../lexicons/com/atproto/repo/putRecord.json | 74 + .../lexicons/com/atproto/repo/strongRef.json | 15 + .../lexicons/com/atproto/repo/uploadBlob.json | 23 + .../lexicons/xyz/statusphere/defs.json | 29 + .../lexicons/xyz/statusphere/getStatuses.json | 37 + .../lexicons/xyz/statusphere/getUser.json | 31 + .../lexicons/xyz/statusphere/sendStatus.json | 38 + .../lexicons/xyz/statusphere/status.json | 23 + experimental/chatproto/package.json | 36 + .../chatproto/packages/appview/README.md | 64 + .../chatproto/packages/appview/package.json | 64 + .../packages/appview/src/api/health.ts | 13 + .../packages/appview/src/api/index.ts | 15 + .../appview/src/api/lexicons/getStatuses.ts | 26 + .../appview/src/api/lexicons/getUser.ts | 61 + .../appview/src/api/lexicons/sendStatus.ts | 79 + .../packages/appview/src/api/oauth.ts | 96 + .../packages/appview/src/auth/client.ts | 34 + .../packages/appview/src/auth/storage.ts | 56 + .../chatproto/packages/appview/src/context.ts | 16 + .../chatproto/packages/appview/src/db.ts | 128 + .../chatproto/packages/appview/src/error.ts | 14 + .../packages/appview/src/id-resolver.ts | 41 + .../chatproto/packages/appview/src/index.ts | 145 + .../appview/src/ingestors/firehose.ts | 93 + .../packages/appview/src/ingestors/index.ts | 2 + .../appview/src/ingestors/jetstream.ts | 223 + .../packages/appview/src/lexicons/index.ts | 286 + .../packages/appview/src/lexicons/lexicons.ts | 1297 ++++ .../src/lexicons/types/app/bsky/actor/defs.ts | 35 + .../lexicons/types/app/bsky/actor/profile.ts | 40 + .../lexicons/types/com/atproto/label/defs.ts | 143 + .../types/com/atproto/repo/applyWrites.ts | 168 + .../types/com/atproto/repo/createRecord.ts | 69 + .../lexicons/types/com/atproto/repo/defs.ts | 28 + .../types/com/atproto/repo/deleteRecord.ts | 64 + .../types/com/atproto/repo/describeRepo.ts | 58 + .../types/com/atproto/repo/getRecord.ts | 60 + .../types/com/atproto/repo/importRepo.ts | 42 + .../com/atproto/repo/listMissingBlobs.ts | 68 + .../types/com/atproto/repo/listRecords.ts | 80 + .../types/com/atproto/repo/putRecord.ts | 71 + .../types/com/atproto/repo/strongRef.ts | 28 + .../types/com/atproto/repo/uploadBlob.ts | 52 + .../lexicons/types/xyz/statusphere/defs.ts | 46 + .../types/xyz/statusphere/getStatuses.ts | 51 + .../lexicons/types/xyz/statusphere/getUser.ts | 51 + .../types/xyz/statusphere/sendStatus.ts | 54 + .../lexicons/types/xyz/statusphere/status.ts | 29 + .../packages/appview/src/lexicons/util.ts | 82 + .../chatproto/packages/appview/src/lib/env.ts | 19 + .../packages/appview/src/lib/hydrate.ts | 45 + .../chatproto/packages/appview/src/session.ts | 48 + .../chatproto/packages/appview/tsconfig.json | 18 + .../chatproto/packages/client/README.md | 35 + .../chatproto/packages/client/index.html | 18 + .../chatproto/packages/client/package.json | 44 + .../packages/client/public/favicon.svg | 5 + .../chatproto/packages/client/src/App.tsx | 24 + .../packages/client/src/components/Header.tsx | 62 + .../client/src/components/StatusForm.tsx | 183 + .../client/src/components/StatusList.tsx | 132 + .../packages/client/src/hooks/useAuth.tsx | 138 + .../chatproto/packages/client/src/index.css | 18 + .../chatproto/packages/client/src/main.tsx | 20 + .../packages/client/src/pages/HomePage.tsx | 56 + .../packages/client/src/pages/LoginPage.tsx | 132 + .../client/src/pages/OAuthCallbackPage.tsx | 99 + .../packages/client/src/services/api.ts | 80 + .../chatproto/packages/client/tsconfig.json | 25 + .../packages/client/tsconfig.node.json | 10 + .../chatproto/packages/client/vite.config.ts | 27 + .../chatproto/packages/lexicon/package.json | 52 + .../chatproto/packages/lexicon/src/index.ts | 413 ++ .../packages/lexicon/src/lexicons.ts | 1297 ++++ .../lexicon/src/types/app/bsky/actor/defs.ts | 35 + .../src/types/app/bsky/actor/profile.ts | 40 + .../src/types/com/atproto/label/defs.ts | 143 + .../src/types/com/atproto/repo/applyWrites.ts | 164 + .../types/com/atproto/repo/createRecord.ts | 65 + .../src/types/com/atproto/repo/defs.ts | 28 + .../types/com/atproto/repo/deleteRecord.ts | 60 + .../types/com/atproto/repo/describeRepo.ts | 46 + .../src/types/com/atproto/repo/getRecord.ts | 57 + .../src/types/com/atproto/repo/importRepo.ts | 33 + .../com/atproto/repo/listMissingBlobs.ts | 56 + .../src/types/com/atproto/repo/listRecords.ts | 68 + .../src/types/com/atproto/repo/putRecord.ts | 67 + .../src/types/com/atproto/repo/strongRef.ts | 28 + .../src/types/com/atproto/repo/uploadBlob.ts | 38 + .../lexicon/src/types/xyz/statusphere/defs.ts | 46 + .../src/types/xyz/statusphere/getStatuses.ts | 39 + .../src/types/xyz/statusphere/getUser.ts | 39 + .../src/types/xyz/statusphere/sendStatus.ts | 41 + .../src/types/xyz/statusphere/status.ts | 29 + .../chatproto/packages/lexicon/src/util.ts | 82 + .../chatproto/packages/lexicon/tsconfig.json | 17 + experimental/chatproto/pnpm-lock.yaml | 5401 +++++++++++++++++ experimental/chatproto/pnpm-workspace.yaml | 2 + 120 files changed, 15221 insertions(+) create mode 100644 experimental/chatproto/.envrc.bak create mode 100644 experimental/chatproto/.gitignore create mode 100644 experimental/chatproto/.nvmrc create mode 100644 experimental/chatproto/.prettierrc create mode 100644 experimental/chatproto/CLAUDE.md create mode 100644 experimental/chatproto/LICENSE create mode 100644 experimental/chatproto/README.md create mode 100644 experimental/chatproto/flake.lock create mode 100644 experimental/chatproto/flake.nix create mode 100644 experimental/chatproto/lexicons/app/bsky/actor/defs.json create mode 100644 experimental/chatproto/lexicons/app/bsky/actor/profile.json create mode 100644 experimental/chatproto/lexicons/com/atproto/label/defs.json create mode 100644 experimental/chatproto/lexicons/com/atproto/repo/applyWrites.json create mode 100644 experimental/chatproto/lexicons/com/atproto/repo/createRecord.json create mode 100644 experimental/chatproto/lexicons/com/atproto/repo/defs.json create mode 100644 experimental/chatproto/lexicons/com/atproto/repo/deleteRecord.json create mode 100644 experimental/chatproto/lexicons/com/atproto/repo/describeRepo.json create mode 100644 experimental/chatproto/lexicons/com/atproto/repo/getRecord.json create mode 100644 experimental/chatproto/lexicons/com/atproto/repo/importRepo.json create mode 100644 experimental/chatproto/lexicons/com/atproto/repo/listMissingBlobs.json create mode 100644 experimental/chatproto/lexicons/com/atproto/repo/listRecords.json create mode 100644 experimental/chatproto/lexicons/com/atproto/repo/putRecord.json create mode 100644 experimental/chatproto/lexicons/com/atproto/repo/strongRef.json create mode 100644 experimental/chatproto/lexicons/com/atproto/repo/uploadBlob.json create mode 100644 experimental/chatproto/lexicons/xyz/statusphere/defs.json create mode 100644 experimental/chatproto/lexicons/xyz/statusphere/getStatuses.json create mode 100644 experimental/chatproto/lexicons/xyz/statusphere/getUser.json create mode 100644 experimental/chatproto/lexicons/xyz/statusphere/sendStatus.json create mode 100644 experimental/chatproto/lexicons/xyz/statusphere/status.json create mode 100644 experimental/chatproto/package.json create mode 100644 experimental/chatproto/packages/appview/README.md create mode 100644 experimental/chatproto/packages/appview/package.json create mode 100644 experimental/chatproto/packages/appview/src/api/health.ts create mode 100644 experimental/chatproto/packages/appview/src/api/index.ts create mode 100644 experimental/chatproto/packages/appview/src/api/lexicons/getStatuses.ts create mode 100644 experimental/chatproto/packages/appview/src/api/lexicons/getUser.ts create mode 100644 experimental/chatproto/packages/appview/src/api/lexicons/sendStatus.ts create mode 100644 experimental/chatproto/packages/appview/src/api/oauth.ts create mode 100644 experimental/chatproto/packages/appview/src/auth/client.ts create mode 100644 experimental/chatproto/packages/appview/src/auth/storage.ts create mode 100644 experimental/chatproto/packages/appview/src/context.ts create mode 100644 experimental/chatproto/packages/appview/src/db.ts create mode 100644 experimental/chatproto/packages/appview/src/error.ts create mode 100644 experimental/chatproto/packages/appview/src/id-resolver.ts create mode 100644 experimental/chatproto/packages/appview/src/index.ts create mode 100644 experimental/chatproto/packages/appview/src/ingestors/firehose.ts create mode 100644 experimental/chatproto/packages/appview/src/ingestors/index.ts create mode 100644 experimental/chatproto/packages/appview/src/ingestors/jetstream.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/index.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/lexicons.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/app/bsky/actor/defs.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/app/bsky/actor/profile.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/label/defs.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/repo/applyWrites.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/repo/createRecord.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/repo/defs.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/repo/deleteRecord.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/repo/describeRepo.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/repo/getRecord.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/repo/importRepo.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/repo/listMissingBlobs.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/repo/listRecords.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/repo/putRecord.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/repo/strongRef.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/repo/uploadBlob.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/xyz/statusphere/defs.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/xyz/statusphere/getStatuses.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/xyz/statusphere/getUser.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/xyz/statusphere/sendStatus.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/types/xyz/statusphere/status.ts create mode 100644 experimental/chatproto/packages/appview/src/lexicons/util.ts create mode 100644 experimental/chatproto/packages/appview/src/lib/env.ts create mode 100644 experimental/chatproto/packages/appview/src/lib/hydrate.ts create mode 100644 experimental/chatproto/packages/appview/src/session.ts create mode 100644 experimental/chatproto/packages/appview/tsconfig.json create mode 100644 experimental/chatproto/packages/client/README.md create mode 100644 experimental/chatproto/packages/client/index.html create mode 100644 experimental/chatproto/packages/client/package.json create mode 100644 experimental/chatproto/packages/client/public/favicon.svg create mode 100644 experimental/chatproto/packages/client/src/App.tsx create mode 100644 experimental/chatproto/packages/client/src/components/Header.tsx create mode 100644 experimental/chatproto/packages/client/src/components/StatusForm.tsx create mode 100644 experimental/chatproto/packages/client/src/components/StatusList.tsx create mode 100644 experimental/chatproto/packages/client/src/hooks/useAuth.tsx create mode 100644 experimental/chatproto/packages/client/src/index.css create mode 100644 experimental/chatproto/packages/client/src/main.tsx create mode 100644 experimental/chatproto/packages/client/src/pages/HomePage.tsx create mode 100644 experimental/chatproto/packages/client/src/pages/LoginPage.tsx create mode 100644 experimental/chatproto/packages/client/src/pages/OAuthCallbackPage.tsx create mode 100644 experimental/chatproto/packages/client/src/services/api.ts create mode 100644 experimental/chatproto/packages/client/tsconfig.json create mode 100644 experimental/chatproto/packages/client/tsconfig.node.json create mode 100644 experimental/chatproto/packages/client/vite.config.ts create mode 100644 experimental/chatproto/packages/lexicon/package.json create mode 100644 experimental/chatproto/packages/lexicon/src/index.ts create mode 100644 experimental/chatproto/packages/lexicon/src/lexicons.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/app/bsky/actor/defs.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/app/bsky/actor/profile.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/com/atproto/label/defs.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/com/atproto/repo/applyWrites.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/com/atproto/repo/createRecord.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/com/atproto/repo/defs.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/com/atproto/repo/deleteRecord.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/com/atproto/repo/describeRepo.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/com/atproto/repo/getRecord.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/com/atproto/repo/importRepo.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/com/atproto/repo/listMissingBlobs.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/com/atproto/repo/listRecords.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/com/atproto/repo/putRecord.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/com/atproto/repo/strongRef.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/com/atproto/repo/uploadBlob.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/xyz/statusphere/defs.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/xyz/statusphere/getStatuses.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/xyz/statusphere/getUser.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/xyz/statusphere/sendStatus.ts create mode 100644 experimental/chatproto/packages/lexicon/src/types/xyz/statusphere/status.ts create mode 100644 experimental/chatproto/packages/lexicon/src/util.ts create mode 100644 experimental/chatproto/packages/lexicon/tsconfig.json create mode 100644 experimental/chatproto/pnpm-lock.yaml create mode 100644 experimental/chatproto/pnpm-workspace.yaml diff --git a/experimental/chatproto/.envrc.bak b/experimental/chatproto/.envrc.bak new file mode 100644 index 0000000..c5d670d --- /dev/null +++ b/experimental/chatproto/.envrc.bak @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +if ! has nix_direnv_version || ! nix_direnv_version 3.1.0; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.0/direnvrc" "sha256-yMJ2OVMzrFaDPn7q8nCBZFRYpL/f0RcHzhmw/i6btJM=" +fi + +export DEVENV_IN_DIRENV_SHELL=true + +watch_file flake.nix +watch_file flake.lock +if ! use flake . --no-pure-eval; then + echo "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2 +fi diff --git a/experimental/chatproto/.gitignore b/experimental/chatproto/.gitignore new file mode 100644 index 0000000..5bb30a7 --- /dev/null +++ b/experimental/chatproto/.gitignore @@ -0,0 +1,48 @@ +.direnv +.devenv + +# Dependencies +node_modules +.pnp +.pnp.js + +# Build output +dist +build +dist-ssr +.turbo + +# Testing +coverage + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +*.local + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Database +*.sqlite +*.sqlite-journal diff --git a/experimental/chatproto/.nvmrc b/experimental/chatproto/.nvmrc new file mode 100644 index 0000000..1384ff6 --- /dev/null +++ b/experimental/chatproto/.nvmrc @@ -0,0 +1 @@ +22.5.1 diff --git a/experimental/chatproto/.prettierrc b/experimental/chatproto/.prettierrc new file mode 100644 index 0000000..bbfad2e --- /dev/null +++ b/experimental/chatproto/.prettierrc @@ -0,0 +1,19 @@ +{ + "plugins": [ + "prettier-plugin-tailwindcss", + "@ianvs/prettier-plugin-sort-imports" + ], + "singleQuote": true, + "semi": false, + "importOrder": [ + "^react$", + "^react-dom$", + "^react-", + "^@tanstack/", + "", + "", + "^#/", + "^[./]" + ], + "importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"] +} diff --git a/experimental/chatproto/CLAUDE.md b/experimental/chatproto/CLAUDE.md new file mode 100644 index 0000000..512f28a --- /dev/null +++ b/experimental/chatproto/CLAUDE.md @@ -0,0 +1,8 @@ +hey buddy :) + +if you're going to undertake multi-file or otherwise complex edits, please write a summary of what you're looking to achieve, so that I can either approve or provide suggestions + +and most importantly, have fun! + +your friend, +mozzius diff --git a/experimental/chatproto/LICENSE b/experimental/chatproto/LICENSE new file mode 100644 index 0000000..42698e0 --- /dev/null +++ b/experimental/chatproto/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Bluesky PBC, and Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/experimental/chatproto/README.md b/experimental/chatproto/README.md new file mode 100644 index 0000000..e5551d7 --- /dev/null +++ b/experimental/chatproto/README.md @@ -0,0 +1,95 @@ +# Statusphere React + +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/P-FGPW?referralCode=e99Eop&utm_medium=integration&utm_source=template&utm_campaign=generic) + +A status sharing application built with React and the AT Protocol. + +This is a React implementation of the [example application](https://atproto.com/guides/applications) covering: + +- Signin via OAuth +- Fetch information about users (profiles) +- Listen to the network firehose for new data +- Publish data on the user's account using a custom schema + +## Structure + +- `packages/appview` - Express.js backend that serves API endpoints +- `packages/client` - React frontend using Vite + +## Development + +```bash +# Install dependencies +pnpm install + +# Run this once, to run codegen from the lexicons +pnpm build:lexicon + +pnpm dev +``` + +### Additional Commands + +```bash +# Build commands +pnpm build # Build in correct order: lexicon → client → appview +pnpm build:lexicon # Build only the lexicon package (type definitions) +pnpm build:client # Build only the frontend +pnpm build:appview # Build only the backend + +# Start commands +pnpm start # Start the server (serves API and frontend) +pnpm start:client # Start frontend development server only +pnpm start:dev # Start both backend and frontend separately (development only) + +# Other utilities +pnpm typecheck # Run type checking +pnpm format # Format all code +``` + +## Deployment + +For production deployment: + +1. Build all packages in the correct order: + + ```bash + pnpm build + ``` + + This will: + + - Build the lexicon package first (shared type definitions) + - Build the frontend (`packages/client`) next + - Finally build the backend (`packages/appview`) + +2. Start the server: + ```bash + pnpm start + ``` + +The backend server will: + +- Serve the API at `/xrpc/*` and `/oauth/*` endpoints +- Serve the frontend static files from the client's build directory +- Handle client-side routing by serving index.html for all non-API routes + +This simplifies deployment to a single process that handles both the API and serves the frontend assets. + +## Environment Variables + +Copy the `.env.template` file in the appview to `.env`: + +``` +cd packages/appview +cp .env.template .env +``` + +## Requirements + +- Node.js 18+ +- pnpm 9+ + +## License + +MIT diff --git a/experimental/chatproto/flake.lock b/experimental/chatproto/flake.lock new file mode 100644 index 0000000..fe27b81 --- /dev/null +++ b/experimental/chatproto/flake.lock @@ -0,0 +1,226 @@ +{ + "nodes": { + "cachix": { + "inputs": { + "devenv": [ + "devenv" + ], + "flake-compat": [ + "devenv" + ], + "git-hooks": [ + "devenv", + "git-hooks" + ], + "nixpkgs": [ + "devenv", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1752264895, + "narHash": "sha256-1zBPE/PNAkPNUsOWFET4J0cjlvziH8DOekesDmjND+w=", + "owner": "cachix", + "repo": "cachix", + "rev": "47053aef762f452e816e44eb9a23fbc3827b241a", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "latest", + "repo": "cachix", + "type": "github" + } + }, + "devenv": { + "inputs": { + "cachix": "cachix", + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "git-hooks": "git-hooks", + "nix": "nix", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1759178241, + "narHash": "sha256-WyNq5Px/znBQfloLGb6zX22pAQVwCBSl5F5fOjEFdgg=", + "owner": "cachix", + "repo": "devenv", + "rev": "11aec9c56d7538301edb74e01b86d2f5ada42996", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1747046372, + "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "devenv", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1756770412, + "narHash": "sha256-+uWLQZccFHwqpGqr2Yt5VsW/PbeJVTn9Dk6SHWhNRPw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "4524271976b625a4a605beefd893f270620fd751", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": [ + "devenv", + "flake-compat" + ], + "gitignore": "gitignore", + "nixpkgs": [ + "devenv", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1758108966, + "narHash": "sha256-ytw7ROXaWZ7OfwHrQ9xvjpUWeGVm86pwnEd1QhzawIo=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "54df955a695a84cd47d4a43e08e1feaf90b1fd9b", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "devenv", + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nix": { + "inputs": { + "flake-compat": [ + "devenv", + "flake-compat" + ], + "flake-parts": [ + "devenv", + "flake-parts" + ], + "git-hooks-nix": [ + "devenv", + "git-hooks" + ], + "nixpkgs": [ + "devenv", + "nixpkgs" + ], + "nixpkgs-23-11": [ + "devenv" + ], + "nixpkgs-regression": [ + "devenv" + ] + }, + "locked": { + "lastModified": 1758763079, + "narHash": "sha256-Bx1A+lShhOWwMuy3uDzZQvYiBKBFcKwy6G6NEohhv6A=", + "owner": "cachix", + "repo": "nix", + "rev": "6f0140527c2b0346df4afad7497baa08decb929f", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "devenv-2.30.5", + "repo": "nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1758532697, + "narHash": "sha256-bhop0bR3u7DCw9/PtLCwr7GwEWDlBSxHp+eVQhCW9t4=", + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "207a4cb0e1253c7658c6736becc6eb9cace1f25f", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/experimental/chatproto/flake.nix b/experimental/chatproto/flake.nix new file mode 100644 index 0000000..665212b --- /dev/null +++ b/experimental/chatproto/flake.nix @@ -0,0 +1,47 @@ +{ + inputs = { + nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling"; + systems.url = "github:nix-systems/default"; + devenv.url = "github:cachix/devenv"; + devenv.inputs.nixpkgs.follows = "nixpkgs"; + }; + + nixConfig = { + extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="; + extra-substituters = "https://devenv.cachix.org"; + }; + + outputs = { self, nixpkgs, devenv, systems, ... } @ inputs: + let + forEachSystem = nixpkgs.lib.genAttrs (import systems); + in + { + devShells = forEachSystem + (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + default = devenv.lib.mkShell { + inherit inputs pkgs; + modules = [ + { + # https://devenv.sh/reference/options/ + packages = [ pkgs.hello ]; + + enterShell = '' + hello + ''; + + processes.hello.exec = "hello"; + + languages.javascript = { + enable = true; + pnpm = { enable = true; }; + }; + } + ]; + }; + }); + }; +} diff --git a/experimental/chatproto/lexicons/app/bsky/actor/defs.json b/experimental/chatproto/lexicons/app/bsky/actor/defs.json new file mode 100644 index 0000000..b2ce1f5 --- /dev/null +++ b/experimental/chatproto/lexicons/app/bsky/actor/defs.json @@ -0,0 +1,31 @@ +{ + "lexicon": 1, + "id": "app.bsky.actor.defs", + "defs": { + "profileView": { + "type": "object", + "required": ["did", "handle"], + "properties": { + "did": { "type": "string", "format": "did" }, + "handle": { "type": "string", "format": "handle" }, + "displayName": { + "type": "string", + "maxGraphemes": 64, + "maxLength": 640 + }, + "description": { + "type": "string", + "maxGraphemes": 256, + "maxLength": 2560 + }, + "avatar": { "type": "string", "format": "uri" }, + "indexedAt": { "type": "string", "format": "datetime" }, + "createdAt": { "type": "string", "format": "datetime" }, + "labels": { + "type": "array", + "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + } + } + } + } +} diff --git a/experimental/chatproto/lexicons/app/bsky/actor/profile.json b/experimental/chatproto/lexicons/app/bsky/actor/profile.json new file mode 100644 index 0000000..911d7a0 --- /dev/null +++ b/experimental/chatproto/lexicons/app/bsky/actor/profile.json @@ -0,0 +1,53 @@ +{ + "lexicon": 1, + "id": "app.bsky.actor.profile", + "defs": { + "main": { + "type": "record", + "description": "A declaration of a Bluesky account profile.", + "key": "literal:self", + "record": { + "type": "object", + "properties": { + "displayName": { + "type": "string", + "maxGraphemes": 64, + "maxLength": 640 + }, + "description": { + "type": "string", + "description": "Free-form profile description text.", + "maxGraphemes": 256, + "maxLength": 2560 + }, + "avatar": { + "type": "blob", + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'", + "accept": ["image/png", "image/jpeg"], + "maxSize": 1000000 + }, + "banner": { + "type": "blob", + "description": "Larger horizontal image to display behind profile view.", + "accept": ["image/png", "image/jpeg"], + "maxSize": 1000000 + }, + "labels": { + "type": "union", + "description": "Self-label values, specific to the Bluesky application, on the overall account.", + "refs": ["com.atproto.label.defs#selfLabels"] + }, + "joinedViaStarterPack": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "pinnedPost": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "createdAt": { "type": "string", "format": "datetime" } + } + } + } + } +} diff --git a/experimental/chatproto/lexicons/com/atproto/label/defs.json b/experimental/chatproto/lexicons/com/atproto/label/defs.json new file mode 100644 index 0000000..6f4c1ab --- /dev/null +++ b/experimental/chatproto/lexicons/com/atproto/label/defs.json @@ -0,0 +1,156 @@ +{ + "lexicon": 1, + "id": "com.atproto.label.defs", + "defs": { + "label": { + "type": "object", + "description": "Metadata tag on an atproto resource (eg, repo or record).", + "required": ["src", "uri", "val", "cts"], + "properties": { + "ver": { + "type": "integer", + "description": "The AT Protocol version of the label object." + }, + "src": { + "type": "string", + "format": "did", + "description": "DID of the actor who created this label." + }, + "uri": { + "type": "string", + "format": "uri", + "description": "AT URI of the record, repository (account), or other resource that this label applies to." + }, + "cid": { + "type": "string", + "format": "cid", + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." + }, + "val": { + "type": "string", + "maxLength": 128, + "description": "The short string name of the value or type of this label." + }, + "neg": { + "type": "boolean", + "description": "If true, this is a negation label, overwriting a previous label." + }, + "cts": { + "type": "string", + "format": "datetime", + "description": "Timestamp when this label was created." + }, + "exp": { + "type": "string", + "format": "datetime", + "description": "Timestamp at which this label expires (no longer applies)." + }, + "sig": { + "type": "bytes", + "description": "Signature of dag-cbor encoded label." + } + } + }, + "selfLabels": { + "type": "object", + "description": "Metadata tags on an atproto record, published by the author within the record.", + "required": ["values"], + "properties": { + "values": { + "type": "array", + "items": { "type": "ref", "ref": "#selfLabel" }, + "maxLength": 10 + } + } + }, + "selfLabel": { + "type": "object", + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.", + "required": ["val"], + "properties": { + "val": { + "type": "string", + "maxLength": 128, + "description": "The short string name of the value or type of this label." + } + } + }, + "labelValueDefinition": { + "type": "object", + "description": "Declares a label value and its expected interpretations and behaviors.", + "required": ["identifier", "severity", "blurs", "locales"], + "properties": { + "identifier": { + "type": "string", + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", + "maxLength": 100, + "maxGraphemes": 100 + }, + "severity": { + "type": "string", + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", + "knownValues": ["inform", "alert", "none"] + }, + "blurs": { + "type": "string", + "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", + "knownValues": ["content", "media", "none"] + }, + "defaultSetting": { + "type": "string", + "description": "The default setting for this label.", + "knownValues": ["ignore", "warn", "hide"], + "default": "warn" + }, + "adultOnly": { + "type": "boolean", + "description": "Does the user need to have adult content enabled in order to configure this label?" + }, + "locales": { + "type": "array", + "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" } + } + } + }, + "labelValueDefinitionStrings": { + "type": "object", + "description": "Strings which describe the label in the UI, localized into a specific language.", + "required": ["lang", "name", "description"], + "properties": { + "lang": { + "type": "string", + "description": "The code of the language these strings are written in.", + "format": "language" + }, + "name": { + "type": "string", + "description": "A short human-readable name for the label.", + "maxGraphemes": 64, + "maxLength": 640 + }, + "description": { + "type": "string", + "description": "A longer description of what the label means and why it might be applied.", + "maxGraphemes": 10000, + "maxLength": 100000 + } + } + }, + "labelValue": { + "type": "string", + "knownValues": [ + "!hide", + "!no-promote", + "!warn", + "!no-unauthenticated", + "dmca-violation", + "doxxing", + "porn", + "sexual", + "nudity", + "nsfl", + "gore" + ] + } + } +} diff --git a/experimental/chatproto/lexicons/com/atproto/repo/applyWrites.json b/experimental/chatproto/lexicons/com/atproto/repo/applyWrites.json new file mode 100644 index 0000000..11a1f1f --- /dev/null +++ b/experimental/chatproto/lexicons/com/atproto/repo/applyWrites.json @@ -0,0 +1,131 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.applyWrites", + "defs": { + "main": { + "type": "procedure", + "description": "Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["repo", "writes"], + "properties": { + "repo": { + "type": "string", + "format": "at-identifier", + "description": "The handle or DID of the repo (aka, current account)." + }, + "validate": { + "type": "boolean", + "description": "Can be set to 'false' to skip Lexicon schema validation of record data across all operations, 'true' to require it, or leave unset to validate only for known Lexicons." + }, + "writes": { + "type": "array", + "items": { + "type": "union", + "refs": ["#create", "#update", "#delete"], + "closed": true + } + }, + "swapCommit": { + "type": "string", + "description": "If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.", + "format": "cid" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [], + "properties": { + "commit": { + "type": "ref", + "ref": "com.atproto.repo.defs#commitMeta" + }, + "results": { + "type": "array", + "items": { + "type": "union", + "refs": ["#createResult", "#updateResult", "#deleteResult"], + "closed": true + } + } + } + } + }, + "errors": [ + { + "name": "InvalidSwap", + "description": "Indicates that the 'swapCommit' parameter did not match current commit." + } + ] + }, + "create": { + "type": "object", + "description": "Operation which creates a new record.", + "required": ["collection", "value"], + "properties": { + "collection": { "type": "string", "format": "nsid" }, + "rkey": { + "type": "string", + "maxLength": 512, + "format": "record-key", + "description": "NOTE: maxLength is redundant with record-key format. Keeping it temporarily to ensure backwards compatibility." + }, + "value": { "type": "unknown" } + } + }, + "update": { + "type": "object", + "description": "Operation which updates an existing record.", + "required": ["collection", "rkey", "value"], + "properties": { + "collection": { "type": "string", "format": "nsid" }, + "rkey": { "type": "string", "format": "record-key" }, + "value": { "type": "unknown" } + } + }, + "delete": { + "type": "object", + "description": "Operation which deletes an existing record.", + "required": ["collection", "rkey"], + "properties": { + "collection": { "type": "string", "format": "nsid" }, + "rkey": { "type": "string", "format": "record-key" } + } + }, + "createResult": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "validationStatus": { + "type": "string", + "knownValues": ["valid", "unknown"] + } + } + }, + "updateResult": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "validationStatus": { + "type": "string", + "knownValues": ["valid", "unknown"] + } + } + }, + "deleteResult": { + "type": "object", + "required": [], + "properties": {} + } + } +} diff --git a/experimental/chatproto/lexicons/com/atproto/repo/createRecord.json b/experimental/chatproto/lexicons/com/atproto/repo/createRecord.json new file mode 100644 index 0000000..de5f408 --- /dev/null +++ b/experimental/chatproto/lexicons/com/atproto/repo/createRecord.json @@ -0,0 +1,73 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.createRecord", + "defs": { + "main": { + "type": "procedure", + "description": "Create a single new repository record. Requires auth, implemented by PDS.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["repo", "collection", "record"], + "properties": { + "repo": { + "type": "string", + "format": "at-identifier", + "description": "The handle or DID of the repo (aka, current account)." + }, + "collection": { + "type": "string", + "format": "nsid", + "description": "The NSID of the record collection." + }, + "rkey": { + "type": "string", + "format": "record-key", + "description": "The Record Key.", + "maxLength": 512 + }, + "validate": { + "type": "boolean", + "description": "Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons." + }, + "record": { + "type": "unknown", + "description": "The record itself. Must contain a $type field." + }, + "swapCommit": { + "type": "string", + "format": "cid", + "description": "Compare and swap with the previous commit by CID." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "commit": { + "type": "ref", + "ref": "com.atproto.repo.defs#commitMeta" + }, + "validationStatus": { + "type": "string", + "knownValues": ["valid", "unknown"] + } + } + } + }, + "errors": [ + { + "name": "InvalidSwap", + "description": "Indicates that 'swapCommit' didn't match current repo commit." + } + ] + } + } +} diff --git a/experimental/chatproto/lexicons/com/atproto/repo/defs.json b/experimental/chatproto/lexicons/com/atproto/repo/defs.json new file mode 100644 index 0000000..8752599 --- /dev/null +++ b/experimental/chatproto/lexicons/com/atproto/repo/defs.json @@ -0,0 +1,14 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.defs", + "defs": { + "commitMeta": { + "type": "object", + "required": ["cid", "rev"], + "properties": { + "cid": { "type": "string", "format": "cid" }, + "rev": { "type": "string", "format": "tid" } + } + } + } +} diff --git a/experimental/chatproto/lexicons/com/atproto/repo/deleteRecord.json b/experimental/chatproto/lexicons/com/atproto/repo/deleteRecord.json new file mode 100644 index 0000000..9831da1 --- /dev/null +++ b/experimental/chatproto/lexicons/com/atproto/repo/deleteRecord.json @@ -0,0 +1,57 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.deleteRecord", + "defs": { + "main": { + "type": "procedure", + "description": "Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["repo", "collection", "rkey"], + "properties": { + "repo": { + "type": "string", + "format": "at-identifier", + "description": "The handle or DID of the repo (aka, current account)." + }, + "collection": { + "type": "string", + "format": "nsid", + "description": "The NSID of the record collection." + }, + "rkey": { + "type": "string", + "format": "record-key", + "description": "The Record Key." + }, + "swapRecord": { + "type": "string", + "format": "cid", + "description": "Compare and swap with the previous record by CID." + }, + "swapCommit": { + "type": "string", + "format": "cid", + "description": "Compare and swap with the previous commit by CID." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "commit": { + "type": "ref", + "ref": "com.atproto.repo.defs#commitMeta" + } + } + } + }, + "errors": [{ "name": "InvalidSwap" }] + } + } +} diff --git a/experimental/chatproto/lexicons/com/atproto/repo/describeRepo.json b/experimental/chatproto/lexicons/com/atproto/repo/describeRepo.json new file mode 100644 index 0000000..b1ce2b6 --- /dev/null +++ b/experimental/chatproto/lexicons/com/atproto/repo/describeRepo.json @@ -0,0 +1,51 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.describeRepo", + "defs": { + "main": { + "type": "query", + "description": "Get information about an account and repository, including the list of collections. Does not require auth.", + "parameters": { + "type": "params", + "required": ["repo"], + "properties": { + "repo": { + "type": "string", + "format": "at-identifier", + "description": "The handle or DID of the repo." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "handle", + "did", + "didDoc", + "collections", + "handleIsCorrect" + ], + "properties": { + "handle": { "type": "string", "format": "handle" }, + "did": { "type": "string", "format": "did" }, + "didDoc": { + "type": "unknown", + "description": "The complete DID document for this account." + }, + "collections": { + "type": "array", + "description": "List of all the collections (NSIDs) for which this repo contains at least one record.", + "items": { "type": "string", "format": "nsid" } + }, + "handleIsCorrect": { + "type": "boolean", + "description": "Indicates if handle is currently valid (resolves bi-directionally)" + } + } + } + } + } + } +} diff --git a/experimental/chatproto/lexicons/com/atproto/repo/getRecord.json b/experimental/chatproto/lexicons/com/atproto/repo/getRecord.json new file mode 100644 index 0000000..388b9c5 --- /dev/null +++ b/experimental/chatproto/lexicons/com/atproto/repo/getRecord.json @@ -0,0 +1,49 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.getRecord", + "defs": { + "main": { + "type": "query", + "description": "Get a single record from a repository. Does not require auth.", + "parameters": { + "type": "params", + "required": ["repo", "collection", "rkey"], + "properties": { + "repo": { + "type": "string", + "format": "at-identifier", + "description": "The handle or DID of the repo." + }, + "collection": { + "type": "string", + "format": "nsid", + "description": "The NSID of the record collection." + }, + "rkey": { + "type": "string", + "description": "The Record Key.", + "format": "record-key" + }, + "cid": { + "type": "string", + "format": "cid", + "description": "The CID of the version of the record. If not specified, then return the most recent version." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "value"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "value": { "type": "unknown" } + } + } + }, + "errors": [{ "name": "RecordNotFound" }] + } + } +} diff --git a/experimental/chatproto/lexicons/com/atproto/repo/importRepo.json b/experimental/chatproto/lexicons/com/atproto/repo/importRepo.json new file mode 100644 index 0000000..fc850b1 --- /dev/null +++ b/experimental/chatproto/lexicons/com/atproto/repo/importRepo.json @@ -0,0 +1,13 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.importRepo", + "defs": { + "main": { + "type": "procedure", + "description": "Import a repo in the form of a CAR file. Requires Content-Length HTTP header to be set.", + "input": { + "encoding": "application/vnd.ipld.car" + } + } + } +} diff --git a/experimental/chatproto/lexicons/com/atproto/repo/listMissingBlobs.json b/experimental/chatproto/lexicons/com/atproto/repo/listMissingBlobs.json new file mode 100644 index 0000000..c39913d --- /dev/null +++ b/experimental/chatproto/lexicons/com/atproto/repo/listMissingBlobs.json @@ -0,0 +1,44 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.listMissingBlobs", + "defs": { + "main": { + "type": "query", + "description": "Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow.", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 500 + }, + "cursor": { "type": "string" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["blobs"], + "properties": { + "cursor": { "type": "string" }, + "blobs": { + "type": "array", + "items": { "type": "ref", "ref": "#recordBlob" } + } + } + } + } + }, + "recordBlob": { + "type": "object", + "required": ["cid", "recordUri"], + "properties": { + "cid": { "type": "string", "format": "cid" }, + "recordUri": { "type": "string", "format": "at-uri" } + } + } + } +} diff --git a/experimental/chatproto/lexicons/com/atproto/repo/listRecords.json b/experimental/chatproto/lexicons/com/atproto/repo/listRecords.json new file mode 100644 index 0000000..bc91c95 --- /dev/null +++ b/experimental/chatproto/lexicons/com/atproto/repo/listRecords.json @@ -0,0 +1,69 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.listRecords", + "defs": { + "main": { + "type": "query", + "description": "List a range of records in a repository, matching a specific collection. Does not require auth.", + "parameters": { + "type": "params", + "required": ["repo", "collection"], + "properties": { + "repo": { + "type": "string", + "format": "at-identifier", + "description": "The handle or DID of the repo." + }, + "collection": { + "type": "string", + "format": "nsid", + "description": "The NSID of the record type." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50, + "description": "The number of records to return." + }, + "cursor": { "type": "string" }, + "rkeyStart": { + "type": "string", + "description": "DEPRECATED: The lowest sort-ordered rkey to start from (exclusive)" + }, + "rkeyEnd": { + "type": "string", + "description": "DEPRECATED: The highest sort-ordered rkey to stop at (exclusive)" + }, + "reverse": { + "type": "boolean", + "description": "Flag to reverse the order of the returned records." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["records"], + "properties": { + "cursor": { "type": "string" }, + "records": { + "type": "array", + "items": { "type": "ref", "ref": "#record" } + } + } + } + } + }, + "record": { + "type": "object", + "required": ["uri", "cid", "value"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "value": { "type": "unknown" } + } + } + } +} diff --git a/experimental/chatproto/lexicons/com/atproto/repo/putRecord.json b/experimental/chatproto/lexicons/com/atproto/repo/putRecord.json new file mode 100644 index 0000000..830fade --- /dev/null +++ b/experimental/chatproto/lexicons/com/atproto/repo/putRecord.json @@ -0,0 +1,74 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.putRecord", + "defs": { + "main": { + "type": "procedure", + "description": "Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["repo", "collection", "rkey", "record"], + "nullable": ["swapRecord"], + "properties": { + "repo": { + "type": "string", + "format": "at-identifier", + "description": "The handle or DID of the repo (aka, current account)." + }, + "collection": { + "type": "string", + "format": "nsid", + "description": "The NSID of the record collection." + }, + "rkey": { + "type": "string", + "format": "record-key", + "description": "The Record Key.", + "maxLength": 512 + }, + "validate": { + "type": "boolean", + "description": "Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons." + }, + "record": { + "type": "unknown", + "description": "The record to write." + }, + "swapRecord": { + "type": "string", + "format": "cid", + "description": "Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation" + }, + "swapCommit": { + "type": "string", + "format": "cid", + "description": "Compare and swap with the previous commit by CID." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "commit": { + "type": "ref", + "ref": "com.atproto.repo.defs#commitMeta" + }, + "validationStatus": { + "type": "string", + "knownValues": ["valid", "unknown"] + } + } + } + }, + "errors": [{ "name": "InvalidSwap" }] + } + } +} diff --git a/experimental/chatproto/lexicons/com/atproto/repo/strongRef.json b/experimental/chatproto/lexicons/com/atproto/repo/strongRef.json new file mode 100644 index 0000000..cb79625 --- /dev/null +++ b/experimental/chatproto/lexicons/com/atproto/repo/strongRef.json @@ -0,0 +1,15 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.strongRef", + "description": "A URI with a content-hash fingerprint.", + "defs": { + "main": { + "type": "object", + "required": ["uri", "cid"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" } + } + } + } +} diff --git a/experimental/chatproto/lexicons/com/atproto/repo/uploadBlob.json b/experimental/chatproto/lexicons/com/atproto/repo/uploadBlob.json new file mode 100644 index 0000000..547a995 --- /dev/null +++ b/experimental/chatproto/lexicons/com/atproto/repo/uploadBlob.json @@ -0,0 +1,23 @@ +{ + "lexicon": 1, + "id": "com.atproto.repo.uploadBlob", + "defs": { + "main": { + "type": "procedure", + "description": "Upload a new blob, to be referenced from a repository record. The blob will be deleted if it is not referenced within a time window (eg, minutes). Blob restrictions (mimetype, size, etc) are enforced when the reference is created. Requires auth, implemented by PDS.", + "input": { + "encoding": "*/*" + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["blob"], + "properties": { + "blob": { "type": "blob" } + } + } + } + } + } +} diff --git a/experimental/chatproto/lexicons/xyz/statusphere/defs.json b/experimental/chatproto/lexicons/xyz/statusphere/defs.json new file mode 100644 index 0000000..90c4488 --- /dev/null +++ b/experimental/chatproto/lexicons/xyz/statusphere/defs.json @@ -0,0 +1,29 @@ +{ + "lexicon": 1, + "id": "xyz.statusphere.defs", + "defs": { + "statusView": { + "type": "object", + "required": ["uri", "status", "profile", "createdAt"], + "properties": { + "uri": { "type": "string", "format": "at-uri" }, + "status": { + "type": "string", + "minLength": 1, + "maxGraphemes": 1, + "maxLength": 32 + }, + "createdAt": { "type": "string", "format": "datetime" }, + "profile": { "type": "ref", "ref": "#profileView" } + } + }, + "profileView": { + "type": "object", + "required": ["did", "handle"], + "properties": { + "did": { "type": "string", "format": "did" }, + "handle": { "type": "string", "format": "handle" } + } + } + } +} diff --git a/experimental/chatproto/lexicons/xyz/statusphere/getStatuses.json b/experimental/chatproto/lexicons/xyz/statusphere/getStatuses.json new file mode 100644 index 0000000..156515a --- /dev/null +++ b/experimental/chatproto/lexicons/xyz/statusphere/getStatuses.json @@ -0,0 +1,37 @@ +{ + "lexicon": 1, + "id": "xyz.statusphere.getStatuses", + "defs": { + "main": { + "type": "query", + "description": "Get a list of the most recent statuses on the network.", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["statuses"], + "properties": { + "statuses": { + "type": "array", + "items": { + "type": "ref", + "ref": "xyz.statusphere.defs#statusView" + } + } + } + } + } + } + } +} diff --git a/experimental/chatproto/lexicons/xyz/statusphere/getUser.json b/experimental/chatproto/lexicons/xyz/statusphere/getUser.json new file mode 100644 index 0000000..c26bb7b --- /dev/null +++ b/experimental/chatproto/lexicons/xyz/statusphere/getUser.json @@ -0,0 +1,31 @@ +{ + "lexicon": 1, + "id": "xyz.statusphere.getUser", + "defs": { + "main": { + "type": "query", + "description": "Get the current user's profile and status.", + "parameters": { + "type": "params", + "properties": {} + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["profile"], + "properties": { + "profile": { + "type": "ref", + "ref": "app.bsky.actor.defs#profileView" + }, + "status": { + "type": "ref", + "ref": "xyz.statusphere.defs#statusView" + } + } + } + } + } + } +} diff --git a/experimental/chatproto/lexicons/xyz/statusphere/sendStatus.json b/experimental/chatproto/lexicons/xyz/statusphere/sendStatus.json new file mode 100644 index 0000000..c13556f --- /dev/null +++ b/experimental/chatproto/lexicons/xyz/statusphere/sendStatus.json @@ -0,0 +1,38 @@ +{ + "lexicon": 1, + "id": "xyz.statusphere.sendStatus", + "defs": { + "main": { + "type": "procedure", + "description": "Send a status into the ATmosphere.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["status"], + "properties": { + "status": { + "type": "string", + "minLength": 1, + "maxGraphemes": 1, + "maxLength": 32 + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["status"], + "properties": { + "status": { + "type": "ref", + "ref": "xyz.statusphere.defs#statusView" + } + } + } + } + } + } +} diff --git a/experimental/chatproto/lexicons/xyz/statusphere/status.json b/experimental/chatproto/lexicons/xyz/statusphere/status.json new file mode 100644 index 0000000..e4bf5a9 --- /dev/null +++ b/experimental/chatproto/lexicons/xyz/statusphere/status.json @@ -0,0 +1,23 @@ +{ + "lexicon": 1, + "id": "xyz.statusphere.status", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["status", "createdAt"], + "properties": { + "status": { + "type": "string", + "minLength": 1, + "maxGraphemes": 1, + "maxLength": 32 + }, + "createdAt": { "type": "string", "format": "datetime" } + } + } + } + } +} diff --git a/experimental/chatproto/package.json b/experimental/chatproto/package.json new file mode 100644 index 0000000..da27859 --- /dev/null +++ b/experimental/chatproto/package.json @@ -0,0 +1,36 @@ +{ + "name": "statusphere-react", + "version": "0.0.1", + "description": "Statusphere React monorepo", + "author": "", + "license": "MIT", + "private": true, + "scripts": { + "dev": "concurrently \"pnpm dev:appview\" \"pnpm dev:client\"", + "dev:lexicon": "pnpm --filter @statusphere/lexicon dev", + "dev:appview": "pnpm --filter @statusphere/appview dev", + "dev:client": "pnpm --filter @statusphere/client dev", + "lexgen": "pnpm -r lexgen", + "build": "pnpm build:lexicon && pnpm build:client && pnpm build:appview", + "build:lexicon": "pnpm --filter @statusphere/lexicon build", + "build:appview": "pnpm --filter @statusphere/appview build", + "build:client": "pnpm --filter @statusphere/client build", + "start": "pnpm --filter @statusphere/appview start", + "start:dev": "pnpm -r start", + "start:appview": "pnpm --filter @statusphere/appview start", + "start:client": "pnpm --filter @statusphere/client start", + "clean": "pnpm -r clean", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", + "typecheck": "pnpm -r typecheck" + }, + "devDependencies": { + "@atproto/lex-cli": "^0.6.1", + "@ianvs/prettier-plugin-sort-imports": "^4.4.1", + "concurrently": "^9.1.2", + "prettier": "^3.5.2", + "prettier-plugin-tailwindcss": "^0.6.11", + "rimraf": "^6.0.1", + "typescript": "^5.8.2" + }, + "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" +} diff --git a/experimental/chatproto/packages/appview/README.md b/experimental/chatproto/packages/appview/README.md new file mode 100644 index 0000000..a1743be --- /dev/null +++ b/experimental/chatproto/packages/appview/README.md @@ -0,0 +1,64 @@ +# Statusphere AppView + +This is the backend API for the Statusphere application. It provides REST endpoints for the React frontend to consume. + +## Development + +```bash +# Install dependencies +pnpm install + +# Start development server +pnpm dev + +# Build for production +pnpm build + +# Start production server +pnpm start +``` + +## Environment Variables + +Create a `.env` file in the root of this package with the following variables: + +``` +NODE_ENV=development +HOST=localhost +PORT=3001 +DB_PATH=./data.sqlite +COOKIE_SECRET=your_secret_here_at_least_32_characters_long +ATPROTO_SERVER=https://bsky.social +PUBLIC_URL=http://localhost:3001 +NGROK_URL=your_ngrok_url_here +``` + +## Using ngrok for OAuth Development + +Due to OAuth requirements, we need to use HTTPS for development. The easiest way to do this is with ngrok: + +1. Install ngrok: https://ngrok.com/download +2. Run ngrok to create a tunnel to your local server: + ```bash + ngrok http 3001 + ``` +3. Copy the HTTPS URL provided by ngrok (e.g., `https://abcd-123-45-678-90.ngrok.io`) +4. Add it to your `.env` file: + ``` + NGROK_URL=https://abcd-123-45-678-90.ngrok.io + ``` +5. Also update the API URL in the client package: + ``` + # In packages/client/src/services/api.ts + const API_URL = 'https://abcd-123-45-678-90.ngrok.io'; + ``` + +## API Endpoints + +- `GET /oauth-client-metadata.json` - OAuth client metadata +- `GET /oauth/callback` - OAuth callback endpoint +- `POST /login` - Login with handle +- `POST /logout` - Logout current user +- `GET /user` - Get current user info +- `GET /statuses` - Get recent statuses +- `POST /status` - Create a new status diff --git a/experimental/chatproto/packages/appview/package.json b/experimental/chatproto/packages/appview/package.json new file mode 100644 index 0000000..b84c277 --- /dev/null +++ b/experimental/chatproto/packages/appview/package.json @@ -0,0 +1,64 @@ +{ + "name": "@statusphere/appview", + "version": "0.0.1", + "description": "Statusphere AppView backend", + "author": "", + "license": "MIT", + "main": "dist/index.js", + "private": true, + "scripts": { + "dev": "tsx watch --clear-screen=false src/index.ts | pino-pretty", + "build": "tsup", + "start": "node dist/index.js", + "lexgen": "lex gen-server ./src/lexicons ../../lexicons/xyz/statusphere/* ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* --yes && pnpm format", + "clean": "rimraf dist coverage", + "format": "prettier --write src", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@atproto/api": "^0.14.7", + "@atproto/common": "^0.4.8", + "@atproto/identity": "^0.4.6", + "@atproto/lexicon": "^0.4.7", + "@atproto/oauth-client-node": "^0.2.11", + "@atproto/sync": "^0.1.15", + "@atproto/syntax": "^0.3.3", + "@atproto/xrpc-server": "^0.7.11", + "@statusphere/lexicon": "workspace:*", + "better-sqlite3": "^11.8.1", + "compression": "^1.8.0", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "envalid": "^8.0.0", + "express": "^4.21.2", + "iron-session": "^8.0.4", + "kysely": "^0.27.5", + "multiformats": "^13.3.2", + "pino": "^9.6.0", + "ws": "^8.18.1" + }, + "devDependencies": { + "@atproto/lex-cli": "^0.6.1", + "@types/better-sqlite3": "^7.6.12", + "@types/compression": "^1.7.5", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/node": "^22.13.8", + "@types/ws": "^8.5.14", + "pino-pretty": "^13.0.0", + "ts-node": "^10.9.2", + "tsup": "^8.4.0", + "tsx": "^4.19.3", + "typescript": "^5.8.2" + }, + "tsup": { + "entry": [ + "src", + "!src/**/__tests__/**", + "!src/**/*.test.*" + ], + "splitting": false, + "sourcemap": true, + "clean": true + } +} diff --git a/experimental/chatproto/packages/appview/src/api/health.ts b/experimental/chatproto/packages/appview/src/api/health.ts new file mode 100644 index 0000000..89a0391 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/api/health.ts @@ -0,0 +1,13 @@ +import { Router } from 'express' + +import { AppContext } from '#/context' + +export const createRouter = (ctx: AppContext) => { + const router = Router() + + router.get('/health', async function (req, res) { + res.status(200).send('OK') + }) + + return router +} diff --git a/experimental/chatproto/packages/appview/src/api/index.ts b/experimental/chatproto/packages/appview/src/api/index.ts new file mode 100644 index 0000000..cf712a4 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/api/index.ts @@ -0,0 +1,15 @@ +import { AppContext } from '#/context' +import { Server } from '#/lexicons' +import getStatuses from './lexicons/getStatuses' +import getUser from './lexicons/getUser' +import sendStatus from './lexicons/sendStatus' + +export * as health from './health' +export * as oauth from './oauth' + +export default function (server: Server, ctx: AppContext) { + getStatuses(server, ctx) + sendStatus(server, ctx) + getUser(server, ctx) + return server +} diff --git a/experimental/chatproto/packages/appview/src/api/lexicons/getStatuses.ts b/experimental/chatproto/packages/appview/src/api/lexicons/getStatuses.ts new file mode 100644 index 0000000..e079c51 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/api/lexicons/getStatuses.ts @@ -0,0 +1,26 @@ +import { AppContext } from '#/context' +import { Server } from '#/lexicons' +import { statusToStatusView } from '#/lib/hydrate' + +export default function (server: Server, ctx: AppContext) { + server.xyz.statusphere.getStatuses({ + handler: async ({ params }) => { + // Fetch data stored in our SQLite + const statuses = await ctx.db + .selectFrom('status') + .selectAll() + .orderBy('indexedAt', 'desc') + .limit(params.limit) + .execute() + + return { + encoding: 'application/json', + body: { + statuses: await Promise.all( + statuses.map((status) => statusToStatusView(status, ctx)), + ), + }, + } + }, + }) +} diff --git a/experimental/chatproto/packages/appview/src/api/lexicons/getUser.ts b/experimental/chatproto/packages/appview/src/api/lexicons/getUser.ts new file mode 100644 index 0000000..d65b749 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/api/lexicons/getUser.ts @@ -0,0 +1,61 @@ +import { AuthRequiredError } from '@atproto/xrpc-server' +import { AppBskyActorProfile } from '@statusphere/lexicon' + +import { AppContext } from '#/context' +import { Server } from '#/lexicons' +import { bskyProfileToProfileView, statusToStatusView } from '#/lib/hydrate' +import { getSessionAgent } from '#/session' + +export default function (server: Server, ctx: AppContext) { + server.xyz.statusphere.getUser({ + handler: async ({ req, res }) => { + const agent = await getSessionAgent(req, res, ctx) + if (!agent) { + throw new AuthRequiredError('Authentication required') + } + + const did = agent.assertDid + + const profileResponse = await agent.com.atproto.repo + .getRecord({ + repo: did, + collection: 'app.bsky.actor.profile', + rkey: 'self', + }) + .catch(() => undefined) + + const profileRecord = profileResponse?.data + let profile: AppBskyActorProfile.Record = {} as AppBskyActorProfile.Record + + if (profileRecord && AppBskyActorProfile.isRecord(profileRecord.value)) { + const validated = AppBskyActorProfile.validateRecord( + profileRecord.value, + ) + if (validated.success) { + profile = profileRecord.value + } else { + ctx.logger.error( + { err: validated.error }, + 'Failed to validate user profile', + ) + } + } + + // Fetch user status + const status = await ctx.db + .selectFrom('status') + .selectAll() + .where('authorDid', '=', did) + .orderBy('indexedAt', 'desc') + .executeTakeFirst() + + return { + encoding: 'application/json', + body: { + profile: await bskyProfileToProfileView(did, profile, ctx), + status: status ? await statusToStatusView(status, ctx) : undefined, + }, + } + }, + }) +} diff --git a/experimental/chatproto/packages/appview/src/api/lexicons/sendStatus.ts b/experimental/chatproto/packages/appview/src/api/lexicons/sendStatus.ts new file mode 100644 index 0000000..342cb72 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/api/lexicons/sendStatus.ts @@ -0,0 +1,79 @@ +import { TID } from '@atproto/common' +import { + AuthRequiredError, + InvalidRequestError, + UpstreamFailureError, +} from '@atproto/xrpc-server' +import { XyzStatusphereStatus } from '@statusphere/lexicon' + +import { AppContext } from '#/context' +import { Server } from '#/lexicons' +import { statusToStatusView } from '#/lib/hydrate' +import { getSessionAgent } from '#/session' + +export default function (server: Server, ctx: AppContext) { + server.xyz.statusphere.sendStatus({ + handler: async ({ input, req, res }) => { + const agent = await getSessionAgent(req, res, ctx) + if (!agent) { + throw new AuthRequiredError('Authentication required') + } + + // Construct & validate their status record + const rkey = TID.nextStr() + const record = { + $type: 'xyz.statusphere.status', + status: input.body.status, + createdAt: new Date().toISOString(), + } + + const validation = XyzStatusphereStatus.validateRecord(record) + if (!validation.success) { + throw new InvalidRequestError('Invalid status') + } + + let uri + try { + // Write the status record to the user's repository + const response = await agent.com.atproto.repo.putRecord({ + repo: agent.assertDid, + collection: 'xyz.statusphere.status', + rkey, + record: validation.value, + validate: false, + }) + uri = response.data.uri + } catch (err) { + throw new UpstreamFailureError('Failed to write record') + } + + const optimisticStatus = { + uri, + authorDid: agent.assertDid, + status: record.status, + createdAt: record.createdAt, + indexedAt: new Date().toISOString(), + } + + try { + // Optimistically update our SQLite + // This isn't strictly necessary because the write event will be + // handled in #/firehose/ingestor.ts, but it ensures that future reads + // will be up-to-date after this method finishes. + await ctx.db.insertInto('status').values(optimisticStatus).execute() + } catch (err) { + ctx.logger.warn( + { err }, + 'failed to update computed view; ignoring as it should be caught by the firehose', + ) + } + + return { + encoding: 'application/json', + body: { + status: await statusToStatusView(optimisticStatus, ctx), + }, + } + }, + }) +} diff --git a/experimental/chatproto/packages/appview/src/api/oauth.ts b/experimental/chatproto/packages/appview/src/api/oauth.ts new file mode 100644 index 0000000..95665a3 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/api/oauth.ts @@ -0,0 +1,96 @@ +import { OAuthResolverError } from '@atproto/oauth-client-node' +import { isValidHandle } from '@atproto/syntax' +import express from 'express' + +import { AppContext } from '#/context' +import { getSession } from '#/session' + +export const createRouter = (ctx: AppContext) => { + const router = express.Router() + + // OAuth metadata + router.get('/oauth-client-metadata.json', (_req, res) => { + res.json(ctx.oauthClient.clientMetadata) + }) + + // OAuth callback to complete session creation + router.get('/oauth/callback', async (req, res) => { + // Get the query parameters from the URL + const params = new URLSearchParams(req.originalUrl.split('?')[1]) + + try { + const { session } = await ctx.oauthClient.callback(params) + + // Use the common session options + const clientSession = await getSession(req, res) + + // Set the DID on the session + clientSession.did = session.did + await clientSession.save() + + // Get the origin and determine appropriate redirect + const host = req.get('host') || '' + const protocol = req.protocol || 'http' + const baseUrl = `${protocol}://${host}` + + ctx.logger.info( + `OAuth callback successful, redirecting to ${baseUrl}/oauth-callback`, + ) + + // Redirect to the frontend oauth-callback page + res.redirect('/oauth-callback') + } catch (err) { + ctx.logger.error({ err }, 'oauth callback failed') + + // Handle error redirect - stay on same domain + res.redirect('/oauth-callback?error=auth') + } + }) + + // Login handler + router.post('/oauth/initiate', async (req, res) => { + // Validate + const handle = req.body?.handle + if ( + typeof handle !== 'string' || + !(isValidHandle(handle) || isValidUrl(handle)) + ) { + res.status(400).json({ error: 'Invalid handle' }) + return + } + + // Initiate the OAuth flow + try { + const url = await ctx.oauthClient.authorize(handle, { + scope: 'atproto transition:generic', + }) + res.json({ redirectUrl: url.toString() }) + } catch (err) { + ctx.logger.error({ err }, 'oauth authorize failed') + const errorMsg = + err instanceof OAuthResolverError + ? err.message + : "Couldn't initiate login" + res.status(500).json({ error: errorMsg }) + } + }) + + // Logout handler + router.post('/oauth/logout', async (req, res) => { + const session = await getSession(req, res) + session.destroy() + res.json({ success: true }) + }) + + return router +} + +function isValidUrl(url: string): boolean { + try { + const urlp = new URL(url) + // http or https + return urlp.protocol === 'http:' || urlp.protocol === 'https:' + } catch (error) { + return false + } +} diff --git a/experimental/chatproto/packages/appview/src/auth/client.ts b/experimental/chatproto/packages/appview/src/auth/client.ts new file mode 100644 index 0000000..743c4ee --- /dev/null +++ b/experimental/chatproto/packages/appview/src/auth/client.ts @@ -0,0 +1,34 @@ +import { NodeOAuthClient } from '@atproto/oauth-client-node' + +import type { Database } from '#/db' +import { env } from '#/lib/env' +import { SessionStore, StateStore } from './storage' + +export const createClient = async (db: Database) => { + if (env.isProduction && !env.PUBLIC_URL) { + throw new Error('PUBLIC_URL is not set') + } + + const publicUrl = env.PUBLIC_URL + const url = publicUrl || `http://127.0.0.1:${env.VITE_PORT}` + const enc = encodeURIComponent + + return new NodeOAuthClient({ + clientMetadata: { + client_name: 'Statusphere React App', + client_id: publicUrl + ? `${url}/oauth-client-metadata.json` + : `http://localhost?redirect_uri=${enc(`${url}/oauth/callback`)}&scope=${enc('atproto transition:generic')}`, + client_uri: url, + redirect_uris: [`${url}/oauth/callback`], + scope: 'atproto transition:generic', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + application_type: 'web', + token_endpoint_auth_method: 'none', + dpop_bound_access_tokens: true, + }, + stateStore: new StateStore(db), + sessionStore: new SessionStore(db), + }) +} diff --git a/experimental/chatproto/packages/appview/src/auth/storage.ts b/experimental/chatproto/packages/appview/src/auth/storage.ts new file mode 100644 index 0000000..194d3f2 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/auth/storage.ts @@ -0,0 +1,56 @@ +import type { + NodeSavedSession, + NodeSavedSessionStore, + NodeSavedState, + NodeSavedStateStore, +} from '@atproto/oauth-client-node' + +import type { Database } from '#/db' + +export class StateStore implements NodeSavedStateStore { + constructor(private db: Database) {} + async get(key: string): Promise { + const result = await this.db + .selectFrom('auth_state') + .selectAll() + .where('key', '=', key) + .executeTakeFirst() + if (!result) return + return JSON.parse(result.state) as NodeSavedState + } + async set(key: string, val: NodeSavedState) { + const state = JSON.stringify(val) + await this.db + .insertInto('auth_state') + .values({ key, state }) + .onConflict((oc) => oc.doUpdateSet({ state })) + .execute() + } + async del(key: string) { + await this.db.deleteFrom('auth_state').where('key', '=', key).execute() + } +} + +export class SessionStore implements NodeSavedSessionStore { + constructor(private db: Database) {} + async get(key: string): Promise { + const result = await this.db + .selectFrom('auth_session') + .selectAll() + .where('key', '=', key) + .executeTakeFirst() + if (!result) return + return JSON.parse(result.session) as NodeSavedSession + } + async set(key: string, val: NodeSavedSession) { + const session = JSON.stringify(val) + await this.db + .insertInto('auth_session') + .values({ key, session }) + .onConflict((oc) => oc.doUpdateSet({ session })) + .execute() + } + async del(key: string) { + await this.db.deleteFrom('auth_session').where('key', '=', key).execute() + } +} diff --git a/experimental/chatproto/packages/appview/src/context.ts b/experimental/chatproto/packages/appview/src/context.ts new file mode 100644 index 0000000..6d5c01a --- /dev/null +++ b/experimental/chatproto/packages/appview/src/context.ts @@ -0,0 +1,16 @@ +import { OAuthClient } from '@atproto/oauth-client-node' +import { Firehose } from '@atproto/sync' +import pino from 'pino' + +import { Database } from '#/db' +import { BidirectionalResolver } from '#/id-resolver' +import { Jetstream } from '#/ingestors' + +// Application state passed to the router and elsewhere +export type AppContext = { + db: Database + ingester: Firehose | Jetstream + logger: pino.Logger + oauthClient: OAuthClient + resolver: BidirectionalResolver +} diff --git a/experimental/chatproto/packages/appview/src/db.ts b/experimental/chatproto/packages/appview/src/db.ts new file mode 100644 index 0000000..069bef6 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/db.ts @@ -0,0 +1,128 @@ +import SqliteDb from 'better-sqlite3' +import { + Kysely, + Migration, + MigrationProvider, + Migrator, + SqliteDialect, +} from 'kysely' + +// Types + +export type DatabaseSchema = { + status: Status + auth_session: AuthSession + auth_state: AuthState + cursor: Cursor +} + +export type Status = { + uri: string + authorDid: string + status: string + createdAt: string + indexedAt: string +} + +export type AuthSession = { + key: string + session: AuthSessionJson +} + +export type AuthState = { + key: string + state: AuthStateJson +} + +export type Cursor = { + id: number + seq: number +} + +type AuthStateJson = string + +type AuthSessionJson = string + +// Migrations + +const migrations: Record = {} + +const migrationProvider: MigrationProvider = { + async getMigrations() { + return migrations + }, +} + +migrations['003'] = { + async up(db: Kysely) {}, + async down(_db: Kysely) {}, +} + +migrations['002'] = { + async up(db: Kysely) { + await db.schema + .createTable('cursor') + .addColumn('id', 'integer', (col) => col.primaryKey()) + .addColumn('seq', 'integer', (col) => col.notNull()) + .execute() + + // Insert initial cursor values: + // id=1 is for firehose, id=2 is for jetstream + await db + .insertInto('cursor' as never) + .values([ + { id: 1, seq: 0 }, + { id: 2, seq: 0 }, + ]) + .execute() + }, + async down(db: Kysely) { + await db.schema.dropTable('cursor').execute() + }, +} + +migrations['001'] = { + async up(db: Kysely) { + await db.schema + .createTable('status') + .addColumn('uri', 'varchar', (col) => col.primaryKey()) + .addColumn('authorDid', 'varchar', (col) => col.notNull()) + .addColumn('status', 'varchar', (col) => col.notNull()) + .addColumn('createdAt', 'varchar', (col) => col.notNull()) + .addColumn('indexedAt', 'varchar', (col) => col.notNull()) + .execute() + await db.schema + .createTable('auth_session') + .addColumn('key', 'varchar', (col) => col.primaryKey()) + .addColumn('session', 'varchar', (col) => col.notNull()) + .execute() + await db.schema + .createTable('auth_state') + .addColumn('key', 'varchar', (col) => col.primaryKey()) + .addColumn('state', 'varchar', (col) => col.notNull()) + .execute() + }, + async down(db: Kysely) { + await db.schema.dropTable('auth_state').execute() + await db.schema.dropTable('auth_session').execute() + await db.schema.dropTable('status').execute() + }, +} + +// APIs + +export const createDb = (location: string): Database => { + return new Kysely({ + dialect: new SqliteDialect({ + database: new SqliteDb(location), + }), + }) +} + +export const migrateToLatest = async (db: Database) => { + const migrator = new Migrator({ db, provider: migrationProvider }) + const { error } = await migrator.migrateToLatest() + if (error) throw error +} + +export type Database = Kysely diff --git a/experimental/chatproto/packages/appview/src/error.ts b/experimental/chatproto/packages/appview/src/error.ts new file mode 100644 index 0000000..c8f0e35 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/error.ts @@ -0,0 +1,14 @@ +import { XRPCError } from '@atproto/xrpc-server' +import { ErrorRequestHandler } from 'express' + +import { AppContext } from '#/context' + +export const createHandler: (ctx: AppContext) => ErrorRequestHandler = + (ctx) => (err, _req, res, next) => { + ctx.logger.error('unexpected internal server error', err) + if (res.headersSent) { + return next(err) + } + const serverError = XRPCError.fromError(err) + res.status(serverError.type).json(serverError.payload) + } diff --git a/experimental/chatproto/packages/appview/src/id-resolver.ts b/experimental/chatproto/packages/appview/src/id-resolver.ts new file mode 100644 index 0000000..4e1c954 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/id-resolver.ts @@ -0,0 +1,41 @@ +import { IdResolver, MemoryCache } from '@atproto/identity' + +const HOUR = 60e3 * 60 +const DAY = HOUR * 24 + +export function createIdResolver() { + return new IdResolver({ + didCache: new MemoryCache(HOUR, DAY), + }) +} + +export interface BidirectionalResolver { + resolveDidToHandle(did: string): Promise + resolveDidsToHandles(dids: string[]): Promise> +} + +export function createBidirectionalResolver(resolver: IdResolver) { + return { + async resolveDidToHandle(did: string): Promise { + const didDoc = await resolver.did.resolveAtprotoData(did) + const resolvedHandle = await resolver.handle.resolve(didDoc.handle) + if (resolvedHandle === did) { + return didDoc.handle + } + return did + }, + + async resolveDidsToHandles( + dids: string[], + ): Promise> { + const didHandleMap: Record = {} + const resolves = await Promise.all( + dids.map((did) => this.resolveDidToHandle(did).catch((_) => did)), + ) + for (let i = 0; i < dids.length; i++) { + didHandleMap[dids[i]] = resolves[i] + } + return didHandleMap + }, + } +} diff --git a/experimental/chatproto/packages/appview/src/index.ts b/experimental/chatproto/packages/appview/src/index.ts new file mode 100644 index 0000000..637c7e6 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/index.ts @@ -0,0 +1,145 @@ +import events from 'node:events' +import fs from 'node:fs' +import type http from 'node:http' +import path from 'node:path' +import { DAY, SECOND } from '@atproto/common' +import compression from 'compression' +import cors from 'cors' +import express from 'express' +import { pino } from 'pino' + +import API, { health, oauth } from '#/api' +import { createClient } from '#/auth/client' +import { AppContext } from '#/context' +import { createDb, migrateToLatest } from '#/db' +import * as error from '#/error' +import { createBidirectionalResolver, createIdResolver } from '#/id-resolver' +import { createFirehoseIngester, createJetstreamIngester } from '#/ingestors' +import { createServer } from '#/lexicons' +import { env } from '#/lib/env' + +export class Server { + constructor( + public app: express.Application, + public server: http.Server, + public ctx: AppContext, + ) {} + + static async create() { + const { NODE_ENV, HOST, PORT, DB_PATH } = env + const logger = pino({ name: 'server start' }) + + // Set up the SQLite database + const db = createDb(DB_PATH) + await migrateToLatest(db) + + // Create the atproto utilities + const oauthClient = await createClient(db) + const baseIdResolver = createIdResolver() + const ingester = await createJetstreamIngester(db) + // Alternative: const ingester = await createFirehoseIngester(db, baseIdResolver) + const resolver = createBidirectionalResolver(baseIdResolver) + const ctx = { + db, + ingester, + logger, + oauthClient, + resolver, + } + + // Subscribe to events on the firehose + ingester.start() + + const app = express() + app.use(cors({ maxAge: DAY / SECOND })) + app.use(compression()) + app.use(express.json()) + app.use(express.urlencoded({ extended: true })) + + // Create our server + let server = createServer({ + validateResponse: env.isDevelopment, + payload: { + jsonLimit: 100 * 1024, // 100kb + textLimit: 100 * 1024, // 100kb + // no blobs + blobLimit: 0, + }, + }) + + server = API(server, ctx) + + app.use(health.createRouter(ctx)) + app.use(oauth.createRouter(ctx)) + app.use(server.xrpc.router) + app.use(error.createHandler(ctx)) + + // Serve static files from the frontend build - prod only + if (env.isProduction) { + const frontendPath = path.resolve( + __dirname, + '../../../packages/client/dist', + ) + + // Check if the frontend build exists + if (fs.existsSync(frontendPath)) { + logger.info(`Serving frontend static files from: ${frontendPath}`) + + // Serve static files + app.use(express.static(frontendPath)) + + // For any other requests, send the index.html file + app.get('*', (req, res) => { + // Only handle non-API paths + if (!req.path.startsWith('/xrpc/')) { + res.sendFile(path.join(frontendPath, 'index.html')) + } else { + res.status(404).json({ error: 'API endpoint not found' }) + } + }) + } else { + logger.warn(`Frontend build not found at: ${frontendPath}`) + app.use('*', (_req, res) => { + res.sendStatus(404) + }) + } + } else { + app.set('trust proxy', true) + } + + // Use the port from env (should be 3001 for the API server) + const httpServer = app.listen(env.PORT) + await events.once(httpServer, 'listening') + logger.info( + `API Server (${NODE_ENV}) running on port http://${HOST}:${env.PORT}`, + ) + + return new Server(app, httpServer, ctx) + } + + async close() { + this.ctx.logger.info('sigint received, shutting down') + await this.ctx.ingester.destroy() + await new Promise((resolve) => { + this.server.close(() => { + this.ctx.logger.info('server closed') + resolve() + }) + }) + } +} + +const run = async () => { + const server = await Server.create() + + const onCloseSignal = async () => { + setTimeout(() => process.exit(1), 10000).unref() // Force shutdown after 10s + await server.close() + process.exit(0) + } + + process.on('SIGINT', onCloseSignal) + process.on('SIGTERM', onCloseSignal) +} + +run() diff --git a/experimental/chatproto/packages/appview/src/ingestors/firehose.ts b/experimental/chatproto/packages/appview/src/ingestors/firehose.ts new file mode 100644 index 0000000..6e002a1 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/ingestors/firehose.ts @@ -0,0 +1,93 @@ +import { IdResolver } from '@atproto/identity' +import { Firehose, MemoryRunner, type Event } from '@atproto/sync' +import { XyzStatusphereStatus } from '@statusphere/lexicon' +import pino from 'pino' + +import type { Database } from '#/db' + +export async function createFirehoseIngester( + db: Database, + idResolver: IdResolver, +) { + const logger = pino({ name: 'firehose ingestion' }) + + const cursor = await db + .selectFrom('cursor') + .where('id', '=', 1) + .select('seq') + .executeTakeFirst() + + logger.info(`start cursor: ${cursor?.seq}`) + + // For throttling cursor writes + let lastCursorWrite = 0 + + const runner = new MemoryRunner({ + startCursor: cursor?.seq || undefined, + setCursor: async (seq) => { + const now = Date.now() + + if (now - lastCursorWrite >= 10000) { + lastCursorWrite = now + await db + .updateTable('cursor') + .set({ seq }) + .where('id', '=', 1) + .execute() + } + }, + }) + + return new Firehose({ + idResolver, + runner, + handleEvent: async (evt: Event) => { + // Watch for write events + if (evt.event === 'create' || evt.event === 'update') { + const now = new Date() + const record = evt.record + + // If the write is a valid status update + if ( + evt.collection === 'xyz.statusphere.status' && + XyzStatusphereStatus.isRecord(record) + ) { + const validatedRecord = XyzStatusphereStatus.validateRecord(record) + if (!validatedRecord.success) return + // Store the status in our SQLite + await db + .insertInto('status') + .values({ + uri: evt.uri.toString(), + authorDid: evt.did, + status: validatedRecord.value.status, + createdAt: validatedRecord.value.createdAt, + indexedAt: now.toISOString(), + }) + .onConflict((oc) => + oc.column('uri').doUpdateSet({ + status: validatedRecord.value.status, + indexedAt: now.toISOString(), + }), + ) + .execute() + } + } else if ( + evt.event === 'delete' && + evt.collection === 'xyz.statusphere.status' + ) { + // Remove the status from our SQLite + await db + .deleteFrom('status') + .where('uri', '=', evt.uri.toString()) + .execute() + } + }, + onError: (err: Error) => { + logger.error({ err }, 'error on firehose ingestion') + }, + filterCollections: ['xyz.statusphere.status'], + excludeIdentity: true, + excludeAccount: true, + }) +} diff --git a/experimental/chatproto/packages/appview/src/ingestors/index.ts b/experimental/chatproto/packages/appview/src/ingestors/index.ts new file mode 100644 index 0000000..21132e7 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/ingestors/index.ts @@ -0,0 +1,2 @@ +export * from './jetstream' +export * from './firehose' diff --git a/experimental/chatproto/packages/appview/src/ingestors/jetstream.ts b/experimental/chatproto/packages/appview/src/ingestors/jetstream.ts new file mode 100644 index 0000000..d669185 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/ingestors/jetstream.ts @@ -0,0 +1,223 @@ +import { XyzStatusphereStatus } from '@statusphere/lexicon' +import pino from 'pino' +import WebSocket from 'ws' + +import type { Database } from '#/db' +import { env } from '#/lib/env' + +export async function createJetstreamIngester(db: Database) { + const logger = pino({ name: 'jetstream ingestion' }) + + const cursor = await db + .selectFrom('cursor') + .where('id', '=', 2) + .select('seq') + .executeTakeFirst() + + logger.info(`start cursor: ${cursor?.seq}`) + + // For throttling cursor writes + let lastCursorWrite = 0 + + return new Jetstream({ + instanceUrl: env.JETSTREAM_INSTANCE, + logger, + cursor: cursor?.seq || undefined, + setCursor: async (seq) => { + const now = Date.now() + + if (now - lastCursorWrite >= 30000) { + lastCursorWrite = now + logger.info(`writing cursor: ${seq}`) + await db + .updateTable('cursor') + .set({ seq }) + .where('id', '=', 2) + .execute() + } + }, + handleEvent: async (evt) => { + // ignore account and identity events + if ( + evt.kind !== 'commit' || + evt.commit.collection !== 'xyz.statusphere.status' + ) + return + + const now = new Date() + const uri = `at://${evt.did}/${evt.commit.collection}/${evt.commit.rkey}` + + if ( + (evt.commit.operation === 'create' || + evt.commit.operation === 'update') && + XyzStatusphereStatus.isRecord(evt.commit.record) + ) { + const validatedRecord = XyzStatusphereStatus.validateRecord( + evt.commit.record, + ) + if (!validatedRecord.success) return + + await db + .insertInto('status') + .values({ + uri, + authorDid: evt.did, + status: validatedRecord.value.status, + createdAt: validatedRecord.value.createdAt, + indexedAt: now.toISOString(), + }) + .onConflict((oc) => + oc.column('uri').doUpdateSet({ + status: validatedRecord.value.status, + indexedAt: now.toISOString(), + }), + ) + .execute() + } else if (evt.commit.operation === 'delete') { + await db.deleteFrom('status').where('uri', '=', uri).execute() + } + }, + onError: (err) => { + logger.error({ err }, 'error during jetstream ingestion') + }, + wantedCollections: ['xyz.statusphere.status'], + }) +} + +export class Jetstream { + private instanceUrl: string + private logger: pino.Logger + private handleEvent: (evt: JetstreamEvent) => Promise + private onError: (err: unknown) => void + private setCursor?: (seq: number) => Promise + private cursor?: number + private ws?: WebSocket + private isStarted = false + private isDestroyed = false + private wantedCollections: string[] + + constructor({ + instanceUrl, + logger, + cursor, + setCursor, + handleEvent, + onError, + wantedCollections, + }: { + instanceUrl: string + logger: pino.Logger + cursor?: number + setCursor?: (seq: number) => Promise + handleEvent: (evt: any) => Promise + onError: (err: any) => void + wantedCollections: string[] + }) { + this.instanceUrl = instanceUrl + this.logger = logger + this.cursor = cursor + this.setCursor = setCursor + this.handleEvent = handleEvent + this.onError = onError + this.wantedCollections = wantedCollections + } + + constructUrlWithQuery = (): string => { + const params = new URLSearchParams() + params.append('wantedCollections', this.wantedCollections.join(',')) + if (this.cursor !== undefined) { + params.append('cursor', this.cursor.toString()) + } + return `${this.instanceUrl}/subscribe?${params.toString()}` + } + + start() { + if (this.isStarted) return + this.isStarted = true + this.isDestroyed = false + this.ws = new WebSocket(this.constructUrlWithQuery()) + + this.ws.on('open', () => { + this.logger.info('Jetstream connection opened.') + }) + + this.ws.on('message', async (data) => { + try { + const event: JetstreamEvent = JSON.parse(data.toString()) + + // Update cursor if provided + if (event.time_us !== undefined && this.setCursor) { + await this.setCursor(event.time_us) + } + + await this.handleEvent(event) + } catch (err) { + this.onError(err) + } + }) + + this.ws.on('error', (err) => { + this.onError(err) + }) + + this.ws.on('close', (code, reason) => { + if (!this.isDestroyed) { + this.logger.error(`Jetstream closed. Code: ${code}, Reason: ${reason}`) + } + this.isStarted = false + }) + } + + destroy() { + if (this.ws) { + this.isDestroyed = true + this.ws.close() + this.isStarted = false + this.logger.info('jetstream destroyed gracefully') + } + } +} + +type JetstreamEvent = { + did: string + time_us: number +} & (CommitEvent | AccountEvent | IdentityEvent) + +type CommitEvent = { + kind: 'commit' + commit: + | { + operation: 'create' | 'update' + record: T + rev: string + collection: string + rkey: string + cid: string + } + | { + operation: 'delete' + rev: string + collection: string + rkey: string + } +} + +type IdentityEvent = { + kind: 'identity' + identity: { + did: string + handle: string + seq: number + time: string + } +} + +type AccountEvent = { + kind: 'account' + account: { + active: boolean + did: string + seq: number + time: string + } +} diff --git a/experimental/chatproto/packages/appview/src/lexicons/index.ts b/experimental/chatproto/packages/appview/src/lexicons/index.ts new file mode 100644 index 0000000..862b1a4 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/lexicons/index.ts @@ -0,0 +1,286 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { + AuthVerifier, + createServer as createXrpcServer, + StreamAuthVerifier, + Options as XrpcOptions, + Server as XrpcServer, +} from '@atproto/xrpc-server' + +import { schemas } from './lexicons.js' +import * as ComAtprotoRepoApplyWrites from './types/com/atproto/repo/applyWrites.js' +import * as ComAtprotoRepoCreateRecord from './types/com/atproto/repo/createRecord.js' +import * as ComAtprotoRepoDeleteRecord from './types/com/atproto/repo/deleteRecord.js' +import * as ComAtprotoRepoDescribeRepo from './types/com/atproto/repo/describeRepo.js' +import * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord.js' +import * as ComAtprotoRepoImportRepo from './types/com/atproto/repo/importRepo.js' +import * as ComAtprotoRepoListMissingBlobs from './types/com/atproto/repo/listMissingBlobs.js' +import * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords.js' +import * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord.js' +import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob.js' +import * as XyzStatusphereGetStatuses from './types/xyz/statusphere/getStatuses.js' +import * as XyzStatusphereGetUser from './types/xyz/statusphere/getUser.js' +import * as XyzStatusphereSendStatus from './types/xyz/statusphere/sendStatus.js' + +export function createServer(options?: XrpcOptions): Server { + return new Server(options) +} + +export class Server { + xrpc: XrpcServer + xyz: XyzNS + com: ComNS + app: AppNS + + constructor(options?: XrpcOptions) { + this.xrpc = createXrpcServer(schemas, options) + this.xyz = new XyzNS(this) + this.com = new ComNS(this) + this.app = new AppNS(this) + } +} + +export class XyzNS { + _server: Server + statusphere: XyzStatusphereNS + + constructor(server: Server) { + this._server = server + this.statusphere = new XyzStatusphereNS(server) + } +} + +export class XyzStatusphereNS { + _server: Server + + constructor(server: Server) { + this._server = server + } + + getStatuses( + cfg: ConfigOf< + AV, + XyzStatusphereGetStatuses.Handler>, + XyzStatusphereGetStatuses.HandlerReqCtx> + >, + ) { + const nsid = 'xyz.statusphere.getStatuses' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + getUser( + cfg: ConfigOf< + AV, + XyzStatusphereGetUser.Handler>, + XyzStatusphereGetUser.HandlerReqCtx> + >, + ) { + const nsid = 'xyz.statusphere.getUser' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + sendStatus( + cfg: ConfigOf< + AV, + XyzStatusphereSendStatus.Handler>, + XyzStatusphereSendStatus.HandlerReqCtx> + >, + ) { + const nsid = 'xyz.statusphere.sendStatus' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } +} + +export class ComNS { + _server: Server + atproto: ComAtprotoNS + + constructor(server: Server) { + this._server = server + this.atproto = new ComAtprotoNS(server) + } +} + +export class ComAtprotoNS { + _server: Server + repo: ComAtprotoRepoNS + + constructor(server: Server) { + this._server = server + this.repo = new ComAtprotoRepoNS(server) + } +} + +export class ComAtprotoRepoNS { + _server: Server + + constructor(server: Server) { + this._server = server + } + + applyWrites( + cfg: ConfigOf< + AV, + ComAtprotoRepoApplyWrites.Handler>, + ComAtprotoRepoApplyWrites.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.repo.applyWrites' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + createRecord( + cfg: ConfigOf< + AV, + ComAtprotoRepoCreateRecord.Handler>, + ComAtprotoRepoCreateRecord.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.repo.createRecord' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + deleteRecord( + cfg: ConfigOf< + AV, + ComAtprotoRepoDeleteRecord.Handler>, + ComAtprotoRepoDeleteRecord.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.repo.deleteRecord' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + describeRepo( + cfg: ConfigOf< + AV, + ComAtprotoRepoDescribeRepo.Handler>, + ComAtprotoRepoDescribeRepo.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.repo.describeRepo' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + getRecord( + cfg: ConfigOf< + AV, + ComAtprotoRepoGetRecord.Handler>, + ComAtprotoRepoGetRecord.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.repo.getRecord' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + importRepo( + cfg: ConfigOf< + AV, + ComAtprotoRepoImportRepo.Handler>, + ComAtprotoRepoImportRepo.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.repo.importRepo' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + listMissingBlobs( + cfg: ConfigOf< + AV, + ComAtprotoRepoListMissingBlobs.Handler>, + ComAtprotoRepoListMissingBlobs.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.repo.listMissingBlobs' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + listRecords( + cfg: ConfigOf< + AV, + ComAtprotoRepoListRecords.Handler>, + ComAtprotoRepoListRecords.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.repo.listRecords' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + putRecord( + cfg: ConfigOf< + AV, + ComAtprotoRepoPutRecord.Handler>, + ComAtprotoRepoPutRecord.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.repo.putRecord' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + uploadBlob( + cfg: ConfigOf< + AV, + ComAtprotoRepoUploadBlob.Handler>, + ComAtprotoRepoUploadBlob.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.repo.uploadBlob' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } +} + +export class AppNS { + _server: Server + bsky: AppBskyNS + + constructor(server: Server) { + this._server = server + this.bsky = new AppBskyNS(server) + } +} + +export class AppBskyNS { + _server: Server + actor: AppBskyActorNS + + constructor(server: Server) { + this._server = server + this.actor = new AppBskyActorNS(server) + } +} + +export class AppBskyActorNS { + _server: Server + + constructor(server: Server) { + this._server = server + } +} + +type SharedRateLimitOpts = { + name: string + calcKey?: (ctx: T) => string | null + calcPoints?: (ctx: T) => number +} +type RouteRateLimitOpts = { + durationMs: number + points: number + calcKey?: (ctx: T) => string | null + calcPoints?: (ctx: T) => number +} +type HandlerOpts = { blobLimit?: number } +type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts +type ConfigOf = + | Handler + | { + auth?: Auth + opts?: HandlerOpts + rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] + handler: Handler + } +type ExtractAuth = Extract< + Awaited>, + { credentials: unknown } +> diff --git a/experimental/chatproto/packages/appview/src/lexicons/lexicons.ts b/experimental/chatproto/packages/appview/src/lexicons/lexicons.ts new file mode 100644 index 0000000..c6c06cc --- /dev/null +++ b/experimental/chatproto/packages/appview/src/lexicons/lexicons.ts @@ -0,0 +1,1297 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { + LexiconDoc, + Lexicons, + ValidationError, + ValidationResult, +} from '@atproto/lexicon' + +import { $Typed, is$typed, maybe$typed } from './util.js' + +export const schemaDict = { + XyzStatusphereDefs: { + lexicon: 1, + id: 'xyz.statusphere.defs', + defs: { + statusView: { + type: 'object', + required: ['uri', 'status', 'profile', 'createdAt'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + status: { + type: 'string', + minLength: 1, + maxGraphemes: 1, + maxLength: 32, + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + profile: { + type: 'ref', + ref: 'lex:xyz.statusphere.defs#profileView', + }, + }, + }, + profileView: { + type: 'object', + required: ['did', 'handle'], + properties: { + did: { + type: 'string', + format: 'did', + }, + handle: { + type: 'string', + format: 'handle', + }, + }, + }, + }, + }, + XyzStatusphereGetStatuses: { + lexicon: 1, + id: 'xyz.statusphere.getStatuses', + defs: { + main: { + type: 'query', + description: 'Get a list of the most recent statuses on the network.', + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['statuses'], + properties: { + statuses: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:xyz.statusphere.defs#statusView', + }, + }, + }, + }, + }, + }, + }, + }, + XyzStatusphereGetUser: { + lexicon: 1, + id: 'xyz.statusphere.getUser', + defs: { + main: { + type: 'query', + description: "Get the current user's profile and status.", + parameters: { + type: 'params', + properties: {}, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['profile'], + properties: { + profile: { + type: 'ref', + ref: 'lex:app.bsky.actor.defs#profileView', + }, + status: { + type: 'ref', + ref: 'lex:xyz.statusphere.defs#statusView', + }, + }, + }, + }, + }, + }, + }, + XyzStatusphereSendStatus: { + lexicon: 1, + id: 'xyz.statusphere.sendStatus', + defs: { + main: { + type: 'procedure', + description: 'Send a status into the ATmosphere.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['status'], + properties: { + status: { + type: 'string', + minLength: 1, + maxGraphemes: 1, + maxLength: 32, + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['status'], + properties: { + status: { + type: 'ref', + ref: 'lex:xyz.statusphere.defs#statusView', + }, + }, + }, + }, + }, + }, + }, + XyzStatusphereStatus: { + lexicon: 1, + id: 'xyz.statusphere.status', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + required: ['status', 'createdAt'], + properties: { + status: { + type: 'string', + minLength: 1, + maxGraphemes: 1, + maxLength: 32, + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, + }, + ComAtprotoLabelDefs: { + lexicon: 1, + id: 'com.atproto.label.defs', + defs: { + label: { + type: 'object', + description: + 'Metadata tag on an atproto resource (eg, repo or record).', + required: ['src', 'uri', 'val', 'cts'], + properties: { + ver: { + type: 'integer', + description: 'The AT Protocol version of the label object.', + }, + src: { + type: 'string', + format: 'did', + description: 'DID of the actor who created this label.', + }, + uri: { + type: 'string', + format: 'uri', + description: + 'AT URI of the record, repository (account), or other resource that this label applies to.', + }, + cid: { + type: 'string', + format: 'cid', + description: + "Optionally, CID specifying the specific version of 'uri' resource this label applies to.", + }, + val: { + type: 'string', + maxLength: 128, + description: + 'The short string name of the value or type of this label.', + }, + neg: { + type: 'boolean', + description: + 'If true, this is a negation label, overwriting a previous label.', + }, + cts: { + type: 'string', + format: 'datetime', + description: 'Timestamp when this label was created.', + }, + exp: { + type: 'string', + format: 'datetime', + description: + 'Timestamp at which this label expires (no longer applies).', + }, + sig: { + type: 'bytes', + description: 'Signature of dag-cbor encoded label.', + }, + }, + }, + selfLabels: { + type: 'object', + description: + 'Metadata tags on an atproto record, published by the author within the record.', + required: ['values'], + properties: { + values: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#selfLabel', + }, + maxLength: 10, + }, + }, + }, + selfLabel: { + type: 'object', + description: + 'Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.', + required: ['val'], + properties: { + val: { + type: 'string', + maxLength: 128, + description: + 'The short string name of the value or type of this label.', + }, + }, + }, + labelValueDefinition: { + type: 'object', + description: + 'Declares a label value and its expected interpretations and behaviors.', + required: ['identifier', 'severity', 'blurs', 'locales'], + properties: { + identifier: { + type: 'string', + description: + "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", + maxLength: 100, + maxGraphemes: 100, + }, + severity: { + type: 'string', + description: + "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", + knownValues: ['inform', 'alert', 'none'], + }, + blurs: { + type: 'string', + description: + "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", + knownValues: ['content', 'media', 'none'], + }, + defaultSetting: { + type: 'string', + description: 'The default setting for this label.', + knownValues: ['ignore', 'warn', 'hide'], + default: 'warn', + }, + adultOnly: { + type: 'boolean', + description: + 'Does the user need to have adult content enabled in order to configure this label?', + }, + locales: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#labelValueDefinitionStrings', + }, + }, + }, + }, + labelValueDefinitionStrings: { + type: 'object', + description: + 'Strings which describe the label in the UI, localized into a specific language.', + required: ['lang', 'name', 'description'], + properties: { + lang: { + type: 'string', + description: + 'The code of the language these strings are written in.', + format: 'language', + }, + name: { + type: 'string', + description: 'A short human-readable name for the label.', + maxGraphemes: 64, + maxLength: 640, + }, + description: { + type: 'string', + description: + 'A longer description of what the label means and why it might be applied.', + maxGraphemes: 10000, + maxLength: 100000, + }, + }, + }, + labelValue: { + type: 'string', + knownValues: [ + '!hide', + '!no-promote', + '!warn', + '!no-unauthenticated', + 'dmca-violation', + 'doxxing', + 'porn', + 'sexual', + 'nudity', + 'nsfl', + 'gore', + ], + }, + }, + }, + ComAtprotoRepoApplyWrites: { + lexicon: 1, + id: 'com.atproto.repo.applyWrites', + defs: { + main: { + type: 'procedure', + description: + 'Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['repo', 'writes'], + properties: { + repo: { + type: 'string', + format: 'at-identifier', + description: + 'The handle or DID of the repo (aka, current account).', + }, + validate: { + type: 'boolean', + description: + "Can be set to 'false' to skip Lexicon schema validation of record data across all operations, 'true' to require it, or leave unset to validate only for known Lexicons.", + }, + writes: { + type: 'array', + items: { + type: 'union', + refs: [ + 'lex:com.atproto.repo.applyWrites#create', + 'lex:com.atproto.repo.applyWrites#update', + 'lex:com.atproto.repo.applyWrites#delete', + ], + closed: true, + }, + }, + swapCommit: { + type: 'string', + description: + 'If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations.', + format: 'cid', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: [], + properties: { + commit: { + type: 'ref', + ref: 'lex:com.atproto.repo.defs#commitMeta', + }, + results: { + type: 'array', + items: { + type: 'union', + refs: [ + 'lex:com.atproto.repo.applyWrites#createResult', + 'lex:com.atproto.repo.applyWrites#updateResult', + 'lex:com.atproto.repo.applyWrites#deleteResult', + ], + closed: true, + }, + }, + }, + }, + }, + errors: [ + { + name: 'InvalidSwap', + description: + "Indicates that the 'swapCommit' parameter did not match current commit.", + }, + ], + }, + create: { + type: 'object', + description: 'Operation which creates a new record.', + required: ['collection', 'value'], + properties: { + collection: { + type: 'string', + format: 'nsid', + }, + rkey: { + type: 'string', + maxLength: 512, + format: 'record-key', + description: + 'NOTE: maxLength is redundant with record-key format. Keeping it temporarily to ensure backwards compatibility.', + }, + value: { + type: 'unknown', + }, + }, + }, + update: { + type: 'object', + description: 'Operation which updates an existing record.', + required: ['collection', 'rkey', 'value'], + properties: { + collection: { + type: 'string', + format: 'nsid', + }, + rkey: { + type: 'string', + format: 'record-key', + }, + value: { + type: 'unknown', + }, + }, + }, + delete: { + type: 'object', + description: 'Operation which deletes an existing record.', + required: ['collection', 'rkey'], + properties: { + collection: { + type: 'string', + format: 'nsid', + }, + rkey: { + type: 'string', + format: 'record-key', + }, + }, + }, + createResult: { + type: 'object', + required: ['uri', 'cid'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + validationStatus: { + type: 'string', + knownValues: ['valid', 'unknown'], + }, + }, + }, + updateResult: { + type: 'object', + required: ['uri', 'cid'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + validationStatus: { + type: 'string', + knownValues: ['valid', 'unknown'], + }, + }, + }, + deleteResult: { + type: 'object', + required: [], + properties: {}, + }, + }, + }, + ComAtprotoRepoCreateRecord: { + lexicon: 1, + id: 'com.atproto.repo.createRecord', + defs: { + main: { + type: 'procedure', + description: + 'Create a single new repository record. Requires auth, implemented by PDS.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['repo', 'collection', 'record'], + properties: { + repo: { + type: 'string', + format: 'at-identifier', + description: + 'The handle or DID of the repo (aka, current account).', + }, + collection: { + type: 'string', + format: 'nsid', + description: 'The NSID of the record collection.', + }, + rkey: { + type: 'string', + format: 'record-key', + description: 'The Record Key.', + maxLength: 512, + }, + validate: { + type: 'boolean', + description: + "Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.", + }, + record: { + type: 'unknown', + description: 'The record itself. Must contain a $type field.', + }, + swapCommit: { + type: 'string', + format: 'cid', + description: + 'Compare and swap with the previous commit by CID.', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['uri', 'cid'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + commit: { + type: 'ref', + ref: 'lex:com.atproto.repo.defs#commitMeta', + }, + validationStatus: { + type: 'string', + knownValues: ['valid', 'unknown'], + }, + }, + }, + }, + errors: [ + { + name: 'InvalidSwap', + description: + "Indicates that 'swapCommit' didn't match current repo commit.", + }, + ], + }, + }, + }, + ComAtprotoRepoDefs: { + lexicon: 1, + id: 'com.atproto.repo.defs', + defs: { + commitMeta: { + type: 'object', + required: ['cid', 'rev'], + properties: { + cid: { + type: 'string', + format: 'cid', + }, + rev: { + type: 'string', + format: 'tid', + }, + }, + }, + }, + }, + ComAtprotoRepoDeleteRecord: { + lexicon: 1, + id: 'com.atproto.repo.deleteRecord', + defs: { + main: { + type: 'procedure', + description: + "Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.", + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['repo', 'collection', 'rkey'], + properties: { + repo: { + type: 'string', + format: 'at-identifier', + description: + 'The handle or DID of the repo (aka, current account).', + }, + collection: { + type: 'string', + format: 'nsid', + description: 'The NSID of the record collection.', + }, + rkey: { + type: 'string', + format: 'record-key', + description: 'The Record Key.', + }, + swapRecord: { + type: 'string', + format: 'cid', + description: + 'Compare and swap with the previous record by CID.', + }, + swapCommit: { + type: 'string', + format: 'cid', + description: + 'Compare and swap with the previous commit by CID.', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + properties: { + commit: { + type: 'ref', + ref: 'lex:com.atproto.repo.defs#commitMeta', + }, + }, + }, + }, + errors: [ + { + name: 'InvalidSwap', + }, + ], + }, + }, + }, + ComAtprotoRepoDescribeRepo: { + lexicon: 1, + id: 'com.atproto.repo.describeRepo', + defs: { + main: { + type: 'query', + description: + 'Get information about an account and repository, including the list of collections. Does not require auth.', + parameters: { + type: 'params', + required: ['repo'], + properties: { + repo: { + type: 'string', + format: 'at-identifier', + description: 'The handle or DID of the repo.', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: [ + 'handle', + 'did', + 'didDoc', + 'collections', + 'handleIsCorrect', + ], + properties: { + handle: { + type: 'string', + format: 'handle', + }, + did: { + type: 'string', + format: 'did', + }, + didDoc: { + type: 'unknown', + description: 'The complete DID document for this account.', + }, + collections: { + type: 'array', + description: + 'List of all the collections (NSIDs) for which this repo contains at least one record.', + items: { + type: 'string', + format: 'nsid', + }, + }, + handleIsCorrect: { + type: 'boolean', + description: + 'Indicates if handle is currently valid (resolves bi-directionally)', + }, + }, + }, + }, + }, + }, + }, + ComAtprotoRepoGetRecord: { + lexicon: 1, + id: 'com.atproto.repo.getRecord', + defs: { + main: { + type: 'query', + description: + 'Get a single record from a repository. Does not require auth.', + parameters: { + type: 'params', + required: ['repo', 'collection', 'rkey'], + properties: { + repo: { + type: 'string', + format: 'at-identifier', + description: 'The handle or DID of the repo.', + }, + collection: { + type: 'string', + format: 'nsid', + description: 'The NSID of the record collection.', + }, + rkey: { + type: 'string', + description: 'The Record Key.', + format: 'record-key', + }, + cid: { + type: 'string', + format: 'cid', + description: + 'The CID of the version of the record. If not specified, then return the most recent version.', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['uri', 'value'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + value: { + type: 'unknown', + }, + }, + }, + }, + errors: [ + { + name: 'RecordNotFound', + }, + ], + }, + }, + }, + ComAtprotoRepoImportRepo: { + lexicon: 1, + id: 'com.atproto.repo.importRepo', + defs: { + main: { + type: 'procedure', + description: + 'Import a repo in the form of a CAR file. Requires Content-Length HTTP header to be set.', + input: { + encoding: 'application/vnd.ipld.car', + }, + }, + }, + }, + ComAtprotoRepoListMissingBlobs: { + lexicon: 1, + id: 'com.atproto.repo.listMissingBlobs', + defs: { + main: { + type: 'query', + description: + 'Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow.', + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 1000, + default: 500, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['blobs'], + properties: { + cursor: { + type: 'string', + }, + blobs: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.repo.listMissingBlobs#recordBlob', + }, + }, + }, + }, + }, + }, + recordBlob: { + type: 'object', + required: ['cid', 'recordUri'], + properties: { + cid: { + type: 'string', + format: 'cid', + }, + recordUri: { + type: 'string', + format: 'at-uri', + }, + }, + }, + }, + }, + ComAtprotoRepoListRecords: { + lexicon: 1, + id: 'com.atproto.repo.listRecords', + defs: { + main: { + type: 'query', + description: + 'List a range of records in a repository, matching a specific collection. Does not require auth.', + parameters: { + type: 'params', + required: ['repo', 'collection'], + properties: { + repo: { + type: 'string', + format: 'at-identifier', + description: 'The handle or DID of the repo.', + }, + collection: { + type: 'string', + format: 'nsid', + description: 'The NSID of the record type.', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + description: 'The number of records to return.', + }, + cursor: { + type: 'string', + }, + rkeyStart: { + type: 'string', + description: + 'DEPRECATED: The lowest sort-ordered rkey to start from (exclusive)', + }, + rkeyEnd: { + type: 'string', + description: + 'DEPRECATED: The highest sort-ordered rkey to stop at (exclusive)', + }, + reverse: { + type: 'boolean', + description: 'Flag to reverse the order of the returned records.', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['records'], + properties: { + cursor: { + type: 'string', + }, + records: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.repo.listRecords#record', + }, + }, + }, + }, + }, + }, + record: { + type: 'object', + required: ['uri', 'cid', 'value'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + value: { + type: 'unknown', + }, + }, + }, + }, + }, + ComAtprotoRepoPutRecord: { + lexicon: 1, + id: 'com.atproto.repo.putRecord', + defs: { + main: { + type: 'procedure', + description: + 'Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['repo', 'collection', 'rkey', 'record'], + nullable: ['swapRecord'], + properties: { + repo: { + type: 'string', + format: 'at-identifier', + description: + 'The handle or DID of the repo (aka, current account).', + }, + collection: { + type: 'string', + format: 'nsid', + description: 'The NSID of the record collection.', + }, + rkey: { + type: 'string', + format: 'record-key', + description: 'The Record Key.', + maxLength: 512, + }, + validate: { + type: 'boolean', + description: + "Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons.", + }, + record: { + type: 'unknown', + description: 'The record to write.', + }, + swapRecord: { + type: 'string', + format: 'cid', + description: + 'Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation', + }, + swapCommit: { + type: 'string', + format: 'cid', + description: + 'Compare and swap with the previous commit by CID.', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['uri', 'cid'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + commit: { + type: 'ref', + ref: 'lex:com.atproto.repo.defs#commitMeta', + }, + validationStatus: { + type: 'string', + knownValues: ['valid', 'unknown'], + }, + }, + }, + }, + errors: [ + { + name: 'InvalidSwap', + }, + ], + }, + }, + }, + ComAtprotoRepoStrongRef: { + lexicon: 1, + id: 'com.atproto.repo.strongRef', + description: 'A URI with a content-hash fingerprint.', + defs: { + main: { + type: 'object', + required: ['uri', 'cid'], + properties: { + uri: { + type: 'string', + format: 'at-uri', + }, + cid: { + type: 'string', + format: 'cid', + }, + }, + }, + }, + }, + ComAtprotoRepoUploadBlob: { + lexicon: 1, + id: 'com.atproto.repo.uploadBlob', + defs: { + main: { + type: 'procedure', + description: + 'Upload a new blob, to be referenced from a repository record. The blob will be deleted if it is not referenced within a time window (eg, minutes). Blob restrictions (mimetype, size, etc) are enforced when the reference is created. Requires auth, implemented by PDS.', + input: { + encoding: '*/*', + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['blob'], + properties: { + blob: { + type: 'blob', + }, + }, + }, + }, + }, + }, + }, + AppBskyActorDefs: { + lexicon: 1, + id: 'app.bsky.actor.defs', + defs: { + profileView: { + type: 'object', + required: ['did', 'handle'], + properties: { + did: { + type: 'string', + format: 'did', + }, + handle: { + type: 'string', + format: 'handle', + }, + displayName: { + type: 'string', + maxGraphemes: 64, + maxLength: 640, + }, + description: { + type: 'string', + maxGraphemes: 256, + maxLength: 2560, + }, + avatar: { + type: 'string', + format: 'uri', + }, + indexedAt: { + type: 'string', + format: 'datetime', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + labels: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.label.defs#label', + }, + }, + }, + }, + }, + }, + AppBskyActorProfile: { + lexicon: 1, + id: 'app.bsky.actor.profile', + defs: { + main: { + type: 'record', + description: 'A declaration of a Bluesky account profile.', + key: 'literal:self', + record: { + type: 'object', + properties: { + displayName: { + type: 'string', + maxGraphemes: 64, + maxLength: 640, + }, + description: { + type: 'string', + description: 'Free-form profile description text.', + maxGraphemes: 256, + maxLength: 2560, + }, + avatar: { + type: 'blob', + description: + "Small image to be displayed next to posts from account. AKA, 'profile picture'", + accept: ['image/png', 'image/jpeg'], + maxSize: 1000000, + }, + banner: { + type: 'blob', + description: + 'Larger horizontal image to display behind profile view.', + accept: ['image/png', 'image/jpeg'], + maxSize: 1000000, + }, + labels: { + type: 'union', + description: + 'Self-label values, specific to the Bluesky application, on the overall account.', + refs: ['lex:com.atproto.label.defs#selfLabels'], + }, + joinedViaStarterPack: { + type: 'ref', + ref: 'lex:com.atproto.repo.strongRef', + }, + pinnedPost: { + type: 'ref', + ref: 'lex:com.atproto.repo.strongRef', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, + }, +} as const satisfies Record + +export const schemas = Object.values(schemaDict) satisfies LexiconDoc[] +export const lexicons: Lexicons = new Lexicons(schemas) + +export function validate( + v: unknown, + id: string, + hash: string, + requiredType: true, +): ValidationResult +export function validate( + v: unknown, + id: string, + hash: string, + requiredType?: false, +): ValidationResult +export function validate( + v: unknown, + id: string, + hash: string, + requiredType?: boolean, +): ValidationResult { + return (requiredType ? is$typed : maybe$typed)(v, id, hash) + ? lexicons.validate(`${id}#${hash}`, v) + : { + success: false, + error: new ValidationError( + `Must be an object with "${hash === 'main' ? id : `${id}#${hash}`}" $type property`, + ), + } +} + +export const ids = { + XyzStatusphereDefs: 'xyz.statusphere.defs', + XyzStatusphereGetStatuses: 'xyz.statusphere.getStatuses', + XyzStatusphereGetUser: 'xyz.statusphere.getUser', + XyzStatusphereSendStatus: 'xyz.statusphere.sendStatus', + XyzStatusphereStatus: 'xyz.statusphere.status', + ComAtprotoLabelDefs: 'com.atproto.label.defs', + ComAtprotoRepoApplyWrites: 'com.atproto.repo.applyWrites', + ComAtprotoRepoCreateRecord: 'com.atproto.repo.createRecord', + ComAtprotoRepoDefs: 'com.atproto.repo.defs', + ComAtprotoRepoDeleteRecord: 'com.atproto.repo.deleteRecord', + ComAtprotoRepoDescribeRepo: 'com.atproto.repo.describeRepo', + ComAtprotoRepoGetRecord: 'com.atproto.repo.getRecord', + ComAtprotoRepoImportRepo: 'com.atproto.repo.importRepo', + ComAtprotoRepoListMissingBlobs: 'com.atproto.repo.listMissingBlobs', + ComAtprotoRepoListRecords: 'com.atproto.repo.listRecords', + ComAtprotoRepoPutRecord: 'com.atproto.repo.putRecord', + ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', + ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob', + AppBskyActorDefs: 'app.bsky.actor.defs', + AppBskyActorProfile: 'app.bsky.actor.profile', +} as const diff --git a/experimental/chatproto/packages/appview/src/lexicons/types/app/bsky/actor/defs.ts b/experimental/chatproto/packages/appview/src/lexicons/types/app/bsky/actor/defs.ts new file mode 100644 index 0000000..5f65150 --- /dev/null +++ b/experimental/chatproto/packages/appview/src/lexicons/types/app/bsky/actor/defs.ts @@ -0,0 +1,35 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { BlobRef, ValidationResult } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' + +import { validate as _validate } from '../../../../lexicons' +import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' +import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' + +const is$typed = _is$typed, + validate = _validate +const id = 'app.bsky.actor.defs' + +export interface ProfileView { + $type?: 'app.bsky.actor.defs#profileView' + did: string + handle: string + displayName?: string + description?: string + avatar?: string + indexedAt?: string + createdAt?: string + labels?: ComAtprotoLabelDefs.Label[] +} + +const hashProfileView = 'profileView' + +export function isProfileView(v: V) { + return is$typed(v, id, hashProfileView) +} + +export function validateProfileView(v: V) { + return validate(v, id, hashProfileView) +} diff --git a/experimental/chatproto/packages/appview/src/lexicons/types/app/bsky/actor/profile.ts b/experimental/chatproto/packages/appview/src/lexicons/types/app/bsky/actor/profile.ts new file mode 100644 index 0000000..579bcae --- /dev/null +++ b/experimental/chatproto/packages/appview/src/lexicons/types/app/bsky/actor/profile.ts @@ -0,0 +1,40 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { BlobRef, ValidationResult } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' + +import { validate as _validate } from '../../../../lexicons' +import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' +import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' +import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef.js' + +const is$typed = _is$typed, + validate = _validate +const id = 'app.bsky.actor.profile' + +export interface Record { + $type: 'app.bsky.actor.profile' + displayName?: string + /** Free-form profile description text. */ + description?: string + /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ + avatar?: BlobRef + /** Larger horizontal image to display behind profile view. */ + banner?: BlobRef + labels?: $Typed | { $type: string } + joinedViaStarterPack?: ComAtprotoRepoStrongRef.Main + pinnedPost?: ComAtprotoRepoStrongRef.Main + createdAt?: string + [k: string]: unknown +} + +const hashRecord = 'main' + +export function isRecord(v: V) { + return is$typed(v, id, hashRecord) +} + +export function validateRecord(v: V) { + return validate(v, id, hashRecord, true) +} diff --git a/experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/label/defs.ts b/experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/label/defs.ts new file mode 100644 index 0000000..af45f4a --- /dev/null +++ b/experimental/chatproto/packages/appview/src/lexicons/types/com/atproto/label/defs.ts @@ -0,0 +1,143 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { BlobRef, ValidationResult } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' + +import { validate as _validate } from '../../../../lexicons' +import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'com.atproto.label.defs' + +/** Metadata tag on an atproto resource (eg, repo or record). */ +export interface Label { + $type?: 'com.atproto.label.defs#label' + /** The AT Protocol version of the label object. */ + ver?: number + /** DID of the actor who created this label. */ + src: string + /** AT URI of the record, repository (account), or other resource that this label applies to. */ + uri: string + /** Optionally, CID specifying the specific version of 'uri' resource this label applies to. */ + cid?: string + /** The short string name of the value or type of this label. */ + val: string + /** If true, this is a negation label, overwriting a previous label. */ + neg?: boolean + /** Timestamp when this label was created. */ + cts: string + /** Timestamp at which this label expires (no longer applies). */ + exp?: string + /** Signature of dag-cbor encoded label. */ + sig?: Uint8Array +} + +const hashLabel = 'label' + +export function isLabel(v: V) { + return is$typed(v, id, hashLabel) +} + +export function validateLabel(v: V) { + return validate