···68686969The backend server will:
70707171-- Serve the API at `/api/*` endpoints
7171+- Serve the API at `/xrpc/*` and `/oauth/*` endpoints
7272- Serve the frontend static files from the client's build directory
7373- Handle client-side routing by serving index.html for all non-API routes
7474
···11+import { AppContext } from '#/context'
22+import { Server } from '#/lexicons'
33+import getStatuses from './lexicons/getStatuses'
44+import getUser from './lexicons/getUser'
55+import sendStatus from './lexicons/sendStatus'
66+77+export * as health from './health'
88+export * as oauth from './oauth'
99+1010+export default function (server: Server, ctx: AppContext) {
1111+ getStatuses(server, ctx)
1212+ sendStatus(server, ctx)
1313+ getUser(server, ctx)
1414+ return server
1515+}
···11+/**
22+ * GENERATED CODE - DO NOT MODIFY
33+ */
44+import { BlobRef, ValidationResult } from '@atproto/lexicon'
55+import { CID } from 'multiformats/cid'
66+77+import { validate as _validate } from '../../../../lexicons'
88+import { is$typed as _is$typed, $Typed, OmitKey } from '../../../../util'
99+1010+const is$typed = _is$typed,
1111+ validate = _validate
1212+const id = 'com.atproto.label.defs'
1313+1414+/** Metadata tag on an atproto resource (eg, repo or record). */
1515+export interface Label {
1616+ $type?: 'com.atproto.label.defs#label'
1717+ /** The AT Protocol version of the label object. */
1818+ ver?: number
1919+ /** DID of the actor who created this label. */
2020+ src: string
2121+ /** AT URI of the record, repository (account), or other resource that this label applies to. */
2222+ uri: string
2323+ /** Optionally, CID specifying the specific version of 'uri' resource this label applies to. */
2424+ cid?: string
2525+ /** The short string name of the value or type of this label. */
2626+ val: string
2727+ /** If true, this is a negation label, overwriting a previous label. */
2828+ neg?: boolean
2929+ /** Timestamp when this label was created. */
3030+ cts: string
3131+ /** Timestamp at which this label expires (no longer applies). */
3232+ exp?: string
3333+ /** Signature of dag-cbor encoded label. */
3434+ sig?: Uint8Array
3535+}
3636+3737+const hashLabel = 'label'
3838+3939+export function isLabel<V>(v: V) {
4040+ return is$typed(v, id, hashLabel)
4141+}
4242+4343+export function validateLabel<V>(v: V) {
4444+ return validate<Label & V>(v, id, hashLabel)
4545+}
4646+4747+/** Metadata tags on an atproto record, published by the author within the record. */
4848+export interface SelfLabels {
4949+ $type?: 'com.atproto.label.defs#selfLabels'
5050+ values: SelfLabel[]
5151+}
5252+5353+const hashSelfLabels = 'selfLabels'
5454+5555+export function isSelfLabels<V>(v: V) {
5656+ return is$typed(v, id, hashSelfLabels)
5757+}
5858+5959+export function validateSelfLabels<V>(v: V) {
6060+ return validate<SelfLabels & V>(v, id, hashSelfLabels)
6161+}
6262+6363+/** Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel. */
6464+export interface SelfLabel {
6565+ $type?: 'com.atproto.label.defs#selfLabel'
6666+ /** The short string name of the value or type of this label. */
6767+ val: string
6868+}
6969+7070+const hashSelfLabel = 'selfLabel'
7171+7272+export function isSelfLabel<V>(v: V) {
7373+ return is$typed(v, id, hashSelfLabel)
7474+}
7575+7676+export function validateSelfLabel<V>(v: V) {
7777+ return validate<SelfLabel & V>(v, id, hashSelfLabel)
7878+}
7979+8080+/** Declares a label value and its expected interpretations and behaviors. */
8181+export interface LabelValueDefinition {
8282+ $type?: 'com.atproto.label.defs#labelValueDefinition'
8383+ /** The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+). */
8484+ identifier: string
8585+ /** How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing. */
8686+ severity: 'inform' | 'alert' | 'none' | (string & {})
8787+ /** 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. */
8888+ blurs: 'content' | 'media' | 'none' | (string & {})
8989+ /** The default setting for this label. */
9090+ defaultSetting: 'ignore' | 'warn' | 'hide' | (string & {})
9191+ /** Does the user need to have adult content enabled in order to configure this label? */
9292+ adultOnly?: boolean
9393+ locales: LabelValueDefinitionStrings[]
9494+}
9595+9696+const hashLabelValueDefinition = 'labelValueDefinition'
9797+9898+export function isLabelValueDefinition<V>(v: V) {
9999+ return is$typed(v, id, hashLabelValueDefinition)
100100+}
101101+102102+export function validateLabelValueDefinition<V>(v: V) {
103103+ return validate<LabelValueDefinition & V>(v, id, hashLabelValueDefinition)
104104+}
105105+106106+/** Strings which describe the label in the UI, localized into a specific language. */
107107+export interface LabelValueDefinitionStrings {
108108+ $type?: 'com.atproto.label.defs#labelValueDefinitionStrings'
109109+ /** The code of the language these strings are written in. */
110110+ lang: string
111111+ /** A short human-readable name for the label. */
112112+ name: string
113113+ /** A longer description of what the label means and why it might be applied. */
114114+ description: string
115115+}
116116+117117+const hashLabelValueDefinitionStrings = 'labelValueDefinitionStrings'
118118+119119+export function isLabelValueDefinitionStrings<V>(v: V) {
120120+ return is$typed(v, id, hashLabelValueDefinitionStrings)
121121+}
122122+123123+export function validateLabelValueDefinitionStrings<V>(v: V) {
124124+ return validate<LabelValueDefinitionStrings & V>(
125125+ v,
126126+ id,
127127+ hashLabelValueDefinitionStrings,
128128+ )
129129+}
130130+131131+export type LabelValue =
132132+ | '!hide'
133133+ | '!no-promote'
134134+ | '!warn'
135135+ | '!no-unauthenticated'
136136+ | 'dmca-violation'
137137+ | 'doxxing'
138138+ | 'porn'
139139+ | 'sexual'
140140+ | 'nudity'
141141+ | 'nsfl'
142142+ | 'gore'
143143+ | (string & {})
···11+/**
22+ * GENERATED CODE - DO NOT MODIFY
33+ */
44+import { BlobRef, ValidationResult } from '@atproto/lexicon'
55+import { CID } from 'multiformats/cid'
66+77+import { validate as _validate } from '../../../lexicons'
88+import { is$typed as _is$typed, $Typed, OmitKey } from '../../../util'
99+1010+const is$typed = _is$typed,
1111+ validate = _validate
1212+const id = 'xyz.statusphere.status'
1313+1414+export interface Record {
1515+ $type: 'xyz.statusphere.status'
1616+ status: string
1717+ createdAt: string
1818+ [k: string]: unknown
1919+}
2020+2121+const hashRecord = 'main'
2222+2323+export function isRecord<V>(v: V) {
2424+ return is$typed(v, id, hashRecord)
2525+}
2626+2727+export function validateRecord<V>(v: V) {
2828+ return validate<Record & V>(v, id, hashRecord, true)
2929+}
+82
packages/appview/src/lexicons/util.ts
···11+/**
22+ * GENERATED CODE - DO NOT MODIFY
33+ */
44+55+import { ValidationResult } from '@atproto/lexicon'
66+77+export type OmitKey<T, K extends keyof T> = {
88+ [K2 in keyof T as K2 extends K ? never : K2]: T[K2]
99+}
1010+1111+export type $Typed<V, T extends string = string> = V & { $type: T }
1212+export type Un$Typed<V extends { $type?: string }> = OmitKey<V, '$type'>
1313+1414+export type $Type<Id extends string, Hash extends string> = Hash extends 'main'
1515+ ? Id
1616+ : `${Id}#${Hash}`
1717+1818+function isObject<V>(v: V): v is V & object {
1919+ return v != null && typeof v === 'object'
2020+}
2121+2222+function is$type<Id extends string, Hash extends string>(
2323+ $type: unknown,
2424+ id: Id,
2525+ hash: Hash,
2626+): $type is $Type<Id, Hash> {
2727+ return hash === 'main'
2828+ ? $type === id
2929+ : // $type === `${id}#${hash}`
3030+ typeof $type === 'string' &&
3131+ $type.length === id.length + 1 + hash.length &&
3232+ $type.charCodeAt(id.length) === 35 /* '#' */ &&
3333+ $type.startsWith(id) &&
3434+ $type.endsWith(hash)
3535+}
3636+3737+export type $TypedObject<
3838+ V,
3939+ Id extends string,
4040+ Hash extends string,
4141+> = V extends {
4242+ $type: $Type<Id, Hash>
4343+}
4444+ ? V
4545+ : V extends { $type?: string }
4646+ ? V extends { $type?: infer T extends $Type<Id, Hash> }
4747+ ? V & { $type: T }
4848+ : never
4949+ : V & { $type: $Type<Id, Hash> }
5050+5151+export function is$typed<V, Id extends string, Hash extends string>(
5252+ v: V,
5353+ id: Id,
5454+ hash: Hash,
5555+): v is $TypedObject<V, Id, Hash> {
5656+ return isObject(v) && '$type' in v && is$type(v.$type, id, hash)
5757+}
5858+5959+export function maybe$typed<V, Id extends string, Hash extends string>(
6060+ v: V,
6161+ id: Id,
6262+ hash: Hash,
6363+): v is V & object & { $type?: $Type<Id, Hash> } {
6464+ return (
6565+ isObject(v) &&
6666+ ('$type' in v ? v.$type === undefined || is$type(v.$type, id, hash) : true)
6767+ )
6868+}
6969+7070+export type Validator<R = unknown> = (v: unknown) => ValidationResult<R>
7171+export type ValidatorParam<V extends Validator> =
7272+ V extends Validator<infer R> ? R : never
7373+7474+/**
7575+ * Utility function that allows to convert a "validate*" utility function into a
7676+ * type predicate.
7777+ */
7878+export function asPredicate<V extends Validator>(validate: V) {
7979+ return function <T>(v: T): v is T & ValidatorParam<V> {
8080+ return validate(v).success
8181+ }
8282+}
+1-1
packages/appview/src/lib/hydrate.ts
···44 XyzStatusphereDefs,
55} from '@statusphere/lexicon'
6677+import { AppContext } from '#/context'
78import { Status } from '#/db'
88-import { AppContext } from '#/index'
991010export async function statusToStatusView(
1111 status: Status,
-347
packages/appview/src/routes.ts
···11-import type { IncomingMessage, ServerResponse } from 'node:http'
22-import { Agent } from '@atproto/api'
33-import { TID } from '@atproto/common'
44-import { OAuthResolverError } from '@atproto/oauth-client-node'
55-import { isValidHandle } from '@atproto/syntax'
66-import {
77- AppBskyActorDefs,
88- AppBskyActorProfile,
99- XyzStatusphereStatus,
1010-} from '@statusphere/lexicon'
1111-import express from 'express'
1212-import { getIronSession, SessionOptions } from 'iron-session'
1313-1414-import type { AppContext } from '#/index'
1515-import { env } from '#/lib/env'
1616-import { bskyProfileToProfileView, statusToStatusView } from '#/lib/hydrate'
1717-1818-type Session = { did: string }
1919-2020-// Common session options
2121-const sessionOptions: SessionOptions = {
2222- cookieName: 'sid',
2323- password: env.COOKIE_SECRET,
2424- cookieOptions: {
2525- secure: env.NODE_ENV === 'production',
2626- httpOnly: true,
2727- sameSite: true,
2828- path: '/',
2929- // Don't set domain explicitly - let browser determine it
3030- domain: undefined,
3131- },
3232-}
3333-3434-// Helper function for defining routes
3535-const handler =
3636- (
3737- fn: (
3838- req: express.Request,
3939- res: express.Response,
4040- next: express.NextFunction,
4141- ) => Promise<void> | void,
4242- ) =>
4343- async (
4444- req: express.Request,
4545- res: express.Response,
4646- next: express.NextFunction,
4747- ) => {
4848- try {
4949- await fn(req, res, next)
5050- } catch (err) {
5151- next(err)
5252- }
5353- }
5454-5555-// Helper function to get the Atproto Agent for the active session
5656-async function getSessionAgent(
5757- req: IncomingMessage | express.Request,
5858- res: ServerResponse<IncomingMessage> | express.Response,
5959- ctx: AppContext,
6060-) {
6161- const session = await getIronSession<Session>(req, res, sessionOptions)
6262-6363- if (!session.did) {
6464- return null
6565- }
6666-6767- try {
6868- const oauthSession = await ctx.oauthClient.restore(session.did)
6969- return oauthSession ? new Agent(oauthSession) : null
7070- } catch (err) {
7171- ctx.logger.warn({ err }, 'oauth restore failed')
7272- session.destroy()
7373- return null
7474- }
7575-}
7676-7777-export const createRouter = (ctx: AppContext) => {
7878- const router = express.Router()
7979-8080- // Simple CORS configuration for all routes
8181- router.use((req, res, next) => {
8282- // Allow requests from either the specific origin or any origin during development
8383- res.header('Access-Control-Allow-Origin', req.headers.origin || '*')
8484- res.header('Access-Control-Allow-Credentials', 'true')
8585- res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
8686- res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
8787-8888- if (req.method === 'OPTIONS') {
8989- res.status(200).end()
9090- return
9191- }
9292- next()
9393- })
9494-9595- // OAuth metadata
9696- router.get(
9797- '/client-metadata.json',
9898- handler((_req, res) => {
9999- res.json(ctx.oauthClient.clientMetadata)
100100- }),
101101- )
102102-103103- // OAuth callback to complete session creation
104104- router.get(
105105- '/oauth/callback',
106106- handler(async (req, res) => {
107107- // Get the query parameters from the URL
108108- const params = new URLSearchParams(req.originalUrl.split('?')[1])
109109-110110- try {
111111- const { session } = await ctx.oauthClient.callback(params)
112112-113113- // Use the common session options
114114- const clientSession = await getIronSession<Session>(
115115- req,
116116- res,
117117- sessionOptions,
118118- )
119119-120120- // Set the DID on the session
121121- clientSession.did = session.did
122122- await clientSession.save()
123123-124124- // Get the origin and determine appropriate redirect
125125- const host = req.get('host') || ''
126126- const protocol = req.protocol || 'http'
127127- const baseUrl = `${protocol}://${host}`
128128-129129- ctx.logger.info(
130130- `OAuth callback successful, redirecting to ${baseUrl}/oauth-callback`,
131131- )
132132-133133- // Redirect to the frontend oauth-callback page
134134- res.redirect('/oauth-callback')
135135- } catch (err) {
136136- ctx.logger.error({ err }, 'oauth callback failed')
137137-138138- // Handle error redirect - stay on same domain
139139- res.redirect('/oauth-callback?error=auth')
140140- }
141141- }),
142142- )
143143-144144- // Login handler
145145- router.post(
146146- '/login',
147147- handler(async (req, res) => {
148148- // Validate
149149- const handle = req.body?.handle
150150- if (typeof handle !== 'string' || !isValidHandle(handle)) {
151151- res.status(400).json({ error: 'invalid handle' })
152152- return
153153- }
154154-155155- // Initiate the OAuth flow
156156- try {
157157- const url = await ctx.oauthClient.authorize(handle, {
158158- scope: 'atproto transition:generic',
159159- })
160160- res.json({ redirectUrl: url.toString() })
161161- } catch (err) {
162162- ctx.logger.error({ err }, 'oauth authorize failed')
163163- const errorMsg =
164164- err instanceof OAuthResolverError
165165- ? err.message
166166- : "couldn't initiate login"
167167- res.status(500).json({ error: errorMsg })
168168- }
169169- }),
170170- )
171171-172172- // Logout handler
173173- router.post(
174174- '/logout',
175175- handler(async (req, res) => {
176176- const session = await getIronSession<Session>(req, res, sessionOptions)
177177- session.destroy()
178178- res.json({ success: true })
179179- }),
180180- )
181181-182182- // Get current user info
183183- router.get(
184184- '/user',
185185- handler(async (req, res) => {
186186- const agent = await getSessionAgent(req, res, ctx)
187187- if (!agent) {
188188- res.status(401).json({ error: 'Not logged in' })
189189- return
190190- }
191191-192192- const did = agent.assertDid
193193-194194- // Fetch user profile
195195- try {
196196- const profileResponse = await agent.com.atproto.repo
197197- .getRecord({
198198- repo: did,
199199- collection: 'app.bsky.actor.profile',
200200- rkey: 'self',
201201- })
202202- .catch(() => undefined)
203203-204204- const profileRecord = profileResponse?.data
205205- let profile: AppBskyActorProfile.Record =
206206- {} as AppBskyActorProfile.Record
207207-208208- if (
209209- profileRecord &&
210210- AppBskyActorProfile.isRecord(profileRecord.value)
211211- ) {
212212- const validated = AppBskyActorProfile.validateRecord(
213213- profileRecord.value,
214214- )
215215- if (validated.success) {
216216- profile = profileRecord.value
217217- } else {
218218- ctx.logger.error(
219219- { err: validated.error },
220220- 'Failed to validate user profile',
221221- )
222222- }
223223- }
224224-225225- // Fetch user status
226226- const status = await ctx.db
227227- .selectFrom('status')
228228- .selectAll()
229229- .where('authorDid', '=', did)
230230- .orderBy('indexedAt', 'desc')
231231- .executeTakeFirst()
232232-233233- res.json({
234234- did: agent.assertDid,
235235- profile: await bskyProfileToProfileView(did, profile, ctx),
236236- status: status ? await statusToStatusView(status, ctx) : undefined,
237237- })
238238- } catch (err) {
239239- ctx.logger.error({ err }, 'Failed to get user info')
240240- res.status(500).json({ error: 'Failed to get user info' })
241241- }
242242- }),
243243- )
244244-245245- // Get statuses
246246- router.get(
247247- '/statuses',
248248- handler(async (req, res) => {
249249- try {
250250- // Fetch data stored in our SQLite
251251- const statuses = await ctx.db
252252- .selectFrom('status')
253253- .selectAll()
254254- .orderBy('indexedAt', 'desc')
255255- .limit(30)
256256- .execute()
257257-258258- res.json({
259259- statuses: await Promise.all(
260260- statuses.map((status) => statusToStatusView(status, ctx)),
261261- ),
262262- })
263263- } catch (err) {
264264- ctx.logger.error({ err }, 'Failed to get statuses')
265265- res.status(500).json({ error: 'Failed to get statuses' })
266266- }
267267- }),
268268- )
269269-270270- // Create status
271271- router.post(
272272- '/status',
273273- handler(async (req, res) => {
274274- // If the user is signed in, get an agent which communicates with their server
275275- const agent = await getSessionAgent(req, res, ctx)
276276- if (!agent) {
277277- res.status(401).json({ error: 'Session required' })
278278- return
279279- }
280280-281281- // Construct & validate their status record
282282- const rkey = TID.nextStr()
283283- const record = {
284284- $type: 'xyz.statusphere.status',
285285- status: req.body?.status,
286286- createdAt: new Date().toISOString(),
287287- }
288288- if (!XyzStatusphereStatus.validateRecord(record).success) {
289289- res.status(400).json({ error: 'Invalid status' })
290290- return
291291- }
292292-293293- let uri
294294- try {
295295- // Write the status record to the user's repository
296296- const response = await agent.com.atproto.repo.putRecord({
297297- repo: agent.assertDid,
298298- collection: 'xyz.statusphere.status',
299299- rkey,
300300- record,
301301- validate: false,
302302- })
303303- uri = response.data.uri
304304- } catch (err) {
305305- ctx.logger.warn({ err }, 'failed to write record')
306306- res.status(500).json({ error: 'Failed to write record' })
307307- return
308308- }
309309-310310- try {
311311- // Optimistically update our SQLite
312312- // This isn't strictly necessary because the write event will be
313313- // handled in #/firehose/ingestor.ts, but it ensures that future reads
314314- // will be up-to-date after this method finishes.
315315- await ctx.db
316316- .insertInto('status')
317317- .values({
318318- uri,
319319- authorDid: agent.assertDid,
320320- status: record.status,
321321- createdAt: record.createdAt,
322322- indexedAt: new Date().toISOString(),
323323- })
324324- .execute()
325325-326326- res.json({
327327- success: true,
328328- uri,
329329- status: await statusToStatusView(record.status, ctx),
330330- })
331331- } catch (err) {
332332- ctx.logger.warn(
333333- { err },
334334- 'failed to update computed view; ignoring as it should be caught by the firehose',
335335- )
336336- res.json({
337337- success: true,
338338- uri,
339339- status: await statusToStatusView(record.status, ctx),
340340- warning: 'Database not updated',
341341- })
342342- }
343343- }),
344344- )
345345-346346- return router
347347-}