Something went wrong. Try again.
Automations and webhooks for the AT Protocol airglow.run
automation webhook atproto atprotocol
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295import { createApp } from "honox/server";import { createMiddleware } from "hono/factory";import { sql } from "drizzle-orm";import { Hono } from "hono";import type { Server, ServerResponse } from "node:http";import { serve } from "@hono/node-server";import { SUBSCRIBER_CHANGED_PATH, subscriberChangedApp } from "@/atmo/callback.js";import { buildDidDocument } from "@/atmo/keys.js";import { getOAuthClient } from "@/auth/client.js";import { config } from "@/config.js";import { db } from "@/db/index.js";import { automations, users } from "@/db/schema.js";import { rateLimit } from "@/rate-limit.js";import { requireCloudflare } from "@/require-cloudflare.js";import { securityHeaders } from "@/security-headers.js";import type { Handle } from "./shutdown.js";
// did:web document for the atmo.pub sender identity, built once: the signing// key never changes within a process, so no per-request key file read.let cachedDidJson: string | null = null;
/** * Serve the files Vite copies from `public/` into the build output. Production * only: in dev, Vite serves `public/` itself. Every public path the pages * reference must be listed here, or it 404s in production while working in dev. * * Exported so a test can mount it against a fixture directory instead of a * real `dist/` build. */export async function mountBuiltAssets(app: Hono, root: string): Promise<void> { const { serveStatic } = await import("@hono/node-server/serve-static"); // Cache headers are set in a middleware after `next()`, only on a 200, not in // serveStatic's `onFound`: a header set there is dropped, since that hook // runs after the response is already built. // // Files under /static/ carry a content hash in their names, so they are // cached for a year and marked immutable. app.use("/static/*", async (c, next) => { await next(); if (c.res.status === 200) c.header("Cache-Control", "public, max-age=31536000, immutable"); }); app.use("/static/*", serveStatic({ root })); // Brand fonts, referenced by _renderer.tsx preloads and global.css.ts // @font-face rules. Their names carry no content hash, so they are cached // for a day rather than marked immutable: a re-subset font reaches browsers // without a rename. app.use("/fonts/*", async (c, next) => { await next(); if (c.res.status === 200) c.header("Cache-Control", "public, max-age=86400"); }); app.use("/fonts/*", serveStatic({ root })); app.use("/favicon.svg", serveStatic({ root })); app.use("/og-image.png", serveStatic({ root }));}
/** * Build the base app: all middleware, the /healthz probe, and the non-route * endpoints (OAuth discovery, did.json, callback, sitemap). It deliberately * does NOT mount the HonoX file routes — `buildWebApp` does that last, so every * middleware registered here wraps them. (Hono `app.use` only applies to routes * registered after it, and `createApp()` registers all file routes eagerly, so * mounting them last is what actually puts them behind the origin lock, the * security headers, and the rate limiters.) * * No side effects: doesn't bind a port or kick off background work. Exported so * integration tests can exercise the middleware wiring without the Vite-injected * `createApp()`, which only resolves route globs during a real build. */export async function buildBaseApp(): Promise<Hono> { const app = new Hono();
// Healthcheck — mounted first so it bypasses security headers / rate limits. // Used by Railway for service liveness. Returns 200 always while the process // is up; future work may degrade to 503 during shutdown for multi-replica // deploys (see docs/architecture.md). app.get("/healthz", (c) => c.json({ ok: true }));
// Lock the origin to Cloudflare: reject any request that did not transit the // edge (missing or wrong X-Origin-Auth secret). The guard exempts /healthz // internally (Railway's internal probe bypasses Cloudflare). No-op until // CLOUDFLARE_ORIGIN_SECRET is set, so dev is unaffected. if (config.cloudflareOriginSecret) { // Visible signal at boot: if the secret is set but the Cloudflare Transform // Rule that injects X-Origin-Auth is missing, every non-healthz request // 403s while /healthz stays green (a silent outage). This line lets an // operator correlate a 403 storm with the lock being active. console.log( "requireCloudflare: origin lock active (non-healthz traffic requires X-Origin-Auth)", ); app.use("*", requireCloudflare(config.cloudflareOriginSecret)); }
// Static files (production only — Vite serves these in dev) if (import.meta.env.PROD) await mountBuiltAssets(app, "./dist");
// Security headers on all responses app.use("*", securityHeaders());
// Require application/json Content-Type on API mutation endpoints app.use( "/api/*", createMiddleware(async (c, next) => { if (c.req.method === "POST" || c.req.method === "PATCH" || c.req.method === "PUT") { const ct = c.req.header("content-type"); if (!ct || !ct.includes("application/json")) { return c.json({ error: "Content-Type must be application/json" }, 415); } } return next(); }), );
// Rate limiting on auth and automation-creation endpoints const authLimiter = rateLimit({ windowMs: 60_000, max: 10 }); const apiLimiter = rateLimit({ windowMs: 60_000, max: 30 });
app.use("/auth/*", authLimiter); app.use("/api/automations", apiLimiter); app.use("/api/secrets/*", apiLimiter); app.use("/api/secrets", apiLimiter); app.use("/api/admin/*", apiLimiter); app.use("/api/settings/notifications", apiLimiter);
// Lexicon NSID suggestions: unauthenticated, and a prefix with a made-up // authority costs a DNS lookup plus listRecords fetches to a PDS the caller // picked. Generous because the combobox fires on every typed segment. The // combobox treats any non-OK response as a failed lookup (keeps its current // suggestions, retries on the next keystroke); the 429 body keeps the // endpoint's `{ suggestions }` shape for any other caller. app.use( "/api/lexicons/suggest", rateLimit({ windowMs: 60_000, max: 120, limitedBody: { suggestions: [] } }), );
// OAuth discovery endpoints (production — loopback clients don't need these) app.get("/oauth/client-metadata.json", async (c) => { const client = await getOAuthClient(); return c.json(client.clientMetadata); });
app.get("/oauth/jwks.json", async (c) => { const client = await getOAuthClient(); return c.json(client.jwks); });
// did:web identity document (atmo.pub sender). Relays resolve it to verify // our service JWTs. Per-client limiter only, deliberately no global ceiling: // the document is public, cheap (cached in-process), and a shared global cap // would let any unauthenticated flood exhaust the budget and lock every relay // out of fetching our key, breaking JWT verification. Per-client throttling // bounds abuse without that shared-fate failure mode. app.use("/.well-known/did.json", rateLimit({ windowMs: 60_000, max: 30 })); app.get("/.well-known/did.json", async (c) => { cachedDidJson ??= await buildDidDocument(); // Cache at the edge (Cloudflare) and in relay DID caches: the document only // changes on signing-key rotation, which is rare. 5 min matches the relay's // own DID cache TTL, so most reads never reach the origin and the per-client // limiter above is just a backstop for cache-miss traffic. // Rotation runbook: after rotating the signing key, purge the Cloudflare // cache for this path and allow up to 5 min for relay DID caches to expire // before retiring the old key, or relay JWT verification fails in the gap. return c.body(cachedDidJson, 200, { "Content-Type": "application/json", "Cache-Control": "public, max-age=300", }); });
// atmo.pub grant-change callback (relay-signed; inert until relay-side // registration). POST only; size cap and token checks live in the sub-app. // Each request triggers signature verification, so the global ceiling caps // total verifications/min regardless of how many per-client buckets an // attacker mints; the per-client limiter (registered second, so its // X-RateLimit headers overwrite the global's on non-throttled responses) is // the primary per-source throttle, with the global ceiling behind it. app.use(SUBSCRIBER_CHANGED_PATH, rateLimit({ windowMs: 60_000, max: 120, global: true })); app.use(SUBSCRIBER_CHANGED_PATH, rateLimit({ windowMs: 60_000, max: 30 })); app.route(SUBSCRIBER_CHANGED_PATH, subscriberChangedApp);
// Dynamic sitemap app.get("/sitemap.xml", async (c) => { const base = config.publicUrl;
// Defensive XML escape: NSID and handle are format-validated on write so // dangerous chars (& < > " ') shouldn't reach here, but a schema drift // could otherwise break the sitemap or inject tags. const xmlEscape = (s: string) => s.replace( /[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch] ?? ch, );
const lexiconRows = await db.selectDistinct({ lexicon: automations.lexicon }).from(automations); const handleRows = await db .select({ handle: users.handle }) .from(users) .where(sql`EXISTS (SELECT 1 FROM automations WHERE automations.did = users.did)`);
const urls = [ ` <url><loc>${base}/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>`, ` <url><loc>${base}/lexicons</loc><changefreq>daily</changefreq><priority>0.8</priority></url>`, ...lexiconRows.map( (r) => ` <url><loc>${base}/lexicons/${xmlEscape(r.lexicon)}</loc><changefreq>weekly</changefreq><priority>0.6</priority></url>`, ), ...handleRows.map( (r) => ` <url><loc>${base}/u/${xmlEscape(r.handle)}</loc><changefreq>weekly</changefreq><priority>0.5</priority></url>`, ), ];
const xml = [ `<?xml version="1.0" encoding="UTF-8"?>`, `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`, ...urls, `</urlset>`, ].join("\n");
return c.body(xml, 200, { "Content-Type": "application/xml" }); });
return app;}
/** * Build the full app: the base app plus the HonoX file routes mounted last. * Vite serves this default export in dev; `app/server.ts` binds it in prod. */export async function buildWebApp(): Promise<Hono> { const app = await buildBaseApp(); // Mount the HonoX file routes LAST. createApp() registers every app/routes/* // route eagerly, and Hono middleware only wraps routes registered after it, // so mounting here (after buildBaseApp's middleware) is what puts the file // routes behind the origin lock, security headers, and rate limiters. See the // buildBaseApp doc comment. // // Do NOT register any route or `app.use` after this line: it would land after // the file routes in the table and so escape every middleware above, silently // reopening the origin-lock / rate-limit gap. New endpoints and middleware go // in buildBaseApp. The `buildWebApp production assembly` test in web.test.ts // guards that a createApp() route stays behind the lock. app.route("/", createApp()); return app;}
/** * Production entry: binds the port to the given app, returns a `Handle` whose * `stop()` stops accepting new connections and waits for in-flight requests * to drain, dropping whatever is still open at `timeoutMs`. * * The caller passes in the app rather than calling `buildWebApp()` internally * so that `app/server.ts` can share one instance between dev (`export default`) * and prod (`serve`). */export function startWeb(opts: { app: Hono; port: number }): Handle { // No `createServer` option, so this is a plain `http.Server`. const server = serve({ fetch: opts.app.fetch, port: opts.port }) as Server; // `server.close()` keeps serving requests on open keep-alive connections, and // a proxy's pooled connections never go idle, so the close would never // finish. Once stopping, every response closes its connection instead, // including those still in flight. Prepended so it runs before the Hono // listener writes any header. let stopping = false; const inFlight = new Set<ServerResponse>(); server.prependListener("request", (_req, res) => { if (stopping) { res.setHeader("Connection", "close"); return; } inFlight.add(res); res.once("close", () => inFlight.delete(res)); }); console.log(`Web: listening on port ${opts.port}`); return { description: `web (port ${opts.port})`, async stop(timeoutMs) { stopping = true; for (const res of inFlight) { if (!res.headersSent) res.setHeader("Connection", "close"); } let deadline: NodeJS.Timeout | undefined; await Promise.race([ new Promise<void>((resolve) => server.close(() => resolve())), new Promise<void>((resolve) => { deadline = setTimeout(() => { server.closeAllConnections(); resolve(); }, timeoutMs); }), ]); clearTimeout(deadline); }, };}