···11+# Airglow
22+33+Webhooks for the AT Protocol. See README.md for the product spec.
44+55+## Stack
66+77+- **Runtime**: Bun
88+- **Server**: Hono + HonoX (file-based routing, islands architecture)
99+- **UI**: hono/jsx (SSR + client hydration)
1010+- **Styling**: @vanilla-extract/css
1111+- **Database**: SQLite via bun:sqlite + Drizzle ORM
1212+- **Toolchain**: Vite+ (`vp`) — wraps Vite 8, Vitest, Oxlint, Oxfmt
1313+- **Lexicons**: managed with `goat lex`, stored in `lexicons/`
1414+1515+## Commands
1616+1717+- `vp dev` — start dev server
1818+- `vp build --mode client` — build client assets
1919+- `vp check` — lint, format, type-check
2020+- `vp test` — run tests
2121+- `bun run start` — run production server
2222+- `bun run db:generate` — generate Drizzle migrations
2323+- `bun run db:migrate` — run migrations
2424+- `goat lex lint lexicons/` — lint lexicon schemas
2525+2626+## Conventions
2727+2828+- Use `bun` instead of `node`, `npm`, etc.
2929+- Bun auto-loads .env — no dotenv needed
3030+- Use `bun:sqlite` for SQLite — not better-sqlite3
3131+- Routes go in `app/routes/`, islands in `app/islands/`
3232+- Shared non-interactive components go in `app/components/`
3333+- Backend logic goes in `lib/`
3434+- Styles use vanilla-extract `.css.ts` files in `app/styles/`
+21
LICENSE
···11+MIT License
22+33+Copyright (c) 2026 Hugo (exosphere.site)
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+117
README.md
···11+# Airglow
22+33+Webhooks for the AT Protocol — subscribe to events, filter them, and forward them to HTTP endpoints.
44+55+Airglow connects to [Jetstream](https://atproto.com/guides/streaming-data#jetstream) (AT Protocol's event streaming service), matches incoming records against user-defined subscriptions, and delivers them to callback URLs. Think IFTTT or Zapier, but the trigger side is always "something happened on the AT Protocol."
66+77+Website: [rglw.app](https://rglw.app)
88+99+## How it works
1010+1111+### For end users
1212+1313+1. Sign in to Airglow using [AT Protocol OAuth](https://atproto.com/specs/oauth).
1414+2. Create a subscription by choosing a lexicon to listen to (e.g. `sh.tangled.feed.star`, `site.standard.document`).
1515+3. Add conditions to filter events — simple equality checks on record fields like the record owner or specific values in the data. The schema is known from the lexicon, so Airglow can present the available fields.
1616+4. Provide a callback URL to receive matching events.
1717+1818+Airglow verifies that the callback URL actually supports the selected lexicon before activating the subscription (see [Callback endpoints](#callback-endpoints) below).
1919+2020+#### Data ownership
2121+2222+Subscriptions are stored on the user's PDS as [`app.rglw.subscription`](lexicons/app/rglw/subscription.json) records. The user's PDS is the source of truth — Airglow instances maintain a local index for fast event matching, but the data belongs to the user. This means subscriptions are portable across Airglow instances and visible to any AT Protocol client.
2323+2424+### For developers
2525+2626+Developers build HTTP endpoints that receive webhook payloads from Airglow.
2727+2828+#### Callback endpoints
2929+3030+A callback server must expose a metadata route so Airglow can discover its endpoints and verify which lexicons each one accepts:
3131+3232+```
3333+GET <server-base-url>/.well-known/airglow
3434+```
3535+3636+This returns a JSON manifest mapping callback paths to the lexicons they handle:
3737+3838+```json
3939+{
4040+ "callbacks": [
4141+ { "path": "/hooks/stars", "lexicons": ["sh.tangled.feed.star"] },
4242+ { "path": "/hooks/posts", "lexicons": ["app.bsky.feed.post"] }
4343+ ]
4444+}
4545+```
4646+4747+When a user registers a callback URL (e.g. `https://example.com/hooks/stars`), Airglow fetches the manifest from `https://example.com/.well-known/airglow` and confirms the path `/hooks/stars` is listed and accepts the requested lexicon.
4848+4949+#### Webhook payload
5050+5151+When a matching event occurs, Airglow sends a POST request to the callback URL. The payload contains the Jetstream event (commit operation, record data, repo DID, timestamp) wrapped in a Airglow envelope with metadata such as the subscription ID and matched condition.
5252+5353+#### Request signing
5454+5555+Airglow signs every outgoing request so that callback endpoints can verify it actually came from a legitimate Airglow instance (similar to how Stripe or GitHub sign webhook deliveries).
5656+5757+#### Response handling
5858+5959+- **2xx** — Success. The event was delivered.
6060+- **4xx** — Logged as a delivery failure. Users can review these errors in Airglow.
6161+- **5xx** — Airglow retries delivery (up to 2 retries with backoff).
6262+6363+### Future: protocol-native discovery
6464+6565+Today, users provide callback URLs manually. In the future, developers will be able to publish an `app.rglw.callback` record on their PDS, declaring their endpoint URL and supported lexicons. Airglow instances could then subscribe to this collection and index available callbacks, letting users browse and pick from discovered endpoints instead of entering URLs by hand.
6666+6767+## Development
6868+6969+### Prerequisites
7070+7171+- [Bun](https://bun.sh/) (runtime and package manager)
7272+- [Vite+](https://viteplus.dev/) (`curl -fsSL https://vite.plus | bash`)
7373+7474+### Getting started
7575+7676+```sh
7777+# Install dependencies
7878+vp install
7979+8080+# Set up the database
8181+cp .env.example .env
8282+bun run db:migrate
8383+8484+# Start the dev server
8585+vp dev
8686+```
8787+8888+The app will be available at `http://localhost:5173`.
8989+9090+### Useful commands
9191+9292+```sh
9393+vp check # lint, format, type-check
9494+vp test # run tests
9595+vp build # build client assets for production
9696+bun run start # run the production server
9797+```
9898+9999+### Lexicons
100100+101101+Lexicon schemas live in `lexicons/` and are managed with [goat](https://github.com/bluesky-social/indigo/tree/main/cmd/goat):
102102+103103+```sh
104104+goat lex lint lexicons/ # validate schemas
105105+goat lex new record app.rglw.<name> # create a new lexicon
106106+```
107107+108108+## Self-hosting
109109+110110+Airglow is designed to be easy to self-host. Instance operators can configure allowlists and blocklists on NSID domains to control which lexicons their instance handles. For example, a typical instance may want to block `app.bsky.*` or `app.bsky.feed.*` since those collections are very active and could overwhelm a small instance.
111111+112112+## References
113113+114114+- [AT Protocol docs](https://atproto.com/docs)
115115+ - [Lexicon spec](https://atproto.com/specs/lexicon)
116116+ - [Jetstream](https://atproto.com/guides/streaming-data#jetstream)
117117+ - [OAuth](https://atproto.com/specs/oauth)
+3
app/client.ts
···11+import { createClient } from "honox/client";
22+33+createClient();
···11+import { drizzle } from "drizzle-orm/bun-sqlite";
22+import { Database } from "bun:sqlite";
33+import { config } from "../config.js";
44+import * as schema from "./schema.js";
55+66+import { mkdirSync } from "node:fs";
77+import { dirname } from "node:path";
88+99+// Ensure the data directory exists
1010+mkdirSync(dirname(config.databasePath), { recursive: true });
1111+1212+const sqlite = new Database(config.databasePath);
1313+sqlite.exec("PRAGMA journal_mode = WAL;");
1414+sqlite.exec("PRAGMA foreign_keys = ON;");
1515+1616+export const db = drizzle(sqlite, { schema });
+5
lib/db/migrate.ts
···11+import { migrate } from "drizzle-orm/bun-sqlite/migrator";
22+import { db } from "./index.js";
33+44+migrate(db, { migrationsFolder: "./lib/db/migrations" });
55+console.log("Migrations complete.");
+50
lib/db/migrations/0000_lethal_titania.sql
···11+CREATE TABLE `delivery_logs` (
22+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
33+ `subscription_uri` text NOT NULL,
44+ `event_time_us` integer NOT NULL,
55+ `payload` text,
66+ `status_code` integer,
77+ `error` text,
88+ `attempt` integer DEFAULT 1 NOT NULL,
99+ `created_at` integer NOT NULL,
1010+ FOREIGN KEY (`subscription_uri`) REFERENCES `subscriptions`(`uri`) ON UPDATE no action ON DELETE cascade
1111+);
1212+--> statement-breakpoint
1313+CREATE TABLE `lexicon_cache` (
1414+ `nsid` text PRIMARY KEY NOT NULL,
1515+ `schema` text NOT NULL,
1616+ `fetched_at` integer NOT NULL
1717+);
1818+--> statement-breakpoint
1919+CREATE TABLE `oauth_sessions` (
2020+ `key` text PRIMARY KEY NOT NULL,
2121+ `value` text NOT NULL,
2222+ `expires_at` integer
2323+);
2424+--> statement-breakpoint
2525+CREATE TABLE `oauth_states` (
2626+ `key` text PRIMARY KEY NOT NULL,
2727+ `value` text NOT NULL,
2828+ `expires_at` integer
2929+);
3030+--> statement-breakpoint
3131+CREATE TABLE `subscriptions` (
3232+ `uri` text PRIMARY KEY NOT NULL,
3333+ `did` text NOT NULL,
3434+ `rkey` text NOT NULL,
3535+ `lexicon` text NOT NULL,
3636+ `callback_url` text NOT NULL,
3737+ `conditions` text DEFAULT '[]' NOT NULL,
3838+ `secret` text NOT NULL,
3939+ `active` integer DEFAULT false NOT NULL,
4040+ `indexed_at` integer NOT NULL
4141+);
4242+--> statement-breakpoint
4343+CREATE TABLE `users` (
4444+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
4545+ `did` text NOT NULL,
4646+ `handle` text NOT NULL,
4747+ `created_at` integer NOT NULL
4848+);
4949+--> statement-breakpoint
5050+CREATE UNIQUE INDEX `users_did_unique` ON `users` (`did`);