Scratch space for learning atproto app development

Compare changes

Choose any two refs to compare.

+2 -2
.env.template
···
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 the default PDS for login and sign-ups
+
# PDS_URL="https://my.pds" # The default PDS for login and sign-ups
-
# Secrets bellow *MUST* be set in production
+
# Secrets below *MUST* be set in production
# May be generated with `openssl rand -base64 33`
# COOKIE_SECRET=""
+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
+6
src/context.ts
···
logger: pino.Logger
oauthClient: NodeOAuthClient
resolver: BidirectionalResolver
+
destroy: () => Promise<void>
}
export async function createAppContext(): Promise<AppContext> {
···
logger,
oauthClient,
resolver,
+
+
async destroy() {
+
await ingester.destroy()
+
await db.destroy()
+
},
}
}
+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()
}
+74 -65
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, STATUS_OPTIONS } from '#/pages/home'
+
import { home } from '#/pages/home'
import { login } from '#/pages/login'
-
import { ifString } from '#/lib/util'
+
+
// Max age, in seconds, for static routes and assets
+
const MAX_AGE = env.NODE_ENV === 'production' ? 60 : 0
type Session = { did?: string }
···
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
+
+
// This page is dynamic and should not be cached publicly
+
res.setHeader('cache-control', `max-age=${MAX_AGE}, private`)
+
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)
+
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 {
+
export const createRouter = (ctx: AppContext): RequestListener => {
const router = express()
// Static assets
-
router.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
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
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
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 {
// Load the session cookie
···
// 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
router.get(
'/login',
-
handler(async (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
router.post(
'/login',
-
express.urlencoded({ extended: true }),
-
handler(async (req: Request, res: Response) => {
-
const input = ifString(req.body.input)
-
-
// Validate
-
if (!input) {
-
res.type('html').send(page(login({ error: 'invalid input' })))
-
return
-
}
-
-
// @NOTE input can be a handle, a DID or a service URL (PDS).
+
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')
-
const error =
-
err instanceof OAuthResolverError
-
? err.message
-
: "couldn't initiate login"
+
const error = err instanceof Error ? err.message : 'unexpected error'
-
res.type('html').send(page(login({ error })))
+
return res.type('html').send(page(login({ error })))
}
}),
)
···
// Signup
router.get(
'/signup',
-
handler(async (req: Request, res: Response) => {
+
handler(async (req, res) => {
+
res.setHeader('cache-control', `max-age=${MAX_AGE}, public`)
+
try {
const service = env.PDS_URL ?? 'https://bsky.social'
const url = await ctx.oauthClient.authorize(service, {
···
// Logout handler
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
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)
···
if (!agent) {
// Serve the logged-out view
-
res.type('html').send(page(home({ statuses, didHandleMap })))
-
return
+
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
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) {
-
res.redirect(`/login`)
-
return
+
return res
+
.status(401)
+
.type('html')
+
.send('<h1>Error: Session required</h1>')
}
-
const status = ifString(req.body?.status)
-
if (!status || !STATUS_OPTIONS.includes(status)) {
-
throw new Error('Invalid status')
-
}
-
-
// Construct & validate their status record
-
const rkey = TID.nextStr()
+
// Construct their status record
const record = {
$type: 'xyz.statusphere.status',
-
status,
+
status: req.body?.status,
createdAt: new Date().toISOString(),
}
+
// Make sure the record generated from the input is valid
if (!Status.validateRecord(record).success) {
-
res.status(400).type('html').send('<h1>Error: Invalid status</h1>')
-
return
+
return res
+
.status(400)
+
.type('html')
+
.send('<h1>Error: Invalid status</h1>')
}
-
// Write the status record to the user's repository
let uri
try {
+
// Write the status record to the user's repository
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')
-
res
+
return res
.status(500)
.type('html')
.send('<h1>Error: Failed to write record</h1>')
-
return
}
try {
···
)
}
-
res.redirect('/')
+
return res.redirect('/')
}),
)