Scratch space for learning atproto app development

Compare changes

Choose any two refs to compare.

+7 -5
.env.template
···
# Environment Configuration
-
NODE_ENV="development" # Options: 'development', 'production'
-
PORT="8080" # The port your server will listen on
-
PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id.
-
DB_PATH="db.sqlite" # The SQLite database path. Set as ":memory:" to use a temporary in-memory database.
+
NODE_ENV="development" # Options: 'development', 'production'
+
PORT="8080" # The port your server will listen on
+
DB_PATH=":memory:" # The SQLite database path. Set as ":memory:" to use a temporary in-memory database.
+
# PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id.
+
# LOG_LEVEL="info" # Options: 'fatal', 'error', 'warn', 'info', 'debug'
+
# PDS_URL="https://my.pds" # The default PDS for login and sign-ups
-
# Secrets: *MUST* set this in production
+
# Secrets below *MUST* be set in production
# May be generated with `openssl rand -base64 33`
# COOKIE_SECRET=""
+2 -2
.gitignore
···
dist-ssr
*.local
.env
+
*.sqlite
# Editor directories and files
!.vscode/extensions.json
···
*.ntvs*
*.njsproj
*.sln
-
*.sw?
-
*.sqlite
+
*.sw?
+2 -8
.vscode/settings.json
···
{
"editor.formatOnSave": true,
-
"editor.defaultFormatter": "biomejs.biome",
+
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit",
"source.organizeImports.biome": "explicit",
···
"url": "https://cdn.jsdelivr.net/npm/tsup/schema.json",
"fileMatch": ["package.json", "tsup.config.json"]
}
-
],
-
"[typescript]": {
-
"editor.defaultFormatter": "esbenp.prettier-vscode"
-
},
-
"[javascript]": {
-
"editor.defaultFormatter": "esbenp.prettier-vscode"
-
}
+
]
}
+42
README.md
···
npm run dev
# Navigate to http://localhost:8080
```
+
+
## Deploying
+
+
In production, you will need a private key to sign OAuth tokens request. Use the
+
following command to generate a new private key:
+
+
```sh
+
./bin/gen-jwk
+
```
+
+
The generated key must be added to the environment variables (`.env` file) in `PRIVATE_KEYS`.
+
+
```env
+
PRIVATE_KEYS='[{"kty":"EC","kid":"12",...}]'
+
```
+
+
> [!NOTE]
+
>
+
> The `PRIVATE_KEYS` can contain multiple keys. The first key in the array is
+
> the most recent one, and it will be used to sign new tokens. When a key is
+
> removed, all associated sessions will be invalidated.
+
+
Make sure to also set the `COOKIE_SECRET`, which is used to sign session
+
cookies, in your environment variables (`.env` file). You should use a random
+
string for this:
+
+
```sh
+
openssl rand -base64 33
+
```
+
+
Finally, set the `PUBLIC_URL` to the URL where your app will be accessible. This
+
will allow the authorization servers to download the app's public keys.
+
+
```env
+
PUBLIC_URL="https://your-app-url.com"
+
```
+
+
> [!NOTE]
+
>
+
> You can use services like [ngrok](https://ngrok.com/) to expose your local
+
> server to the internet for testing purposes. Just set the `PUBLIC_URL` to the
+
> ngrok URL.
+1 -1
src/auth/client.ts
···
// If a keyset is defined (meaning the client is confidential). Let's make
// sure it has a private key for signing. Note: findPrivateKey will throw if
-
// the keyset does no contain a suitable private key.
+
// the keyset does not contain a suitable private key.
const pk = keyset?.findPrivateKey({ use: 'sig' })
const clientMetadata: OAuthClientMetadataInput = env.PUBLIC_URL
+3 -11
src/auth/storage.ts
···
NodeSavedState,
NodeSavedStateStore,
} from '@atproto/oauth-client-node'
-
import { Database } from '#/db'
+
import type { Database } from '#/db'
export class StateStore implements NodeSavedStateStore {
constructor(private db: Database) {}
async get(key: string): Promise<NodeSavedState | undefined> {
-
const result = await this.db
-
.selectFrom('auth_state')
-
.selectAll()
-
.where('key', '=', key)
-
.executeTakeFirst()
+
const result = await this.db.selectFrom('auth_state').selectAll().where('key', '=', key).executeTakeFirst()
if (!result) return
return JSON.parse(result.state) as NodeSavedState
}
···
export class SessionStore implements NodeSavedSessionStore {
constructor(private db: Database) {}
async get(key: string): Promise<NodeSavedSession | undefined> {
-
const result = await this.db
-
.selectFrom('auth_session')
-
.selectAll()
-
.where('key', '=', key)
-
.executeTakeFirst()
+
const result = await this.db.selectFrom('auth_session').selectAll().where('key', '=', key).executeTakeFirst()
if (!result) return
return JSON.parse(result.session) as NodeSavedSession
}
+16 -4
src/context.ts
···
import { pino } from 'pino'
import { createOAuthClient } from '#/auth/client'
-
import { createDb, Database } from '#/db'
+
import { createDb, Database, migrateToLatest } from '#/db'
import { createIngester } from '#/ingester'
import { env } from '#/env'
+
import {
+
BidirectionalResolver,
+
createBidirectionalResolver,
+
} from '#/id-resolver'
/**
* Application state passed to the router and elsewhere
···
ingester: Firehose
logger: pino.Logger
oauthClient: NodeOAuthClient
-
identityResolver: NodeOAuthClient['identityResolver']
+
resolver: BidirectionalResolver
+
destroy: () => Promise<void>
}
export async function createAppContext(): Promise<AppContext> {
-
const db = await createDb()
+
const db = createDb(env.DB_PATH)
+
await migrateToLatest(db)
const oauthClient = await createOAuthClient(db)
const ingester = createIngester(db)
const logger = pino({ name: 'server', level: env.LOG_LEVEL })
+
const resolver = createBidirectionalResolver(oauthClient)
return {
db,
ingester,
logger,
oauthClient,
-
identityResolver: oauthClient.identityResolver,
+
resolver,
+
+
async destroy() {
+
await ingester.destroy()
+
await db.destroy()
+
},
}
}
+10 -17
src/db.ts
···
import SqliteDb from 'better-sqlite3'
import {
Kysely,
-
Migration,
-
MigrationProvider,
Migrator,
SqliteDialect,
+
Migration,
+
MigrationProvider,
} from 'kysely'
-
-
import { env } from '#/env'
// Types
···
// APIs
-
export async function createDb(options?: {
-
/** @default true */
-
migrate?: boolean
-
}): Promise<Database> {
-
const db = new Kysely<DatabaseSchema>({
+
export const createDb = (location: string): Database => {
+
return new Kysely<DatabaseSchema>({
dialect: new SqliteDialect({
-
database: new SqliteDb(env.DB_PATH),
+
database: new SqliteDb(location),
}),
})
+
}
-
if (options?.migrate !== false) {
-
const migrator = new Migrator({ db, provider: migrationProvider })
-
const { error } = await migrator.migrateToLatest()
-
if (error) throw error
-
}
-
-
return db
+
export const migrateToLatest = async (db: Database) => {
+
const migrator = new Migrator({ db, provider: migrationProvider })
+
const { error } = await migrator.migrateToLatest()
+
if (error) throw error
}
export type Database = Kysely<DatabaseSchema>
+12 -9
src/env.ts
···
import dotenv from 'dotenv'
-
import { cleanEnv, port, str, testOnly } from 'envalid'
-
import { envalidJsonWebKeys } from '#/lib/jwk'
+
import { cleanEnv, port, str, testOnly, url } from 'envalid'
+
import { envalidJsonWebKeys as keys } from '#/lib/jwk'
dotenv.config()
···
choices: ['development', 'production', 'test'],
}),
PORT: port({ devDefault: testOnly(3000) }),
-
PUBLIC_URL: str({}),
+
PUBLIC_URL: url({ default: undefined }),
DB_PATH: str({ devDefault: ':memory:' }),
COOKIE_SECRET: str({ devDefault: '00000000000000000000000000000000' }),
-
PRIVATE_KEYS: envalidJsonWebKeys({ default: undefined }),
-
LOG_LEVEL: str({ default: 'info' }),
-
PDS_OWNER: str({ default: 'Bluesky' }),
-
PDS_URL: str({ default: 'https://bsky.social' }),
-
PLC_URL: str({ default: 'https://plc.directory' }),
-
FIREHOSE_URL: str({ default: 'wss://bsky.network' }),
+
PRIVATE_KEYS: keys({ default: undefined }),
+
LOG_LEVEL: str({
+
devDefault: 'debug',
+
default: 'info',
+
choices: ['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent'],
+
}),
+
PDS_URL: url({ default: undefined }),
+
PLC_URL: url({ default: undefined }),
+
FIREHOSE_URL: url({ default: undefined }),
})
+37
src/id-resolver.ts
···
+
import { OAuthClient } from '@atproto/oauth-client-node'
+
+
export interface BidirectionalResolver {
+
resolveDidToHandle(did: string): Promise<string | undefined>
+
resolveDidsToHandles(
+
dids: string[],
+
): Promise<Record<string, string | undefined>>
+
}
+
+
export function createBidirectionalResolver({
+
identityResolver,
+
}: OAuthClient): BidirectionalResolver {
+
return {
+
async resolveDidToHandle(did: string): Promise<string | undefined> {
+
try {
+
const { handle } = await identityResolver.resolve(did)
+
if (handle) return handle
+
} catch {
+
// Ignore
+
}
+
},
+
+
async resolveDidsToHandles(
+
dids: string[],
+
): Promise<Record<string, string | undefined>> {
+
const uniqueDids = [...new Set(dids)]
+
+
return Object.fromEntries(
+
await Promise.all(
+
uniqueDids.map((did) =>
+
this.resolveDidToHandle(did).then((handle) => [did, handle]),
+
),
+
),
+
)
+
},
+
}
+
}
+3 -4
src/index.ts
···
-
import { createHttpTerminator } from 'http-terminator'
import { once } from 'node:events'
import { createAppContext } from '#/context'
import { env } from '#/env'
+
import { startServer } from '#/lib/http'
import { run } from '#/lib/process'
import { createRouter } from '#/routes'
-
import { startServer } from '#/lib/http'
run(async (killSignal) => {
// Create the application context
···
// Gracefully shutdown the http server
await terminate()
-
// Close the firehose connection
-
await ctx.ingester.destroy()
+
// Gracefully shutdown the application context
+
await ctx.destroy()
})
+6 -6
src/ingester.ts
···
export function createIngester(db: Database) {
const logger = pino({ name: 'firehose', level: env.LOG_LEVEL })
return new Firehose({
-
service: env.FIREHOSE_URL,
-
idResolver: new IdResolver({
-
plcUrl: env.PLC_URL,
-
didCache: new MemoryCache(HOUR, DAY),
-
}),
+
filterCollections: ['xyz.statusphere.status'],
handleEvent: async (evt: Event) => {
// Watch for write events
if (evt.event === 'create' || evt.event === 'update') {
···
onError: (err: unknown) => {
logger.error({ err }, 'error on firehose ingestion')
},
-
filterCollections: ['xyz.statusphere.status'],
excludeIdentity: true,
excludeAccount: true,
+
service: env.FIREHOSE_URL,
+
idResolver: new IdResolver({
+
plcUrl: env.PLC_URL,
+
didCache: new MemoryCache(HOUR, DAY),
+
}),
})
}
+12 -21
src/lib/http.ts
···
+
import { Request, Response } from 'express'
import { createHttpTerminator } from 'http-terminator'
import { once } from 'node:events'
import type {
···
export type NextFunction = (err?: unknown) => void
-
export type Handler<
+
export type Middleware<
Req extends IncomingMessage = IncomingMessage,
-
Res extends ServerResponse<Req> = ServerResponse<Req>,
+
Res extends ServerResponse = ServerResponse,
> = (req: Req, res: Res, next: NextFunction) => void
-
export type AsyncHandler<
+
export type Handler<
Req extends IncomingMessage = IncomingMessage,
-
Res extends ServerResponse<Req> = ServerResponse<Req>,
-
> = (req: Req, res: Res, next: NextFunction) => Promise<void>
-
+
Res extends ServerResponse = ServerResponse,
+
> = (req: Req, res: Res) => unknown | Promise<unknown>
/**
* Wraps a request handler middleware to ensure that `next` is called if it
* throws or returns a promise that rejects.
*/
export function handler<
-
Req extends IncomingMessage = IncomingMessage,
-
Res extends ServerResponse<Req> = ServerResponse<Req>,
-
>(fn: Handler<Req, Res> | AsyncHandler<Req, Res>): Handler<Req, Res> {
-
return (req, res, next) => {
-
// Optimization: NodeJS prefers objects over functions for garbage collection
-
const nextSafe = nextOnce.bind({ next })
+
Req extends IncomingMessage = Request,
+
Res extends ServerResponse = Response,
+
>(fn: Handler<Req, Res>): Middleware<Req, Res> {
+
return async (req, res, next) => {
try {
-
const result = fn(req, res, nextSafe)
-
if (result instanceof Promise) result.catch(nextSafe)
+
await fn(req, res)
} catch (err) {
-
nextSafe(err)
+
next(err)
}
-
}
-
-
function nextOnce(this: { next: NextFunction | null }, err?: unknown) {
-
const { next } = this
-
this.next = null
-
next?.(err)
}
}
+3 -3
src/lib/process.ts
···
* Runs a function with an abort signal that will be triggered when the process
* receives a termination signal.
*/
-
export async function run<F extends (signal: AbortSignal) => unknown>(
+
export async function run<F extends (signal: AbortSignal) => Promise<void>>(
fn: F,
-
): Promise<Awaited<ReturnType<F>>> {
+
): Promise<void> {
const killController = new AbortController()
const abort = (signal?: string) => {
···
for (const sig of SIGNALS) process.on(sig, abort)
try {
-
return (await fn(killController.signal)) as Awaited<ReturnType<F>>
+
await fn(killController.signal)
} finally {
abort()
}
+4
src/lib/util.ts
···
+
export function ifString<T>(value: T): (T & string) | undefined {
+
if (typeof value === 'string') return value
+
return undefined
+
}
+1 -1
src/pages/home.ts
···
const TODAY = new Date().toDateString()
-
const STATUS_OPTIONS = [
+
export const STATUS_OPTIONS = [
'๐Ÿ‘',
'๐Ÿ‘Ž',
'๐Ÿ’™',
+7 -1
src/pages/login.ts
···
}
function content({ error }: Props) {
+
const signupService =
+
!env.PDS_URL || env.PDS_URL === 'https://bsky.social'
+
? 'Bluesky'
+
: new URL(env.PDS_URL).hostname
+
return html`<div id="root">
<div id="header">
<h1>Statusphere</h1>
···
placeholder="Enter your handle (eg alice.bsky.social)"
required
/>
+
<button type="submit">Log in</button>
</form>
<a href="/signup" class="button signup-cta">
-
Login or Sign up with a ${env.PDS_OWNER} account
+
Login or Sign up with a ${signupService} account
</a>
${error ? html`<p>Error: <i>${error}</i></p>` : undefined}
+25 -7
src/pages/public/styles.css
···
Josh's Custom CSS Reset
https://www.joshwcomeau.com/css/custom-css-reset/
*/
-
*, *::before, *::after {
+
*,
+
*::before,
+
*::after {
box-sizing: border-box;
}
* {
···
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
-
img, picture, video, canvas, svg {
+
img,
+
picture,
+
video,
+
canvas,
+
svg {
display: block;
max-width: 100%;
}
-
input, button, textarea, select {
+
input,
+
button,
+
textarea,
+
select {
font: inherit;
}
-
p, h1, h2, h3, h4, h5, h6 {
+
p,
+
h1,
+
h2,
+
h3,
+
h4,
+
h5,
+
h6 {
overflow-wrap: break-word;
}
-
#root, #__next {
+
#root,
+
#__next {
isolation: isolate;
}
/*
Common components
*/
-
button, .button {
+
button,
+
.button {
display: inline-block;
border: 0;
background-color: var(--primary-500);
···
cursor: pointer;
text-decoration: none;
}
-
button:hover, .button:hover {
+
button:hover,
+
.button:hover {
background: var(--primary-400);
}
+104 -88
src/routes.ts
···
import { OAuthResolverError } from '@atproto/oauth-client-node'
import express, { Request, Response } from 'express'
import { getIronSession } from 'iron-session'
-
import assert from 'node:assert'
import type {
IncomingMessage,
RequestListener,
···
import path from 'node:path'
import type { AppContext } from '#/context'
+
import { env } from '#/env'
import * as Profile from '#/lexicon/types/app/bsky/actor/profile'
import * as Status from '#/lexicon/types/xyz/statusphere/status'
-
import { env } from '#/env'
import { handler } from '#/lib/http'
+
import { ifString } from '#/lib/util'
import { page } from '#/lib/view'
import { home } from '#/pages/home'
import { login } from '#/pages/login'
+
// Max age, in seconds, for static routes and assets
+
const MAX_AGE = env.NODE_ENV === 'production' ? 60 : 0
+
type Session = { did?: string }
// Helper function to get the Atproto Agent for the active session
···
res: ServerResponse,
ctx: AppContext,
) {
+
res.setHeader('Vary', 'Cookie')
+
const session = await getIronSession<Session>(req, res, {
cookieName: 'sid',
password: env.COOKIE_SECRET,
})
if (!session.did) return null
-
try {
-
// force rotating the credentials if the request has a no-cache header
-
const refresh = req.headers['cache-control']?.includes('no-cache') || 'auto'
-
const oauthSession = await ctx.oauthClient.restore(session.did, refresh)
+
// This page is dynamic and should not be cached publicly
+
res.setHeader('cache-control', `max-age=${MAX_AGE}, private`)
+
+
try {
+
const oauthSession = await ctx.oauthClient.restore(session.did)
return oauthSession ? new Agent(oauthSession) : null
} catch (err) {
ctx.logger.warn({ err }, 'oauth restore failed')
···
}
}
-
export function createRouter(ctx: AppContext): RequestListener {
-
const app = express()
+
export const createRouter = (ctx: AppContext): RequestListener => {
+
const router = express()
// Static assets
-
app.use('/public', express.static(path.join(__dirname, 'pages', 'public')))
+
router.use(
+
'/public',
+
express.static(path.join(__dirname, 'pages', 'public'), {
+
maxAge: MAX_AGE * 1000,
+
}),
+
)
// OAuth metadata
-
app.get(
+
router.get(
'/oauth-client-metadata.json',
-
handler((req: Request, res: Response) => {
+
handler((req, res) => {
+
res.setHeader('cache-control', `max-age=${MAX_AGE}, public`)
res.json(ctx.oauthClient.clientMetadata)
}),
)
// Public keys
-
app.get(
+
router.get(
'/.well-known/jwks.json',
-
handler((req: Request, res: Response) => {
+
handler((req, res) => {
+
res.setHeader('cache-control', `max-age=${MAX_AGE}, public`)
res.json(ctx.oauthClient.jwks)
}),
)
// OAuth callback to complete session creation
-
app.get(
+
router.get(
'/oauth/callback',
-
handler(async (req: Request, res: Response) => {
+
handler(async (req, res) => {
+
res.setHeader('cache-control', 'no-store')
+
const params = new URLSearchParams(req.originalUrl.split('?')[1])
try {
-
const { session } = await ctx.oauthClient.callback(params)
-
const clientSession = await getIronSession<Session>(req, res, {
+
// Load the session cookie
+
const session = await getIronSession<Session>(req, res, {
cookieName: 'sid',
password: env.COOKIE_SECRET,
})
-
assert(!clientSession.did, 'session already exists')
-
clientSession.did = session.did
-
await clientSession.save()
-
return res.redirect('/')
+
+
// If the user is already signed in, destroy the old credentials
+
if (session.did) {
+
try {
+
const oauthSession = await ctx.oauthClient.restore(session.did)
+
if (oauthSession) oauthSession.signOut()
+
} catch (err) {
+
ctx.logger.warn({ err }, 'oauth restore failed')
+
}
+
}
+
+
// Complete the OAuth flow
+
const oauth = await ctx.oauthClient.callback(params)
+
+
// Update the session cookie
+
session.did = oauth.session.did
+
+
await session.save()
} catch (err) {
ctx.logger.error({ err }, 'oauth callback failed')
-
return res.redirect('/?error')
}
+
+
return res.redirect('/')
}),
)
// Login page
-
app.get(
+
router.get(
'/login',
-
handler((req: Request, res: Response) => {
+
handler(async (req, res) => {
+
res.setHeader('cache-control', `max-age=${MAX_AGE}, public`)
res.type('html').send(page(login({})))
}),
)
// Login handler
-
app.post(
+
router.post(
'/login',
-
express.urlencoded({ extended: true }),
-
handler(async (req: Request, res: Response) => {
-
// Validate
-
const input = req.body?.input
-
if (typeof input !== 'string') {
-
return void res
-
.type('html')
-
.send(page(login({ error: 'invalid input' })))
-
}
+
express.urlencoded(),
+
handler(async (req, res) => {
+
// Never store this route
+
res.setHeader('cache-control', 'no-store')
// Initiate the OAuth flow
try {
+
// Validate input: can be a handle, a DID or a service URL (PDS).
+
const input = ifString(req.body.input)
+
if (!input) {
+
throw new Error('Invalid input')
+
}
+
+
// Initiate the OAuth flow
const url = await ctx.oauthClient.authorize(input, {
scope: 'atproto transition:generic',
})
+
res.redirect(url.toString())
} catch (err) {
ctx.logger.error({ err }, 'oauth authorize failed')
-
res.type('html').send(
-
page(
-
login({
-
error:
-
err instanceof OAuthResolverError
-
? err.message
-
: "couldn't initiate login",
-
}),
-
),
-
)
+
+
const error = err instanceof Error ? err.message : 'unexpected error'
+
+
return res.type('html').send(page(login({ error })))
}
}),
)
// Signup
-
app.get(
+
router.get(
'/signup',
-
handler(async (req: Request, res: Response) => {
+
handler(async (req, res) => {
+
res.setHeader('cache-control', `max-age=${MAX_AGE}, public`)
+
try {
-
const url = await ctx.oauthClient.authorize(env.PDS_URL, {
+
const service = env.PDS_URL ?? 'https://bsky.social'
+
const url = await ctx.oauthClient.authorize(service, {
scope: 'atproto transition:generic',
})
res.redirect(url.toString())
···
)
// Logout handler
-
app.post(
+
router.post(
'/logout',
-
handler(async (req: Request, res: Response) => {
+
handler(async (req, res) => {
+
// Never store this route
+
res.setHeader('cache-control', 'no-store')
+
const session = await getIronSession<Session>(req, res, {
cookieName: 'sid',
password: env.COOKIE_SECRET,
···
)
// Homepage
-
app.get(
+
router.get(
'/',
-
handler(async (req: Request, res: Response) => {
+
handler(async (req, res) => {
// If the user is signed in, get an agent which communicates with their server
const agent = await getSessionAgent(req, res, ctx)
···
.executeTakeFirst()
: undefined
-
// Map (unique) user DIDs to their domain-name handles
-
const uniqueDids = [...new Set(statuses.map((s) => s.authorDid))]
-
-
const didHandleMap: Record<string, string | undefined> =
-
Object.fromEntries(
-
await Promise.all(
-
uniqueDids.map((did) =>
-
ctx.identityResolver.resolve(did).then(
-
(r) => [did, r.handle],
-
() => [did, undefined],
-
),
-
),
-
),
-
)
+
// Map user DIDs to their domain-name handles
+
const didHandleMap = await ctx.resolver.resolveDidsToHandles(
+
statuses.map((s) => s.authorDid),
+
)
if (!agent) {
// Serve the logged-out view
-
return void res
-
.type('html')
-
.send(page(home({ statuses, didHandleMap })))
+
return res.type('html').send(page(home({ statuses, didHandleMap })))
}
// Fetch additional information about the logged-in user
···
: {}
// Serve the logged-in view
-
res.type('html').send(
-
page(
-
home({
-
statuses,
-
didHandleMap,
-
profile,
-
myStatus,
-
}),
-
),
-
)
+
res
+
.type('html')
+
.send(page(home({ statuses, didHandleMap, profile, myStatus })))
}),
)
// "Set status" handler
-
app.post(
+
router.post(
'/status',
-
express.urlencoded({ extended: true }),
-
handler(async (req: Request, res: Response) => {
+
express.urlencoded(),
+
handler(async (req, res) => {
// If the user is signed in, get an agent which communicates with their server
const agent = await getSessionAgent(req, res, ctx)
if (!agent) {
-
return void res
+
return res
.status(401)
.type('html')
.send('<h1>Error: Session required</h1>')
}
-
// Construct & validate their status record
-
const rkey = TID.nextStr()
+
// Construct their status record
const record = {
$type: 'xyz.statusphere.status',
status: req.body?.status,
createdAt: new Date().toISOString(),
}
+
+
// Make sure the record generated from the input is valid
if (!Status.validateRecord(record).success) {
-
return void res
+
return res
.status(400)
.type('html')
.send('<h1>Error: Invalid status</h1>')
···
const res = await agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection: 'xyz.statusphere.status',
-
rkey,
+
rkey: TID.nextStr(),
record,
validate: false,
})
uri = res.data.uri
} catch (err) {
ctx.logger.warn({ err }, 'failed to write record')
-
return void res
+
return res
.status(500)
.type('html')
.send('<h1>Error: Failed to write record</h1>')
···
}),
)
-
return app
+
return router
}