├── .nvmrc ├── pnpm-workspace.yaml ├── packages ├── appview │ ├── src │ │ ├── ingestors │ │ │ ├── index.ts │ │ │ ├── firehose.ts │ │ │ └── jetstream.ts │ │ ├── api │ │ │ ├── health.ts │ │ │ ├── index.ts │ │ │ ├── lexicons │ │ │ │ ├── getStatuses.ts │ │ │ │ ├── getUser.ts │ │ │ │ └── sendStatus.ts │ │ │ └── oauth.ts │ │ ├── error.ts │ │ ├── context.ts │ │ ├── lib │ │ │ ├── env.ts │ │ │ └── hydrate.ts │ │ ├── lexicons │ │ │ ├── types │ │ │ │ ├── com │ │ │ │ │ └── atproto │ │ │ │ │ │ ├── repo │ │ │ │ │ │ ├── strongRef.ts │ │ │ │ │ │ ├── defs.ts │ │ │ │ │ │ ├── importRepo.ts │ │ │ │ │ │ ├── uploadBlob.ts │ │ │ │ │ │ ├── getRecord.ts │ │ │ │ │ │ ├── describeRepo.ts │ │ │ │ │ │ ├── listMissingBlobs.ts │ │ │ │ │ │ ├── deleteRecord.ts │ │ │ │ │ │ ├── createRecord.ts │ │ │ │ │ │ ├── listRecords.ts │ │ │ │ │ │ ├── putRecord.ts │ │ │ │ │ │ └── applyWrites.ts │ │ │ │ │ │ └── label │ │ │ │ │ │ └── defs.ts │ │ │ │ ├── xyz │ │ │ │ │ └── statusphere │ │ │ │ │ │ ├── status.ts │ │ │ │ │ │ ├── defs.ts │ │ │ │ │ │ ├── getStatuses.ts │ │ │ │ │ │ ├── sendStatus.ts │ │ │ │ │ │ └── getUser.ts │ │ │ │ └── app │ │ │ │ │ └── bsky │ │ │ │ │ └── actor │ │ │ │ │ ├── defs.ts │ │ │ │ │ └── profile.ts │ │ │ ├── util.ts │ │ │ └── index.ts │ │ ├── auth │ │ │ ├── client.ts │ │ │ └── storage.ts │ │ ├── id-resolver.ts │ │ ├── session.ts │ │ ├── db.ts │ │ └── index.ts │ ├── tsconfig.json │ ├── .env.template │ ├── README.md │ └── package.json ├── client │ ├── tsconfig.node.json │ ├── public │ │ └── favicon.svg │ ├── src │ │ ├── index.css │ │ ├── main.tsx │ │ ├── App.tsx │ │ ├── pages │ │ │ ├── HomePage.tsx │ │ │ ├── OAuthCallbackPage.tsx │ │ │ └── LoginPage.tsx │ │ ├── services │ │ │ └── api.ts │ │ ├── components │ │ │ ├── Header.tsx │ │ │ ├── StatusList.tsx │ │ │ └── StatusForm.tsx │ │ └── hooks │ │ │ └── useAuth.tsx │ ├── index.html │ ├── README.md │ ├── vite.config.ts │ ├── tsconfig.json │ └── package.json └── lexicon │ ├── tsconfig.json │ ├── src │ ├── types │ │ ├── com │ │ │ └── atproto │ │ │ │ ├── repo │ │ │ │ ├── strongRef.ts │ │ │ │ ├── defs.ts │ │ │ │ ├── importRepo.ts │ │ │ │ ├── uploadBlob.ts │ │ │ │ ├── describeRepo.ts │ │ │ │ ├── listMissingBlobs.ts │ │ │ │ ├── getRecord.ts │ │ │ │ ├── deleteRecord.ts │ │ │ │ ├── listRecords.ts │ │ │ │ ├── createRecord.ts │ │ │ │ ├── putRecord.ts │ │ │ │ └── applyWrites.ts │ │ │ │ └── label │ │ │ │ └── defs.ts │ │ ├── xyz │ │ │ └── statusphere │ │ │ │ ├── status.ts │ │ │ │ ├── getStatuses.ts │ │ │ │ ├── sendStatus.ts │ │ │ │ ├── getUser.ts │ │ │ │ └── defs.ts │ │ └── app │ │ │ └── bsky │ │ │ └── actor │ │ │ ├── defs.ts │ │ │ └── profile.ts │ └── util.ts │ └── package.json ├── CLAUDE.md ├── lexicons ├── com │ └── atproto │ │ ├── repo │ │ ├── defs.json │ │ ├── importRepo.json │ │ ├── strongRef.json │ │ ├── uploadBlob.json │ │ ├── listMissingBlobs.json │ │ ├── getRecord.json │ │ ├── describeRepo.json │ │ ├── deleteRecord.json │ │ ├── listRecords.json │ │ ├── createRecord.json │ │ ├── putRecord.json │ │ └── applyWrites.json │ │ └── label │ │ └── defs.json ├── xyz │ └── statusphere │ │ ├── status.json │ │ ├── getUser.json │ │ ├── defs.json │ │ ├── getStatuses.json │ │ └── sendStatus.json └── app │ └── bsky │ └── actor │ ├── defs.json │ └── profile.json ├── .prettierrc ├── .gitignore ├── LICENSE ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.5.1 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' -------------------------------------------------------------------------------- /packages/appview/src/ingestors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './jetstream' 2 | export * from './firehose' 3 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | hey buddy :) 2 | 3 | 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 4 | 5 | and most importantly, have fun! 6 | 7 | your friend, 8 | mozzius 9 | -------------------------------------------------------------------------------- /packages/client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/client/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/appview/src/api/health.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | 3 | import { AppContext } from '#/context' 4 | 5 | export const createRouter = (ctx: AppContext) => { 6 | const router = Router() 7 | 8 | router.get('/health', async function (req, res) { 9 | res.status(200).send('OK') 10 | }) 11 | 12 | return router 13 | } 14 | -------------------------------------------------------------------------------- /lexicons/com/atproto/repo/defs.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.defs", 4 | "defs": { 5 | "commitMeta": { 6 | "type": "object", 7 | "required": ["cid", "rev"], 8 | "properties": { 9 | "cid": { "type": "string", "format": "cid" }, 10 | "rev": { "type": "string", "format": "tid" } 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lexicons/com/atproto/repo/importRepo.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.importRepo", 4 | "defs": { 5 | "main": { 6 | "type": "procedure", 7 | "description": "Import a repo in the form of a CAR file. Requires Content-Length HTTP header to be set.", 8 | "input": { 9 | "encoding": "application/vnd.ipld.car" 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lexicons/com/atproto/repo/strongRef.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.strongRef", 4 | "description": "A URI with a content-hash fingerprint.", 5 | "defs": { 6 | "main": { 7 | "type": "object", 8 | "required": ["uri", "cid"], 9 | "properties": { 10 | "uri": { "type": "string", "format": "at-uri" }, 11 | "cid": { "type": "string", "format": "cid" } 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/client/src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @keyframes fadeOut { 4 | 0% { opacity: 1; } 5 | 75% { opacity: 1; } /* Hold full opacity for most of the animation */ 6 | 100% { opacity: 0; } 7 | } 8 | 9 | .status-message-fade { 10 | animation: fadeOut 2s forwards; 11 | } 12 | 13 | /* Base styling */ 14 | @layer base { 15 | body { 16 | @apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-tailwindcss", 4 | "@ianvs/prettier-plugin-sort-imports" 5 | ], 6 | "singleQuote": true, 7 | "semi": false, 8 | "importOrder": [ 9 | "^react$", 10 | "^react-dom$", 11 | "^react-", 12 | "^@tanstack/", 13 | "", 14 | "", 15 | "^#/", 16 | "^[./]" 17 | ], 18 | "importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/appview/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "baseUrl": ".", 11 | "outDir": "dist", 12 | "paths": { 13 | "#/*": ["./src/*"] 14 | } 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules", "dist"] 18 | } 19 | -------------------------------------------------------------------------------- /packages/lexicon/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "declaration": true, 11 | "declarationMap": true, 12 | "sourceMap": true, 13 | "outDir": "dist" 14 | }, 15 | "include": ["src/**/*"], 16 | "exclude": ["node_modules", "dist"] 17 | } 18 | -------------------------------------------------------------------------------- /packages/appview/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { AppContext } from '#/context' 2 | import { Server } from '#/lexicons' 3 | import getStatuses from './lexicons/getStatuses' 4 | import getUser from './lexicons/getUser' 5 | import sendStatus from './lexicons/sendStatus' 6 | 7 | export * as health from './health' 8 | export * as oauth from './oauth' 9 | 10 | export default function (server: Server, ctx: AppContext) { 11 | getStatuses(server, ctx) 12 | sendStatus(server, ctx) 13 | getUser(server, ctx) 14 | return server 15 | } 16 | -------------------------------------------------------------------------------- /packages/appview/src/error.ts: -------------------------------------------------------------------------------- 1 | import { XRPCError } from '@atproto/xrpc-server' 2 | import { ErrorRequestHandler } from 'express' 3 | 4 | import { AppContext } from '#/context' 5 | 6 | export const createHandler: (ctx: AppContext) => ErrorRequestHandler = 7 | (ctx) => (err, _req, res, next) => { 8 | ctx.logger.error('unexpected internal server error', err) 9 | if (res.headersSent) { 10 | return next(err) 11 | } 12 | const serverError = XRPCError.fromError(err) 13 | res.status(serverError.type).json(serverError.payload) 14 | } 15 | -------------------------------------------------------------------------------- /packages/appview/src/context.ts: -------------------------------------------------------------------------------- 1 | import { OAuthClient } from '@atproto/oauth-client-node' 2 | import { Firehose } from '@atproto/sync' 3 | import pino from 'pino' 4 | 5 | import { Database } from '#/db' 6 | import { BidirectionalResolver } from '#/id-resolver' 7 | import { Jetstream } from '#/ingestors' 8 | 9 | // Application state passed to the router and elsewhere 10 | export type AppContext = { 11 | db: Database 12 | ingester: Firehose | Jetstream 13 | logger: pino.Logger 14 | oauthClient: OAuthClient 15 | resolver: BidirectionalResolver 16 | } 17 | -------------------------------------------------------------------------------- /packages/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Statusphere React 8 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /lexicons/xyz/statusphere/status.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "xyz.statusphere.status", 4 | "defs": { 5 | "main": { 6 | "type": "record", 7 | "key": "tid", 8 | "record": { 9 | "type": "object", 10 | "required": ["status", "createdAt"], 11 | "properties": { 12 | "status": { 13 | "type": "string", 14 | "minLength": 1, 15 | "maxGraphemes": 1, 16 | "maxLength": 32 17 | }, 18 | "createdAt": { "type": "string", "format": "datetime" } 19 | } 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { BrowserRouter } from 'react-router-dom' 4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 5 | 6 | import App from '#/App' 7 | 8 | import '#/index.css' 9 | 10 | const queryClient = new QueryClient() 11 | 12 | ReactDOM.createRoot(document.getElementById('root')!).render( 13 | 14 | 15 | 16 | 17 | 18 | 19 | , 20 | ) 21 | -------------------------------------------------------------------------------- /packages/appview/.env.template: -------------------------------------------------------------------------------- 1 | # Environment Configuration 2 | NODE_ENV="development" # Options: 'development', 'production' 3 | PORT="3001" # The port your server will listen on 4 | VITE_PORT="3000" # The port the vite dev server is on (dev only) 5 | HOST="127.0.0.1" # Hostname for the server 6 | PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id. 7 | DB_PATH=":memory:" # The SQLite database path. Leave as ":memory:" to use a temporary in-memory database. 8 | 9 | # Secrets 10 | # Must set this in production. May be generated with `openssl rand -base64 33` 11 | # COOKIE_SECRET="" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | .pnp 4 | .pnp.js 5 | 6 | # Build output 7 | dist 8 | build 9 | dist-ssr 10 | .turbo 11 | 12 | # Testing 13 | coverage 14 | 15 | # Environment 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | *.local 22 | 23 | # Logs 24 | logs 25 | *.log 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | pnpm-debug.log* 30 | lerna-debug.log* 31 | 32 | # Editor directories and files 33 | .vscode/* 34 | !.vscode/extensions.json 35 | .idea 36 | .DS_Store 37 | *.suo 38 | *.ntvs* 39 | *.njsproj 40 | *.sln 41 | *.sw? 42 | 43 | # Database 44 | *.sqlite 45 | *.sqlite-journal -------------------------------------------------------------------------------- /packages/appview/src/lib/env.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import { cleanEnv, host, port, str, testOnly } from 'envalid' 3 | 4 | dotenv.config() 5 | 6 | export const env = cleanEnv(process.env, { 7 | NODE_ENV: str({ 8 | devDefault: testOnly('test'), 9 | choices: ['development', 'production', 'test'], 10 | }), 11 | HOST: host({ devDefault: '127.0.0.1' }), 12 | PORT: port({ devDefault: 3001 }), 13 | VITE_PORT: port({ default: 3000 }), 14 | DB_PATH: str({ devDefault: ':memory:' }), 15 | COOKIE_SECRET: str({ devDefault: '0'.repeat(32) }), 16 | SERVICE_DID: str({ default: undefined }), 17 | PUBLIC_URL: str({ devDefault: '' }), 18 | JETSTREAM_INSTANCE: str({ default: 'wss://jetstream2.us-east.bsky.network' }), 19 | }) 20 | -------------------------------------------------------------------------------- /packages/client/README.md: -------------------------------------------------------------------------------- 1 | # Statusphere Client 2 | 3 | This is the React frontend for the Statusphere application. 4 | 5 | ## Development 6 | 7 | ```bash 8 | # Install dependencies 9 | pnpm install 10 | 11 | # Start development server 12 | pnpm dev 13 | 14 | # Build for production 15 | pnpm build 16 | 17 | # Preview production build 18 | pnpm preview 19 | ``` 20 | 21 | ## Features 22 | 23 | - Display statuses from all users 24 | - Create new statuses 25 | - Login with your Bluesky handle 26 | - View your profile info 27 | - Responsive design 28 | 29 | ## Architecture 30 | 31 | - React 18 with TypeScript 32 | - React Router for navigation 33 | - Context API for state management 34 | - Vite for development and building 35 | - CSS for styling 36 | -------------------------------------------------------------------------------- /packages/client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite' 2 | import react from '@vitejs/plugin-react' 3 | import { defineConfig } from 'vite' 4 | import tsconfigPaths from 'vite-tsconfig-paths' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | react({ 10 | babel: { 11 | plugins: [['babel-plugin-react-compiler', { target: '19' }]], 12 | }, 13 | }), 14 | tailwindcss(), 15 | tsconfigPaths(), 16 | ], 17 | server: { 18 | host: '127.0.0.1', 19 | port: 3000, 20 | proxy: { 21 | '^/(xrpc|oauth|client-metadata\.json)/.*': { 22 | target: 'http://localhost:3001', 23 | changeOrigin: true, 24 | }, 25 | }, 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "baseUrl": ".", 19 | "paths": { 20 | "#/*": ["./src/*"] 21 | } 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /packages/client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom' 2 | 3 | import { AuthProvider } from '#/hooks/useAuth' 4 | import HomePage from '#/pages/HomePage' 5 | import LoginPage from '#/pages/LoginPage' 6 | import OAuthCallbackPage from '#/pages/OAuthCallbackPage' 7 | 8 | function App() { 9 | return ( 10 |
11 |
12 | 13 | 14 | } /> 15 | } /> 16 | } /> 17 | 18 | 19 |
20 |
21 | ) 22 | } 23 | 24 | export default App 25 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/com/atproto/repo/strongRef.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { CID } from 'multiformats/cid' 6 | 7 | import { validate as _validate } from '../../../../lexicons' 8 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 9 | 10 | const is$typed = _is$typed, 11 | validate = _validate 12 | const id = 'com.atproto.repo.strongRef' 13 | 14 | export interface Main { 15 | $type?: 'com.atproto.repo.strongRef' 16 | uri: string 17 | cid: string 18 | } 19 | 20 | const hashMain = 'main' 21 | 22 | export function isMain(v: V) { 23 | return is$typed(v, id, hashMain) 24 | } 25 | 26 | export function validateMain(v: V) { 27 | return validate
(v, id, hashMain) 28 | } 29 | -------------------------------------------------------------------------------- /lexicons/com/atproto/repo/uploadBlob.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.uploadBlob", 4 | "defs": { 5 | "main": { 6 | "type": "procedure", 7 | "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.", 8 | "input": { 9 | "encoding": "*/*" 10 | }, 11 | "output": { 12 | "encoding": "application/json", 13 | "schema": { 14 | "type": "object", 15 | "required": ["blob"], 16 | "properties": { 17 | "blob": { "type": "blob" } 18 | } 19 | } 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/com/atproto/repo/strongRef.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { CID } from 'multiformats/cid' 6 | 7 | import { validate as _validate } from '../../../../lexicons' 8 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 9 | 10 | const is$typed = _is$typed, 11 | validate = _validate 12 | const id = 'com.atproto.repo.strongRef' 13 | 14 | export interface Main { 15 | $type?: 'com.atproto.repo.strongRef' 16 | uri: string 17 | cid: string 18 | } 19 | 20 | const hashMain = 'main' 21 | 22 | export function isMain(v: V) { 23 | return is$typed(v, id, hashMain) 24 | } 25 | 26 | export function validateMain(v: V) { 27 | return validate
(v, id, hashMain) 28 | } 29 | -------------------------------------------------------------------------------- /packages/appview/src/api/lexicons/getStatuses.ts: -------------------------------------------------------------------------------- 1 | import { AppContext } from '#/context' 2 | import { Server } from '#/lexicons' 3 | import { statusToStatusView } from '#/lib/hydrate' 4 | 5 | export default function (server: Server, ctx: AppContext) { 6 | server.xyz.statusphere.getStatuses({ 7 | handler: async ({ params }) => { 8 | // Fetch data stored in our SQLite 9 | const statuses = await ctx.db 10 | .selectFrom('status') 11 | .selectAll() 12 | .orderBy('indexedAt', 'desc') 13 | .limit(params.limit) 14 | .execute() 15 | 16 | return { 17 | encoding: 'application/json', 18 | body: { 19 | statuses: await Promise.all( 20 | statuses.map((status) => statusToStatusView(status, ctx)), 21 | ), 22 | }, 23 | } 24 | }, 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/xyz/statusphere/status.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { CID } from 'multiformats/cid' 6 | 7 | import { validate as _validate } from '../../../lexicons' 8 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../util' 9 | 10 | const is$typed = _is$typed, 11 | validate = _validate 12 | const id = 'xyz.statusphere.status' 13 | 14 | export interface Record { 15 | $type: 'xyz.statusphere.status' 16 | status: string 17 | createdAt: string 18 | [k: string]: unknown 19 | } 20 | 21 | const hashRecord = 'main' 22 | 23 | export function isRecord(v: V) { 24 | return is$typed(v, id, hashRecord) 25 | } 26 | 27 | export function validateRecord(v: V) { 28 | return validate(v, id, hashRecord, true) 29 | } 30 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/xyz/statusphere/status.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { CID } from 'multiformats/cid' 6 | 7 | import { validate as _validate } from '../../../lexicons' 8 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../util' 9 | 10 | const is$typed = _is$typed, 11 | validate = _validate 12 | const id = 'xyz.statusphere.status' 13 | 14 | export interface Record { 15 | $type: 'xyz.statusphere.status' 16 | status: string 17 | createdAt: string 18 | [k: string]: unknown 19 | } 20 | 21 | const hashRecord = 'main' 22 | 23 | export function isRecord(v: V) { 24 | return is$typed(v, id, hashRecord) 25 | } 26 | 27 | export function validateRecord(v: V) { 28 | return validate(v, id, hashRecord, true) 29 | } 30 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/com/atproto/repo/defs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { CID } from 'multiformats/cid' 6 | 7 | import { validate as _validate } from '../../../../lexicons' 8 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 9 | 10 | const is$typed = _is$typed, 11 | validate = _validate 12 | const id = 'com.atproto.repo.defs' 13 | 14 | export interface CommitMeta { 15 | $type?: 'com.atproto.repo.defs#commitMeta' 16 | cid: string 17 | rev: string 18 | } 19 | 20 | const hashCommitMeta = 'commitMeta' 21 | 22 | export function isCommitMeta(v: V) { 23 | return is$typed(v, id, hashCommitMeta) 24 | } 25 | 26 | export function validateCommitMeta(v: V) { 27 | return validate(v, id, hashCommitMeta) 28 | } 29 | -------------------------------------------------------------------------------- /lexicons/xyz/statusphere/getUser.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "xyz.statusphere.getUser", 4 | "defs": { 5 | "main": { 6 | "type": "query", 7 | "description": "Get the current user's profile and status.", 8 | "parameters": { 9 | "type": "params", 10 | "properties": {} 11 | }, 12 | "output": { 13 | "encoding": "application/json", 14 | "schema": { 15 | "type": "object", 16 | "required": ["profile"], 17 | "properties": { 18 | "profile": { 19 | "type": "ref", 20 | "ref": "app.bsky.actor.defs#profileView" 21 | }, 22 | "status": { 23 | "type": "ref", 24 | "ref": "xyz.statusphere.defs#statusView" 25 | } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/com/atproto/repo/defs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { CID } from 'multiformats/cid' 6 | 7 | import { validate as _validate } from '../../../../lexicons' 8 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 9 | 10 | const is$typed = _is$typed, 11 | validate = _validate 12 | const id = 'com.atproto.repo.defs' 13 | 14 | export interface CommitMeta { 15 | $type?: 'com.atproto.repo.defs#commitMeta' 16 | cid: string 17 | rev: string 18 | } 19 | 20 | const hashCommitMeta = 'commitMeta' 21 | 22 | export function isCommitMeta(v: V) { 23 | return is$typed(v, id, hashCommitMeta) 24 | } 25 | 26 | export function validateCommitMeta(v: V) { 27 | return validate(v, id, hashCommitMeta) 28 | } 29 | -------------------------------------------------------------------------------- /lexicons/xyz/statusphere/defs.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "xyz.statusphere.defs", 4 | "defs": { 5 | "statusView": { 6 | "type": "object", 7 | "required": ["uri", "status", "profile", "createdAt"], 8 | "properties": { 9 | "uri": { "type": "string", "format": "at-uri" }, 10 | "status": { 11 | "type": "string", 12 | "minLength": 1, 13 | "maxGraphemes": 1, 14 | "maxLength": 32 15 | }, 16 | "createdAt": { "type": "string", "format": "datetime" }, 17 | "profile": { "type": "ref", "ref": "#profileView" } 18 | } 19 | }, 20 | "profileView": { 21 | "type": "object", 22 | "required": ["did", "handle"], 23 | "properties": { 24 | "did": { "type": "string", "format": "did" }, 25 | "handle": { "type": "string", "format": "handle" } 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/com/atproto/repo/importRepo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HeadersMap, XRPCError } from '@atproto/xrpc' 6 | import { CID } from 'multiformats/cid' 7 | 8 | import { validate as _validate } from '../../../../lexicons' 9 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 10 | 11 | const is$typed = _is$typed, 12 | validate = _validate 13 | const id = 'com.atproto.repo.importRepo' 14 | 15 | export interface QueryParams {} 16 | 17 | export type InputSchema = string | Uint8Array | Blob 18 | 19 | export interface CallOptions { 20 | signal?: AbortSignal 21 | headers?: HeadersMap 22 | qp?: QueryParams 23 | encoding?: 'application/vnd.ipld.car' 24 | } 25 | 26 | export interface Response { 27 | success: boolean 28 | headers: HeadersMap 29 | } 30 | 31 | export function toKnownErr(e: any) { 32 | return e 33 | } 34 | -------------------------------------------------------------------------------- /lexicons/app/bsky/actor/defs.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "app.bsky.actor.defs", 4 | "defs": { 5 | "profileView": { 6 | "type": "object", 7 | "required": ["did", "handle"], 8 | "properties": { 9 | "did": { "type": "string", "format": "did" }, 10 | "handle": { "type": "string", "format": "handle" }, 11 | "displayName": { 12 | "type": "string", 13 | "maxGraphemes": 64, 14 | "maxLength": 640 15 | }, 16 | "description": { 17 | "type": "string", 18 | "maxGraphemes": 256, 19 | "maxLength": 2560 20 | }, 21 | "avatar": { "type": "string", "format": "uri" }, 22 | "indexedAt": { "type": "string", "format": "datetime" }, 23 | "createdAt": { "type": "string", "format": "datetime" }, 24 | "labels": { 25 | "type": "array", 26 | "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 27 | } 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lexicons/xyz/statusphere/getStatuses.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "xyz.statusphere.getStatuses", 4 | "defs": { 5 | "main": { 6 | "type": "query", 7 | "description": "Get a list of the most recent statuses on the network.", 8 | "parameters": { 9 | "type": "params", 10 | "properties": { 11 | "limit": { 12 | "type": "integer", 13 | "minimum": 1, 14 | "maximum": 100, 15 | "default": 50 16 | } 17 | } 18 | }, 19 | "output": { 20 | "encoding": "application/json", 21 | "schema": { 22 | "type": "object", 23 | "required": ["statuses"], 24 | "properties": { 25 | "statuses": { 26 | "type": "array", 27 | "items": { 28 | "type": "ref", 29 | "ref": "xyz.statusphere.defs#statusView" 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/com/atproto/repo/uploadBlob.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HeadersMap, XRPCError } from '@atproto/xrpc' 6 | import { CID } from 'multiformats/cid' 7 | 8 | import { validate as _validate } from '../../../../lexicons' 9 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 10 | 11 | const is$typed = _is$typed, 12 | validate = _validate 13 | const id = 'com.atproto.repo.uploadBlob' 14 | 15 | export interface QueryParams {} 16 | 17 | export type InputSchema = string | Uint8Array | Blob 18 | 19 | export interface OutputSchema { 20 | blob: BlobRef 21 | } 22 | 23 | export interface CallOptions { 24 | signal?: AbortSignal 25 | headers?: HeadersMap 26 | qp?: QueryParams 27 | encoding?: string 28 | } 29 | 30 | export interface Response { 31 | success: boolean 32 | headers: HeadersMap 33 | data: OutputSchema 34 | } 35 | 36 | export function toKnownErr(e: any) { 37 | return e 38 | } 39 | -------------------------------------------------------------------------------- /lexicons/xyz/statusphere/sendStatus.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "xyz.statusphere.sendStatus", 4 | "defs": { 5 | "main": { 6 | "type": "procedure", 7 | "description": "Send a status into the ATmosphere.", 8 | "input": { 9 | "encoding": "application/json", 10 | "schema": { 11 | "type": "object", 12 | "required": ["status"], 13 | "properties": { 14 | "status": { 15 | "type": "string", 16 | "minLength": 1, 17 | "maxGraphemes": 1, 18 | "maxLength": 32 19 | } 20 | } 21 | } 22 | }, 23 | "output": { 24 | "encoding": "application/json", 25 | "schema": { 26 | "type": "object", 27 | "required": ["status"], 28 | "properties": { 29 | "status": { 30 | "type": "ref", 31 | "ref": "xyz.statusphere.defs#statusView" 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/xyz/statusphere/getStatuses.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HeadersMap, XRPCError } from '@atproto/xrpc' 6 | import { CID } from 'multiformats/cid' 7 | 8 | import { validate as _validate } from '../../../lexicons' 9 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../util' 10 | import type * as XyzStatusphereDefs from './defs.js' 11 | 12 | const is$typed = _is$typed, 13 | validate = _validate 14 | const id = 'xyz.statusphere.getStatuses' 15 | 16 | export interface QueryParams { 17 | limit?: number 18 | } 19 | 20 | export type InputSchema = undefined 21 | 22 | export interface OutputSchema { 23 | statuses: XyzStatusphereDefs.StatusView[] 24 | } 25 | 26 | export interface CallOptions { 27 | signal?: AbortSignal 28 | headers?: HeadersMap 29 | } 30 | 31 | export interface Response { 32 | success: boolean 33 | headers: HeadersMap 34 | data: OutputSchema 35 | } 36 | 37 | export function toKnownErr(e: any) { 38 | return e 39 | } 40 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/app/bsky/actor/defs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { CID } from 'multiformats/cid' 6 | 7 | import { validate as _validate } from '../../../../lexicons' 8 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 9 | import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' 10 | 11 | const is$typed = _is$typed, 12 | validate = _validate 13 | const id = 'app.bsky.actor.defs' 14 | 15 | export interface ProfileView { 16 | $type?: 'app.bsky.actor.defs#profileView' 17 | did: string 18 | handle: string 19 | displayName?: string 20 | description?: string 21 | avatar?: string 22 | indexedAt?: string 23 | createdAt?: string 24 | labels?: ComAtprotoLabelDefs.Label[] 25 | } 26 | 27 | const hashProfileView = 'profileView' 28 | 29 | export function isProfileView(v: V) { 30 | return is$typed(v, id, hashProfileView) 31 | } 32 | 33 | export function validateProfileView(v: V) { 34 | return validate(v, id, hashProfileView) 35 | } 36 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/app/bsky/actor/defs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { CID } from 'multiformats/cid' 6 | 7 | import { validate as _validate } from '../../../../lexicons' 8 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 9 | import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' 10 | 11 | const is$typed = _is$typed, 12 | validate = _validate 13 | const id = 'app.bsky.actor.defs' 14 | 15 | export interface ProfileView { 16 | $type?: 'app.bsky.actor.defs#profileView' 17 | did: string 18 | handle: string 19 | displayName?: string 20 | description?: string 21 | avatar?: string 22 | indexedAt?: string 23 | createdAt?: string 24 | labels?: ComAtprotoLabelDefs.Label[] 25 | } 26 | 27 | const hashProfileView = 'profileView' 28 | 29 | export function isProfileView(v: V) { 30 | return is$typed(v, id, hashProfileView) 31 | } 32 | 33 | export function validateProfileView(v: V) { 34 | return validate(v, id, hashProfileView) 35 | } 36 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/xyz/statusphere/sendStatus.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HeadersMap, XRPCError } from '@atproto/xrpc' 6 | import { CID } from 'multiformats/cid' 7 | 8 | import { validate as _validate } from '../../../lexicons' 9 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../util' 10 | import type * as XyzStatusphereDefs from './defs.js' 11 | 12 | const is$typed = _is$typed, 13 | validate = _validate 14 | const id = 'xyz.statusphere.sendStatus' 15 | 16 | export interface QueryParams {} 17 | 18 | export interface InputSchema { 19 | status: string 20 | } 21 | 22 | export interface OutputSchema { 23 | status: XyzStatusphereDefs.StatusView 24 | } 25 | 26 | export interface CallOptions { 27 | signal?: AbortSignal 28 | headers?: HeadersMap 29 | qp?: QueryParams 30 | encoding?: 'application/json' 31 | } 32 | 33 | export interface Response { 34 | success: boolean 35 | headers: HeadersMap 36 | data: OutputSchema 37 | } 38 | 39 | export function toKnownErr(e: any) { 40 | return e 41 | } 42 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/xyz/statusphere/getUser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HeadersMap, XRPCError } from '@atproto/xrpc' 6 | import { CID } from 'multiformats/cid' 7 | 8 | import { validate as _validate } from '../../../lexicons' 9 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../util' 10 | import type * as AppBskyActorDefs from '../../app/bsky/actor/defs.js' 11 | import type * as XyzStatusphereDefs from './defs.js' 12 | 13 | const is$typed = _is$typed, 14 | validate = _validate 15 | const id = 'xyz.statusphere.getUser' 16 | 17 | export interface QueryParams {} 18 | 19 | export type InputSchema = undefined 20 | 21 | export interface OutputSchema { 22 | profile: AppBskyActorDefs.ProfileView 23 | status?: XyzStatusphereDefs.StatusView 24 | } 25 | 26 | export interface CallOptions { 27 | signal?: AbortSignal 28 | headers?: HeadersMap 29 | } 30 | 31 | export interface Response { 32 | success: boolean 33 | headers: HeadersMap 34 | data: OutputSchema 35 | } 36 | 37 | export function toKnownErr(e: any) { 38 | return e 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Bluesky PBC, and Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/xyz/statusphere/defs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { CID } from 'multiformats/cid' 6 | 7 | import { validate as _validate } from '../../../lexicons' 8 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../util' 9 | 10 | const is$typed = _is$typed, 11 | validate = _validate 12 | const id = 'xyz.statusphere.defs' 13 | 14 | export interface StatusView { 15 | $type?: 'xyz.statusphere.defs#statusView' 16 | uri: string 17 | status: string 18 | createdAt: string 19 | profile: ProfileView 20 | } 21 | 22 | const hashStatusView = 'statusView' 23 | 24 | export function isStatusView(v: V) { 25 | return is$typed(v, id, hashStatusView) 26 | } 27 | 28 | export function validateStatusView(v: V) { 29 | return validate(v, id, hashStatusView) 30 | } 31 | 32 | export interface ProfileView { 33 | $type?: 'xyz.statusphere.defs#profileView' 34 | did: string 35 | handle: string 36 | } 37 | 38 | const hashProfileView = 'profileView' 39 | 40 | export function isProfileView(v: V) { 41 | return is$typed(v, id, hashProfileView) 42 | } 43 | 44 | export function validateProfileView(v: V) { 45 | return validate(v, id, hashProfileView) 46 | } 47 | -------------------------------------------------------------------------------- /packages/appview/src/auth/client.ts: -------------------------------------------------------------------------------- 1 | import { NodeOAuthClient } from '@atproto/oauth-client-node' 2 | 3 | import type { Database } from '#/db' 4 | import { env } from '#/lib/env' 5 | import { SessionStore, StateStore } from './storage' 6 | 7 | export const createClient = async (db: Database) => { 8 | if (env.isProduction && !env.PUBLIC_URL) { 9 | throw new Error('PUBLIC_URL is not set') 10 | } 11 | 12 | const publicUrl = env.PUBLIC_URL 13 | const url = publicUrl || `http://127.0.0.1:${env.VITE_PORT}` 14 | const enc = encodeURIComponent 15 | 16 | return new NodeOAuthClient({ 17 | clientMetadata: { 18 | client_name: 'Statusphere React App', 19 | client_id: publicUrl 20 | ? `${url}/oauth-client-metadata.json` 21 | : `http://localhost?redirect_uri=${enc(`${url}/oauth/callback`)}&scope=${enc('atproto transition:generic')}`, 22 | client_uri: url, 23 | redirect_uris: [`${url}/oauth/callback`], 24 | scope: 'atproto transition:generic', 25 | grant_types: ['authorization_code', 'refresh_token'], 26 | response_types: ['code'], 27 | application_type: 'web', 28 | token_endpoint_auth_method: 'none', 29 | dpop_bound_access_tokens: true, 30 | }, 31 | stateStore: new StateStore(db), 32 | sessionStore: new SessionStore(db), 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/xyz/statusphere/defs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { CID } from 'multiformats/cid' 6 | 7 | import { validate as _validate } from '../../../lexicons' 8 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../util' 9 | 10 | const is$typed = _is$typed, 11 | validate = _validate 12 | const id = 'xyz.statusphere.defs' 13 | 14 | export interface StatusView { 15 | $type?: 'xyz.statusphere.defs#statusView' 16 | uri: string 17 | status: string 18 | createdAt: string 19 | profile: ProfileView 20 | } 21 | 22 | const hashStatusView = 'statusView' 23 | 24 | export function isStatusView(v: V) { 25 | return is$typed(v, id, hashStatusView) 26 | } 27 | 28 | export function validateStatusView(v: V) { 29 | return validate(v, id, hashStatusView) 30 | } 31 | 32 | export interface ProfileView { 33 | $type?: 'xyz.statusphere.defs#profileView' 34 | did: string 35 | handle: string 36 | } 37 | 38 | const hashProfileView = 'profileView' 39 | 40 | export function isProfileView(v: V) { 41 | return is$typed(v, id, hashProfileView) 42 | } 43 | 44 | export function validateProfileView(v: V) { 45 | return validate(v, id, hashProfileView) 46 | } 47 | -------------------------------------------------------------------------------- /lexicons/com/atproto/repo/listMissingBlobs.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.listMissingBlobs", 4 | "defs": { 5 | "main": { 6 | "type": "query", 7 | "description": "Returns a list of missing blobs for the requesting account. Intended to be used in the account migration flow.", 8 | "parameters": { 9 | "type": "params", 10 | "properties": { 11 | "limit": { 12 | "type": "integer", 13 | "minimum": 1, 14 | "maximum": 1000, 15 | "default": 500 16 | }, 17 | "cursor": { "type": "string" } 18 | } 19 | }, 20 | "output": { 21 | "encoding": "application/json", 22 | "schema": { 23 | "type": "object", 24 | "required": ["blobs"], 25 | "properties": { 26 | "cursor": { "type": "string" }, 27 | "blobs": { 28 | "type": "array", 29 | "items": { "type": "ref", "ref": "#recordBlob" } 30 | } 31 | } 32 | } 33 | } 34 | }, 35 | "recordBlob": { 36 | "type": "object", 37 | "required": ["cid", "recordUri"], 38 | "properties": { 39 | "cid": { "type": "string", "format": "cid" }, 40 | "recordUri": { "type": "string", "format": "at-uri" } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/appview/src/id-resolver.ts: -------------------------------------------------------------------------------- 1 | import { IdResolver, MemoryCache } from '@atproto/identity' 2 | 3 | const HOUR = 60e3 * 60 4 | const DAY = HOUR * 24 5 | 6 | export function createIdResolver() { 7 | return new IdResolver({ 8 | didCache: new MemoryCache(HOUR, DAY), 9 | }) 10 | } 11 | 12 | export interface BidirectionalResolver { 13 | resolveDidToHandle(did: string): Promise 14 | resolveDidsToHandles(dids: string[]): Promise> 15 | } 16 | 17 | export function createBidirectionalResolver(resolver: IdResolver) { 18 | return { 19 | async resolveDidToHandle(did: string): Promise { 20 | const didDoc = await resolver.did.resolveAtprotoData(did) 21 | const resolvedHandle = await resolver.handle.resolve(didDoc.handle) 22 | if (resolvedHandle === did) { 23 | return didDoc.handle 24 | } 25 | return did 26 | }, 27 | 28 | async resolveDidsToHandles( 29 | dids: string[], 30 | ): Promise> { 31 | const didHandleMap: Record = {} 32 | const resolves = await Promise.all( 33 | dids.map((did) => this.resolveDidToHandle(did).catch((_) => did)), 34 | ) 35 | for (let i = 0; i < dids.length; i++) { 36 | didHandleMap[dids[i]] = resolves[i] 37 | } 38 | return didHandleMap 39 | }, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/com/atproto/repo/importRepo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import stream from 'node:stream' 5 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 6 | import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 7 | import express from 'express' 8 | import { CID } from 'multiformats/cid' 9 | 10 | import { validate as _validate } from '../../../../lexicons' 11 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 12 | 13 | const is$typed = _is$typed, 14 | validate = _validate 15 | const id = 'com.atproto.repo.importRepo' 16 | 17 | export interface QueryParams {} 18 | 19 | export type InputSchema = string | Uint8Array | Blob 20 | 21 | export interface HandlerInput { 22 | encoding: 'application/vnd.ipld.car' 23 | body: stream.Readable 24 | } 25 | 26 | export interface HandlerError { 27 | status: number 28 | message?: string 29 | } 30 | 31 | export type HandlerOutput = HandlerError | void 32 | export type HandlerReqCtx = { 33 | auth: HA 34 | params: QueryParams 35 | input: HandlerInput 36 | req: express.Request 37 | res: express.Response 38 | resetRouteRateLimits: () => Promise 39 | } 40 | export type Handler = ( 41 | ctx: HandlerReqCtx, 42 | ) => Promise | HandlerOutput 43 | -------------------------------------------------------------------------------- /packages/appview/src/lib/hydrate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AppBskyActorDefs, 3 | AppBskyActorProfile, 4 | XyzStatusphereDefs, 5 | } from '@statusphere/lexicon' 6 | 7 | import { AppContext } from '#/context' 8 | import { Status } from '#/db' 9 | 10 | const INVALID_HANDLE = 'handle.invalid' 11 | 12 | export async function statusToStatusView( 13 | status: Status, 14 | ctx: AppContext, 15 | ): Promise { 16 | return { 17 | uri: status.uri, 18 | status: status.status, 19 | createdAt: status.createdAt, 20 | profile: { 21 | did: status.authorDid, 22 | handle: await ctx.resolver 23 | .resolveDidToHandle(status.authorDid) 24 | .then((handle) => (handle.startsWith('did:') ? INVALID_HANDLE : handle)) 25 | .catch(() => INVALID_HANDLE), 26 | }, 27 | } 28 | } 29 | 30 | export async function bskyProfileToProfileView( 31 | did: string, 32 | profile: AppBskyActorProfile.Record, 33 | ctx: AppContext, 34 | ): Promise { 35 | return { 36 | $type: 'app.bsky.actor.defs#profileView', 37 | did: did, 38 | handle: await ctx.resolver.resolveDidToHandle(did), 39 | avatar: profile.avatar 40 | ? `https://atproto.pictures/img/${did}/${profile.avatar.ref}` 41 | : undefined, 42 | displayName: profile.displayName, 43 | createdAt: profile.createdAt, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/com/atproto/repo/describeRepo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HeadersMap, XRPCError } from '@atproto/xrpc' 6 | import { CID } from 'multiformats/cid' 7 | 8 | import { validate as _validate } from '../../../../lexicons' 9 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 10 | 11 | const is$typed = _is$typed, 12 | validate = _validate 13 | const id = 'com.atproto.repo.describeRepo' 14 | 15 | export interface QueryParams { 16 | /** The handle or DID of the repo. */ 17 | repo: string 18 | } 19 | 20 | export type InputSchema = undefined 21 | 22 | export interface OutputSchema { 23 | handle: string 24 | did: string 25 | /** The complete DID document for this account. */ 26 | didDoc: { [_ in string]: unknown } 27 | /** List of all the collections (NSIDs) for which this repo contains at least one record. */ 28 | collections: string[] 29 | /** Indicates if handle is currently valid (resolves bi-directionally) */ 30 | handleIsCorrect: boolean 31 | } 32 | 33 | export interface CallOptions { 34 | signal?: AbortSignal 35 | headers?: HeadersMap 36 | } 37 | 38 | export interface Response { 39 | success: boolean 40 | headers: HeadersMap 41 | data: OutputSchema 42 | } 43 | 44 | export function toKnownErr(e: any) { 45 | return e 46 | } 47 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/com/atproto/repo/listMissingBlobs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HeadersMap, XRPCError } from '@atproto/xrpc' 6 | import { CID } from 'multiformats/cid' 7 | 8 | import { validate as _validate } from '../../../../lexicons' 9 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 10 | 11 | const is$typed = _is$typed, 12 | validate = _validate 13 | const id = 'com.atproto.repo.listMissingBlobs' 14 | 15 | export interface QueryParams { 16 | limit?: number 17 | cursor?: string 18 | } 19 | 20 | export type InputSchema = undefined 21 | 22 | export interface OutputSchema { 23 | cursor?: string 24 | blobs: RecordBlob[] 25 | } 26 | 27 | export interface CallOptions { 28 | signal?: AbortSignal 29 | headers?: HeadersMap 30 | } 31 | 32 | export interface Response { 33 | success: boolean 34 | headers: HeadersMap 35 | data: OutputSchema 36 | } 37 | 38 | export function toKnownErr(e: any) { 39 | return e 40 | } 41 | 42 | export interface RecordBlob { 43 | $type?: 'com.atproto.repo.listMissingBlobs#recordBlob' 44 | cid: string 45 | recordUri: string 46 | } 47 | 48 | const hashRecordBlob = 'recordBlob' 49 | 50 | export function isRecordBlob(v: V) { 51 | return is$typed(v, id, hashRecordBlob) 52 | } 53 | 54 | export function validateRecordBlob(v: V) { 55 | return validate(v, id, hashRecordBlob) 56 | } 57 | -------------------------------------------------------------------------------- /packages/appview/src/session.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, ServerResponse } from 'node:http' 2 | import { Agent } from '@atproto/api' 3 | import { Request, Response } from 'express' 4 | import { getIronSession, SessionOptions } from 'iron-session' 5 | 6 | import { AppContext } from '#/context' 7 | import { env } from '#/lib/env' 8 | 9 | type Session = { did: string } 10 | 11 | // Common session options 12 | const sessionOptions: SessionOptions = { 13 | cookieName: 'sid', 14 | password: env.COOKIE_SECRET, 15 | cookieOptions: { 16 | secure: env.NODE_ENV === 'production', 17 | httpOnly: true, 18 | sameSite: true, 19 | path: '/', 20 | // Don't set domain explicitly - let browser determine it 21 | domain: undefined, 22 | }, 23 | } 24 | 25 | export async function getSessionAgent( 26 | req: IncomingMessage | Request, 27 | res: ServerResponse | Response, 28 | ctx: AppContext, 29 | ) { 30 | const session = await getIronSession(req, res, sessionOptions) 31 | 32 | if (!session.did) { 33 | return null 34 | } 35 | 36 | try { 37 | const oauthSession = await ctx.oauthClient.restore(session.did) 38 | return oauthSession ? new Agent(oauthSession) : null 39 | } catch (err) { 40 | ctx.logger.warn({ err }, 'oauth restore failed') 41 | session.destroy() 42 | return null 43 | } 44 | } 45 | 46 | export async function getSession(req: Request, res: Response) { 47 | return getIronSession(req, res, sessionOptions) 48 | } 49 | -------------------------------------------------------------------------------- /packages/lexicon/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@statusphere/lexicon", 3 | "version": "0.0.1", 4 | "description": "Generated API client for Statusphere lexicons", 5 | "author": "", 6 | "license": "MIT", 7 | "main": "dist/index.js", 8 | "module": "dist/index.mjs", 9 | "types": "dist/index.d.ts", 10 | "exports": { 11 | ".": { 12 | "types": "./dist/index.d.ts", 13 | "import": "./dist/index.mjs", 14 | "require": "./dist/index.js" 15 | } 16 | }, 17 | "private": true, 18 | "scripts": { 19 | "build": "pnpm lexgen && tsup", 20 | "dev": "tsup --watch", 21 | "clean": "rimraf dist", 22 | "typecheck": "tsc --noEmit", 23 | "lexgen": "lex gen-api ./src ../../lexicons/xyz/statusphere/* ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* --yes && pnpm format", 24 | "format": "prettier --write src" 25 | }, 26 | "dependencies": { 27 | "@atproto/api": "^0.14.7", 28 | "@atproto/lexicon": "^0.4.7", 29 | "@atproto/syntax": "^0.3.3", 30 | "@atproto/xrpc": "^0.6.9", 31 | "multiformats": "^13.3.2" 32 | }, 33 | "devDependencies": { 34 | "@atproto/lex-cli": "^0.6.1", 35 | "@types/node": "^22.13.8", 36 | "rimraf": "^6.0.1", 37 | "tsup": "^8.4.0", 38 | "typescript": "^5.8.2" 39 | }, 40 | "tsup": { 41 | "entry": [ 42 | "src/index.ts" 43 | ], 44 | "format": [ 45 | "cjs", 46 | "esm" 47 | ], 48 | "dts": true, 49 | "sourcemap": true, 50 | "clean": true 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/app/bsky/actor/profile.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { CID } from 'multiformats/cid' 6 | 7 | import { validate as _validate } from '../../../../lexicons' 8 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 9 | import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' 10 | import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef.js' 11 | 12 | const is$typed = _is$typed, 13 | validate = _validate 14 | const id = 'app.bsky.actor.profile' 15 | 16 | export interface Record { 17 | $type: 'app.bsky.actor.profile' 18 | displayName?: string 19 | /** Free-form profile description text. */ 20 | description?: string 21 | /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 22 | avatar?: BlobRef 23 | /** Larger horizontal image to display behind profile view. */ 24 | banner?: BlobRef 25 | labels?: $Typed | { $type: string } 26 | joinedViaStarterPack?: ComAtprotoRepoStrongRef.Main 27 | pinnedPost?: ComAtprotoRepoStrongRef.Main 28 | createdAt?: string 29 | [k: string]: unknown 30 | } 31 | 32 | const hashRecord = 'main' 33 | 34 | export function isRecord(v: V) { 35 | return is$typed(v, id, hashRecord) 36 | } 37 | 38 | export function validateRecord(v: V) { 39 | return validate(v, id, hashRecord, true) 40 | } 41 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/app/bsky/actor/profile.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { CID } from 'multiformats/cid' 6 | 7 | import { validate as _validate } from '../../../../lexicons' 8 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 9 | import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' 10 | import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef.js' 11 | 12 | const is$typed = _is$typed, 13 | validate = _validate 14 | const id = 'app.bsky.actor.profile' 15 | 16 | export interface Record { 17 | $type: 'app.bsky.actor.profile' 18 | displayName?: string 19 | /** Free-form profile description text. */ 20 | description?: string 21 | /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 22 | avatar?: BlobRef 23 | /** Larger horizontal image to display behind profile view. */ 24 | banner?: BlobRef 25 | labels?: $Typed | { $type: string } 26 | joinedViaStarterPack?: ComAtprotoRepoStrongRef.Main 27 | pinnedPost?: ComAtprotoRepoStrongRef.Main 28 | createdAt?: string 29 | [k: string]: unknown 30 | } 31 | 32 | const hashRecord = 'main' 33 | 34 | export function isRecord(v: V) { 35 | return is$typed(v, id, hashRecord) 36 | } 37 | 38 | export function validateRecord(v: V) { 39 | return validate(v, id, hashRecord, true) 40 | } 41 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@statusphere/client", 3 | "private": true, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "start": "vite preview --port 3000", 12 | "clean": "rimraf dist", 13 | "typecheck": "tsc --noEmit" 14 | }, 15 | "dependencies": { 16 | "@atproto/api": "^0.14.7", 17 | "@atproto/xrpc": "^0.6.9", 18 | "@statusphere/lexicon": "workspace:*", 19 | "@tailwindcss/vite": "^4.0.9", 20 | "@tanstack/react-query": "^5.66.11", 21 | "iron-session": "^8.0.4", 22 | "react": "^19.0.0", 23 | "react-dom": "^19.0.0", 24 | "react-router-dom": "^7.2.0" 25 | }, 26 | "devDependencies": { 27 | "@types/react": "^19.0.10", 28 | "@types/react-dom": "^19.0.4", 29 | "@typescript-eslint/eslint-plugin": "^8.25.0", 30 | "@typescript-eslint/parser": "^8.25.0", 31 | "@vitejs/plugin-react": "^4.3.4", 32 | "autoprefixer": "^10.4.20", 33 | "babel-plugin-react-compiler": "19.0.0-beta-e1e972c-20250221", 34 | "eslint": "^9.21.0", 35 | "eslint-plugin-react-compiler": "19.0.0-beta-e1e972c-20250221", 36 | "eslint-plugin-react-hooks": "^5.2.0", 37 | "eslint-plugin-react-refresh": "^0.4.19", 38 | "postcss": "^8.5.3", 39 | "tailwindcss": "^4.0.9", 40 | "typescript": "^5.8.2", 41 | "vite": "^6.2.0", 42 | "vite-tsconfig-paths": "^5.1.4" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/com/atproto/repo/uploadBlob.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import stream from 'node:stream' 5 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 6 | import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 7 | import express from 'express' 8 | import { CID } from 'multiformats/cid' 9 | 10 | import { validate as _validate } from '../../../../lexicons' 11 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 12 | 13 | const is$typed = _is$typed, 14 | validate = _validate 15 | const id = 'com.atproto.repo.uploadBlob' 16 | 17 | export interface QueryParams {} 18 | 19 | export type InputSchema = string | Uint8Array | Blob 20 | 21 | export interface OutputSchema { 22 | blob: BlobRef 23 | } 24 | 25 | export interface HandlerInput { 26 | encoding: '*/*' 27 | body: stream.Readable 28 | } 29 | 30 | export interface HandlerSuccess { 31 | encoding: 'application/json' 32 | body: OutputSchema 33 | headers?: { [key: string]: string } 34 | } 35 | 36 | export interface HandlerError { 37 | status: number 38 | message?: string 39 | } 40 | 41 | export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 42 | export type HandlerReqCtx = { 43 | auth: HA 44 | params: QueryParams 45 | input: HandlerInput 46 | req: express.Request 47 | res: express.Response 48 | resetRouteRateLimits: () => Promise 49 | } 50 | export type Handler = ( 51 | ctx: HandlerReqCtx, 52 | ) => Promise | HandlerOutput 53 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/xyz/statusphere/getStatuses.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 6 | import express from 'express' 7 | import { CID } from 'multiformats/cid' 8 | 9 | import { validate as _validate } from '../../../lexicons' 10 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../util' 11 | import type * as XyzStatusphereDefs from './defs.js' 12 | 13 | const is$typed = _is$typed, 14 | validate = _validate 15 | const id = 'xyz.statusphere.getStatuses' 16 | 17 | export interface QueryParams { 18 | limit: number 19 | } 20 | 21 | export type InputSchema = undefined 22 | 23 | export interface OutputSchema { 24 | statuses: XyzStatusphereDefs.StatusView[] 25 | } 26 | 27 | export type HandlerInput = undefined 28 | 29 | export interface HandlerSuccess { 30 | encoding: 'application/json' 31 | body: OutputSchema 32 | headers?: { [key: string]: string } 33 | } 34 | 35 | export interface HandlerError { 36 | status: number 37 | message?: string 38 | } 39 | 40 | export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 41 | export type HandlerReqCtx = { 42 | auth: HA 43 | params: QueryParams 44 | input: HandlerInput 45 | req: express.Request 46 | res: express.Response 47 | resetRouteRateLimits: () => Promise 48 | } 49 | export type Handler = ( 50 | ctx: HandlerReqCtx, 51 | ) => Promise | HandlerOutput 52 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/xyz/statusphere/sendStatus.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 6 | import express from 'express' 7 | import { CID } from 'multiformats/cid' 8 | 9 | import { validate as _validate } from '../../../lexicons' 10 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../util' 11 | import type * as XyzStatusphereDefs from './defs.js' 12 | 13 | const is$typed = _is$typed, 14 | validate = _validate 15 | const id = 'xyz.statusphere.sendStatus' 16 | 17 | export interface QueryParams {} 18 | 19 | export interface InputSchema { 20 | status: string 21 | } 22 | 23 | export interface OutputSchema { 24 | status: XyzStatusphereDefs.StatusView 25 | } 26 | 27 | export interface HandlerInput { 28 | encoding: 'application/json' 29 | body: InputSchema 30 | } 31 | 32 | export interface HandlerSuccess { 33 | encoding: 'application/json' 34 | body: OutputSchema 35 | headers?: { [key: string]: string } 36 | } 37 | 38 | export interface HandlerError { 39 | status: number 40 | message?: string 41 | } 42 | 43 | export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 44 | export type HandlerReqCtx = { 45 | auth: HA 46 | params: QueryParams 47 | input: HandlerInput 48 | req: express.Request 49 | res: express.Response 50 | resetRouteRateLimits: () => Promise 51 | } 52 | export type Handler = ( 53 | ctx: HandlerReqCtx, 54 | ) => Promise | HandlerOutput 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "statusphere-react", 3 | "version": "0.0.1", 4 | "description": "Statusphere React monorepo", 5 | "author": "", 6 | "license": "MIT", 7 | "private": true, 8 | "scripts": { 9 | "dev": "concurrently \"pnpm dev:appview\" \"pnpm dev:client\"", 10 | "dev:lexicon": "pnpm --filter @statusphere/lexicon dev", 11 | "dev:appview": "pnpm --filter @statusphere/appview dev", 12 | "dev:client": "pnpm --filter @statusphere/client dev", 13 | "lexgen": "pnpm -r lexgen", 14 | "build": "pnpm build:lexicon && pnpm build:client && pnpm build:appview", 15 | "build:lexicon": "pnpm --filter @statusphere/lexicon build", 16 | "build:appview": "pnpm --filter @statusphere/appview build", 17 | "build:client": "pnpm --filter @statusphere/client build", 18 | "start": "pnpm --filter @statusphere/appview start", 19 | "start:dev": "pnpm -r start", 20 | "start:appview": "pnpm --filter @statusphere/appview start", 21 | "start:client": "pnpm --filter @statusphere/client start", 22 | "clean": "pnpm -r clean", 23 | "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", 24 | "typecheck": "pnpm -r typecheck" 25 | }, 26 | "devDependencies": { 27 | "@atproto/lex-cli": "^0.6.1", 28 | "@ianvs/prettier-plugin-sort-imports": "^4.4.1", 29 | "concurrently": "^9.1.2", 30 | "prettier": "^3.5.2", 31 | "prettier-plugin-tailwindcss": "^0.6.11", 32 | "rimraf": "^6.0.1", 33 | "typescript": "^5.8.2" 34 | }, 35 | "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" 36 | } 37 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/com/atproto/repo/getRecord.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HeadersMap, XRPCError } from '@atproto/xrpc' 6 | import { CID } from 'multiformats/cid' 7 | 8 | import { validate as _validate } from '../../../../lexicons' 9 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 10 | 11 | const is$typed = _is$typed, 12 | validate = _validate 13 | const id = 'com.atproto.repo.getRecord' 14 | 15 | export interface QueryParams { 16 | /** The handle or DID of the repo. */ 17 | repo: string 18 | /** The NSID of the record collection. */ 19 | collection: string 20 | /** The Record Key. */ 21 | rkey: string 22 | /** The CID of the version of the record. If not specified, then return the most recent version. */ 23 | cid?: string 24 | } 25 | 26 | export type InputSchema = undefined 27 | 28 | export interface OutputSchema { 29 | uri: string 30 | cid?: string 31 | value: { [_ in string]: unknown } 32 | } 33 | 34 | export interface CallOptions { 35 | signal?: AbortSignal 36 | headers?: HeadersMap 37 | } 38 | 39 | export interface Response { 40 | success: boolean 41 | headers: HeadersMap 42 | data: OutputSchema 43 | } 44 | 45 | export class RecordNotFoundError extends XRPCError { 46 | constructor(src: XRPCError) { 47 | super(src.status, src.error, src.message, src.headers, { cause: src }) 48 | } 49 | } 50 | 51 | export function toKnownErr(e: any) { 52 | if (e instanceof XRPCError) { 53 | if (e.error === 'RecordNotFound') return new RecordNotFoundError(e) 54 | } 55 | 56 | return e 57 | } 58 | -------------------------------------------------------------------------------- /lexicons/com/atproto/repo/getRecord.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.getRecord", 4 | "defs": { 5 | "main": { 6 | "type": "query", 7 | "description": "Get a single record from a repository. Does not require auth.", 8 | "parameters": { 9 | "type": "params", 10 | "required": ["repo", "collection", "rkey"], 11 | "properties": { 12 | "repo": { 13 | "type": "string", 14 | "format": "at-identifier", 15 | "description": "The handle or DID of the repo." 16 | }, 17 | "collection": { 18 | "type": "string", 19 | "format": "nsid", 20 | "description": "The NSID of the record collection." 21 | }, 22 | "rkey": { 23 | "type": "string", 24 | "description": "The Record Key.", 25 | "format": "record-key" 26 | }, 27 | "cid": { 28 | "type": "string", 29 | "format": "cid", 30 | "description": "The CID of the version of the record. If not specified, then return the most recent version." 31 | } 32 | } 33 | }, 34 | "output": { 35 | "encoding": "application/json", 36 | "schema": { 37 | "type": "object", 38 | "required": ["uri", "value"], 39 | "properties": { 40 | "uri": { "type": "string", "format": "at-uri" }, 41 | "cid": { "type": "string", "format": "cid" }, 42 | "value": { "type": "unknown" } 43 | } 44 | } 45 | }, 46 | "errors": [{ "name": "RecordNotFound" }] 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/xyz/statusphere/getUser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 6 | import express from 'express' 7 | import { CID } from 'multiformats/cid' 8 | 9 | import { validate as _validate } from '../../../lexicons' 10 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../util' 11 | import type * as AppBskyActorDefs from '../../app/bsky/actor/defs.js' 12 | import type * as XyzStatusphereDefs from './defs.js' 13 | 14 | const is$typed = _is$typed, 15 | validate = _validate 16 | const id = 'xyz.statusphere.getUser' 17 | 18 | export interface QueryParams {} 19 | 20 | export type InputSchema = undefined 21 | 22 | export interface OutputSchema { 23 | profile: AppBskyActorDefs.ProfileView 24 | status?: XyzStatusphereDefs.StatusView 25 | } 26 | 27 | export type HandlerInput = undefined 28 | 29 | export interface HandlerSuccess { 30 | encoding: 'application/json' 31 | body: OutputSchema 32 | headers?: { [key: string]: string } 33 | } 34 | 35 | export interface HandlerError { 36 | status: number 37 | message?: string 38 | } 39 | 40 | export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 41 | export type HandlerReqCtx = { 42 | auth: HA 43 | params: QueryParams 44 | input: HandlerInput 45 | req: express.Request 46 | res: express.Response 47 | resetRouteRateLimits: () => Promise 48 | } 49 | export type Handler = ( 50 | ctx: HandlerReqCtx, 51 | ) => Promise | HandlerOutput 52 | -------------------------------------------------------------------------------- /lexicons/com/atproto/repo/describeRepo.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.describeRepo", 4 | "defs": { 5 | "main": { 6 | "type": "query", 7 | "description": "Get information about an account and repository, including the list of collections. Does not require auth.", 8 | "parameters": { 9 | "type": "params", 10 | "required": ["repo"], 11 | "properties": { 12 | "repo": { 13 | "type": "string", 14 | "format": "at-identifier", 15 | "description": "The handle or DID of the repo." 16 | } 17 | } 18 | }, 19 | "output": { 20 | "encoding": "application/json", 21 | "schema": { 22 | "type": "object", 23 | "required": [ 24 | "handle", 25 | "did", 26 | "didDoc", 27 | "collections", 28 | "handleIsCorrect" 29 | ], 30 | "properties": { 31 | "handle": { "type": "string", "format": "handle" }, 32 | "did": { "type": "string", "format": "did" }, 33 | "didDoc": { 34 | "type": "unknown", 35 | "description": "The complete DID document for this account." 36 | }, 37 | "collections": { 38 | "type": "array", 39 | "description": "List of all the collections (NSIDs) for which this repo contains at least one record.", 40 | "items": { "type": "string", "format": "nsid" } 41 | }, 42 | "handleIsCorrect": { 43 | "type": "boolean", 44 | "description": "Indicates if handle is currently valid (resolves bi-directionally)" 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/client/src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import Header from '#/components/Header' 2 | import StatusForm, { STATUS_OPTIONS } from '#/components/StatusForm' 3 | import StatusList from '#/components/StatusList' 4 | import { useAuth } from '#/hooks/useAuth' 5 | 6 | const HomePage = () => { 7 | const { user, loading, error } = useAuth() 8 | 9 | // Get a random emoji from the STATUS_OPTIONS array 10 | const randomEmoji = 11 | STATUS_OPTIONS[Math.floor(Math.random() * STATUS_OPTIONS.length)] 12 | 13 | if (loading) { 14 | return ( 15 |
16 |
{randomEmoji}
17 |
18 | ) 19 | } 20 | 21 | if (error) { 22 | return ( 23 |
24 |
25 |

26 | Error 27 |

28 |

{error}

29 | 33 | Try logging in again 34 | 35 |
36 |
37 | ) 38 | } 39 | 40 | return ( 41 |
42 |
43 | 44 | {user && } 45 | 46 |
47 |

48 | Recent Statuses 49 |

50 | 51 |
52 |
53 | ) 54 | } 55 | 56 | export default HomePage 57 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/com/atproto/repo/deleteRecord.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HeadersMap, XRPCError } from '@atproto/xrpc' 6 | import { CID } from 'multiformats/cid' 7 | 8 | import { validate as _validate } from '../../../../lexicons' 9 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 10 | import type * as ComAtprotoRepoDefs from './defs.js' 11 | 12 | const is$typed = _is$typed, 13 | validate = _validate 14 | const id = 'com.atproto.repo.deleteRecord' 15 | 16 | export interface QueryParams {} 17 | 18 | export interface InputSchema { 19 | /** The handle or DID of the repo (aka, current account). */ 20 | repo: string 21 | /** The NSID of the record collection. */ 22 | collection: string 23 | /** The Record Key. */ 24 | rkey: string 25 | /** Compare and swap with the previous record by CID. */ 26 | swapRecord?: string 27 | /** Compare and swap with the previous commit by CID. */ 28 | swapCommit?: string 29 | } 30 | 31 | export interface OutputSchema { 32 | commit?: ComAtprotoRepoDefs.CommitMeta 33 | } 34 | 35 | export interface CallOptions { 36 | signal?: AbortSignal 37 | headers?: HeadersMap 38 | qp?: QueryParams 39 | encoding?: 'application/json' 40 | } 41 | 42 | export interface Response { 43 | success: boolean 44 | headers: HeadersMap 45 | data: OutputSchema 46 | } 47 | 48 | export class InvalidSwapError extends XRPCError { 49 | constructor(src: XRPCError) { 50 | super(src.status, src.error, src.message, src.headers, { cause: src }) 51 | } 52 | } 53 | 54 | export function toKnownErr(e: any) { 55 | if (e instanceof XRPCError) { 56 | if (e.error === 'InvalidSwap') return new InvalidSwapError(e) 57 | } 58 | 59 | return e 60 | } 61 | -------------------------------------------------------------------------------- /lexicons/app/bsky/actor/profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "app.bsky.actor.profile", 4 | "defs": { 5 | "main": { 6 | "type": "record", 7 | "description": "A declaration of a Bluesky account profile.", 8 | "key": "literal:self", 9 | "record": { 10 | "type": "object", 11 | "properties": { 12 | "displayName": { 13 | "type": "string", 14 | "maxGraphemes": 64, 15 | "maxLength": 640 16 | }, 17 | "description": { 18 | "type": "string", 19 | "description": "Free-form profile description text.", 20 | "maxGraphemes": 256, 21 | "maxLength": 2560 22 | }, 23 | "avatar": { 24 | "type": "blob", 25 | "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'", 26 | "accept": ["image/png", "image/jpeg"], 27 | "maxSize": 1000000 28 | }, 29 | "banner": { 30 | "type": "blob", 31 | "description": "Larger horizontal image to display behind profile view.", 32 | "accept": ["image/png", "image/jpeg"], 33 | "maxSize": 1000000 34 | }, 35 | "labels": { 36 | "type": "union", 37 | "description": "Self-label values, specific to the Bluesky application, on the overall account.", 38 | "refs": ["com.atproto.label.defs#selfLabels"] 39 | }, 40 | "joinedViaStarterPack": { 41 | "type": "ref", 42 | "ref": "com.atproto.repo.strongRef" 43 | }, 44 | "pinnedPost": { 45 | "type": "ref", 46 | "ref": "com.atproto.repo.strongRef" 47 | }, 48 | "createdAt": { "type": "string", "format": "datetime" } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/appview/README.md: -------------------------------------------------------------------------------- 1 | # Statusphere AppView 2 | 3 | This is the backend API for the Statusphere application. It provides REST endpoints for the React frontend to consume. 4 | 5 | ## Development 6 | 7 | ```bash 8 | # Install dependencies 9 | pnpm install 10 | 11 | # Start development server 12 | pnpm dev 13 | 14 | # Build for production 15 | pnpm build 16 | 17 | # Start production server 18 | pnpm start 19 | ``` 20 | 21 | ## Environment Variables 22 | 23 | Create a `.env` file in the root of this package with the following variables: 24 | 25 | ``` 26 | NODE_ENV=development 27 | HOST=localhost 28 | PORT=3001 29 | DB_PATH=./data.sqlite 30 | COOKIE_SECRET=your_secret_here_at_least_32_characters_long 31 | ATPROTO_SERVER=https://bsky.social 32 | PUBLIC_URL=http://localhost:3001 33 | NGROK_URL=your_ngrok_url_here 34 | ``` 35 | 36 | ## Using ngrok for OAuth Development 37 | 38 | Due to OAuth requirements, we need to use HTTPS for development. The easiest way to do this is with ngrok: 39 | 40 | 1. Install ngrok: https://ngrok.com/download 41 | 2. Run ngrok to create a tunnel to your local server: 42 | ```bash 43 | ngrok http 3001 44 | ``` 45 | 3. Copy the HTTPS URL provided by ngrok (e.g., `https://abcd-123-45-678-90.ngrok.io`) 46 | 4. Add it to your `.env` file: 47 | ``` 48 | NGROK_URL=https://abcd-123-45-678-90.ngrok.io 49 | ``` 50 | 5. Also update the API URL in the client package: 51 | ``` 52 | # In packages/client/src/services/api.ts 53 | const API_URL = 'https://abcd-123-45-678-90.ngrok.io'; 54 | ``` 55 | 56 | ## API Endpoints 57 | 58 | - `GET /oauth-client-metadata.json` - OAuth client metadata 59 | - `GET /oauth/callback` - OAuth callback endpoint 60 | - `POST /login` - Login with handle 61 | - `POST /logout` - Logout current user 62 | - `GET /user` - Get current user info 63 | - `GET /statuses` - Get recent statuses 64 | - `POST /status` - Create a new status 65 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/com/atproto/repo/getRecord.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 6 | import express from 'express' 7 | import { CID } from 'multiformats/cid' 8 | 9 | import { validate as _validate } from '../../../../lexicons' 10 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 11 | 12 | const is$typed = _is$typed, 13 | validate = _validate 14 | const id = 'com.atproto.repo.getRecord' 15 | 16 | export interface QueryParams { 17 | /** The handle or DID of the repo. */ 18 | repo: string 19 | /** The NSID of the record collection. */ 20 | collection: string 21 | /** The Record Key. */ 22 | rkey: string 23 | /** The CID of the version of the record. If not specified, then return the most recent version. */ 24 | cid?: string 25 | } 26 | 27 | export type InputSchema = undefined 28 | 29 | export interface OutputSchema { 30 | uri: string 31 | cid?: string 32 | value: { [_ in string]: unknown } 33 | } 34 | 35 | export type HandlerInput = undefined 36 | 37 | export interface HandlerSuccess { 38 | encoding: 'application/json' 39 | body: OutputSchema 40 | headers?: { [key: string]: string } 41 | } 42 | 43 | export interface HandlerError { 44 | status: number 45 | message?: string 46 | error?: 'RecordNotFound' 47 | } 48 | 49 | export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 50 | export type HandlerReqCtx = { 51 | auth: HA 52 | params: QueryParams 53 | input: HandlerInput 54 | req: express.Request 55 | res: express.Response 56 | resetRouteRateLimits: () => Promise 57 | } 58 | export type Handler = ( 59 | ctx: HandlerReqCtx, 60 | ) => Promise | HandlerOutput 61 | -------------------------------------------------------------------------------- /packages/appview/src/auth/storage.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NodeSavedSession, 3 | NodeSavedSessionStore, 4 | NodeSavedState, 5 | NodeSavedStateStore, 6 | } from '@atproto/oauth-client-node' 7 | 8 | import type { Database } from '#/db' 9 | 10 | export class StateStore implements NodeSavedStateStore { 11 | constructor(private db: Database) {} 12 | async get(key: string): Promise { 13 | const result = await this.db 14 | .selectFrom('auth_state') 15 | .selectAll() 16 | .where('key', '=', key) 17 | .executeTakeFirst() 18 | if (!result) return 19 | return JSON.parse(result.state) as NodeSavedState 20 | } 21 | async set(key: string, val: NodeSavedState) { 22 | const state = JSON.stringify(val) 23 | await this.db 24 | .insertInto('auth_state') 25 | .values({ key, state }) 26 | .onConflict((oc) => oc.doUpdateSet({ state })) 27 | .execute() 28 | } 29 | async del(key: string) { 30 | await this.db.deleteFrom('auth_state').where('key', '=', key).execute() 31 | } 32 | } 33 | 34 | export class SessionStore implements NodeSavedSessionStore { 35 | constructor(private db: Database) {} 36 | async get(key: string): Promise { 37 | const result = await this.db 38 | .selectFrom('auth_session') 39 | .selectAll() 40 | .where('key', '=', key) 41 | .executeTakeFirst() 42 | if (!result) return 43 | return JSON.parse(result.session) as NodeSavedSession 44 | } 45 | async set(key: string, val: NodeSavedSession) { 46 | const session = JSON.stringify(val) 47 | await this.db 48 | .insertInto('auth_session') 49 | .values({ key, session }) 50 | .onConflict((oc) => oc.doUpdateSet({ session })) 51 | .execute() 52 | } 53 | async del(key: string) { 54 | await this.db.deleteFrom('auth_session').where('key', '=', key).execute() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/com/atproto/repo/describeRepo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 6 | import express from 'express' 7 | import { CID } from 'multiformats/cid' 8 | 9 | import { validate as _validate } from '../../../../lexicons' 10 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 11 | 12 | const is$typed = _is$typed, 13 | validate = _validate 14 | const id = 'com.atproto.repo.describeRepo' 15 | 16 | export interface QueryParams { 17 | /** The handle or DID of the repo. */ 18 | repo: string 19 | } 20 | 21 | export type InputSchema = undefined 22 | 23 | export interface OutputSchema { 24 | handle: string 25 | did: string 26 | /** The complete DID document for this account. */ 27 | didDoc: { [_ in string]: unknown } 28 | /** List of all the collections (NSIDs) for which this repo contains at least one record. */ 29 | collections: string[] 30 | /** Indicates if handle is currently valid (resolves bi-directionally) */ 31 | handleIsCorrect: boolean 32 | } 33 | 34 | export type HandlerInput = undefined 35 | 36 | export interface HandlerSuccess { 37 | encoding: 'application/json' 38 | body: OutputSchema 39 | headers?: { [key: string]: string } 40 | } 41 | 42 | export interface HandlerError { 43 | status: number 44 | message?: string 45 | } 46 | 47 | export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 48 | export type HandlerReqCtx = { 49 | auth: HA 50 | params: QueryParams 51 | input: HandlerInput 52 | req: express.Request 53 | res: express.Response 54 | resetRouteRateLimits: () => Promise 55 | } 56 | export type Handler = ( 57 | ctx: HandlerReqCtx, 58 | ) => Promise | HandlerOutput 59 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/com/atproto/repo/listRecords.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HeadersMap, XRPCError } from '@atproto/xrpc' 6 | import { CID } from 'multiformats/cid' 7 | 8 | import { validate as _validate } from '../../../../lexicons' 9 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 10 | 11 | const is$typed = _is$typed, 12 | validate = _validate 13 | const id = 'com.atproto.repo.listRecords' 14 | 15 | export interface QueryParams { 16 | /** The handle or DID of the repo. */ 17 | repo: string 18 | /** The NSID of the record type. */ 19 | collection: string 20 | /** The number of records to return. */ 21 | limit?: number 22 | cursor?: string 23 | /** DEPRECATED: The lowest sort-ordered rkey to start from (exclusive) */ 24 | rkeyStart?: string 25 | /** DEPRECATED: The highest sort-ordered rkey to stop at (exclusive) */ 26 | rkeyEnd?: string 27 | /** Flag to reverse the order of the returned records. */ 28 | reverse?: boolean 29 | } 30 | 31 | export type InputSchema = undefined 32 | 33 | export interface OutputSchema { 34 | cursor?: string 35 | records: Record[] 36 | } 37 | 38 | export interface CallOptions { 39 | signal?: AbortSignal 40 | headers?: HeadersMap 41 | } 42 | 43 | export interface Response { 44 | success: boolean 45 | headers: HeadersMap 46 | data: OutputSchema 47 | } 48 | 49 | export function toKnownErr(e: any) { 50 | return e 51 | } 52 | 53 | export interface Record { 54 | $type?: 'com.atproto.repo.listRecords#record' 55 | uri: string 56 | cid: string 57 | value: { [_ in string]: unknown } 58 | } 59 | 60 | const hashRecord = 'record' 61 | 62 | export function isRecord(v: V) { 63 | return is$typed(v, id, hashRecord) 64 | } 65 | 66 | export function validateRecord(v: V) { 67 | return validate(v, id, hashRecord) 68 | } 69 | -------------------------------------------------------------------------------- /lexicons/com/atproto/repo/deleteRecord.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.deleteRecord", 4 | "defs": { 5 | "main": { 6 | "type": "procedure", 7 | "description": "Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.", 8 | "input": { 9 | "encoding": "application/json", 10 | "schema": { 11 | "type": "object", 12 | "required": ["repo", "collection", "rkey"], 13 | "properties": { 14 | "repo": { 15 | "type": "string", 16 | "format": "at-identifier", 17 | "description": "The handle or DID of the repo (aka, current account)." 18 | }, 19 | "collection": { 20 | "type": "string", 21 | "format": "nsid", 22 | "description": "The NSID of the record collection." 23 | }, 24 | "rkey": { 25 | "type": "string", 26 | "format": "record-key", 27 | "description": "The Record Key." 28 | }, 29 | "swapRecord": { 30 | "type": "string", 31 | "format": "cid", 32 | "description": "Compare and swap with the previous record by CID." 33 | }, 34 | "swapCommit": { 35 | "type": "string", 36 | "format": "cid", 37 | "description": "Compare and swap with the previous commit by CID." 38 | } 39 | } 40 | } 41 | }, 42 | "output": { 43 | "encoding": "application/json", 44 | "schema": { 45 | "type": "object", 46 | "properties": { 47 | "commit": { 48 | "type": "ref", 49 | "ref": "com.atproto.repo.defs#commitMeta" 50 | } 51 | } 52 | } 53 | }, 54 | "errors": [{ "name": "InvalidSwap" }] 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/com/atproto/repo/listMissingBlobs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 6 | import express from 'express' 7 | import { CID } from 'multiformats/cid' 8 | 9 | import { validate as _validate } from '../../../../lexicons' 10 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 11 | 12 | const is$typed = _is$typed, 13 | validate = _validate 14 | const id = 'com.atproto.repo.listMissingBlobs' 15 | 16 | export interface QueryParams { 17 | limit: number 18 | cursor?: string 19 | } 20 | 21 | export type InputSchema = undefined 22 | 23 | export interface OutputSchema { 24 | cursor?: string 25 | blobs: RecordBlob[] 26 | } 27 | 28 | export type HandlerInput = undefined 29 | 30 | export interface HandlerSuccess { 31 | encoding: 'application/json' 32 | body: OutputSchema 33 | headers?: { [key: string]: string } 34 | } 35 | 36 | export interface HandlerError { 37 | status: number 38 | message?: string 39 | } 40 | 41 | export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 42 | export type HandlerReqCtx = { 43 | auth: HA 44 | params: QueryParams 45 | input: HandlerInput 46 | req: express.Request 47 | res: express.Response 48 | resetRouteRateLimits: () => Promise 49 | } 50 | export type Handler = ( 51 | ctx: HandlerReqCtx, 52 | ) => Promise | HandlerOutput 53 | 54 | export interface RecordBlob { 55 | $type?: 'com.atproto.repo.listMissingBlobs#recordBlob' 56 | cid: string 57 | recordUri: string 58 | } 59 | 60 | const hashRecordBlob = 'recordBlob' 61 | 62 | export function isRecordBlob(v: V) { 63 | return is$typed(v, id, hashRecordBlob) 64 | } 65 | 66 | export function validateRecordBlob(v: V) { 67 | return validate(v, id, hashRecordBlob) 68 | } 69 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/com/atproto/repo/deleteRecord.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 6 | import express from 'express' 7 | import { CID } from 'multiformats/cid' 8 | 9 | import { validate as _validate } from '../../../../lexicons' 10 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 11 | import type * as ComAtprotoRepoDefs from './defs.js' 12 | 13 | const is$typed = _is$typed, 14 | validate = _validate 15 | const id = 'com.atproto.repo.deleteRecord' 16 | 17 | export interface QueryParams {} 18 | 19 | export interface InputSchema { 20 | /** The handle or DID of the repo (aka, current account). */ 21 | repo: string 22 | /** The NSID of the record collection. */ 23 | collection: string 24 | /** The Record Key. */ 25 | rkey: string 26 | /** Compare and swap with the previous record by CID. */ 27 | swapRecord?: string 28 | /** Compare and swap with the previous commit by CID. */ 29 | swapCommit?: string 30 | } 31 | 32 | export interface OutputSchema { 33 | commit?: ComAtprotoRepoDefs.CommitMeta 34 | } 35 | 36 | export interface HandlerInput { 37 | encoding: 'application/json' 38 | body: InputSchema 39 | } 40 | 41 | export interface HandlerSuccess { 42 | encoding: 'application/json' 43 | body: OutputSchema 44 | headers?: { [key: string]: string } 45 | } 46 | 47 | export interface HandlerError { 48 | status: number 49 | message?: string 50 | error?: 'InvalidSwap' 51 | } 52 | 53 | export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 54 | export type HandlerReqCtx = { 55 | auth: HA 56 | params: QueryParams 57 | input: HandlerInput 58 | req: express.Request 59 | res: express.Response 60 | resetRouteRateLimits: () => Promise 61 | } 62 | export type Handler = ( 63 | ctx: HandlerReqCtx, 64 | ) => Promise | HandlerOutput 65 | -------------------------------------------------------------------------------- /packages/appview/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@statusphere/appview", 3 | "version": "0.0.1", 4 | "description": "Statusphere AppView backend", 5 | "author": "", 6 | "license": "MIT", 7 | "main": "dist/index.js", 8 | "private": true, 9 | "scripts": { 10 | "dev": "tsx watch --clear-screen=false src/index.ts | pino-pretty", 11 | "build": "tsup", 12 | "start": "node dist/index.js", 13 | "lexgen": "lex gen-server ./src/lexicons ../../lexicons/xyz/statusphere/* ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* --yes && pnpm format", 14 | "clean": "rimraf dist coverage", 15 | "format": "prettier --write src", 16 | "typecheck": "tsc --noEmit" 17 | }, 18 | "dependencies": { 19 | "@atproto/api": "^0.14.7", 20 | "@atproto/common": "^0.4.8", 21 | "@atproto/identity": "^0.4.6", 22 | "@atproto/lexicon": "^0.4.7", 23 | "@atproto/oauth-client-node": "^0.2.11", 24 | "@atproto/sync": "^0.1.15", 25 | "@atproto/syntax": "^0.3.3", 26 | "@atproto/xrpc-server": "^0.7.11", 27 | "@statusphere/lexicon": "workspace:*", 28 | "better-sqlite3": "^11.8.1", 29 | "compression": "^1.8.0", 30 | "cors": "^2.8.5", 31 | "dotenv": "^16.4.7", 32 | "envalid": "^8.0.0", 33 | "express": "^4.21.2", 34 | "iron-session": "^8.0.4", 35 | "kysely": "^0.27.5", 36 | "multiformats": "^13.3.2", 37 | "pino": "^9.6.0", 38 | "ws": "^8.18.1" 39 | }, 40 | "devDependencies": { 41 | "@atproto/lex-cli": "^0.6.1", 42 | "@types/better-sqlite3": "^7.6.12", 43 | "@types/compression": "^1.7.5", 44 | "@types/cors": "^2.8.17", 45 | "@types/express": "^5.0.0", 46 | "@types/node": "^22.13.8", 47 | "@types/ws": "^8.5.14", 48 | "pino-pretty": "^13.0.0", 49 | "ts-node": "^10.9.2", 50 | "tsup": "^8.4.0", 51 | "tsx": "^4.19.3", 52 | "typescript": "^5.8.2" 53 | }, 54 | "tsup": { 55 | "entry": [ 56 | "src", 57 | "!src/**/__tests__/**", 58 | "!src/**/*.test.*" 59 | ], 60 | "splitting": false, 61 | "sourcemap": true, 62 | "clean": true 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/com/atproto/repo/createRecord.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HeadersMap, XRPCError } from '@atproto/xrpc' 6 | import { CID } from 'multiformats/cid' 7 | 8 | import { validate as _validate } from '../../../../lexicons' 9 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 10 | import type * as ComAtprotoRepoDefs from './defs.js' 11 | 12 | const is$typed = _is$typed, 13 | validate = _validate 14 | const id = 'com.atproto.repo.createRecord' 15 | 16 | export interface QueryParams {} 17 | 18 | export interface InputSchema { 19 | /** The handle or DID of the repo (aka, current account). */ 20 | repo: string 21 | /** The NSID of the record collection. */ 22 | collection: string 23 | /** The Record Key. */ 24 | rkey?: string 25 | /** 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. */ 26 | validate?: boolean 27 | /** The record itself. Must contain a $type field. */ 28 | record: { [_ in string]: unknown } 29 | /** Compare and swap with the previous commit by CID. */ 30 | swapCommit?: string 31 | } 32 | 33 | export interface OutputSchema { 34 | uri: string 35 | cid: string 36 | commit?: ComAtprotoRepoDefs.CommitMeta 37 | validationStatus?: 'valid' | 'unknown' | (string & {}) 38 | } 39 | 40 | export interface CallOptions { 41 | signal?: AbortSignal 42 | headers?: HeadersMap 43 | qp?: QueryParams 44 | encoding?: 'application/json' 45 | } 46 | 47 | export interface Response { 48 | success: boolean 49 | headers: HeadersMap 50 | data: OutputSchema 51 | } 52 | 53 | export class InvalidSwapError extends XRPCError { 54 | constructor(src: XRPCError) { 55 | super(src.status, src.error, src.message, src.headers, { cause: src }) 56 | } 57 | } 58 | 59 | export function toKnownErr(e: any) { 60 | if (e instanceof XRPCError) { 61 | if (e.error === 'InvalidSwap') return new InvalidSwapError(e) 62 | } 63 | 64 | return e 65 | } 66 | -------------------------------------------------------------------------------- /packages/client/src/services/api.ts: -------------------------------------------------------------------------------- 1 | import * as Lexicon from '@statusphere/lexicon' 2 | import type { 3 | XyzStatusphereGetStatuses, 4 | XyzStatusphereGetUser, 5 | XyzStatusphereSendStatus, 6 | } from '@statusphere/lexicon' 7 | 8 | class StatusphereAgent extends Lexicon.AtpBaseClient { 9 | constructor() { 10 | super(StatusphereAgent.fetchHandler) 11 | } 12 | 13 | private static fetchHandler: Lexicon.AtpBaseClient['fetchHandler'] = ( 14 | path, 15 | options, 16 | ) => { 17 | return fetch(path, { 18 | ...options, 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | }, 22 | credentials: 'include', 23 | }) 24 | } 25 | } 26 | 27 | const agent = new StatusphereAgent() 28 | 29 | // API service 30 | export const api = { 31 | // Login 32 | async login(handle: string) { 33 | const response = await fetch('/oauth/initiate', { 34 | method: 'POST', 35 | headers: { 36 | 'Content-Type': 'application/json', 37 | }, 38 | credentials: 'include', 39 | body: JSON.stringify({ handle }), 40 | }) 41 | 42 | if (!response.ok) { 43 | const error = await response.json() 44 | throw new Error(error.error || 'Login failed') 45 | } 46 | 47 | return response.json() 48 | }, 49 | 50 | // Logout 51 | async logout() { 52 | const response = await fetch('/oauth/logout', { 53 | method: 'POST', 54 | credentials: 'include', 55 | }) 56 | 57 | if (!response.ok) { 58 | throw new Error('Logout failed') 59 | } 60 | 61 | return response.json() 62 | }, 63 | 64 | // Get current user 65 | getCurrentUser(params: XyzStatusphereGetUser.QueryParams) { 66 | return agent.xyz.statusphere.getUser(params) 67 | }, 68 | 69 | // Get statuses 70 | getStatuses(params: XyzStatusphereGetStatuses.QueryParams) { 71 | return agent.xyz.statusphere.getStatuses(params) 72 | }, 73 | 74 | // Create status 75 | createStatus(params: XyzStatusphereSendStatus.InputSchema) { 76 | return agent.xyz.statusphere.sendStatus(params) 77 | }, 78 | } 79 | 80 | export default api 81 | -------------------------------------------------------------------------------- /packages/appview/src/api/lexicons/getUser.ts: -------------------------------------------------------------------------------- 1 | import { AuthRequiredError } from '@atproto/xrpc-server' 2 | import { AppBskyActorProfile } from '@statusphere/lexicon' 3 | 4 | import { AppContext } from '#/context' 5 | import { Server } from '#/lexicons' 6 | import { bskyProfileToProfileView, statusToStatusView } from '#/lib/hydrate' 7 | import { getSessionAgent } from '#/session' 8 | 9 | export default function (server: Server, ctx: AppContext) { 10 | server.xyz.statusphere.getUser({ 11 | handler: async ({ req, res }) => { 12 | const agent = await getSessionAgent(req, res, ctx) 13 | if (!agent) { 14 | throw new AuthRequiredError('Authentication required') 15 | } 16 | 17 | const did = agent.assertDid 18 | 19 | const profileResponse = await agent.com.atproto.repo 20 | .getRecord({ 21 | repo: did, 22 | collection: 'app.bsky.actor.profile', 23 | rkey: 'self', 24 | }) 25 | .catch(() => undefined) 26 | 27 | const profileRecord = profileResponse?.data 28 | let profile: AppBskyActorProfile.Record = {} as AppBskyActorProfile.Record 29 | 30 | if (profileRecord && AppBskyActorProfile.isRecord(profileRecord.value)) { 31 | const validated = AppBskyActorProfile.validateRecord( 32 | profileRecord.value, 33 | ) 34 | if (validated.success) { 35 | profile = profileRecord.value 36 | } else { 37 | ctx.logger.error( 38 | { err: validated.error }, 39 | 'Failed to validate user profile', 40 | ) 41 | } 42 | } 43 | 44 | // Fetch user status 45 | const status = await ctx.db 46 | .selectFrom('status') 47 | .selectAll() 48 | .where('authorDid', '=', did) 49 | .orderBy('indexedAt', 'desc') 50 | .executeTakeFirst() 51 | 52 | return { 53 | encoding: 'application/json', 54 | body: { 55 | profile: await bskyProfileToProfileView(did, profile, ctx), 56 | status: status ? await statusToStatusView(status, ctx) : undefined, 57 | }, 58 | } 59 | }, 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/com/atproto/repo/putRecord.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HeadersMap, XRPCError } from '@atproto/xrpc' 6 | import { CID } from 'multiformats/cid' 7 | 8 | import { validate as _validate } from '../../../../lexicons' 9 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 10 | import type * as ComAtprotoRepoDefs from './defs.js' 11 | 12 | const is$typed = _is$typed, 13 | validate = _validate 14 | const id = 'com.atproto.repo.putRecord' 15 | 16 | export interface QueryParams {} 17 | 18 | export interface InputSchema { 19 | /** The handle or DID of the repo (aka, current account). */ 20 | repo: string 21 | /** The NSID of the record collection. */ 22 | collection: string 23 | /** The Record Key. */ 24 | rkey: string 25 | /** 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. */ 26 | validate?: boolean 27 | /** The record to write. */ 28 | record: { [_ in string]: unknown } 29 | /** Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation */ 30 | swapRecord?: string | null 31 | /** Compare and swap with the previous commit by CID. */ 32 | swapCommit?: string 33 | } 34 | 35 | export interface OutputSchema { 36 | uri: string 37 | cid: string 38 | commit?: ComAtprotoRepoDefs.CommitMeta 39 | validationStatus?: 'valid' | 'unknown' | (string & {}) 40 | } 41 | 42 | export interface CallOptions { 43 | signal?: AbortSignal 44 | headers?: HeadersMap 45 | qp?: QueryParams 46 | encoding?: 'application/json' 47 | } 48 | 49 | export interface Response { 50 | success: boolean 51 | headers: HeadersMap 52 | data: OutputSchema 53 | } 54 | 55 | export class InvalidSwapError extends XRPCError { 56 | constructor(src: XRPCError) { 57 | super(src.status, src.error, src.message, src.headers, { cause: src }) 58 | } 59 | } 60 | 61 | export function toKnownErr(e: any) { 62 | if (e instanceof XRPCError) { 63 | if (e.error === 'InvalidSwap') return new InvalidSwapError(e) 64 | } 65 | 66 | return e 67 | } 68 | -------------------------------------------------------------------------------- /packages/client/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom' 2 | 3 | import { useAuth } from '#/hooks/useAuth' 4 | 5 | const Header = () => { 6 | const { user, logout } = useAuth() 7 | 8 | const handleLogout = async () => { 9 | try { 10 | await logout() 11 | } catch (error) { 12 | console.error('Logout failed:', error) 13 | } 14 | } 15 | 16 | return ( 17 |
18 |
19 |

20 | 24 | Statusphere 25 | 26 |

27 | 57 |
58 |
59 | ) 60 | } 61 | 62 | export default Header 63 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/com/atproto/repo/createRecord.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 6 | import express from 'express' 7 | import { CID } from 'multiformats/cid' 8 | 9 | import { validate as _validate } from '../../../../lexicons' 10 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 11 | import type * as ComAtprotoRepoDefs from './defs.js' 12 | 13 | const is$typed = _is$typed, 14 | validate = _validate 15 | const id = 'com.atproto.repo.createRecord' 16 | 17 | export interface QueryParams {} 18 | 19 | export interface InputSchema { 20 | /** The handle or DID of the repo (aka, current account). */ 21 | repo: string 22 | /** The NSID of the record collection. */ 23 | collection: string 24 | /** The Record Key. */ 25 | rkey?: string 26 | /** 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. */ 27 | validate?: boolean 28 | /** The record itself. Must contain a $type field. */ 29 | record: { [_ in string]: unknown } 30 | /** Compare and swap with the previous commit by CID. */ 31 | swapCommit?: string 32 | } 33 | 34 | export interface OutputSchema { 35 | uri: string 36 | cid: string 37 | commit?: ComAtprotoRepoDefs.CommitMeta 38 | validationStatus?: 'valid' | 'unknown' | (string & {}) 39 | } 40 | 41 | export interface HandlerInput { 42 | encoding: 'application/json' 43 | body: InputSchema 44 | } 45 | 46 | export interface HandlerSuccess { 47 | encoding: 'application/json' 48 | body: OutputSchema 49 | headers?: { [key: string]: string } 50 | } 51 | 52 | export interface HandlerError { 53 | status: number 54 | message?: string 55 | error?: 'InvalidSwap' 56 | } 57 | 58 | export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 59 | export type HandlerReqCtx = { 60 | auth: HA 61 | params: QueryParams 62 | input: HandlerInput 63 | req: express.Request 64 | res: express.Response 65 | resetRouteRateLimits: () => Promise 66 | } 67 | export type Handler = ( 68 | ctx: HandlerReqCtx, 69 | ) => Promise | HandlerOutput 70 | -------------------------------------------------------------------------------- /lexicons/com/atproto/repo/listRecords.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.listRecords", 4 | "defs": { 5 | "main": { 6 | "type": "query", 7 | "description": "List a range of records in a repository, matching a specific collection. Does not require auth.", 8 | "parameters": { 9 | "type": "params", 10 | "required": ["repo", "collection"], 11 | "properties": { 12 | "repo": { 13 | "type": "string", 14 | "format": "at-identifier", 15 | "description": "The handle or DID of the repo." 16 | }, 17 | "collection": { 18 | "type": "string", 19 | "format": "nsid", 20 | "description": "The NSID of the record type." 21 | }, 22 | "limit": { 23 | "type": "integer", 24 | "minimum": 1, 25 | "maximum": 100, 26 | "default": 50, 27 | "description": "The number of records to return." 28 | }, 29 | "cursor": { "type": "string" }, 30 | "rkeyStart": { 31 | "type": "string", 32 | "description": "DEPRECATED: The lowest sort-ordered rkey to start from (exclusive)" 33 | }, 34 | "rkeyEnd": { 35 | "type": "string", 36 | "description": "DEPRECATED: The highest sort-ordered rkey to stop at (exclusive)" 37 | }, 38 | "reverse": { 39 | "type": "boolean", 40 | "description": "Flag to reverse the order of the returned records." 41 | } 42 | } 43 | }, 44 | "output": { 45 | "encoding": "application/json", 46 | "schema": { 47 | "type": "object", 48 | "required": ["records"], 49 | "properties": { 50 | "cursor": { "type": "string" }, 51 | "records": { 52 | "type": "array", 53 | "items": { "type": "ref", "ref": "#record" } 54 | } 55 | } 56 | } 57 | } 58 | }, 59 | "record": { 60 | "type": "object", 61 | "required": ["uri", "cid", "value"], 62 | "properties": { 63 | "uri": { "type": "string", "format": "at-uri" }, 64 | "cid": { "type": "string", "format": "cid" }, 65 | "value": { "type": "unknown" } 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/com/atproto/repo/listRecords.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 6 | import express from 'express' 7 | import { CID } from 'multiformats/cid' 8 | 9 | import { validate as _validate } from '../../../../lexicons' 10 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 11 | 12 | const is$typed = _is$typed, 13 | validate = _validate 14 | const id = 'com.atproto.repo.listRecords' 15 | 16 | export interface QueryParams { 17 | /** The handle or DID of the repo. */ 18 | repo: string 19 | /** The NSID of the record type. */ 20 | collection: string 21 | /** The number of records to return. */ 22 | limit: number 23 | cursor?: string 24 | /** DEPRECATED: The lowest sort-ordered rkey to start from (exclusive) */ 25 | rkeyStart?: string 26 | /** DEPRECATED: The highest sort-ordered rkey to stop at (exclusive) */ 27 | rkeyEnd?: string 28 | /** Flag to reverse the order of the returned records. */ 29 | reverse?: boolean 30 | } 31 | 32 | export type InputSchema = undefined 33 | 34 | export interface OutputSchema { 35 | cursor?: string 36 | records: Record[] 37 | } 38 | 39 | export type HandlerInput = undefined 40 | 41 | export interface HandlerSuccess { 42 | encoding: 'application/json' 43 | body: OutputSchema 44 | headers?: { [key: string]: string } 45 | } 46 | 47 | export interface HandlerError { 48 | status: number 49 | message?: string 50 | } 51 | 52 | export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 53 | export type HandlerReqCtx = { 54 | auth: HA 55 | params: QueryParams 56 | input: HandlerInput 57 | req: express.Request 58 | res: express.Response 59 | resetRouteRateLimits: () => Promise 60 | } 61 | export type Handler = ( 62 | ctx: HandlerReqCtx, 63 | ) => Promise | HandlerOutput 64 | 65 | export interface Record { 66 | $type?: 'com.atproto.repo.listRecords#record' 67 | uri: string 68 | cid: string 69 | value: { [_ in string]: unknown } 70 | } 71 | 72 | const hashRecord = 'record' 73 | 74 | export function isRecord(v: V) { 75 | return is$typed(v, id, hashRecord) 76 | } 77 | 78 | export function validateRecord(v: V) { 79 | return validate(v, id, hashRecord) 80 | } 81 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/com/atproto/repo/putRecord.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 6 | import express from 'express' 7 | import { CID } from 'multiformats/cid' 8 | 9 | import { validate as _validate } from '../../../../lexicons' 10 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 11 | import type * as ComAtprotoRepoDefs from './defs.js' 12 | 13 | const is$typed = _is$typed, 14 | validate = _validate 15 | const id = 'com.atproto.repo.putRecord' 16 | 17 | export interface QueryParams {} 18 | 19 | export interface InputSchema { 20 | /** The handle or DID of the repo (aka, current account). */ 21 | repo: string 22 | /** The NSID of the record collection. */ 23 | collection: string 24 | /** The Record Key. */ 25 | rkey: string 26 | /** 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. */ 27 | validate?: boolean 28 | /** The record to write. */ 29 | record: { [_ in string]: unknown } 30 | /** Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation */ 31 | swapRecord?: string | null 32 | /** Compare and swap with the previous commit by CID. */ 33 | swapCommit?: string 34 | } 35 | 36 | export interface OutputSchema { 37 | uri: string 38 | cid: string 39 | commit?: ComAtprotoRepoDefs.CommitMeta 40 | validationStatus?: 'valid' | 'unknown' | (string & {}) 41 | } 42 | 43 | export interface HandlerInput { 44 | encoding: 'application/json' 45 | body: InputSchema 46 | } 47 | 48 | export interface HandlerSuccess { 49 | encoding: 'application/json' 50 | body: OutputSchema 51 | headers?: { [key: string]: string } 52 | } 53 | 54 | export interface HandlerError { 55 | status: number 56 | message?: string 57 | error?: 'InvalidSwap' 58 | } 59 | 60 | export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 61 | export type HandlerReqCtx = { 62 | auth: HA 63 | params: QueryParams 64 | input: HandlerInput 65 | req: express.Request 66 | res: express.Response 67 | resetRouteRateLimits: () => Promise 68 | } 69 | export type Handler = ( 70 | ctx: HandlerReqCtx, 71 | ) => Promise | HandlerOutput 72 | -------------------------------------------------------------------------------- /packages/lexicon/src/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | 5 | import { ValidationResult } from '@atproto/lexicon' 6 | 7 | export type OmitKey = { 8 | [K2 in keyof T as K2 extends K ? never : K2]: T[K2] 9 | } 10 | 11 | export type $Typed = V & { $type: T } 12 | export type Un$Typed = OmitKey 13 | 14 | export type $Type = Hash extends 'main' 15 | ? Id 16 | : `${Id}#${Hash}` 17 | 18 | function isObject(v: V): v is V & object { 19 | return v != null && typeof v === 'object' 20 | } 21 | 22 | function is$type( 23 | $type: unknown, 24 | id: Id, 25 | hash: Hash, 26 | ): $type is $Type { 27 | return hash === 'main' 28 | ? $type === id 29 | : // $type === `${id}#${hash}` 30 | typeof $type === 'string' && 31 | $type.length === id.length + 1 + hash.length && 32 | $type.charCodeAt(id.length) === 35 /* '#' */ && 33 | $type.startsWith(id) && 34 | $type.endsWith(hash) 35 | } 36 | 37 | export type $TypedObject< 38 | V, 39 | Id extends string, 40 | Hash extends string, 41 | > = V extends { 42 | $type: $Type 43 | } 44 | ? V 45 | : V extends { $type?: string } 46 | ? V extends { $type?: infer T extends $Type } 47 | ? V & { $type: T } 48 | : never 49 | : V & { $type: $Type } 50 | 51 | export function is$typed( 52 | v: V, 53 | id: Id, 54 | hash: Hash, 55 | ): v is $TypedObject { 56 | return isObject(v) && '$type' in v && is$type(v.$type, id, hash) 57 | } 58 | 59 | export function maybe$typed( 60 | v: V, 61 | id: Id, 62 | hash: Hash, 63 | ): v is V & object & { $type?: $Type } { 64 | return ( 65 | isObject(v) && 66 | ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true) 67 | ) 68 | } 69 | 70 | export type Validator = (v: unknown) => ValidationResult 71 | export type ValidatorParam = 72 | V extends Validator ? R : never 73 | 74 | /** 75 | * Utility function that allows to convert a "validate*" utility function into a 76 | * type predicate. 77 | */ 78 | export function asPredicate(validate: V) { 79 | return function (v: T): v is T & ValidatorParam { 80 | return validate(v).success 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | 5 | import { ValidationResult } from '@atproto/lexicon' 6 | 7 | export type OmitKey = { 8 | [K2 in keyof T as K2 extends K ? never : K2]: T[K2] 9 | } 10 | 11 | export type $Typed = V & { $type: T } 12 | export type Un$Typed = OmitKey 13 | 14 | export type $Type = Hash extends 'main' 15 | ? Id 16 | : `${Id}#${Hash}` 17 | 18 | function isObject(v: V): v is V & object { 19 | return v != null && typeof v === 'object' 20 | } 21 | 22 | function is$type( 23 | $type: unknown, 24 | id: Id, 25 | hash: Hash, 26 | ): $type is $Type { 27 | return hash === 'main' 28 | ? $type === id 29 | : // $type === `${id}#${hash}` 30 | typeof $type === 'string' && 31 | $type.length === id.length + 1 + hash.length && 32 | $type.charCodeAt(id.length) === 35 /* '#' */ && 33 | $type.startsWith(id) && 34 | $type.endsWith(hash) 35 | } 36 | 37 | export type $TypedObject< 38 | V, 39 | Id extends string, 40 | Hash extends string, 41 | > = V extends { 42 | $type: $Type 43 | } 44 | ? V 45 | : V extends { $type?: string } 46 | ? V extends { $type?: infer T extends $Type } 47 | ? V & { $type: T } 48 | : never 49 | : V & { $type: $Type } 50 | 51 | export function is$typed( 52 | v: V, 53 | id: Id, 54 | hash: Hash, 55 | ): v is $TypedObject { 56 | return isObject(v) && '$type' in v && is$type(v.$type, id, hash) 57 | } 58 | 59 | export function maybe$typed( 60 | v: V, 61 | id: Id, 62 | hash: Hash, 63 | ): v is V & object & { $type?: $Type } { 64 | return ( 65 | isObject(v) && 66 | ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true) 67 | ) 68 | } 69 | 70 | export type Validator = (v: unknown) => ValidationResult 71 | export type ValidatorParam = 72 | V extends Validator ? R : never 73 | 74 | /** 75 | * Utility function that allows to convert a "validate*" utility function into a 76 | * type predicate. 77 | */ 78 | export function asPredicate(validate: V) { 79 | return function (v: T): v is T & ValidatorParam { 80 | return validate(v).success 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lexicons/com/atproto/repo/createRecord.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.createRecord", 4 | "defs": { 5 | "main": { 6 | "type": "procedure", 7 | "description": "Create a single new repository record. Requires auth, implemented by PDS.", 8 | "input": { 9 | "encoding": "application/json", 10 | "schema": { 11 | "type": "object", 12 | "required": ["repo", "collection", "record"], 13 | "properties": { 14 | "repo": { 15 | "type": "string", 16 | "format": "at-identifier", 17 | "description": "The handle or DID of the repo (aka, current account)." 18 | }, 19 | "collection": { 20 | "type": "string", 21 | "format": "nsid", 22 | "description": "The NSID of the record collection." 23 | }, 24 | "rkey": { 25 | "type": "string", 26 | "format": "record-key", 27 | "description": "The Record Key.", 28 | "maxLength": 512 29 | }, 30 | "validate": { 31 | "type": "boolean", 32 | "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." 33 | }, 34 | "record": { 35 | "type": "unknown", 36 | "description": "The record itself. Must contain a $type field." 37 | }, 38 | "swapCommit": { 39 | "type": "string", 40 | "format": "cid", 41 | "description": "Compare and swap with the previous commit by CID." 42 | } 43 | } 44 | } 45 | }, 46 | "output": { 47 | "encoding": "application/json", 48 | "schema": { 49 | "type": "object", 50 | "required": ["uri", "cid"], 51 | "properties": { 52 | "uri": { "type": "string", "format": "at-uri" }, 53 | "cid": { "type": "string", "format": "cid" }, 54 | "commit": { 55 | "type": "ref", 56 | "ref": "com.atproto.repo.defs#commitMeta" 57 | }, 58 | "validationStatus": { 59 | "type": "string", 60 | "knownValues": ["valid", "unknown"] 61 | } 62 | } 63 | } 64 | }, 65 | "errors": [ 66 | { 67 | "name": "InvalidSwap", 68 | "description": "Indicates that 'swapCommit' didn't match current repo commit." 69 | } 70 | ] 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Statusphere React 2 | 3 | [![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) 4 | 5 | A status sharing application built with React and the AT Protocol. 6 | 7 | This is a React implementation of the [example application](https://atproto.com/guides/applications) covering: 8 | 9 | - Signin via OAuth 10 | - Fetch information about users (profiles) 11 | - Listen to the network firehose for new data 12 | - Publish data on the user's account using a custom schema 13 | 14 | ## Structure 15 | 16 | - `packages/appview` - Express.js backend that serves API endpoints 17 | - `packages/client` - React frontend using Vite 18 | 19 | ## Development 20 | 21 | ```bash 22 | # Install dependencies 23 | pnpm install 24 | 25 | # Run this once, to run codegen from the lexicons 26 | pnpm build:lexicon 27 | 28 | pnpm dev 29 | ``` 30 | 31 | ### Additional Commands 32 | 33 | ```bash 34 | # Build commands 35 | pnpm build # Build in correct order: lexicon → client → appview 36 | pnpm build:lexicon # Build only the lexicon package (type definitions) 37 | pnpm build:client # Build only the frontend 38 | pnpm build:appview # Build only the backend 39 | 40 | # Start commands 41 | pnpm start # Start the server (serves API and frontend) 42 | pnpm start:client # Start frontend development server only 43 | pnpm start:dev # Start both backend and frontend separately (development only) 44 | 45 | # Other utilities 46 | pnpm typecheck # Run type checking 47 | pnpm format # Format all code 48 | ``` 49 | 50 | ## Deployment 51 | 52 | For production deployment: 53 | 54 | 1. Build all packages in the correct order: 55 | 56 | ```bash 57 | pnpm build 58 | ``` 59 | 60 | This will: 61 | 62 | - Build the lexicon package first (shared type definitions) 63 | - Build the frontend (`packages/client`) next 64 | - Finally build the backend (`packages/appview`) 65 | 66 | 2. Start the server: 67 | ```bash 68 | pnpm start 69 | ``` 70 | 71 | The backend server will: 72 | 73 | - Serve the API at `/xrpc/*` and `/oauth/*` endpoints 74 | - Serve the frontend static files from the client's build directory 75 | - Handle client-side routing by serving index.html for all non-API routes 76 | 77 | This simplifies deployment to a single process that handles both the API and serves the frontend assets. 78 | 79 | ## Environment Variables 80 | 81 | Copy the `.env.template` file in the appview to `.env`: 82 | 83 | ``` 84 | cd packages/appview 85 | cp .env.template .env 86 | ``` 87 | 88 | ## Requirements 89 | 90 | - Node.js 18+ 91 | - pnpm 9+ 92 | 93 | ## License 94 | 95 | MIT 96 | -------------------------------------------------------------------------------- /packages/appview/src/api/lexicons/sendStatus.ts: -------------------------------------------------------------------------------- 1 | import { TID } from '@atproto/common' 2 | import { 3 | AuthRequiredError, 4 | InvalidRequestError, 5 | UpstreamFailureError, 6 | } from '@atproto/xrpc-server' 7 | import { XyzStatusphereStatus } from '@statusphere/lexicon' 8 | 9 | import { AppContext } from '#/context' 10 | import { Server } from '#/lexicons' 11 | import { statusToStatusView } from '#/lib/hydrate' 12 | import { getSessionAgent } from '#/session' 13 | 14 | export default function (server: Server, ctx: AppContext) { 15 | server.xyz.statusphere.sendStatus({ 16 | handler: async ({ input, req, res }) => { 17 | const agent = await getSessionAgent(req, res, ctx) 18 | if (!agent) { 19 | throw new AuthRequiredError('Authentication required') 20 | } 21 | 22 | // Construct & validate their status record 23 | const rkey = TID.nextStr() 24 | const record = { 25 | $type: 'xyz.statusphere.status', 26 | status: input.body.status, 27 | createdAt: new Date().toISOString(), 28 | } 29 | 30 | const validation = XyzStatusphereStatus.validateRecord(record) 31 | if (!validation.success) { 32 | throw new InvalidRequestError('Invalid status') 33 | } 34 | 35 | let uri 36 | try { 37 | // Write the status record to the user's repository 38 | const response = await agent.com.atproto.repo.putRecord({ 39 | repo: agent.assertDid, 40 | collection: 'xyz.statusphere.status', 41 | rkey, 42 | record: validation.value, 43 | validate: false, 44 | }) 45 | uri = response.data.uri 46 | } catch (err) { 47 | throw new UpstreamFailureError('Failed to write record') 48 | } 49 | 50 | const optimisticStatus = { 51 | uri, 52 | authorDid: agent.assertDid, 53 | status: record.status, 54 | createdAt: record.createdAt, 55 | indexedAt: new Date().toISOString(), 56 | } 57 | 58 | try { 59 | // Optimistically update our SQLite 60 | // This isn't strictly necessary because the write event will be 61 | // handled in #/firehose/ingestor.ts, but it ensures that future reads 62 | // will be up-to-date after this method finishes. 63 | await ctx.db.insertInto('status').values(optimisticStatus).execute() 64 | } catch (err) { 65 | ctx.logger.warn( 66 | { err }, 67 | 'failed to update computed view; ignoring as it should be caught by the firehose', 68 | ) 69 | } 70 | 71 | return { 72 | encoding: 'application/json', 73 | body: { 74 | status: await statusToStatusView(optimisticStatus, ctx), 75 | }, 76 | } 77 | }, 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /lexicons/com/atproto/repo/putRecord.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.putRecord", 4 | "defs": { 5 | "main": { 6 | "type": "procedure", 7 | "description": "Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.", 8 | "input": { 9 | "encoding": "application/json", 10 | "schema": { 11 | "type": "object", 12 | "required": ["repo", "collection", "rkey", "record"], 13 | "nullable": ["swapRecord"], 14 | "properties": { 15 | "repo": { 16 | "type": "string", 17 | "format": "at-identifier", 18 | "description": "The handle or DID of the repo (aka, current account)." 19 | }, 20 | "collection": { 21 | "type": "string", 22 | "format": "nsid", 23 | "description": "The NSID of the record collection." 24 | }, 25 | "rkey": { 26 | "type": "string", 27 | "format": "record-key", 28 | "description": "The Record Key.", 29 | "maxLength": 512 30 | }, 31 | "validate": { 32 | "type": "boolean", 33 | "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." 34 | }, 35 | "record": { 36 | "type": "unknown", 37 | "description": "The record to write." 38 | }, 39 | "swapRecord": { 40 | "type": "string", 41 | "format": "cid", 42 | "description": "Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation" 43 | }, 44 | "swapCommit": { 45 | "type": "string", 46 | "format": "cid", 47 | "description": "Compare and swap with the previous commit by CID." 48 | } 49 | } 50 | } 51 | }, 52 | "output": { 53 | "encoding": "application/json", 54 | "schema": { 55 | "type": "object", 56 | "required": ["uri", "cid"], 57 | "properties": { 58 | "uri": { "type": "string", "format": "at-uri" }, 59 | "cid": { "type": "string", "format": "cid" }, 60 | "commit": { 61 | "type": "ref", 62 | "ref": "com.atproto.repo.defs#commitMeta" 63 | }, 64 | "validationStatus": { 65 | "type": "string", 66 | "knownValues": ["valid", "unknown"] 67 | } 68 | } 69 | } 70 | }, 71 | "errors": [{ "name": "InvalidSwap" }] 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/appview/src/ingestors/firehose.ts: -------------------------------------------------------------------------------- 1 | import { IdResolver } from '@atproto/identity' 2 | import { Firehose, MemoryRunner, type Event } from '@atproto/sync' 3 | import { XyzStatusphereStatus } from '@statusphere/lexicon' 4 | import pino from 'pino' 5 | 6 | import type { Database } from '#/db' 7 | 8 | export async function createFirehoseIngester( 9 | db: Database, 10 | idResolver: IdResolver, 11 | ) { 12 | const logger = pino({ name: 'firehose ingestion' }) 13 | 14 | const cursor = await db 15 | .selectFrom('cursor') 16 | .where('id', '=', 1) 17 | .select('seq') 18 | .executeTakeFirst() 19 | 20 | logger.info(`start cursor: ${cursor?.seq}`) 21 | 22 | // For throttling cursor writes 23 | let lastCursorWrite = 0 24 | 25 | const runner = new MemoryRunner({ 26 | startCursor: cursor?.seq || undefined, 27 | setCursor: async (seq) => { 28 | const now = Date.now() 29 | 30 | if (now - lastCursorWrite >= 10000) { 31 | lastCursorWrite = now 32 | await db 33 | .updateTable('cursor') 34 | .set({ seq }) 35 | .where('id', '=', 1) 36 | .execute() 37 | } 38 | }, 39 | }) 40 | 41 | return new Firehose({ 42 | idResolver, 43 | runner, 44 | handleEvent: async (evt: Event) => { 45 | // Watch for write events 46 | if (evt.event === 'create' || evt.event === 'update') { 47 | const now = new Date() 48 | const record = evt.record 49 | 50 | // If the write is a valid status update 51 | if ( 52 | evt.collection === 'xyz.statusphere.status' && 53 | XyzStatusphereStatus.isRecord(record) 54 | ) { 55 | const validatedRecord = XyzStatusphereStatus.validateRecord(record) 56 | if (!validatedRecord.success) return 57 | // Store the status in our SQLite 58 | await db 59 | .insertInto('status') 60 | .values({ 61 | uri: evt.uri.toString(), 62 | authorDid: evt.did, 63 | status: validatedRecord.value.status, 64 | createdAt: validatedRecord.value.createdAt, 65 | indexedAt: now.toISOString(), 66 | }) 67 | .onConflict((oc) => 68 | oc.column('uri').doUpdateSet({ 69 | status: validatedRecord.value.status, 70 | indexedAt: now.toISOString(), 71 | }), 72 | ) 73 | .execute() 74 | } 75 | } else if ( 76 | evt.event === 'delete' && 77 | evt.collection === 'xyz.statusphere.status' 78 | ) { 79 | // Remove the status from our SQLite 80 | await db 81 | .deleteFrom('status') 82 | .where('uri', '=', evt.uri.toString()) 83 | .execute() 84 | } 85 | }, 86 | onError: (err: Error) => { 87 | logger.error({ err }, 'error on firehose ingestion') 88 | }, 89 | filterCollections: ['xyz.statusphere.status'], 90 | excludeIdentity: true, 91 | excludeAccount: true, 92 | }) 93 | } 94 | -------------------------------------------------------------------------------- /packages/appview/src/api/oauth.ts: -------------------------------------------------------------------------------- 1 | import { OAuthResolverError } from '@atproto/oauth-client-node' 2 | import { isValidHandle } from '@atproto/syntax' 3 | import express from 'express' 4 | 5 | import { AppContext } from '#/context' 6 | import { getSession } from '#/session' 7 | 8 | export const createRouter = (ctx: AppContext) => { 9 | const router = express.Router() 10 | 11 | // OAuth metadata 12 | router.get('/oauth-client-metadata.json', (_req, res) => { 13 | res.json(ctx.oauthClient.clientMetadata) 14 | }) 15 | 16 | // OAuth callback to complete session creation 17 | router.get('/oauth/callback', async (req, res) => { 18 | // Get the query parameters from the URL 19 | const params = new URLSearchParams(req.originalUrl.split('?')[1]) 20 | 21 | try { 22 | const { session } = await ctx.oauthClient.callback(params) 23 | 24 | // Use the common session options 25 | const clientSession = await getSession(req, res) 26 | 27 | // Set the DID on the session 28 | clientSession.did = session.did 29 | await clientSession.save() 30 | 31 | // Get the origin and determine appropriate redirect 32 | const host = req.get('host') || '' 33 | const protocol = req.protocol || 'http' 34 | const baseUrl = `${protocol}://${host}` 35 | 36 | ctx.logger.info( 37 | `OAuth callback successful, redirecting to ${baseUrl}/oauth-callback`, 38 | ) 39 | 40 | // Redirect to the frontend oauth-callback page 41 | res.redirect('/oauth-callback') 42 | } catch (err) { 43 | ctx.logger.error({ err }, 'oauth callback failed') 44 | 45 | // Handle error redirect - stay on same domain 46 | res.redirect('/oauth-callback?error=auth') 47 | } 48 | }) 49 | 50 | // Login handler 51 | router.post('/oauth/initiate', async (req, res) => { 52 | // Validate 53 | const handle = req.body?.handle 54 | if ( 55 | typeof handle !== 'string' || 56 | !(isValidHandle(handle) || isValidUrl(handle)) 57 | ) { 58 | res.status(400).json({ error: 'Invalid handle' }) 59 | return 60 | } 61 | 62 | // Initiate the OAuth flow 63 | try { 64 | const url = await ctx.oauthClient.authorize(handle, { 65 | scope: 'atproto transition:generic', 66 | }) 67 | res.json({ redirectUrl: url.toString() }) 68 | } catch (err) { 69 | ctx.logger.error({ err }, 'oauth authorize failed') 70 | const errorMsg = 71 | err instanceof OAuthResolverError 72 | ? err.message 73 | : "Couldn't initiate login" 74 | res.status(500).json({ error: errorMsg }) 75 | } 76 | }) 77 | 78 | // Logout handler 79 | router.post('/oauth/logout', async (req, res) => { 80 | const session = await getSession(req, res) 81 | session.destroy() 82 | res.json({ success: true }) 83 | }) 84 | 85 | return router 86 | } 87 | 88 | function isValidUrl(url: string): boolean { 89 | try { 90 | const urlp = new URL(url) 91 | // http or https 92 | return urlp.protocol === 'http:' || urlp.protocol === 'https:' 93 | } catch (error) { 94 | return false 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /packages/appview/src/db.ts: -------------------------------------------------------------------------------- 1 | import SqliteDb from 'better-sqlite3' 2 | import { 3 | Kysely, 4 | Migration, 5 | MigrationProvider, 6 | Migrator, 7 | SqliteDialect, 8 | } from 'kysely' 9 | 10 | // Types 11 | 12 | export type DatabaseSchema = { 13 | status: Status 14 | auth_session: AuthSession 15 | auth_state: AuthState 16 | cursor: Cursor 17 | } 18 | 19 | export type Status = { 20 | uri: string 21 | authorDid: string 22 | status: string 23 | createdAt: string 24 | indexedAt: string 25 | } 26 | 27 | export type AuthSession = { 28 | key: string 29 | session: AuthSessionJson 30 | } 31 | 32 | export type AuthState = { 33 | key: string 34 | state: AuthStateJson 35 | } 36 | 37 | export type Cursor = { 38 | id: number 39 | seq: number 40 | } 41 | 42 | type AuthStateJson = string 43 | 44 | type AuthSessionJson = string 45 | 46 | // Migrations 47 | 48 | const migrations: Record = {} 49 | 50 | const migrationProvider: MigrationProvider = { 51 | async getMigrations() { 52 | return migrations 53 | }, 54 | } 55 | 56 | migrations['003'] = { 57 | async up(db: Kysely) {}, 58 | async down(_db: Kysely) {}, 59 | } 60 | 61 | migrations['002'] = { 62 | async up(db: Kysely) { 63 | await db.schema 64 | .createTable('cursor') 65 | .addColumn('id', 'integer', (col) => col.primaryKey()) 66 | .addColumn('seq', 'integer', (col) => col.notNull()) 67 | .execute() 68 | 69 | // Insert initial cursor values: 70 | // id=1 is for firehose, id=2 is for jetstream 71 | await db 72 | .insertInto('cursor' as never) 73 | .values([ 74 | { id: 1, seq: 0 }, 75 | { id: 2, seq: 0 }, 76 | ]) 77 | .execute() 78 | }, 79 | async down(db: Kysely) { 80 | await db.schema.dropTable('cursor').execute() 81 | }, 82 | } 83 | 84 | migrations['001'] = { 85 | async up(db: Kysely) { 86 | await db.schema 87 | .createTable('status') 88 | .addColumn('uri', 'varchar', (col) => col.primaryKey()) 89 | .addColumn('authorDid', 'varchar', (col) => col.notNull()) 90 | .addColumn('status', 'varchar', (col) => col.notNull()) 91 | .addColumn('createdAt', 'varchar', (col) => col.notNull()) 92 | .addColumn('indexedAt', 'varchar', (col) => col.notNull()) 93 | .execute() 94 | await db.schema 95 | .createTable('auth_session') 96 | .addColumn('key', 'varchar', (col) => col.primaryKey()) 97 | .addColumn('session', 'varchar', (col) => col.notNull()) 98 | .execute() 99 | await db.schema 100 | .createTable('auth_state') 101 | .addColumn('key', 'varchar', (col) => col.primaryKey()) 102 | .addColumn('state', 'varchar', (col) => col.notNull()) 103 | .execute() 104 | }, 105 | async down(db: Kysely) { 106 | await db.schema.dropTable('auth_state').execute() 107 | await db.schema.dropTable('auth_session').execute() 108 | await db.schema.dropTable('status').execute() 109 | }, 110 | } 111 | 112 | // APIs 113 | 114 | export const createDb = (location: string): Database => { 115 | return new Kysely({ 116 | dialect: new SqliteDialect({ 117 | database: new SqliteDb(location), 118 | }), 119 | }) 120 | } 121 | 122 | export const migrateToLatest = async (db: Database) => { 123 | const migrator = new Migrator({ db, provider: migrationProvider }) 124 | const { error } = await migrator.migrateToLatest() 125 | if (error) throw error 126 | } 127 | 128 | export type Database = Kysely 129 | -------------------------------------------------------------------------------- /packages/client/src/pages/OAuthCallbackPage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { useNavigate } from 'react-router-dom' 3 | 4 | import { api } from '../services/api' 5 | 6 | const OAuthCallbackPage = () => { 7 | const [error, setError] = useState(null) 8 | const [message, setMessage] = useState('Completing authentication...') 9 | const navigate = useNavigate() 10 | 11 | useEffect(() => { 12 | console.log('OAuth callback page reached') 13 | setMessage('OAuth callback page reached. Checking authentication...') 14 | 15 | const checkAuth = async () => { 16 | try { 17 | // Check if there's an error in the URL 18 | const params = new URLSearchParams(window.location.search) 19 | if (params.get('error')) { 20 | console.error('Auth error detected in URL params') 21 | setError('Authentication failed') 22 | return 23 | } 24 | 25 | // Give cookies a moment to be processed 26 | await new Promise((resolve) => setTimeout(resolve, 500)) 27 | setMessage("Checking if we're authenticated...") 28 | 29 | // Check if we're authenticated by fetching current user 30 | try { 31 | console.log('Checking current user') 32 | console.log( 33 | 'Cookies being sent:', 34 | document.cookie 35 | .split(';') 36 | .map((c) => c.trim()) 37 | .join(', '), 38 | ) 39 | 40 | const user = await api.getCurrentUser({}) 41 | console.log('Current user check result:', user) 42 | 43 | if (user) { 44 | console.log('Successfully authenticated', user) 45 | setMessage('Authentication successful! Redirecting...') 46 | // Redirect to home after a short delay 47 | setTimeout(() => { 48 | navigate('/') 49 | }, 1000) 50 | } else { 51 | console.error('Auth check returned no user') 52 | setError('Authentication session not found') 53 | } 54 | } catch (apiErr) { 55 | console.error('API error during auth check:', apiErr) 56 | setError('Failed to verify authentication') 57 | } 58 | } catch (err) { 59 | console.error('General error in OAuth callback:', err) 60 | setError('Failed to complete authentication') 61 | } 62 | } 63 | 64 | checkAuth() 65 | }, [navigate]) 66 | 67 | return ( 68 |
69 |
70 | {error ? ( 71 |
72 |

73 | Authentication Failed 74 |

75 |

{error}

76 | 82 |
83 | ) : ( 84 |
85 |

86 | Authentication in Progress 87 |

88 |
89 |
90 |
91 |

{message}

92 |
93 | )} 94 |
95 |
96 | ) 97 | } 98 | 99 | export default OAuthCallbackPage 100 | -------------------------------------------------------------------------------- /packages/client/src/hooks/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, ReactNode, useContext, useState } from 'react' 2 | import { useQuery, useQueryClient } from '@tanstack/react-query' 3 | import { XRPCError } from '@atproto/xrpc' 4 | import { XyzStatusphereGetUser } from '@statusphere/lexicon' 5 | 6 | import api from '#/services/api' 7 | 8 | interface AuthContextType { 9 | user: XyzStatusphereGetUser.OutputSchema | null 10 | loading: boolean 11 | error: string | null 12 | login: (handle: string) => Promise<{ redirectUrl: string }> 13 | logout: () => Promise 14 | } 15 | 16 | const AuthContext = createContext(undefined) 17 | 18 | export function AuthProvider({ children }: { children: ReactNode }) { 19 | const [error, setError] = useState(null) 20 | const queryClient = useQueryClient() 21 | 22 | // Use React Query to fetch and manage user data 23 | const { 24 | data: user, 25 | isLoading: loading, 26 | error: queryError, 27 | } = useQuery({ 28 | queryKey: ['currentUser'], 29 | queryFn: async () => { 30 | // Check for error parameter in URL (from OAuth redirect) 31 | const urlParams = new URLSearchParams(window.location.search) 32 | const errorParam = urlParams.get('error') 33 | 34 | if (errorParam) { 35 | setError('Authentication failed. Please try again.') 36 | 37 | // Remove the error parameter from the URL 38 | const newUrl = window.location.pathname 39 | window.history.replaceState({}, document.title, newUrl) 40 | return null 41 | } 42 | 43 | try { 44 | const { data: userData } = await api.getCurrentUser({}) 45 | 46 | // Clean up URL if needed 47 | if (window.location.search && userData) { 48 | window.history.replaceState( 49 | {}, 50 | document.title, 51 | window.location.pathname, 52 | ) 53 | } 54 | 55 | return userData 56 | } catch (apiErr) { 57 | if ( 58 | apiErr instanceof XRPCError && 59 | apiErr.error === 'AuthenticationRequired' 60 | ) { 61 | return null 62 | } 63 | 64 | console.error('🚫 API error during auth check:', apiErr) 65 | 66 | // If it's a network error, provide a more helpful message 67 | if ( 68 | apiErr instanceof TypeError && 69 | apiErr.message.includes('Failed to fetch') 70 | ) { 71 | throw new Error( 72 | 'Cannot connect to API server. Please check your network connection or server status.', 73 | ) 74 | } 75 | 76 | throw apiErr 77 | } 78 | }, 79 | retry: false, 80 | staleTime: 5 * 60 * 1000, // 5 minutes 81 | }) 82 | 83 | const login = async (handle: string) => { 84 | setError(null) 85 | 86 | try { 87 | return await api.login(handle) 88 | } catch (err) { 89 | const message = err instanceof Error ? err.message : 'Login failed' 90 | setError(message) 91 | throw err 92 | } 93 | } 94 | 95 | const logout = async () => { 96 | try { 97 | await api.logout() 98 | // Reset the user data in React Query cache 99 | queryClient.setQueryData(['currentUser'], null) 100 | // Invalidate any user-dependent queries 101 | queryClient.invalidateQueries({ queryKey: ['statuses'] }) 102 | } catch (err) { 103 | const message = err instanceof Error ? err.message : 'Logout failed' 104 | setError(message) 105 | throw err 106 | } 107 | } 108 | 109 | // Combine state error with query error 110 | const combinedError = 111 | error || (queryError instanceof Error ? queryError.message : null) 112 | 113 | return ( 114 | 123 | {children} 124 | 125 | ) 126 | } 127 | 128 | export function useAuth() { 129 | const context = useContext(AuthContext) 130 | 131 | if (context === undefined) { 132 | throw new Error('useAuth must be used within an AuthProvider') 133 | } 134 | 135 | return context 136 | } 137 | 138 | export default useAuth 139 | -------------------------------------------------------------------------------- /packages/client/src/components/StatusList.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useQuery } from '@tanstack/react-query' 3 | 4 | import api from '#/services/api' 5 | import { STATUS_OPTIONS } from './StatusForm' 6 | 7 | const StatusList = () => { 8 | // Use React Query to fetch and cache statuses 9 | const { data, isPending, isError, error } = useQuery({ 10 | queryKey: ['statuses'], 11 | queryFn: async () => { 12 | const { data } = await api.getStatuses({ limit: 30 }) 13 | return data 14 | }, 15 | placeholderData: (previousData) => previousData, // Use previous data while refetching 16 | refetchInterval: 30e3, // Refetch every 30 seconds 17 | }) 18 | 19 | useEffect(() => { 20 | if (error) { 21 | console.error(error) 22 | } 23 | }, [error]) 24 | 25 | // Destructure data 26 | const statuses = data?.statuses || [] 27 | 28 | // Get a random emoji from the STATUS_OPTIONS array 29 | const randomEmoji = 30 | STATUS_OPTIONS[Math.floor(Math.random() * STATUS_OPTIONS.length)] 31 | 32 | if (isPending && !data) { 33 | return ( 34 |
35 |
36 | {randomEmoji} 37 |
38 |
39 | Loading statuses... 40 |
41 |
42 | ) 43 | } 44 | 45 | if (isError) { 46 | return ( 47 |
48 | {(error as Error)?.message || 'Failed to load statuses'} 49 |
50 | ) 51 | } 52 | 53 | if (statuses.length === 0) { 54 | return ( 55 |
56 | No statuses yet. 57 |
58 | ) 59 | } 60 | 61 | // Helper to format dates 62 | const formatDate = (dateString: string) => { 63 | const date = new Date(dateString) 64 | const today = new Date() 65 | const isToday = 66 | date.getDate() === today.getDate() && 67 | date.getMonth() === today.getMonth() && 68 | date.getFullYear() === today.getFullYear() 69 | 70 | if (isToday) { 71 | return 'today' 72 | } else { 73 | return date.toLocaleDateString(undefined, { 74 | year: 'numeric', 75 | month: 'long', 76 | day: 'numeric', 77 | }) 78 | } 79 | } 80 | 81 | return ( 82 |
83 |
84 |
85 | {statuses.map((status) => { 86 | const handle = 87 | status.profile.handle || status.profile.did.substring(0, 15) + '...' 88 | const formattedDate = formatDate(status.createdAt) 89 | const isToday = formattedDate === 'today' 90 | 91 | return ( 92 |
96 |
97 |
{status.status}
98 |
99 |
100 |
101 | 107 | @{handle} 108 | {' '} 109 | {isToday ? ( 110 | 111 | is feeling{' '} 112 | {status.status}{' '} 113 | today 114 | 115 | ) : ( 116 | 117 | was feeling{' '} 118 | {status.status} on{' '} 119 | {formattedDate} 120 | 121 | )} 122 |
123 |
124 |
125 | ) 126 | })} 127 |
128 |
129 | ) 130 | } 131 | 132 | export default StatusList 133 | -------------------------------------------------------------------------------- /lexicons/com/atproto/repo/applyWrites.json: -------------------------------------------------------------------------------- 1 | { 2 | "lexicon": 1, 3 | "id": "com.atproto.repo.applyWrites", 4 | "defs": { 5 | "main": { 6 | "type": "procedure", 7 | "description": "Apply a batch transaction of repository creates, updates, and deletes. Requires auth, implemented by PDS.", 8 | "input": { 9 | "encoding": "application/json", 10 | "schema": { 11 | "type": "object", 12 | "required": ["repo", "writes"], 13 | "properties": { 14 | "repo": { 15 | "type": "string", 16 | "format": "at-identifier", 17 | "description": "The handle or DID of the repo (aka, current account)." 18 | }, 19 | "validate": { 20 | "type": "boolean", 21 | "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." 22 | }, 23 | "writes": { 24 | "type": "array", 25 | "items": { 26 | "type": "union", 27 | "refs": ["#create", "#update", "#delete"], 28 | "closed": true 29 | } 30 | }, 31 | "swapCommit": { 32 | "type": "string", 33 | "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.", 34 | "format": "cid" 35 | } 36 | } 37 | } 38 | }, 39 | "output": { 40 | "encoding": "application/json", 41 | "schema": { 42 | "type": "object", 43 | "required": [], 44 | "properties": { 45 | "commit": { 46 | "type": "ref", 47 | "ref": "com.atproto.repo.defs#commitMeta" 48 | }, 49 | "results": { 50 | "type": "array", 51 | "items": { 52 | "type": "union", 53 | "refs": ["#createResult", "#updateResult", "#deleteResult"], 54 | "closed": true 55 | } 56 | } 57 | } 58 | } 59 | }, 60 | "errors": [ 61 | { 62 | "name": "InvalidSwap", 63 | "description": "Indicates that the 'swapCommit' parameter did not match current commit." 64 | } 65 | ] 66 | }, 67 | "create": { 68 | "type": "object", 69 | "description": "Operation which creates a new record.", 70 | "required": ["collection", "value"], 71 | "properties": { 72 | "collection": { "type": "string", "format": "nsid" }, 73 | "rkey": { 74 | "type": "string", 75 | "maxLength": 512, 76 | "format": "record-key", 77 | "description": "NOTE: maxLength is redundant with record-key format. Keeping it temporarily to ensure backwards compatibility." 78 | }, 79 | "value": { "type": "unknown" } 80 | } 81 | }, 82 | "update": { 83 | "type": "object", 84 | "description": "Operation which updates an existing record.", 85 | "required": ["collection", "rkey", "value"], 86 | "properties": { 87 | "collection": { "type": "string", "format": "nsid" }, 88 | "rkey": { "type": "string", "format": "record-key" }, 89 | "value": { "type": "unknown" } 90 | } 91 | }, 92 | "delete": { 93 | "type": "object", 94 | "description": "Operation which deletes an existing record.", 95 | "required": ["collection", "rkey"], 96 | "properties": { 97 | "collection": { "type": "string", "format": "nsid" }, 98 | "rkey": { "type": "string", "format": "record-key" } 99 | } 100 | }, 101 | "createResult": { 102 | "type": "object", 103 | "required": ["uri", "cid"], 104 | "properties": { 105 | "uri": { "type": "string", "format": "at-uri" }, 106 | "cid": { "type": "string", "format": "cid" }, 107 | "validationStatus": { 108 | "type": "string", 109 | "knownValues": ["valid", "unknown"] 110 | } 111 | } 112 | }, 113 | "updateResult": { 114 | "type": "object", 115 | "required": ["uri", "cid"], 116 | "properties": { 117 | "uri": { "type": "string", "format": "at-uri" }, 118 | "cid": { "type": "string", "format": "cid" }, 119 | "validationStatus": { 120 | "type": "string", 121 | "knownValues": ["valid", "unknown"] 122 | } 123 | } 124 | }, 125 | "deleteResult": { 126 | "type": "object", 127 | "required": [], 128 | "properties": {} 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /packages/appview/src/index.ts: -------------------------------------------------------------------------------- 1 | import events from 'node:events' 2 | import fs from 'node:fs' 3 | import type http from 'node:http' 4 | import path from 'node:path' 5 | import { DAY, SECOND } from '@atproto/common' 6 | import compression from 'compression' 7 | import cors from 'cors' 8 | import express from 'express' 9 | import { pino } from 'pino' 10 | 11 | import API, { health, oauth } from '#/api' 12 | import { createClient } from '#/auth/client' 13 | import { AppContext } from '#/context' 14 | import { createDb, migrateToLatest } from '#/db' 15 | import * as error from '#/error' 16 | import { createBidirectionalResolver, createIdResolver } from '#/id-resolver' 17 | import { createFirehoseIngester, createJetstreamIngester } from '#/ingestors' 18 | import { createServer } from '#/lexicons' 19 | import { env } from '#/lib/env' 20 | 21 | export class Server { 22 | constructor( 23 | public app: express.Application, 24 | public server: http.Server, 25 | public ctx: AppContext, 26 | ) {} 27 | 28 | static async create() { 29 | const { NODE_ENV, HOST, PORT, DB_PATH } = env 30 | const logger = pino({ name: 'server start' }) 31 | 32 | // Set up the SQLite database 33 | const db = createDb(DB_PATH) 34 | await migrateToLatest(db) 35 | 36 | // Create the atproto utilities 37 | const oauthClient = await createClient(db) 38 | const baseIdResolver = createIdResolver() 39 | const ingester = await createJetstreamIngester(db) 40 | // Alternative: const ingester = await createFirehoseIngester(db, baseIdResolver) 41 | const resolver = createBidirectionalResolver(baseIdResolver) 42 | const ctx = { 43 | db, 44 | ingester, 45 | logger, 46 | oauthClient, 47 | resolver, 48 | } 49 | 50 | // Subscribe to events on the firehose 51 | ingester.start() 52 | 53 | const app = express() 54 | app.use(cors({ maxAge: DAY / SECOND })) 55 | app.use(compression()) 56 | app.use(express.json()) 57 | app.use(express.urlencoded({ extended: true })) 58 | 59 | // Create our server 60 | let server = createServer({ 61 | validateResponse: env.isDevelopment, 62 | payload: { 63 | jsonLimit: 100 * 1024, // 100kb 64 | textLimit: 100 * 1024, // 100kb 65 | // no blobs 66 | blobLimit: 0, 67 | }, 68 | }) 69 | 70 | server = API(server, ctx) 71 | 72 | app.use(health.createRouter(ctx)) 73 | app.use(oauth.createRouter(ctx)) 74 | app.use(server.xrpc.router) 75 | app.use(error.createHandler(ctx)) 76 | 77 | // Serve static files from the frontend build - prod only 78 | if (env.isProduction) { 79 | const frontendPath = path.resolve( 80 | __dirname, 81 | '../../../packages/client/dist', 82 | ) 83 | 84 | // Check if the frontend build exists 85 | if (fs.existsSync(frontendPath)) { 86 | logger.info(`Serving frontend static files from: ${frontendPath}`) 87 | 88 | // Serve static files 89 | app.use(express.static(frontendPath)) 90 | 91 | // For any other requests, send the index.html file 92 | app.get('*', (req, res) => { 93 | // Only handle non-API paths 94 | if (!req.path.startsWith('/xrpc/')) { 95 | res.sendFile(path.join(frontendPath, 'index.html')) 96 | } else { 97 | res.status(404).json({ error: 'API endpoint not found' }) 98 | } 99 | }) 100 | } else { 101 | logger.warn(`Frontend build not found at: ${frontendPath}`) 102 | app.use('*', (_req, res) => { 103 | res.sendStatus(404) 104 | }) 105 | } 106 | } else { 107 | app.set('trust proxy', true) 108 | } 109 | 110 | // Use the port from env (should be 3001 for the API server) 111 | const httpServer = app.listen(env.PORT) 112 | await events.once(httpServer, 'listening') 113 | logger.info( 114 | `API Server (${NODE_ENV}) running on port http://${HOST}:${env.PORT}`, 115 | ) 116 | 117 | return new Server(app, httpServer, ctx) 118 | } 119 | 120 | async close() { 121 | this.ctx.logger.info('sigint received, shutting down') 122 | await this.ctx.ingester.destroy() 123 | await new Promise((resolve) => { 124 | this.server.close(() => { 125 | this.ctx.logger.info('server closed') 126 | resolve() 127 | }) 128 | }) 129 | } 130 | } 131 | 132 | const run = async () => { 133 | const server = await Server.create() 134 | 135 | const onCloseSignal = async () => { 136 | setTimeout(() => process.exit(1), 10000).unref() // Force shutdown after 10s 137 | await server.close() 138 | process.exit(0) 139 | } 140 | 141 | process.on('SIGINT', onCloseSignal) 142 | process.on('SIGTERM', onCloseSignal) 143 | } 144 | 145 | run() 146 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/com/atproto/repo/applyWrites.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HeadersMap, XRPCError } from '@atproto/xrpc' 6 | import { CID } from 'multiformats/cid' 7 | 8 | import { validate as _validate } from '../../../../lexicons' 9 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 10 | import type * as ComAtprotoRepoDefs from './defs.js' 11 | 12 | const is$typed = _is$typed, 13 | validate = _validate 14 | const id = 'com.atproto.repo.applyWrites' 15 | 16 | export interface QueryParams {} 17 | 18 | export interface InputSchema { 19 | /** The handle or DID of the repo (aka, current account). */ 20 | repo: string 21 | /** 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. */ 22 | validate?: boolean 23 | writes: ($Typed | $Typed | $Typed)[] 24 | /** If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations. */ 25 | swapCommit?: string 26 | } 27 | 28 | export interface OutputSchema { 29 | commit?: ComAtprotoRepoDefs.CommitMeta 30 | results?: ( 31 | | $Typed 32 | | $Typed 33 | | $Typed 34 | )[] 35 | } 36 | 37 | export interface CallOptions { 38 | signal?: AbortSignal 39 | headers?: HeadersMap 40 | qp?: QueryParams 41 | encoding?: 'application/json' 42 | } 43 | 44 | export interface Response { 45 | success: boolean 46 | headers: HeadersMap 47 | data: OutputSchema 48 | } 49 | 50 | export class InvalidSwapError extends XRPCError { 51 | constructor(src: XRPCError) { 52 | super(src.status, src.error, src.message, src.headers, { cause: src }) 53 | } 54 | } 55 | 56 | export function toKnownErr(e: any) { 57 | if (e instanceof XRPCError) { 58 | if (e.error === 'InvalidSwap') return new InvalidSwapError(e) 59 | } 60 | 61 | return e 62 | } 63 | 64 | /** Operation which creates a new record. */ 65 | export interface Create { 66 | $type?: 'com.atproto.repo.applyWrites#create' 67 | collection: string 68 | /** NOTE: maxLength is redundant with record-key format. Keeping it temporarily to ensure backwards compatibility. */ 69 | rkey?: string 70 | value: { [_ in string]: unknown } 71 | } 72 | 73 | const hashCreate = 'create' 74 | 75 | export function isCreate(v: V) { 76 | return is$typed(v, id, hashCreate) 77 | } 78 | 79 | export function validateCreate(v: V) { 80 | return validate(v, id, hashCreate) 81 | } 82 | 83 | /** Operation which updates an existing record. */ 84 | export interface Update { 85 | $type?: 'com.atproto.repo.applyWrites#update' 86 | collection: string 87 | rkey: string 88 | value: { [_ in string]: unknown } 89 | } 90 | 91 | const hashUpdate = 'update' 92 | 93 | export function isUpdate(v: V) { 94 | return is$typed(v, id, hashUpdate) 95 | } 96 | 97 | export function validateUpdate(v: V) { 98 | return validate(v, id, hashUpdate) 99 | } 100 | 101 | /** Operation which deletes an existing record. */ 102 | export interface Delete { 103 | $type?: 'com.atproto.repo.applyWrites#delete' 104 | collection: string 105 | rkey: string 106 | } 107 | 108 | const hashDelete = 'delete' 109 | 110 | export function isDelete(v: V) { 111 | return is$typed(v, id, hashDelete) 112 | } 113 | 114 | export function validateDelete(v: V) { 115 | return validate(v, id, hashDelete) 116 | } 117 | 118 | export interface CreateResult { 119 | $type?: 'com.atproto.repo.applyWrites#createResult' 120 | uri: string 121 | cid: string 122 | validationStatus?: 'valid' | 'unknown' | (string & {}) 123 | } 124 | 125 | const hashCreateResult = 'createResult' 126 | 127 | export function isCreateResult(v: V) { 128 | return is$typed(v, id, hashCreateResult) 129 | } 130 | 131 | export function validateCreateResult(v: V) { 132 | return validate(v, id, hashCreateResult) 133 | } 134 | 135 | export interface UpdateResult { 136 | $type?: 'com.atproto.repo.applyWrites#updateResult' 137 | uri: string 138 | cid: string 139 | validationStatus?: 'valid' | 'unknown' | (string & {}) 140 | } 141 | 142 | const hashUpdateResult = 'updateResult' 143 | 144 | export function isUpdateResult(v: V) { 145 | return is$typed(v, id, hashUpdateResult) 146 | } 147 | 148 | export function validateUpdateResult(v: V) { 149 | return validate(v, id, hashUpdateResult) 150 | } 151 | 152 | export interface DeleteResult { 153 | $type?: 'com.atproto.repo.applyWrites#deleteResult' 154 | } 155 | 156 | const hashDeleteResult = 'deleteResult' 157 | 158 | export function isDeleteResult(v: V) { 159 | return is$typed(v, id, hashDeleteResult) 160 | } 161 | 162 | export function validateDeleteResult(v: V) { 163 | return validate(v, id, hashDeleteResult) 164 | } 165 | -------------------------------------------------------------------------------- /packages/client/src/pages/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { useMutation } from '@tanstack/react-query' 4 | 5 | import Header from '#/components/Header' 6 | import { useAuth } from '#/hooks/useAuth' 7 | 8 | const LoginPage = () => { 9 | const [handle, setHandle] = useState('') 10 | const [error, setError] = useState(null) 11 | const { login } = useAuth() 12 | 13 | const mutation = useMutation({ 14 | mutationFn: async (handleValue: string) => { 15 | const result = await login(handleValue) 16 | 17 | // Add a small delay before redirecting for better UX 18 | await new Promise((resolve) => setTimeout(resolve, 500)) 19 | 20 | return result 21 | }, 22 | onSuccess: (data) => { 23 | // Redirect to ATProto OAuth flow 24 | window.location.href = data.redirectUrl 25 | }, 26 | onError: (err) => { 27 | const message = err instanceof Error ? err.message : 'Login failed' 28 | setError(message) 29 | }, 30 | }) 31 | 32 | const handleSubmit = (e: React.FormEvent) => { 33 | e.preventDefault() 34 | setError(null) 35 | 36 | if (!handle.trim()) { 37 | setError('Handle cannot be empty') 38 | return 39 | } 40 | 41 | mutation.mutate(handle) 42 | } 43 | 44 | // count success as also pending, since the browser should be redirecting 45 | const pending = mutation.isPending || mutation.isSuccess 46 | 47 | return ( 48 |
49 |
50 | 51 |
52 |

Login with ATProto

53 | 54 | {error && ( 55 |
56 | {error} 57 |
58 | )} 59 | 60 |
61 |
62 | 68 | setHandle(e.target.value)} 73 | placeholder="example.bsky.social" 74 | disabled={pending} 75 | className="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 transition-colors" 76 | /> 77 |

78 | You can also enter an AT Protocol PDS URL, i.e.{' '} 79 | https://bsky.social 80 |

81 |
82 | 83 | 117 |
118 | 119 |
120 | 124 | Cancel 125 | 126 |
127 |
128 |
129 | ) 130 | } 131 | 132 | export default LoginPage 133 | -------------------------------------------------------------------------------- /packages/appview/src/lexicons/types/com/atproto/repo/applyWrites.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 6 | import express from 'express' 7 | import { CID } from 'multiformats/cid' 8 | 9 | import { validate as _validate } from '../../../../lexicons' 10 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 11 | import type * as ComAtprotoRepoDefs from './defs.js' 12 | 13 | const is$typed = _is$typed, 14 | validate = _validate 15 | const id = 'com.atproto.repo.applyWrites' 16 | 17 | export interface QueryParams {} 18 | 19 | export interface InputSchema { 20 | /** The handle or DID of the repo (aka, current account). */ 21 | repo: string 22 | /** 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. */ 23 | validate?: boolean 24 | writes: ($Typed | $Typed | $Typed)[] 25 | /** If provided, the entire operation will fail if the current repo commit CID does not match this value. Used to prevent conflicting repo mutations. */ 26 | swapCommit?: string 27 | } 28 | 29 | export interface OutputSchema { 30 | commit?: ComAtprotoRepoDefs.CommitMeta 31 | results?: ( 32 | | $Typed 33 | | $Typed 34 | | $Typed 35 | )[] 36 | } 37 | 38 | export interface HandlerInput { 39 | encoding: 'application/json' 40 | body: InputSchema 41 | } 42 | 43 | export interface HandlerSuccess { 44 | encoding: 'application/json' 45 | body: OutputSchema 46 | headers?: { [key: string]: string } 47 | } 48 | 49 | export interface HandlerError { 50 | status: number 51 | message?: string 52 | error?: 'InvalidSwap' 53 | } 54 | 55 | export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 56 | export type HandlerReqCtx = { 57 | auth: HA 58 | params: QueryParams 59 | input: HandlerInput 60 | req: express.Request 61 | res: express.Response 62 | resetRouteRateLimits: () => Promise 63 | } 64 | export type Handler = ( 65 | ctx: HandlerReqCtx, 66 | ) => Promise | HandlerOutput 67 | 68 | /** Operation which creates a new record. */ 69 | export interface Create { 70 | $type?: 'com.atproto.repo.applyWrites#create' 71 | collection: string 72 | /** NOTE: maxLength is redundant with record-key format. Keeping it temporarily to ensure backwards compatibility. */ 73 | rkey?: string 74 | value: { [_ in string]: unknown } 75 | } 76 | 77 | const hashCreate = 'create' 78 | 79 | export function isCreate(v: V) { 80 | return is$typed(v, id, hashCreate) 81 | } 82 | 83 | export function validateCreate(v: V) { 84 | return validate(v, id, hashCreate) 85 | } 86 | 87 | /** Operation which updates an existing record. */ 88 | export interface Update { 89 | $type?: 'com.atproto.repo.applyWrites#update' 90 | collection: string 91 | rkey: string 92 | value: { [_ in string]: unknown } 93 | } 94 | 95 | const hashUpdate = 'update' 96 | 97 | export function isUpdate(v: V) { 98 | return is$typed(v, id, hashUpdate) 99 | } 100 | 101 | export function validateUpdate(v: V) { 102 | return validate(v, id, hashUpdate) 103 | } 104 | 105 | /** Operation which deletes an existing record. */ 106 | export interface Delete { 107 | $type?: 'com.atproto.repo.applyWrites#delete' 108 | collection: string 109 | rkey: string 110 | } 111 | 112 | const hashDelete = 'delete' 113 | 114 | export function isDelete(v: V) { 115 | return is$typed(v, id, hashDelete) 116 | } 117 | 118 | export function validateDelete(v: V) { 119 | return validate(v, id, hashDelete) 120 | } 121 | 122 | export interface CreateResult { 123 | $type?: 'com.atproto.repo.applyWrites#createResult' 124 | uri: string 125 | cid: string 126 | validationStatus?: 'valid' | 'unknown' | (string & {}) 127 | } 128 | 129 | const hashCreateResult = 'createResult' 130 | 131 | export function isCreateResult(v: V) { 132 | return is$typed(v, id, hashCreateResult) 133 | } 134 | 135 | export function validateCreateResult(v: V) { 136 | return validate(v, id, hashCreateResult) 137 | } 138 | 139 | export interface UpdateResult { 140 | $type?: 'com.atproto.repo.applyWrites#updateResult' 141 | uri: string 142 | cid: string 143 | validationStatus?: 'valid' | 'unknown' | (string & {}) 144 | } 145 | 146 | const hashUpdateResult = 'updateResult' 147 | 148 | export function isUpdateResult(v: V) { 149 | return is$typed(v, id, hashUpdateResult) 150 | } 151 | 152 | export function validateUpdateResult(v: V) { 153 | return validate(v, id, hashUpdateResult) 154 | } 155 | 156 | export interface DeleteResult { 157 | $type?: 'com.atproto.repo.applyWrites#deleteResult' 158 | } 159 | 160 | const hashDeleteResult = 'deleteResult' 161 | 162 | export function isDeleteResult(v: V) { 163 | return is$typed(v, id, hashDeleteResult) 164 | } 165 | 166 | export function validateDeleteResult(v: V) { 167 | return validate(v, id, hashDeleteResult) 168 | } 169 | -------------------------------------------------------------------------------- /packages/lexicon/src/types/com/atproto/label/defs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GENERATED CODE - DO NOT MODIFY 3 | */ 4 | import { BlobRef, ValidationResult } from '@atproto/lexicon' 5 | import { CID } from 'multiformats/cid' 6 | 7 | import { validate as _validate } from '../../../../lexicons' 8 | import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util' 9 | 10 | const is$typed = _is$typed, 11 | validate = _validate 12 | const id = 'com.atproto.label.defs' 13 | 14 | /** Metadata tag on an atproto resource (eg, repo or record). */ 15 | export interface Label { 16 | $type?: 'com.atproto.label.defs#label' 17 | /** The AT Protocol version of the label object. */ 18 | ver?: number 19 | /** DID of the actor who created this label. */ 20 | src: string 21 | /** AT URI of the record, repository (account), or other resource that this label applies to. */ 22 | uri: string 23 | /** Optionally, CID specifying the specific version of 'uri' resource this label applies to. */ 24 | cid?: string 25 | /** The short string name of the value or type of this label. */ 26 | val: string 27 | /** If true, this is a negation label, overwriting a previous label. */ 28 | neg?: boolean 29 | /** Timestamp when this label was created. */ 30 | cts: string 31 | /** Timestamp at which this label expires (no longer applies). */ 32 | exp?: string 33 | /** Signature of dag-cbor encoded label. */ 34 | sig?: Uint8Array 35 | } 36 | 37 | const hashLabel = 'label' 38 | 39 | export function isLabel(v: V) { 40 | return is$typed(v, id, hashLabel) 41 | } 42 | 43 | export function validateLabel(v: V) { 44 | return validate