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 -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),
+
}),
})
}
+5 -4
src/lib/http.ts
···
+
import { Request, Response } from 'express'
import { createHttpTerminator } from 'http-terminator'
import { once } from 'node:events'
import type {
···
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 Handler<
Req extends IncomingMessage = IncomingMessage,
-
Res extends ServerResponse<Req> = ServerResponse<Req>,
+
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>,
+
Req extends IncomingMessage = Request,
+
Res extends ServerResponse = Response,
>(fn: Handler<Req, Res>): Middleware<Req, Res> {
return async (req, res, next) => {
try {
+30 -37
src/routes.ts
···
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 {
const oauthSession = await ctx.oauthClient.restore(session.did)
return oauthSession ? new Agent(oauthSession) : null
···
// 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])
···
// 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`)
-
-
return res.type('html').send(page(login({})))
+
res.type('html').send(page(login({})))
}),
)
···
router.post(
'/login',
express.urlencoded(),
-
handler(async (req: Request, res: Response) => {
+
handler(async (req, res) => {
+
// Never store this route
res.setHeader('cache-control', 'no-store')
-
const input = ifString(req.body.input)
-
-
// Validate
-
if (!input) {
-
return res.type('html').send(page(login({ error: 'invalid input' })))
-
}
-
-
// @NOTE "input" can be a handle, a DID or a service URL (PDS).
-
// 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'
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 {
···
// 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')
···
// Homepage
router.get(
'/',
-
handler(async (req: Request, res: Response) => {
-
// Prevent caching of this page when the credentials change
-
res.setHeader('Vary', 'Cookie')
-
+
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)
···
return res.type('html').send(page(home({ statuses, didHandleMap })))
}
-
// Make sure this page does not get cached in public caches (proxies)
-
res.setHeader('cache-control', 'private')
-
// Fetch additional information about the logged-in user
const profileResponse = await agent.com.atproto.repo
.getRecord({
···
router.post(
'/status',
express.urlencoded(),
-
handler(async (req: Request, res: Response) => {
-
// Never store this route
-
res.setHeader('cache-control', 'no-store')
-
+
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) {
···
.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,
···
const res = await agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection: 'xyz.statusphere.status',
-
rkey,
+
rkey: TID.nextStr(),
record,
validate: false,
})