Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

documentation: add technical docs

Hugo b999c498 1deb63d5

+331 -20
+1 -1
.env.example
··· 3 3 PUBLIC_URL=http://127.0.0.1:5175 4 4 PDS_URL=http://localhost:3000 5 5 JETSTREAM_URL=wss://jetstream2.us-east.bsky.network/subscribe 6 - COOKIE_SECRET= 6 + COOKIE_SECRET= # openssl rand -base64 32 7 7 NSID_ALLOWLIST= 8 8 NSID_BLOCKLIST=
+1 -2
app/routes/auth/callback.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { setSignedCookie } from "hono/cookie"; 3 3 import { getOAuthClient } from "@/auth/client.js"; 4 + import { COOKIE_NAME } from "@/auth/middleware.js"; 4 5 import { config } from "@/config.js"; 5 6 import { db } from "@/db/index.js"; 6 7 import { users } from "@/db/schema.js"; 7 - 8 - const COOKIE_NAME = "airglow_session"; 9 8 10 9 export default createRoute(async (c) => { 11 10 const params = new URL(c.req.url).searchParams;
+2 -1
app/routes/auth/signout.ts
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { deleteCookie } from "hono/cookie"; 3 + import { COOKIE_NAME } from "@/auth/middleware.js"; 3 4 4 5 export const POST = createRoute((c) => { 5 - deleteCookie(c, "airglow_session", { path: "/" }); 6 + deleteCookie(c, COOKIE_NAME, { path: "/" }); 6 7 return c.redirect("/"); 7 8 });
+99
docs/dev-server.md
··· 1 + # Dev server and SSR architecture 2 + 3 + Airglow uses HonoX for file-based routing with islands architecture. This creates a split-runtime situation during development: the server-side code runs on Node (via Vite), while production runs on Bun. 4 + 5 + ## How HonoX dev mode works 6 + 7 + ``` 8 + Browser request 9 + 10 + Vite dev server (Node) 11 + 12 + HonoX vite plugin (SSR middleware) 13 + 14 + app/server.ts → routes → lib/* 15 + 16 + HTML response (SSR) + client JS (HMR) 17 + ``` 18 + 19 + `vp dev` (Vite+) starts a Vite dev server. HonoX registers a middleware via `configureServer` that intercepts all requests and runs them through the Hono app using Vite's SSR module runner. Route files in `app/routes/` are discovered and registered automatically. 20 + 21 + The key constraint: **all server-side code executes in Node**, not Bun. This means: 22 + - `bun:sqlite` is not available (see [sqlite-bun-compat.md](./sqlite-bun-compat.md)) 23 + - CJS packages must be loaded via `createRequire()` (see [oauth.md](./oauth.md)) 24 + - `.env` must be loaded manually for Node 25 + 26 + ## HonoX's `noExternal: true` 27 + 28 + HonoX's Vite plugin unconditionally sets: 29 + 30 + ```ts 31 + { ssr: { noExternal: true } } 32 + ``` 33 + 34 + This tells Vite to ESM-transform **all** `node_modules` during SSR instead of letting Node load them natively. This is required for HonoX's file-based routing to work (it needs to process route imports through Vite's module graph). But it breaks CJS packages that use `module.exports` or `require()`. 35 + 36 + Workarounds: 37 + - **Vite aliases** redirect `bun:sqlite` and `better-sqlite3` to a `createRequire()` shim 38 + - **`createRequire()`** loads `@atproto/oauth-client-node` bypassing Vite's transform 39 + 40 + ## PDS proxy 41 + 42 + The local PDS runs in Docker on `localhost:3000` but is configured with hostname `pds.dev`. The browser needs to interact with the PDS (OAuth authorize page, asset loading), but `pds.dev` is unreachable. 43 + 44 + A custom Vite plugin (`pdsProxy` in `vite.config.ts`) solves this: 45 + 46 + ``` 47 + Browser → http://127.0.0.1:5175/oauth/authorize 48 + ↓ (Vite middleware, before HonoX) 49 + pdsProxy plugin 50 + ↓ (http.request to localhost:3000) 51 + Local PDS 52 + ↓ (response with pds.dev URLs rewritten to 127.0.0.1:5175) 53 + Browser 54 + ``` 55 + 56 + ### Plugin lifecycle 57 + 58 + 1. At plugin creation time, `getPdsIssuer()` synchronously fetches the PDS issuer URL via `execSync` + `curl`. This must be synchronous because Vite's `defineConfig` doesn't support async in all contexts. 59 + 2. In `configureServer`, the middleware is registered **before** HonoX's middleware (plugin order matters — `pdsProxy()` is listed first in the `plugins` array). 60 + 3. Requests matching `/oauth/`, `/.well-known/`, `/xrpc/`, `/@atproto/` are proxied. 61 + 62 + ### Why `http.request()` and not `fetch()` 63 + 64 + Node's `fetch()` follows the Fetch spec and silently strips `sec-fetch-*` headers. The PDS validates these headers: 65 + - `sec-fetch-site: same-origin` — required for the PDS to accept the request 66 + - `sec-fetch-mode: navigate` — required for page loads (the browser sends this naturally) 67 + 68 + `http.request()` from `node:http` forwards headers as-is. 69 + 70 + ### Response rewriting 71 + 72 + Text responses (HTML, JSON, JavaScript) have all occurrences of the PDS issuer URL (e.g. `https://pds.dev`) replaced with the app origin (`http://127.0.0.1:5175`). This ensures: 73 + - The PDS OAuth SPA's internal API calls go through the proxy 74 + - Redirect URLs in JSON responses point to the proxy 75 + - Location headers in 3xx responses are rewritten 76 + 77 + Additionally: 78 + - `Strict-Transport-Security` headers are stripped (we're on HTTP) 79 + - `upgrade-insecure-requests` is removed from CSP 80 + - `Secure` flag is stripped from cookies 81 + 82 + ## Production 83 + 84 + In production, none of these workarounds apply: 85 + - `bun run start` runs the Hono app directly under Bun 86 + - `bun:sqlite` resolves natively 87 + - CJS packages are loaded by Bun's module system 88 + - No PDS proxy (the app and PDS are on reachable domains) 89 + - No `.env` loader needed (Bun auto-loads it) 90 + 91 + ## Key configuration 92 + 93 + | Setting | Dev | Production | 94 + |---|---|---| 95 + | Runtime | Node (via Vite) | Bun | 96 + | SQLite driver | `better-sqlite3` (via alias) | `bun:sqlite` | 97 + | PDS access | Vite proxy + URL rewriting | Direct HTTPS | 98 + | `.env` loading | Manual loader in `lib/config.ts` | Bun auto-loads | 99 + | OAuth client type | Loopback (`http://localhost?...`) | Confidential (HTTPS metadata URL) |
+119
docs/oauth.md
··· 1 + # AT Protocol OAuth 2 + 3 + Airglow authenticates users via AT Protocol OAuth, which requires PAR (Pushed Authorization Requests), PKCE, and DPoP. The implementation lives in `lib/auth/` and uses `@atproto/oauth-client-node`. 4 + 5 + ## Flow 6 + 7 + 1. User submits their handle on `/auth/login` 8 + 2. Server resolves the handle to a DID (via local PDS in dev, DNS in prod) 9 + 3. `NodeOAuthClient.authorize()` performs a server-side PAR request to the user's PDS, which returns a `request_uri` 10 + 4. Server redirects the browser to the PDS's `/oauth/authorize` page 11 + 5. User authorizes the app on the PDS 12 + 6. PDS redirects back to `/auth/callback` with an authorization code 13 + 7. `NodeOAuthClient.callback()` exchanges the code for tokens (DPoP-bound) 14 + 8. Server upserts the user in the local DB and sets a signed session cookie 15 + 9. User is redirected to `/dashboard` 16 + 17 + ## Local dev vs production 18 + 19 + The AT Protocol OAuth spec defines two client types. Airglow uses both depending on the environment. 20 + 21 + ### Local dev (loopback client) 22 + 23 + When `PDS_URL` is set or `PUBLIC_URL` is a loopback address, the OAuth client uses the **loopback** format: 24 + 25 + - `client_id` is `http://localhost?redirect_uri=...&scope=...` (metadata encoded in the URL itself) 26 + - `application_type: "native"`, `token_endpoint_auth_method: "none"` 27 + - No signing key, no JWKS endpoint 28 + - `allowHttp: true` on the OAuth client 29 + 30 + ### Production (confidential client) 31 + 32 + - `client_id` is `https://<domain>/oauth/client-metadata.json` (served by the app) 33 + - `application_type: "web"`, `token_endpoint_auth_method: "private_key_jwt"` 34 + - ES256 signing key auto-generated at `data/oauth-key.json` 35 + - JWKS served at `/oauth/jwks.json` 36 + - Both endpoints are registered in `app/server.ts` 37 + 38 + ## CJS compatibility workaround 39 + 40 + `@atproto/oauth-client-node` ships as CommonJS. HonoX forces `ssr: { noExternal: true }` in its Vite plugin, which makes Vite ESM-transform all `node_modules` during SSR. CJS modules break because `exports` and `module` are not defined in ESM context. 41 + 42 + The workaround in `lib/auth/client.ts`: 43 + 44 + ```ts 45 + import { createRequire } from "node:module"; 46 + const require = createRequire(import.meta.url); 47 + const { NodeOAuthClient, JoseKey, requestLocalLock } = require( 48 + "@atproto/oauth-client-node", 49 + ) as typeof import("@atproto/oauth-client-node"); 50 + ``` 51 + 52 + `createRequire()` gives us a CJS-compatible `require()` in an ESM module. This bypasses Vite's transform pipeline entirely. Type safety is preserved via `import type` and the `as typeof import(...)` cast. 53 + 54 + In production (running directly under Bun), this still works — Bun supports `createRequire()`. 55 + 56 + ## Local PDS: URL rewriting and proxy 57 + 58 + A local PDS (e.g. running in Docker) registers DIDs with its configured public hostname (e.g. `pds.dev`). This hostname is not reachable from the dev machine — `pds.dev` is a real domain that redirects to GitHub. Two layers of rewriting handle this. 59 + 60 + ### Server-side: custom fetch 61 + 62 + `lib/auth/client.ts` discovers the PDS hostname by querying `/xrpc/com.atproto.server.describeServer` and passes a custom `fetch` to `NodeOAuthClient` that rewrites URLs: 63 + 64 + ``` 65 + https://pds.dev/xrpc/... → http://localhost:3000/xrpc/... 66 + ``` 67 + 68 + This handles all server-side HTTP calls the OAuth client makes (PAR, token exchange, DID resolution). 69 + 70 + ### Browser-side: Vite proxy 71 + 72 + The OAuth authorize page must load in the browser. The browser can't reach `pds.dev`, and the PDS rejects requests where the `Host` header doesn't match its configured hostname. 73 + 74 + `vite.config.ts` registers a Vite middleware plugin (`pdsProxy`) that: 75 + 76 + 1. Intercepts requests to `/oauth/`, `/.well-known/`, `/xrpc/`, `/@atproto/` 77 + 2. Forwards them to the local PDS via `http.request()` (not `fetch()` — see below) 78 + 3. Rewrites request headers: sets `sec-fetch-site: same-origin`, `Host: <pds-hostname>` 79 + 4. Rewrites response bodies: replaces `https://pds.dev` with `http://127.0.0.1:5175` 80 + 5. Strips HTTPS-only headers (HSTS, CSP `upgrade-insecure-requests`) 81 + 6. Strips `Secure` flag from cookies 82 + 83 + The login handler rewrites the authorize URL for the browser: 84 + ```ts 85 + const redirectUrl = rewritePdsUrl(url.toString(), "browser"); 86 + // https://pds.dev/oauth/authorize?... → http://127.0.0.1:5175/oauth/authorize?... 87 + ``` 88 + 89 + ### Why `http.request()` instead of `fetch()` 90 + 91 + Node's `fetch()` (based on undici) enforces the Fetch spec and silently strips `sec-fetch-*` headers. The PDS checks these headers to verify same-origin requests. `http.request()` from `node:http` has no such restrictions — headers are forwarded as-is. 92 + 93 + ### Why `127.0.0.1` instead of `localhost` 94 + 95 + AT Protocol OAuth requires `PUBLIC_URL` to use `127.0.0.1` for loopback clients. The PDS also rejects requests where `sec-fetch-site` indicates cross-site — using the IP address instead of `localhost` avoids same-site/cross-site mismatches between the app and the PDS proxy. 96 + 97 + ## Session management 98 + 99 + Sessions use Hono's signed cookies (`hono/cookie`): 100 + 101 + - Cookie name: `airglow_session` 102 + - Value: user's DID, HMAC-signed with `COOKIE_SECRET` 103 + - `httpOnly`, `sameSite: Lax`, 30-day expiry 104 + - `secure` only when `PUBLIC_URL` is HTTPS 105 + 106 + OAuth token state (access/refresh tokens, DPoP keys) is stored separately in the `oauth_sessions` SQLite table, managed by `NodeOAuthClient`. 107 + 108 + ## Key files 109 + 110 + | File | Role | 111 + |---|---| 112 + | `lib/auth/client.ts` | OAuth client singleton, key management, handle resolution, URL rewriting | 113 + | `lib/auth/storage.ts` | SQLite-backed stores for OAuth state and sessions | 114 + | `lib/auth/middleware.ts` | Cookie-based session middleware (`getSessionUser`, `requireAuth`) | 115 + | `app/routes/auth/login.tsx` | Login form + POST handler (initiates OAuth) | 116 + | `app/routes/auth/callback.tsx` | OAuth callback (token exchange, user upsert, cookie) | 117 + | `app/routes/auth/signout.ts` | Clears session cookie | 118 + | `app/routes/dashboard/_middleware.ts` | Auth guard for dashboard routes | 119 + | `vite.config.ts` | PDS proxy plugin for local dev |
+97
docs/sqlite-bun-compat.md
··· 1 + # SQLite and Bun compatibility 2 + 3 + Airglow uses `bun:sqlite` as its SQLite driver with Drizzle ORM. This works perfectly when running under Bun (production, migrations, tests). But during development, HonoX runs server-side rendering through Vite, which executes on Node — and Node cannot resolve `bun:` module schemes. 4 + 5 + ## The problem 6 + 7 + ``` 8 + Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. 9 + Received protocol 'bun:' 10 + ``` 11 + 12 + The import chain that triggers this: 13 + 14 + ``` 15 + app/server.ts → lib/auth/client.ts → lib/auth/storage.ts → lib/db/index.ts 16 + 17 + import { Database } from "bun:sqlite" 18 + import { drizzle } from "drizzle-orm/bun-sqlite" 19 + ``` 20 + 21 + HonoX's Vite plugin sets `ssr: { noExternal: true }`, which forces Vite to ESM-transform every dependency during SSR. This means Vite follows all imports and tries to resolve them through Node's module system. 22 + 23 + ## The workaround 24 + 25 + Three Vite `resolve.alias` entries in `vite.config.ts` swap the Bun-specific modules for Node-compatible equivalents during dev: 26 + 27 + ```ts 28 + resolve: { 29 + alias: { 30 + "bun:sqlite": "/lib/db/sqlite-compat.ts", 31 + "better-sqlite3": "/lib/db/sqlite-compat.ts", 32 + "drizzle-orm/bun-sqlite": "drizzle-orm/better-sqlite3", 33 + }, 34 + } 35 + ``` 36 + 37 + ### What each alias does 38 + 39 + **`"bun:sqlite"` -> `lib/db/sqlite-compat.ts`** 40 + 41 + Replaces the Bun-native SQLite driver with `better-sqlite3`. The shim uses `createRequire()` to load the CJS package: 42 + 43 + ```ts 44 + import { createRequire } from "node:module"; 45 + const require = createRequire(import.meta.url); 46 + const BetterSqlite3 = require("better-sqlite3"); 47 + export default BetterSqlite3; 48 + export const Database = BetterSqlite3; 49 + ``` 50 + 51 + `createRequire()` is necessary because `better-sqlite3` is a CJS module (uses `module.exports`). HonoX's `noExternal: true` forces Vite to ESM-transform it, which fails with `module is not defined`. `require()` bypasses Vite's transform entirely. 52 + 53 + **`"better-sqlite3"` -> `lib/db/sqlite-compat.ts`** 54 + 55 + The Drizzle adapter (`drizzle-orm/better-sqlite3`) internally imports `better-sqlite3`. Without this alias, Vite would try to ESM-transform the CJS module directly. Pointing it to the same shim ensures all access goes through `createRequire()`. 56 + 57 + **`"drizzle-orm/bun-sqlite"` -> `"drizzle-orm/better-sqlite3"`** 58 + 59 + The Drizzle ORM adapter for `bun:sqlite` calls bun-specific APIs on prepared statements (e.g. `stmt.values()`) that don't exist on `better-sqlite3`. Swapping the adapter ensures Drizzle uses `better-sqlite3`-compatible query methods. 60 + 61 + ## Why not just use `better-sqlite3` everywhere? 62 + 63 + `bun:sqlite` is Bun's built-in SQLite binding — zero native compilation, faster than `better-sqlite3` under Bun, and no external dependency. The aliases only apply during Vite dev (where Node runs SSR). In production (`bun run start`), the real `bun:sqlite` is used since Bun resolves it natively without Vite. 64 + 65 + ## Why not `ssr.external`? 66 + 67 + The obvious fix would be to tell Vite to externalize `bun:sqlite`: 68 + 69 + ```ts 70 + ssr: { external: ["bun:sqlite"] } 71 + ``` 72 + 73 + This doesn't work because HonoX's Vite plugin unconditionally sets `ssr: { noExternal: true }`, which overrides any `external` setting. Attempts to override this via `configResolved` also failed — the config is effectively locked by HonoX. 74 + 75 + ## Environment variables 76 + 77 + The `.env` file is auto-loaded by Bun but not by Node (Vite SSR). `lib/config.ts` includes a minimal `.env` loader for the Node context: 78 + 79 + ```ts 80 + if (typeof globalThis.Bun === "undefined" && existsSync(".env")) { 81 + for (const line of readFileSync(".env", "utf-8").split("\n")) { 82 + const m = line.match(/^([^#=\s]+)=(.*)$/); 83 + if (m?.[1] && !(m[1] in process.env)) process.env[m[1]] = m[2]; 84 + } 85 + } 86 + ``` 87 + 88 + This only fills in variables that aren't already in `process.env`, so it doesn't override shell environment variables. 89 + 90 + ## Key files 91 + 92 + | File | Role | 93 + |---|---| 94 + | `lib/db/sqlite-compat.ts` | `createRequire()` shim for `better-sqlite3` | 95 + | `lib/db/index.ts` | Database connection (imports `bun:sqlite`, aliased in dev) | 96 + | `vite.config.ts` | Aliases and PDS proxy plugin | 97 + | `lib/config.ts` | `.env` loader for Node SSR context |
+4 -3
lib/auth/client.ts
··· 13 13 import { sessionStore, stateStore } from "./storage.js"; 14 14 15 15 const KEY_PATH = resolve("./data/oauth-key.json"); 16 + const OAUTH_SCOPE = "atproto transition:generic"; 16 17 17 18 const pdsUrl = config.pdsUrl; 18 19 const isLocalDev = ··· 116 117 const redirectUri = config.publicUrl.replace("localhost", "127.0.0.1") + "/auth/callback"; 117 118 client = new NodeOAuthClient({ 118 119 clientMetadata: { 119 - client_id: `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent("atproto transition:generic")}`, 120 + client_id: `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(OAUTH_SCOPE)}`, 120 121 client_name: "Airglow", 121 122 client_uri: config.publicUrl, 122 123 redirect_uris: [redirectUri], 123 - scope: "atproto transition:generic", 124 + scope: OAUTH_SCOPE, 124 125 response_types: ["code"], 125 126 grant_types: ["authorization_code", "refresh_token"], 126 127 application_type: "native", ··· 140 141 client_name: "Airglow", 141 142 client_uri: config.publicUrl, 142 143 redirect_uris: [`${config.publicUrl}/auth/callback`], 143 - scope: "atproto transition:generic", 144 + scope: OAUTH_SCOPE, 144 145 response_types: ["code"], 145 146 grant_types: ["authorization_code", "refresh_token"], 146 147 application_type: "web",
+1 -1
lib/auth/middleware.ts
··· 5 5 import { db } from "../db/index.js"; 6 6 import { users } from "../db/schema.js"; 7 7 8 - const COOKIE_NAME = "airglow_session"; 8 + export const COOKIE_NAME = "airglow_session"; 9 9 10 10 export type SessionUser = { 11 11 id: number;
+6 -10
lib/auth/storage.ts
··· 10 10 11 11 export const stateStore: NodeSavedStateStore = { 12 12 async set(key: string, value: NodeSavedState) { 13 + const json = JSON.stringify(value); 13 14 await db 14 15 .insert(oauthStates) 15 - .values({ key, value: JSON.stringify(value) }) 16 - .onConflictDoUpdate({ 17 - target: oauthStates.key, 18 - set: { value: JSON.stringify(value) }, 19 - }); 16 + .values({ key, value: json }) 17 + .onConflictDoUpdate({ target: oauthStates.key, set: { value: json } }); 20 18 }, 21 19 async get(key: string) { 22 20 const row = await db.query.oauthStates.findFirst({ ··· 32 30 33 31 export const sessionStore: NodeSavedSessionStore = { 34 32 async set(key: string, value: NodeSavedSession) { 33 + const json = JSON.stringify(value); 35 34 await db 36 35 .insert(oauthSessions) 37 - .values({ key, value: JSON.stringify(value) }) 38 - .onConflictDoUpdate({ 39 - target: oauthSessions.key, 40 - set: { value: JSON.stringify(value) }, 41 - }); 36 + .values({ key, value: json }) 37 + .onConflictDoUpdate({ target: oauthSessions.key, set: { value: json } }); 42 38 }, 43 39 async get(key: string) { 44 40 const row = await db.query.oauthSessions.findFirst({
+1 -1
lib/config.ts
··· 19 19 publicUrl: env("PUBLIC_URL", "http://127.0.0.1:5175"), 20 20 pdsUrl: process.env.PDS_URL?.replace(/\/$/, "") || "", 21 21 jetstreamUrl: env("JETSTREAM_URL", "wss://jetstream2.us-east.bsky.network/subscribe"), 22 - cookieSecret: process.env.COOKIE_SECRET || crypto.randomUUID(), 22 + cookieSecret: env("COOKIE_SECRET", ""), 23 23 nsidAllowlist: env("NSID_ALLOWLIST", "").split(",").filter(Boolean), 24 24 nsidBlocklist: env("NSID_BLOCKLIST", "").split(",").filter(Boolean), 25 25 } as const;
-1
lib/db/index.ts
··· 6 6 import { mkdirSync } from "node:fs"; 7 7 import { dirname } from "node:path"; 8 8 9 - // Ensure the data directory exists 10 9 mkdirSync(dirname(config.databasePath), { recursive: true }); 11 10 12 11 const sqlite = new Database(config.databasePath);