Something went wrong. Try again.
Automations and webhooks for the AT Protocol airglow.run
automation webhook atproto atprotocol
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281import { describe, it, expect, vi } from "vitest";import { Hono } from "hono";import { Agent, request } from "node:http";import { createServer } from "node:net";import { resolve } from "node:path";
// Origin lock on, so requireCloudflare is mounted. Other fields are read only// inside route handlers we don't invoke here.vi.mock("@/config.js", () => ({ config: { cloudflareOriginSecret: "test-origin-secret-1234567890", trustProxyHops: 1, publicUrl: "https://airglow.example", },}));
// web.ts imports db for the sitemap handler, which this test never hits.vi.mock("@/db/index.js", () => ({ db: {} }));
// Stub honox/server: importing it eagerly glob-imports every app/routes/* file// (incl. OG routes that pull opentype.js, which the test runtime can't load).// buildBaseApp never calls createApp -- only buildWebApp does -- so stubbing it// is safe for this test.vi.mock("honox/server", () => ({ createApp: vi.fn(), Script: () => null }));
import { createApp } from "honox/server";import { buildBaseApp, buildWebApp, mountBuiltAssets, startWeb } from "./web.js";
const SECRET = "test-origin-secret-1234567890";
describe("buildBaseApp origin-lock wiring", () => { // Mirror buildWebApp: mount a stand-in for the HonoX file routes LAST, exactly // as `app.route("/", createApp())` does, so the guard's coverage of mounted // routes is what's under test (the bug was the guard not wrapping them). async function appWithFileRoutes() { const app = await buildBaseApp(); const fileRoutes = new Hono(); fileRoutes.get("/u/test", (c) => c.text("profile page")); fileRoutes.post("/api/automations", (c) => c.text("created")); app.route("/", fileRoutes); return app; }
const withSecret = { headers: { "x-origin-auth": SECRET } };
it("exempts /healthz from the lock (Railway's internal probe carries no secret)", async () => { const app = await appWithFileRoutes(); const res = await app.request("http://localhost/healthz"); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); });
it("rejects a mounted file route without the secret (the P0 regression guard)", async () => { const app = await appWithFileRoutes(); expect((await app.request("http://localhost/u/test")).status).toBe(403); expect((await app.request("http://localhost/api/automations", { method: "POST" })).status).toBe( 403, ); });
it("admits a mounted file route carrying the correct secret", async () => { const app = await appWithFileRoutes(); const res = await app.request("http://localhost/u/test", withSecret); expect(res.status).toBe(200); expect(await res.text()).toBe("profile page"); });});
describe("buildWebApp production assembly", () => { // Guards the real wiring (not just buildBaseApp): buildWebApp must mount the // HonoX file routes UNDER the middleware. A regression that reorders the mount // ahead of the origin lock, or that adds a route after createApp(), would let // a file route through without the secret. it("keeps a createApp() file route behind the origin lock", async () => { const fileRoutes = new Hono(); fileRoutes.get("/dashboard", (c) => c.text("dashboard island")); vi.mocked(createApp).mockReturnValueOnce(fileRoutes as unknown as ReturnType<typeof createApp>);
const app = await buildWebApp(); expect((await app.request("http://localhost/dashboard")).status).toBe(403);
const ok = await app.request("http://localhost/dashboard", { headers: { "x-origin-auth": SECRET }, }); expect(ok.status).toBe(200); expect(await ok.text()).toBe("dashboard island"); });});
describe("mountBuiltAssets", () => { // `public/` is what the client build copies into `dist/`, so mounting it // stands in for a real build. Production 404'd on the brand fonts because // /fonts/* was never mounted while Vite served it fine in dev. async function assetsApp() { const app = new Hono(); await mountBuiltAssets(app, resolve("public")); return app; }
it.each([ "/fonts/OverusedGrotesk-VF.woff2", "/fonts/JunicodeVF-Roman.woff2", "/fonts/JunicodeVF-Italic.woff2", ])("serves the brand font %s", async (path) => { const res = await (await assetsApp()).request(`http://localhost${path}`); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toBe("font/woff2"); expect(res.headers.get("cache-control")).toBe("public, max-age=86400"); });
it("serves a found /static/* file with the immutable cache header", async () => { const res = await ( await assetsApp() ).request("http://localhost/static/favicons/airglow.run.cf36edf8.svg"); expect(res.status).toBe(200); expect(res.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); });
it("falls through on a missing /static/* file without a cache header", async () => { const res = await (await assetsApp()).request("http://localhost/static/favicons/nope.svg"); expect(res.status).toBe(404); expect(res.headers.get("cache-control")).toBeNull(); });
it("still serves /favicon.svg", async () => { const res = await (await assetsApp()).request("http://localhost/favicon.svg"); expect(res.status).toBe(200); });
it("falls through on a missing font without a cache header", async () => { const res = await (await assetsApp()).request("http://localhost/fonts/nope.woff2"); expect(res.status).toBe(404); expect(res.headers.get("cache-control")).toBeNull(); });});
describe("startWeb stop", () => { async function freePort(): Promise<number> { const probe = createServer(); await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve)); const { port } = probe.address() as { port: number }; await new Promise<void>((resolve) => probe.close(() => resolve())); return port; }
function get(port: number, path: string, agent?: Agent) { return new Promise<{ body: string; connection: string | undefined }>((resolve, reject) => { const req = request({ host: "127.0.0.1", port, path, agent }, (res) => { let body = ""; res.setEncoding("utf8"); res.on("data", (chunk: string) => (body += chunk)); res.on("end", () => resolve({ body, connection: res.headers.connection })); res.on("error", reject); }); req.on("error", reject); req.end(); }); }
async function start(app: Hono) { const port = await freePort(); const handle = startWeb({ app, port }); // `serve()` binds asynchronously; wait until the port answers. await vi.waitFor(() => get(port, "/ping")); return { port, handle }; }
function appWith(extra: (app: Hono) => void) { const app = new Hono(); app.get("/ping", (c) => c.text("pong")); extra(app); return app; }
it("drains an in-flight request before resolving", async () => { let release!: () => void; const released = new Promise<void>((resolve) => (release = resolve)); const { port, handle } = await start( appWith((app) => app.get("/slow", async (c) => { await released; return c.text("finished"); }), ), );
const inFlight = get(port, "/slow"); await new Promise((resolve) => setTimeout(resolve, 50)); let stopped = false; const stopping = handle.stop(5_000).then(() => (stopped = true)); await new Promise((resolve) => setTimeout(resolve, 50)); expect(stopped).toBe(false);
release(); expect((await inFlight).body).toBe("finished"); const startedAt = Date.now(); await stopping; expect(Date.now() - startedAt).toBeLessThan(1_000); });
it("resolves at the deadline when a request never finishes", async () => { const { port, handle } = await start( appWith((app) => app.get("/hang", () => new Promise<Response>(() => {}))), );
const hung = get(port, "/hang").catch(() => "dropped"); await new Promise((resolve) => setTimeout(resolve, 50)); const startedAt = Date.now(); await handle.stop(200); expect(Date.now() - startedAt).toBeGreaterThanOrEqual(190); // The deadline drops the connection rather than leaving it open. expect(await hung).toBe("dropped"); });
it("stops promptly once the only connection is idle", async () => { const agent = new Agent({ keepAlive: true }); try { const { port, handle } = await start(appWith(() => {})); await get(port, "/ping", agent); const startedAt = Date.now(); await handle.stop(5_000); expect(Date.now() - startedAt).toBeLessThan(1_000); } finally { agent.destroy(); } });
it("closes keep-alive connections that keep sending requests", async () => { const agent = new Agent({ keepAlive: true, maxSockets: 1 }); try { // stop() begins inside a /busy handler, so a request is always in flight // when it runs. Begun from outside, it could land between two requests, // when server.close() drops the idle connection and no response carries // `Connection: close`. let stopArmed = false; let stopBegun!: (stop: { startedAt: number; done: Promise<void> }) => void; const stopping = new Promise<{ startedAt: number; done: Promise<void> }>( (resolve) => (stopBegun = resolve), ); let handle!: Awaited<ReturnType<typeof start>>["handle"]; const started = await start( appWith((app) => app.get("/busy", async (c) => { const beginsStop = stopArmed; if (beginsStop) { stopArmed = false; stopBegun({ startedAt: Date.now(), done: handle.stop(5_000) }); } await new Promise((resolve) => setTimeout(resolve, 20)); return c.text(beginsStop ? "began stop" : "done"); }), ), ); handle = started.handle; // Steady traffic on one pooled connection, like a proxy in front of us. const responses: { body: string; connection: string | undefined }[] = []; const traffic = (async () => { while (true) { try { responses.push(await get(started.port, "/busy", agent)); } catch { return; } } })(); await new Promise((resolve) => setTimeout(resolve, 50));
stopArmed = true; const { startedAt, done } = await stopping; await done; expect(Date.now() - startedAt).toBeLessThan(1_000); await traffic; // The request in flight when stop() began closed its connection, so the // traffic ended with it. expect(responses.at(-1)).toEqual({ body: "began stop", connection: "close" }); } finally { agent.destroy(); } });});