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=""
+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
}
+13 -2
src/context.ts
···
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 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()
+
},
}
}
+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()
}
+26 -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);
}
···
.error.visible {
display: block;
}
+
#header {
background-color: #fff;
text-align: center;
+78 -79
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
-
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 {
+
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)
···
.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
-
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
-
}
-
-
const status = ifString(req.body?.status)
-
if (!status || !STATUS_OPTIONS.includes(status)) {
-
throw new Error('Invalid status')
+
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,
+
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('/')
}),
)