···1-# Environment Configuration
2-NODE_ENV="development" # Options: 'development', 'production'
3-PORT="8080" # The port your server will listen on
4-HOST="localhost" # Hostname for the server
5-PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id.
6-DB_PATH=":memory:" # The SQLite database path. Leave as ":memory:" to use a temporary in-memory database.
7-8-# Secrets
9-# Must set this in production. May be generated with `openssl rand -base64 33`
10-# COOKIE_SECRET=""
···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
+53-11
README.md
···1-# AT Protocol "Statusphere" Example App
23-An example application covering:
0045- Signin via OAuth
6- Fetch information about users (profiles)
7- Listen to the network firehose for new data
8- Publish data on the user's account using a custom schema
910-See https://atproto.com/guides/applications for a guide through the codebase.
1112-## Getting Started
01314-```sh
15-git clone https://github.com/bluesky-social/statusphere-example-app.git
16-cd statusphere-example-app
17-cp .env.template .env
18-npm install
19-npm run dev
20-# Navigate to http://localhost:8080
000021```
00000000000000000000000000000000000
···1+# Statusphere React
23+A monorepo for the Statusphere application, which includes a React client and a Node.js backend.
4+5+This is a React refactoring of the [example application](https://atproto.com/guides/applications) covering:
67- Signin via OAuth
8- Fetch information about users (profiles)
9- Listen to the network firehose for new data
10- Publish data on the user's account using a custom schema
1112+## Structure
1314+- `packages/appview` - Express.js backend that serves API endpoints
15+- `packages/client` - React frontend using Vite
1617+## Development
18+19+```bash
20+# Install dependencies
21+pnpm install
22+23+# Option 1: Local development (login won't work due to OAuth requirements)
24+pnpm dev
25+26+# Option 2: Development with OAuth login support (recommended)
27+pnpm dev:oauth
28```
29+30+### OAuth Development
31+32+Due to OAuth requirements, HTTPS is needed for development. We've made this easy:
33+34+- `pnpm dev:oauth` - Sets up everything automatically:
35+ 1. Starts ngrok to create an HTTPS tunnel
36+ 2. Configures environment variables with the ngrok URL
37+ 3. Starts both the API server and client app
38+ 4. Handles proper shutdown of all processes
39+40+This all-in-one command makes OAuth development seamless.
41+42+### Additional Commands
43+44+```bash
45+# Build both packages
46+pnpm build
47+48+# Run typecheck on both packages
49+pnpm typecheck
50+51+# Format all code
52+pnpm format
53+```
54+55+## Requirements
56+57+- Node.js 18+
58+- pnpm 9+
59+- ngrok (for OAuth development)
60+61+## License
62+63+MIT
···1+{
2+ "lexicon": 1,
3+ "id": "com.atproto.label.defs",
4+ "defs": {
5+ "label": {
6+ "type": "object",
7+ "description": "Metadata tag on an atproto resource (eg, repo or record).",
8+ "required": ["src", "uri", "val", "cts"],
9+ "properties": {
10+ "ver": {
11+ "type": "integer",
12+ "description": "The AT Protocol version of the label object."
13+ },
14+ "src": {
15+ "type": "string",
16+ "format": "did",
17+ "description": "DID of the actor who created this label."
18+ },
19+ "uri": {
20+ "type": "string",
21+ "format": "uri",
22+ "description": "AT URI of the record, repository (account), or other resource that this label applies to."
23+ },
24+ "cid": {
25+ "type": "string",
26+ "format": "cid",
27+ "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to."
28+ },
29+ "val": {
30+ "type": "string",
31+ "maxLength": 128,
32+ "description": "The short string name of the value or type of this label."
33+ },
34+ "neg": {
35+ "type": "boolean",
36+ "description": "If true, this is a negation label, overwriting a previous label."
37+ },
38+ "cts": {
39+ "type": "string",
40+ "format": "datetime",
41+ "description": "Timestamp when this label was created."
42+ },
43+ "exp": {
44+ "type": "string",
45+ "format": "datetime",
46+ "description": "Timestamp at which this label expires (no longer applies)."
47+ },
48+ "sig": {
49+ "type": "bytes",
50+ "description": "Signature of dag-cbor encoded label."
51+ }
52+ }
53+ },
54+ "selfLabels": {
55+ "type": "object",
56+ "description": "Metadata tags on an atproto record, published by the author within the record.",
57+ "required": ["values"],
58+ "properties": {
59+ "values": {
60+ "type": "array",
61+ "items": { "type": "ref", "ref": "#selfLabel" },
62+ "maxLength": 10
63+ }
64+ }
65+ },
66+ "selfLabel": {
67+ "type": "object",
68+ "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.",
69+ "required": ["val"],
70+ "properties": {
71+ "val": {
72+ "type": "string",
73+ "maxLength": 128,
74+ "description": "The short string name of the value or type of this label."
75+ }
76+ }
77+ },
78+ "labelValueDefinition": {
79+ "type": "object",
80+ "description": "Declares a label value and its expected interpretations and behaviors.",
81+ "required": ["identifier", "severity", "blurs", "locales"],
82+ "properties": {
83+ "identifier": {
84+ "type": "string",
85+ "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
86+ "maxLength": 100,
87+ "maxGraphemes": 100
88+ },
89+ "severity": {
90+ "type": "string",
91+ "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
92+ "knownValues": ["inform", "alert", "none"]
93+ },
94+ "blurs": {
95+ "type": "string",
96+ "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
97+ "knownValues": ["content", "media", "none"]
98+ },
99+ "defaultSetting": {
100+ "type": "string",
101+ "description": "The default setting for this label.",
102+ "knownValues": ["ignore", "warn", "hide"],
103+ "default": "warn"
104+ },
105+ "adultOnly": {
106+ "type": "boolean",
107+ "description": "Does the user need to have adult content enabled in order to configure this label?"
108+ },
109+ "locales": {
110+ "type": "array",
111+ "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" }
112+ }
113+ }
114+ },
115+ "labelValueDefinitionStrings": {
116+ "type": "object",
117+ "description": "Strings which describe the label in the UI, localized into a specific language.",
118+ "required": ["lang", "name", "description"],
119+ "properties": {
120+ "lang": {
121+ "type": "string",
122+ "description": "The code of the language these strings are written in.",
123+ "format": "language"
124+ },
125+ "name": {
126+ "type": "string",
127+ "description": "A short human-readable name for the label.",
128+ "maxGraphemes": 64,
129+ "maxLength": 640
130+ },
131+ "description": {
132+ "type": "string",
133+ "description": "A longer description of what the label means and why it might be applied.",
134+ "maxGraphemes": 10000,
135+ "maxLength": 100000
136+ }
137+ }
138+ },
139+ "labelValue": {
140+ "type": "string",
141+ "knownValues": [
142+ "!hide",
143+ "!no-promote",
144+ "!warn",
145+ "!no-unauthenticated",
146+ "dmca-violation",
147+ "doxxing",
148+ "porn",
149+ "sexual",
150+ "nudity",
151+ "nsfl",
152+ "gore"
153+ ]
154+ }
155+ }
156+}
···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+}
-156
lexicons/defs.json
···1-{
2- "lexicon": 1,
3- "id": "com.atproto.label.defs",
4- "defs": {
5- "label": {
6- "type": "object",
7- "description": "Metadata tag on an atproto resource (eg, repo or record).",
8- "required": ["src", "uri", "val", "cts"],
9- "properties": {
10- "ver": {
11- "type": "integer",
12- "description": "The AT Protocol version of the label object."
13- },
14- "src": {
15- "type": "string",
16- "format": "did",
17- "description": "DID of the actor who created this label."
18- },
19- "uri": {
20- "type": "string",
21- "format": "uri",
22- "description": "AT URI of the record, repository (account), or other resource that this label applies to."
23- },
24- "cid": {
25- "type": "string",
26- "format": "cid",
27- "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to."
28- },
29- "val": {
30- "type": "string",
31- "maxLength": 128,
32- "description": "The short string name of the value or type of this label."
33- },
34- "neg": {
35- "type": "boolean",
36- "description": "If true, this is a negation label, overwriting a previous label."
37- },
38- "cts": {
39- "type": "string",
40- "format": "datetime",
41- "description": "Timestamp when this label was created."
42- },
43- "exp": {
44- "type": "string",
45- "format": "datetime",
46- "description": "Timestamp at which this label expires (no longer applies)."
47- },
48- "sig": {
49- "type": "bytes",
50- "description": "Signature of dag-cbor encoded label."
51- }
52- }
53- },
54- "selfLabels": {
55- "type": "object",
56- "description": "Metadata tags on an atproto record, published by the author within the record.",
57- "required": ["values"],
58- "properties": {
59- "values": {
60- "type": "array",
61- "items": { "type": "ref", "ref": "#selfLabel" },
62- "maxLength": 10
63- }
64- }
65- },
66- "selfLabel": {
67- "type": "object",
68- "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel.",
69- "required": ["val"],
70- "properties": {
71- "val": {
72- "type": "string",
73- "maxLength": 128,
74- "description": "The short string name of the value or type of this label."
75- }
76- }
77- },
78- "labelValueDefinition": {
79- "type": "object",
80- "description": "Declares a label value and its expected interpretations and behaviors.",
81- "required": ["identifier", "severity", "blurs", "locales"],
82- "properties": {
83- "identifier": {
84- "type": "string",
85- "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).",
86- "maxLength": 100,
87- "maxGraphemes": 100
88- },
89- "severity": {
90- "type": "string",
91- "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.",
92- "knownValues": ["inform", "alert", "none"]
93- },
94- "blurs": {
95- "type": "string",
96- "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.",
97- "knownValues": ["content", "media", "none"]
98- },
99- "defaultSetting": {
100- "type": "string",
101- "description": "The default setting for this label.",
102- "knownValues": ["ignore", "warn", "hide"],
103- "default": "warn"
104- },
105- "adultOnly": {
106- "type": "boolean",
107- "description": "Does the user need to have adult content enabled in order to configure this label?"
108- },
109- "locales": {
110- "type": "array",
111- "items": { "type": "ref", "ref": "#labelValueDefinitionStrings" }
112- }
113- }
114- },
115- "labelValueDefinitionStrings": {
116- "type": "object",
117- "description": "Strings which describe the label in the UI, localized into a specific language.",
118- "required": ["lang", "name", "description"],
119- "properties": {
120- "lang": {
121- "type": "string",
122- "description": "The code of the language these strings are written in.",
123- "format": "language"
124- },
125- "name": {
126- "type": "string",
127- "description": "A short human-readable name for the label.",
128- "maxGraphemes": 64,
129- "maxLength": 640
130- },
131- "description": {
132- "type": "string",
133- "description": "A longer description of what the label means and why it might be applied.",
134- "maxGraphemes": 10000,
135- "maxLength": 100000
136- }
137- }
138- },
139- "labelValue": {
140- "type": "string",
141- "knownValues": [
142- "!hide",
143- "!no-promote",
144- "!warn",
145- "!no-unauthenticated",
146- "dmca-violation",
147- "doxxing",
148- "porn",
149- "sexual",
150- "nudity",
151- "nsfl",
152- "gore"
153- ]
154- }
155- }
156- }
···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 /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
···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
···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+}
···4 NodeSavedState,
5 NodeSavedStateStore,
6} from '@atproto/oauth-client-node'
07import type { Database } from '#/db'
89export class StateStore implements NodeSavedStateStore {
···4 NodeSavedState,
5 NodeSavedStateStore,
6} from '@atproto/oauth-client-node'
7+8import type { Database } from '#/db'
910export class StateStore implements NodeSavedStateStore {
···1-import pino from 'pino'
2import { IdResolver } from '@atproto/identity'
3import { Firehose, type Event } from '@atproto/sync'
0004import type { Database } from '#/db'
5-import * as Status from '#/lexicon/types/xyz/statusphere/status'
67export function createIngester(db: Database, idResolver: IdResolver) {
8 const logger = pino({ name: 'firehose ingestion' })
···17 // If the write is a valid status update
18 if (
19 evt.collection === 'xyz.statusphere.status' &&
20- Status.isRecord(record)
21 ) {
22- const validatedRecord = Status.validateRecord(record)
23 if (!validatedRecord.success) return
24 // Store the status in our SQLite
25 await db
···01import { IdResolver } from '@atproto/identity'
2import { Firehose, type Event } from '@atproto/sync'
3+import { XyzStatusphereStatus } from '@statusphere/lexicon'
4+import pino from 'pino'
5+6import type { Database } from '#/db'
078export function createIngester(db: Database, idResolver: IdResolver) {
9 const logger = pino({ name: 'firehose ingestion' })
···18 // If the write is a valid status update
19 if (
20 evt.collection === 'xyz.statusphere.status' &&
21+ XyzStatusphereStatus.isRecord(record)
22 ) {
23+ const validatedRecord = XyzStatusphereStatus.validateRecord(record)
24 if (!validatedRecord.success) return
25 // Store the status in our SQLite
26 await db
-129
src/lexicon/index.ts
···1-/**
2- * GENERATED CODE - DO NOT MODIFY
3- */
4-import {
5- createServer as createXrpcServer,
6- Server as XrpcServer,
7- Options as XrpcOptions,
8- AuthVerifier,
9- StreamAuthVerifier,
10-} from '@atproto/xrpc-server'
11-import { schemas } from './lexicons.js'
12-13-export function createServer(options?: XrpcOptions): Server {
14- return new Server(options)
15-}
16-17-export class Server {
18- xrpc: XrpcServer
19- app: AppNS
20- xyz: XyzNS
21- com: ComNS
22-23- constructor(options?: XrpcOptions) {
24- this.xrpc = createXrpcServer(schemas, options)
25- this.app = new AppNS(this)
26- this.xyz = new XyzNS(this)
27- this.com = new ComNS(this)
28- }
29-}
30-31-export class AppNS {
32- _server: Server
33- bsky: AppBskyNS
34-35- constructor(server: Server) {
36- this._server = server
37- this.bsky = new AppBskyNS(server)
38- }
39-}
40-41-export class AppBskyNS {
42- _server: Server
43- actor: AppBskyActorNS
44-45- constructor(server: Server) {
46- this._server = server
47- this.actor = new AppBskyActorNS(server)
48- }
49-}
50-51-export class AppBskyActorNS {
52- _server: Server
53-54- constructor(server: Server) {
55- this._server = server
56- }
57-}
58-59-export class XyzNS {
60- _server: Server
61- statusphere: XyzStatusphereNS
62-63- constructor(server: Server) {
64- this._server = server
65- this.statusphere = new XyzStatusphereNS(server)
66- }
67-}
68-69-export class XyzStatusphereNS {
70- _server: Server
71-72- constructor(server: Server) {
73- this._server = server
74- }
75-}
76-77-export class ComNS {
78- _server: Server
79- atproto: ComAtprotoNS
80-81- constructor(server: Server) {
82- this._server = server
83- this.atproto = new ComAtprotoNS(server)
84- }
85-}
86-87-export class ComAtprotoNS {
88- _server: Server
89- repo: ComAtprotoRepoNS
90-91- constructor(server: Server) {
92- this._server = server
93- this.repo = new ComAtprotoRepoNS(server)
94- }
95-}
96-97-export class ComAtprotoRepoNS {
98- _server: Server
99-100- constructor(server: Server) {
101- this._server = server
102- }
103-}
104-105-type SharedRateLimitOpts<T> = {
106- name: string
107- calcKey?: (ctx: T) => string | null
108- calcPoints?: (ctx: T) => number
109-}
110-type RouteRateLimitOpts<T> = {
111- durationMs: number
112- points: number
113- calcKey?: (ctx: T) => string | null
114- calcPoints?: (ctx: T) => number
115-}
116-type HandlerOpts = { blobLimit?: number }
117-type HandlerRateLimitOpts<T> = SharedRateLimitOpts<T> | RouteRateLimitOpts<T>
118-type ConfigOf<Auth, Handler, ReqCtx> =
119- | Handler
120- | {
121- auth?: Auth
122- opts?: HandlerOpts
123- rateLimit?: HandlerRateLimitOpts<ReqCtx> | HandlerRateLimitOpts<ReqCtx>[]
124- handler: Handler
125- }
126-type ExtractAuth<AV extends AuthVerifier | StreamAuthVerifier> = Extract<
127- Awaited<ReturnType<AV>>,
128- { credentials: unknown }
129->
···1-// @ts-ignore
2-import ssr from 'uhtml/ssr'
3-import type initSSR from 'uhtml/types/init-ssr'
4-import type { Hole } from 'uhtml/types/keyed'
5-6-export type { Hole }
7-8-export const { html }: ReturnType<typeof initSSR> = ssr()
9-10-export function page(hole: Hole) {
11- return `<!DOCTYPE html>\n${hole.toDOM().toString()}`
12-}