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.

feat: gallery and featured automations

Hugo e70e6a5b 0ae0f53a

+836 -10
+1 -1
.claude/launch.json
··· 5 5 "name": "dev", 6 6 "runtimeExecutable": "bun", 7 7 "runtimeArgs": ["run", "dev"], 8 - "port": 5173 8 + "port": 5175 9 9 } 10 10 ] 11 11 }
+4
.env.example
··· 7 7 SECRETS_KEY= # optional, enables encrypted user secrets. openssl rand -base64 32 8 8 NSID_ALLOWLIST= 9 9 NSID_BLOCKLIST= 10 + # Hand-picked automation AT URIs, comma-separated. Shown on the homepage 11 + # and pinned at the top of the gallery tagged "Featured". 12 + # Example: at://did:plc:abc/run.airglow.automation/xyz,at://did:plc:def/run.airglow.automation/abc 13 + FEATURED_AUTOMATIONS=
+69
app/components/AutomationCard/index.tsx
··· 1 + import { Copy } from "../../icons.ts"; 2 + import { Button } from "../Button/index.tsx"; 3 + import { NsidCode } from "../NsidCode/index.tsx"; 4 + import * as s from "./styles.css.ts"; 5 + 6 + type AutomationCardProps = { 7 + handle: string; 8 + did: string; 9 + rkey: string; 10 + name: string; 11 + description?: string | null; 12 + lexicon: string; 13 + viewerAuthenticated: boolean; 14 + isOwner?: boolean; 15 + featured?: boolean; 16 + }; 17 + 18 + export function AutomationCard({ 19 + handle, 20 + did, 21 + rkey, 22 + name, 23 + description, 24 + lexicon, 25 + viewerAuthenticated, 26 + isOwner, 27 + featured, 28 + }: AutomationCardProps) { 29 + const detailHref = `/u/${handle}/${rkey}`; 30 + const useHref = isOwner 31 + ? `/dashboard/automations/${rkey}` 32 + : `/dashboard/automations/new?from=${encodeURIComponent(did)}&rkey=${encodeURIComponent(rkey)}`; 33 + 34 + return ( 35 + <article class={featured ? `${s.card} ${s.cardFeatured}` : s.card}> 36 + <div class={s.lexiconRow}> 37 + <NsidCode>{lexicon}</NsidCode> 38 + </div> 39 + <h3 class={s.title} title={name}> 40 + <a href={detailHref} class={s.titleLink}> 41 + {name} 42 + </a> 43 + </h3> 44 + {description && <p class={s.description}>{description}</p>} 45 + <div class={s.footer}> 46 + <span class={s.author}> 47 + <a href={`/u/${handle}`} class={s.authorLink}> 48 + @{handle} 49 + </a> 50 + </span> 51 + <span class={s.action}> 52 + {viewerAuthenticated ? ( 53 + <Button href={useHref} size="sm" variant="secondary"> 54 + <Copy size={14} /> {isOwner ? "Manage" : "Use this automation"} 55 + </Button> 56 + ) : ( 57 + <Button 58 + href={`/auth/login?returnTo=${encodeURIComponent(detailHref)}`} 59 + size="sm" 60 + variant="secondary" 61 + > 62 + Sign in to use 63 + </Button> 64 + )} 65 + </span> 66 + </div> 67 + </article> 68 + ); 69 + }
+97
app/components/AutomationCard/styles.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../../styles/theme.css.ts"; 3 + import { space } from "../../styles/tokens/spacing.ts"; 4 + import { fontSize, fontWeight, lineHeight } from "../../styles/tokens/typography.ts"; 5 + import { radii } from "../../styles/tokens/radii.ts"; 6 + 7 + export const card = style({ 8 + position: "relative", 9 + display: "flex", 10 + flexDirection: "column", 11 + gap: space[3], 12 + padding: space[5], 13 + backgroundColor: vars.color.surface, 14 + border: `1px solid ${vars.color.border}`, 15 + borderRadius: radii.lg, 16 + boxShadow: vars.shadow.highlight, 17 + transition: "border-color 160ms ease, box-shadow 160ms ease", 18 + ":hover": { 19 + borderColor: vars.color.accent, 20 + boxShadow: `${vars.shadow.highlight}, ${vars.shadow.md}`, 21 + }, 22 + }); 23 + 24 + export const cardFeatured = style({ 25 + borderColor: `color-mix(in oklch, ${vars.color.accent} 35%, ${vars.color.border})`, 26 + }); 27 + 28 + export const lexiconRow = style({ 29 + display: "flex", 30 + alignItems: "center", 31 + gap: space[2], 32 + fontSize: fontSize.xs, 33 + color: vars.color.textMuted, 34 + }); 35 + 36 + export const title = style({ 37 + fontSize: fontSize.base, 38 + fontWeight: fontWeight.semibold, 39 + color: vars.color.heading, 40 + lineHeight: lineHeight.snug, 41 + margin: 0, 42 + overflow: "hidden", 43 + textOverflow: "ellipsis", 44 + whiteSpace: "nowrap", 45 + }); 46 + 47 + export const titleLink = style({ 48 + color: "inherit", 49 + textDecoration: "none", 50 + "::before": { 51 + content: '""', 52 + position: "absolute", 53 + inset: 0, 54 + borderRadius: radii.lg, 55 + }, 56 + }); 57 + 58 + export const description = style({ 59 + fontSize: fontSize.sm, 60 + color: vars.color.textSecondary, 61 + lineHeight: lineHeight.normal, 62 + margin: 0, 63 + display: "-webkit-box", 64 + WebkitLineClamp: 2, 65 + WebkitBoxOrient: "vertical", 66 + overflow: "hidden", 67 + }); 68 + 69 + export const footer = style({ 70 + marginBlockStart: "auto", 71 + display: "flex", 72 + alignItems: "center", 73 + justifyContent: "space-between", 74 + gap: space[3], 75 + paddingBlockStart: space[3], 76 + borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 77 + }); 78 + 79 + export const author = style({ 80 + fontSize: fontSize.xs, 81 + color: vars.color.textMuted, 82 + }); 83 + 84 + export const authorLink = style({ 85 + position: "relative", 86 + zIndex: 1, 87 + color: "inherit", 88 + textDecoration: "none", 89 + ":hover": { 90 + color: vars.color.text, 91 + }, 92 + }); 93 + 94 + export const action = style({ 95 + position: "relative", 96 + zIndex: 1, 97 + });
+3
app/components/Layout/Footer/index.tsx
··· 5 5 <footer class={s.footer}> 6 6 <div class={s.inner}> 7 7 <div class={s.links}> 8 + <a href="/pricing" class={s.link}> 9 + Pricing 10 + </a> 8 11 <a href="https://tangled.org/exosphere.site/airglow" class={s.link}> 9 12 Source 10 13 </a>
+5 -1
app/components/Layout/Header/index.tsx
··· 14 14 Airglow 15 15 </a> 16 16 <div class={s.nav}> 17 - {user && ( 17 + {user ? ( 18 18 <> 19 19 <a href="/dashboard" class={s.navLink}> 20 20 Dashboard ··· 29 29 </button> 30 30 </form> 31 31 </> 32 + ) : ( 33 + <a href="/auth/login" class={s.navLink}> 34 + Sign in 35 + </a> 32 36 )} 33 37 {actions} 34 38 </div>
+155
app/routes/automations.tsx
··· 1 + import { createRoute } from "honox/factory"; 2 + import { desc, eq, count, sql } from "drizzle-orm"; 3 + import { getSessionUser } from "@/auth/middleware.js"; 4 + import { getFeaturedAutomations, getFeaturedUris } from "@/automations/featured.js"; 5 + import { searchAutomations } from "@/automations/search.js"; 6 + import { db } from "@/db/index.js"; 7 + import { automations } from "@/db/schema.js"; 8 + import { AppShell } from "../components/Layout/AppShell/index.js"; 9 + import { Header } from "../components/Layout/Header/index.js"; 10 + import { Container } from "../components/Layout/Container/index.js"; 11 + import { PageHeader } from "../components/Layout/PageHeader/index.js"; 12 + import { AutomationCard } from "../components/AutomationCard/index.js"; 13 + import { Input } from "../components/Input/index.js"; 14 + import { Select } from "../components/Select/index.js"; 15 + import { Button } from "../components/Button/index.js"; 16 + import ThemeToggle from "../islands/ThemeToggle.js"; 17 + import * as s from "../styles/pages/automations.css.js"; 18 + 19 + const PAGE_SIZE = 18; 20 + 21 + export default createRoute(async (c) => { 22 + const viewer = await getSessionUser(c); 23 + 24 + const q = (c.req.query("q") ?? "").trim().slice(0, 128); 25 + const lexicon = (c.req.query("lexicon") ?? "").trim().slice(0, 256); 26 + const limit = Math.min(Math.max(Number(c.req.query("limit")) || PAGE_SIZE, PAGE_SIZE), 200); 27 + 28 + const featuredUris = getFeaturedUris(); 29 + const showFeatured = !q && !lexicon; 30 + const featured = showFeatured ? await getFeaturedAutomations() : []; 31 + 32 + const rows = await searchAutomations(db, { 33 + q, 34 + lexicon, 35 + limit: limit + 1, 36 + excludeUris: showFeatured ? featuredUris : undefined, 37 + }); 38 + 39 + const hasMore = rows.length > limit; 40 + const visible = hasMore ? rows.slice(0, limit) : rows; 41 + 42 + const lexiconRows = await db 43 + .select({ lexicon: automations.lexicon, count: count() }) 44 + .from(automations) 45 + .where(eq(automations.active, true)) 46 + .groupBy(automations.lexicon) 47 + .orderBy(desc(sql`count(*)`)) 48 + .limit(50); 49 + 50 + const buildQuery = (overrides: { limit?: number }) => { 51 + const p = new URLSearchParams(); 52 + if (q) p.set("q", q); 53 + if (lexicon) p.set("lexicon", lexicon); 54 + if (overrides.limit) p.set("limit", String(overrides.limit)); 55 + const qs = p.toString(); 56 + return qs ? `/automations?${qs}` : "/automations"; 57 + }; 58 + 59 + c.header("Cache-Control", viewer ? "private, no-store" : "public, s-maxage=60"); 60 + 61 + return c.render( 62 + <AppShell header={<Header user={viewer} actions={<ThemeToggle />} />}> 63 + <Container> 64 + <PageHeader 65 + title="Automations" 66 + description="Browse public automations. Duplicate any of them to your account." 67 + /> 68 + 69 + <form method="get" action="/automations" class={s.filterBar}> 70 + <div class={s.field}> 71 + <label class={s.fieldLabel} for="q"> 72 + Search 73 + </label> 74 + <Input id="q" name="q" type="search" placeholder="Search automations..." value={q} /> 75 + </div> 76 + <div class={s.field}> 77 + <label class={s.fieldLabel} for="lexicon"> 78 + Lexicon 79 + </label> 80 + <Select id="lexicon" name="lexicon"> 81 + <option value="">All lexicons</option> 82 + {lexiconRows.map((row) => ( 83 + <option key={row.lexicon} value={row.lexicon} selected={row.lexicon === lexicon}> 84 + {row.lexicon} ({row.count}) 85 + </option> 86 + ))} 87 + </Select> 88 + </div> 89 + </form> 90 + 91 + {featured.length > 0 && ( 92 + <section class={s.featuredSection}> 93 + <h2 class={s.featuredTitle}>Featured</h2> 94 + <div class={s.grid}> 95 + {featured.map((a) => ( 96 + <AutomationCard 97 + key={`${a.did}/${a.rkey}`} 98 + handle={a.handle} 99 + did={a.did} 100 + rkey={a.rkey} 101 + name={a.name} 102 + description={a.description} 103 + lexicon={a.lexicon} 104 + viewerAuthenticated={Boolean(viewer)} 105 + isOwner={viewer?.did === a.did} 106 + featured 107 + /> 108 + ))} 109 + </div> 110 + </section> 111 + )} 112 + 113 + {visible.length > 0 ? ( 114 + <> 115 + <div class={s.grid}> 116 + {visible.map((a) => ( 117 + <AutomationCard 118 + key={`${a.did}/${a.rkey}`} 119 + handle={a.handle} 120 + did={a.did} 121 + rkey={a.rkey} 122 + name={a.name} 123 + description={a.description} 124 + lexicon={a.lexicon} 125 + viewerAuthenticated={Boolean(viewer)} 126 + isOwner={viewer?.did === a.did} 127 + /> 128 + ))} 129 + </div> 130 + {hasMore && ( 131 + <div class={s.loadMoreWrap}> 132 + <Button 133 + href={buildQuery({ limit: limit + PAGE_SIZE })} 134 + variant="secondary" 135 + size="sm" 136 + > 137 + Load more 138 + </Button> 139 + </div> 140 + )} 141 + </> 142 + ) : featured.length === 0 ? ( 143 + <p class={s.empty}> 144 + No automations match your filters. Try a broader search or pick a different lexicon. 145 + </p> 146 + ) : null} 147 + </Container> 148 + </AppShell>, 149 + { 150 + title: "Automations | Airglow", 151 + description: 152 + "Browse public automations on Airglow. Duplicate webhooks, Bluesky posts, and PDS record actions to your own AT Protocol account.", 153 + }, 154 + ); 155 + });
+42 -8
app/routes/index.tsx
··· 3 3 import { raw } from "hono/html"; 4 4 import { Code, Filter, Globe, Repeat } from "../icons.js"; 5 5 import { getSessionUser } from "@/auth/middleware.js"; 6 + import { getFeaturedAutomations } from "@/automations/featured.js"; 6 7 import { db } from "@/db/index.js"; 7 8 import { automations } from "@/db/schema.js"; 8 9 import { AppShell } from "../components/Layout/AppShell/index.js"; ··· 11 12 import { Button } from "../components/Button/index.js"; 12 13 import { Table } from "../components/Table/index.js"; 13 14 import { NsidCode } from "../components/NsidCode/index.js"; 15 + import { AutomationCard } from "../components/AutomationCard/index.js"; 14 16 import ThemeToggle from "../islands/ThemeToggle.js"; 15 17 import FlowAction from "../islands/FlowAction.js"; 16 18 import * as s from "../styles/pages/landing.css.js"; ··· 49 51 50 52 export default createRoute(async (c) => { 51 53 const user = await getSessionUser(c); 52 - const topLexicons = await getTopLexicons(); 54 + const [topLexicons, featured] = await Promise.all([getTopLexicons(), getFeaturedAutomations()]); 53 55 54 56 // Edge-cache the anonymous landing at Fastly. Vary: Cookie keeps authenticated 55 57 // variants keyed separately; private/no-store stops the auth'd variant from ··· 78 80 conditions, and send a webhook, create a record on your PDS, or post to Bluesky. 79 81 </p> 80 82 <div class={s.heroActions}> 83 + <Button href="/automations">Browse automations</Button> 81 84 {user ? ( 82 - <Button href="/dashboard">Go to dashboard</Button> 85 + <Button href="/dashboard" variant="secondary"> 86 + Go to dashboard 87 + </Button> 83 88 ) : ( 84 - <Button href="/auth/login">Get started</Button> 89 + <Button href="/auth/login" variant="secondary"> 90 + Sign in 91 + </Button> 85 92 )} 86 - <Button href="https://tangled.org/exosphere.site/airglow" variant="secondary"> 87 - <Code size={16} /> View source 88 - </Button> 89 93 </div> 90 94 </div> 91 95 </Container> ··· 149 153 </Container> 150 154 </section> 151 155 156 + {featured.length > 0 && ( 157 + <section class={s.gallery}> 158 + <Container> 159 + <div class={s.sectionHead}> 160 + <div class={s.sectionEyebrow}>Gallery</div> 161 + <h2 class={s.sectionTitle}>Automations people run</h2> 162 + <p class={s.sectionSub}>Duplicate any of these to your account in one click.</p> 163 + </div> 164 + <div class={s.galleryGrid}> 165 + {featured.map((a) => ( 166 + <AutomationCard 167 + key={`${a.did}/${a.rkey}`} 168 + handle={a.handle} 169 + did={a.did} 170 + rkey={a.rkey} 171 + name={a.name} 172 + description={a.description} 173 + lexicon={a.lexicon} 174 + viewerAuthenticated={Boolean(user)} 175 + isOwner={user?.did === a.did} 176 + /> 177 + ))} 178 + </div> 179 + <div class={s.galleryFooter}> 180 + <a href="/automations" class={s.galleryMore}> 181 + Browse all automations → 182 + </a> 183 + </div> 184 + </Container> 185 + </section> 186 + )} 187 + 152 188 <section class={s.features}> 153 189 <Container> 154 190 <div class={s.sectionHead}> ··· 206 242 <thead> 207 243 <tr> 208 244 <th>NSID</th> 209 - <th class={s.countCell}>Automations</th> 210 245 </tr> 211 246 </thead> 212 247 <tbody> ··· 217 252 <NsidCode>{row.lexicon}</NsidCode> 218 253 </a> 219 254 </td> 220 - <td class={s.countCell}>{row.count}</td> 221 255 </tr> 222 256 ))} 223 257 </tbody>
+37
app/routes/pricing.tsx
··· 1 + import { createRoute } from "honox/factory"; 2 + import { getSessionUser } from "@/auth/middleware.js"; 3 + import { AppShell } from "../components/Layout/AppShell/index.js"; 4 + import { Header } from "../components/Layout/Header/index.js"; 5 + import { Container } from "../components/Layout/Container/index.js"; 6 + import { PageHeader } from "../components/Layout/PageHeader/index.js"; 7 + import { Stack } from "../components/Layout/Stack/index.js"; 8 + import ThemeToggle from "../islands/ThemeToggle.js"; 9 + 10 + export default createRoute(async (c) => { 11 + const user = await getSessionUser(c); 12 + 13 + c.header("Cache-Control", "public, s-maxage=3600, stale-while-revalidate=86400"); 14 + 15 + return c.render( 16 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 17 + <Container> 18 + <PageHeader title="Pricing" description="Free while we're early." /> 19 + <Stack gap={4}> 20 + <p> 21 + Hosted Airglow is free with no usage caps right now. Paid plans will come later, with a 22 + free tier. 23 + </p> 24 + <p> 25 + You can self-host anytime. Airglow is MIT-licensed, and your automations live on your 26 + PDS. We'll eventually make it easy to migrate between instances. 27 + </p> 28 + </Stack> 29 + </Container> 30 + </AppShell>, 31 + { 32 + title: "Pricing | Airglow", 33 + description: 34 + "Airglow pricing. Free while we're early, no usage caps. MIT-licensed and self-hostable.", 35 + }, 36 + ); 37 + });
+74
app/styles/pages/automations.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../theme.css.ts"; 3 + import { space } from "../tokens/spacing.ts"; 4 + import { fontSize, fontWeight } from "../tokens/typography.ts"; 5 + import { radii } from "../tokens/radii.ts"; 6 + import { mq } from "../utils.ts"; 7 + 8 + export const filterBar = style({ 9 + display: "grid", 10 + gridTemplateColumns: "1fr", 11 + gap: space[3], 12 + marginBlockEnd: space[6], 13 + "@media": { 14 + [mq.md]: { 15 + gridTemplateColumns: "minmax(0, 1fr) auto", 16 + alignItems: "end", 17 + }, 18 + }, 19 + }); 20 + 21 + export const field = style({ 22 + display: "flex", 23 + flexDirection: "column", 24 + gap: space[1], 25 + }); 26 + 27 + export const fieldLabel = style({ 28 + fontSize: fontSize.xs, 29 + color: vars.color.textMuted, 30 + textTransform: "uppercase", 31 + letterSpacing: "0.08em", 32 + fontWeight: fontWeight.semibold, 33 + }); 34 + 35 + export const featuredSection = style({ 36 + marginBlockEnd: space[7], 37 + }); 38 + 39 + export const featuredTitle = style({ 40 + fontSize: fontSize.sm, 41 + fontWeight: fontWeight.semibold, 42 + color: vars.color.accent, 43 + textTransform: "uppercase", 44 + letterSpacing: "0.14em", 45 + marginBlockEnd: space[4], 46 + }); 47 + 48 + export const grid = style({ 49 + display: "grid", 50 + gridTemplateColumns: "1fr", 51 + gap: space[4], 52 + "@media": { 53 + [mq.md]: { 54 + gridTemplateColumns: "1fr 1fr", 55 + }, 56 + [mq.lg]: { 57 + gridTemplateColumns: "1fr 1fr 1fr", 58 + }, 59 + }, 60 + }); 61 + 62 + export const empty = style({ 63 + textAlign: "center", 64 + padding: space[8], 65 + color: vars.color.textSecondary, 66 + border: `1px dashed ${vars.color.border}`, 67 + borderRadius: radii.lg, 68 + }); 69 + 70 + export const loadMoreWrap = style({ 71 + marginBlockStart: space[6], 72 + display: "flex", 73 + justifyContent: "center", 74 + });
+36
app/styles/pages/landing.css.ts
··· 330 330 opacity: 0.6, 331 331 }); 332 332 333 + /* ---------- gallery ---------- */ 334 + 335 + export const gallery = style({ 336 + paddingBlock: "80px", 337 + borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 338 + }); 339 + 340 + export const galleryGrid = style({ 341 + display: "grid", 342 + gridTemplateColumns: "1fr", 343 + gap: space[4], 344 + "@media": { 345 + [mq.md]: { 346 + gridTemplateColumns: "1fr 1fr", 347 + }, 348 + [mq.lg]: { 349 + gridTemplateColumns: "1fr 1fr 1fr", 350 + }, 351 + }, 352 + }); 353 + 354 + export const galleryFooter = style({ 355 + marginBlockStart: space[6], 356 + textAlign: "center", 357 + }); 358 + 359 + export const galleryMore = style({ 360 + color: vars.color.accent, 361 + textDecoration: "none", 362 + fontSize: fontSize.sm, 363 + fontWeight: fontWeight.medium, 364 + ":hover": { 365 + textDecoration: "underline", 366 + }, 367 + }); 368 + 333 369 /* ---------- features ---------- */ 334 370 335 371 export const features = style({
+40
lib/automations/featured.ts
··· 1 + import { eq, inArray } from "drizzle-orm"; 2 + import { config } from "../config.js"; 3 + import { db } from "../db/index.js"; 4 + import { automations, users } from "../db/schema.js"; 5 + 6 + export type FeaturedAutomation = { 7 + uri: string; 8 + did: string; 9 + rkey: string; 10 + name: string; 11 + description: string | null; 12 + lexicon: string; 13 + handle: string; 14 + }; 15 + 16 + export function getFeaturedUris(): string[] { 17 + return config.featuredAutomations; 18 + } 19 + 20 + export async function getFeaturedAutomations(): Promise<FeaturedAutomation[]> { 21 + const uris = getFeaturedUris(); 22 + if (uris.length === 0) return []; 23 + 24 + const rows = await db 25 + .select({ 26 + uri: automations.uri, 27 + did: automations.did, 28 + rkey: automations.rkey, 29 + name: automations.name, 30 + description: automations.description, 31 + lexicon: automations.lexicon, 32 + handle: users.handle, 33 + }) 34 + .from(automations) 35 + .innerJoin(users, eq(users.did, automations.did)) 36 + .where(inArray(automations.uri, uris)); 37 + 38 + const byUri = new Map(rows.map((r) => [r.uri, r])); 39 + return uris.map((u) => byUri.get(u)).filter((r): r is FeaturedAutomation => Boolean(r)); 40 + }
+195
lib/automations/search.test.ts
··· 1 + import { describe, it, expect, beforeEach } from "vitest"; 2 + import { createTestDb } from "../test/db.js"; 3 + import { automations, users } from "../db/schema.js"; 4 + import { makeAutomation, makeBskyPostAction, makeRecordAction } from "../test/fixtures.js"; 5 + import { searchAutomations } from "./search.js"; 6 + 7 + type TestDb = ReturnType<typeof createTestDb>; 8 + 9 + async function seedUser(db: TestDb, did: string, handle: string) { 10 + await db.insert(users).values({ did, handle, createdAt: new Date() }); 11 + } 12 + 13 + async function seedAutomation( 14 + db: TestDb, 15 + auto: Parameters<typeof makeAutomation>[0] & { did: string }, 16 + ) { 17 + await db.insert(automations).values(makeAutomation(auto)); 18 + } 19 + 20 + describe("searchAutomations", () => { 21 + let db: TestDb; 22 + 23 + beforeEach(async () => { 24 + db = createTestDb(); 25 + await seedUser(db, "did:plc:alice", "alice.test"); 26 + await seedUser(db, "did:plc:bob", "bob.test"); 27 + }); 28 + 29 + it("returns active automations matching the lexicon NSID substring", async () => { 30 + await seedAutomation(db, { 31 + uri: "at://did:plc:alice/run.airglow.automation/a1", 32 + did: "did:plc:alice", 33 + rkey: "a1", 34 + name: "Post new automations", 35 + lexicon: "run.airglow.automation", 36 + }); 37 + await seedAutomation(db, { 38 + uri: "at://did:plc:bob/run.airglow.automation/b1", 39 + did: "did:plc:bob", 40 + rkey: "b1", 41 + name: "Other", 42 + lexicon: "app.bsky.feed.like", 43 + }); 44 + 45 + for (const q of ["airglow", "run.airg", "run.airglow", "run.airglow.automation"]) { 46 + const rows = await searchAutomations(db, { q, limit: 10 }); 47 + expect( 48 + rows.map((r) => r.rkey), 49 + `q=${q}`, 50 + ).toEqual(["a1"]); 51 + } 52 + }); 53 + 54 + it("matches on name and description", async () => { 55 + await seedAutomation(db, { 56 + uri: "at://did:plc:alice/run.airglow.automation/a1", 57 + did: "did:plc:alice", 58 + rkey: "a1", 59 + name: "Crosspost to Bluesky", 60 + description: "Mirror posts across networks", 61 + lexicon: "app.bsky.feed.post", 62 + }); 63 + 64 + expect((await searchAutomations(db, { q: "Crosspost", limit: 10 })).length).toBe(1); 65 + expect((await searchAutomations(db, { q: "Mirror posts", limit: 10 })).length).toBe(1); 66 + }); 67 + 68 + it("matches on action target collections (write-to)", async () => { 69 + await seedAutomation(db, { 70 + uri: "at://did:plc:alice/run.airglow.automation/a1", 71 + did: "did:plc:alice", 72 + rkey: "a1", 73 + name: "Post on Bluesky", 74 + lexicon: "app.bsky.feed.like", 75 + actions: [makeBskyPostAction()], 76 + }); 77 + await seedAutomation(db, { 78 + uri: "at://did:plc:bob/run.airglow.automation/b1", 79 + did: "did:plc:bob", 80 + rkey: "b1", 81 + name: "Write to airglow record", 82 + lexicon: "app.bsky.feed.like", 83 + actions: [makeRecordAction({ targetCollection: "run.airglow.automation" })], 84 + }); 85 + 86 + const rows = await searchAutomations(db, { q: "run.airglow", limit: 10 }); 87 + expect(rows.map((r) => r.rkey).sort()).toEqual(["b1"]); 88 + }); 89 + 90 + it("filters out inactive automations", async () => { 91 + await seedAutomation(db, { 92 + uri: "at://did:plc:alice/run.airglow.automation/a1", 93 + did: "did:plc:alice", 94 + rkey: "a1", 95 + name: "Inactive", 96 + lexicon: "run.airglow.automation", 97 + active: false, 98 + }); 99 + 100 + expect(await searchAutomations(db, { q: "airglow", limit: 10 })).toEqual([]); 101 + }); 102 + 103 + it("filters by exact lexicon match", async () => { 104 + await seedAutomation(db, { 105 + uri: "at://did:plc:alice/run.airglow.automation/a1", 106 + did: "did:plc:alice", 107 + rkey: "a1", 108 + name: "A", 109 + lexicon: "run.airglow.automation", 110 + }); 111 + await seedAutomation(db, { 112 + uri: "at://did:plc:bob/run.airglow.automation/b1", 113 + did: "did:plc:bob", 114 + rkey: "b1", 115 + name: "B", 116 + lexicon: "app.bsky.feed.like", 117 + }); 118 + 119 + const rows = await searchAutomations(db, { 120 + lexicon: "run.airglow.automation", 121 + limit: 10, 122 + }); 123 + expect(rows.map((r) => r.rkey)).toEqual(["a1"]); 124 + }); 125 + 126 + it("excludes URIs listed in excludeUris", async () => { 127 + await seedAutomation(db, { 128 + uri: "at://did:plc:alice/run.airglow.automation/a1", 129 + did: "did:plc:alice", 130 + rkey: "a1", 131 + name: "A", 132 + lexicon: "run.airglow.automation", 133 + }); 134 + await seedAutomation(db, { 135 + uri: "at://did:plc:bob/run.airglow.automation/b1", 136 + did: "did:plc:bob", 137 + rkey: "b1", 138 + name: "B", 139 + lexicon: "run.airglow.automation", 140 + }); 141 + 142 + const rows = await searchAutomations(db, { 143 + limit: 10, 144 + excludeUris: ["at://did:plc:alice/run.airglow.automation/a1"], 145 + }); 146 + expect(rows.map((r) => r.rkey)).toEqual(["b1"]); 147 + }); 148 + 149 + it("treats LIKE wildcards in the query as literal characters", async () => { 150 + await seedAutomation(db, { 151 + uri: "at://did:plc:alice/run.airglow.automation/plain", 152 + did: "did:plc:alice", 153 + rkey: "plain", 154 + name: "Plain name", 155 + lexicon: "run.airglow.automation", 156 + }); 157 + await seedAutomation(db, { 158 + uri: "at://did:plc:bob/run.airglow.automation/scored", 159 + did: "did:plc:bob", 160 + rkey: "scored", 161 + name: "has_underscore", 162 + lexicon: "run.airglow.automation", 163 + }); 164 + 165 + // '_' is a LIKE wildcard; must only match the automation containing a literal '_' 166 + const underscore = await searchAutomations(db, { q: "_", limit: 10 }); 167 + expect(underscore.map((r) => r.rkey)).toEqual(["scored"]); 168 + 169 + // '%' is a LIKE wildcard; no name contains a literal '%', so zero matches 170 + const percent = await searchAutomations(db, { q: "%", limit: 10 }); 171 + expect(percent).toEqual([]); 172 + }); 173 + 174 + it("orders by indexedAt descending", async () => { 175 + await seedAutomation(db, { 176 + uri: "at://did:plc:alice/run.airglow.automation/older", 177 + did: "did:plc:alice", 178 + rkey: "older", 179 + name: "Older", 180 + lexicon: "run.airglow.automation", 181 + indexedAt: new Date("2024-01-01"), 182 + }); 183 + await seedAutomation(db, { 184 + uri: "at://did:plc:bob/run.airglow.automation/newer", 185 + did: "did:plc:bob", 186 + rkey: "newer", 187 + name: "Newer", 188 + lexicon: "run.airglow.automation", 189 + indexedAt: new Date("2024-06-01"), 190 + }); 191 + 192 + const rows = await searchAutomations(db, { q: "airglow", limit: 10 }); 193 + expect(rows.map((r) => r.rkey)).toEqual(["newer", "older"]); 194 + }); 195 + });
+72
lib/automations/search.ts
··· 1 + import { and, desc, eq, notInArray, or, sql } from "drizzle-orm"; 2 + import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"; 3 + import { automations, users } from "../db/schema.js"; 4 + import type * as schema from "../db/schema.js"; 5 + 6 + // Both bun:sqlite and better-sqlite3 backends extend BaseSQLiteDatabase. 7 + // Using the base lets tests use better-sqlite3 while prod uses bun:sqlite. 8 + type Db = BaseSQLiteDatabase<"sync" | "async", unknown, typeof schema>; 9 + 10 + export type AutomationSearchResult = { 11 + uri: string; 12 + did: string; 13 + rkey: string; 14 + name: string; 15 + description: string | null; 16 + lexicon: string; 17 + handle: string; 18 + }; 19 + 20 + export type AutomationSearchParams = { 21 + q?: string; 22 + lexicon?: string; 23 + limit: number; 24 + excludeUris?: string[]; 25 + }; 26 + 27 + export async function searchAutomations( 28 + db: Db, 29 + params: AutomationSearchParams, 30 + ): Promise<AutomationSearchResult[]> { 31 + const conditions = [eq(automations.active, true)]; 32 + 33 + if (params.lexicon) { 34 + conditions.push(eq(automations.lexicon, params.lexicon)); 35 + } 36 + 37 + if (params.q) { 38 + // Escape LIKE wildcards with a backslash and declare ESCAPE '\' so '%' and 39 + // '_' in user input match literally instead of acting as wildcards. 40 + const pattern = `%${params.q.replace(/[\\%_]/g, (m) => `\\${m}`)}%`; 41 + const matcher = or( 42 + sql`${automations.name} LIKE ${pattern} ESCAPE '\\'`, 43 + sql`${automations.description} LIKE ${pattern} ESCAPE '\\'`, 44 + sql`${automations.lexicon} LIKE ${pattern} ESCAPE '\\'`, 45 + // actions is a JSON-mode TEXT column; LIKE matches targetCollection NSIDs too 46 + sql`${automations.actions} LIKE ${pattern} ESCAPE '\\'`, 47 + ); 48 + if (matcher) conditions.push(matcher); 49 + } 50 + 51 + if (params.excludeUris && params.excludeUris.length > 0) { 52 + conditions.push(notInArray(automations.uri, params.excludeUris)); 53 + } 54 + 55 + const whereClause = conditions.length === 1 ? conditions[0] : and(...conditions); 56 + 57 + return db 58 + .select({ 59 + uri: automations.uri, 60 + did: automations.did, 61 + rkey: automations.rkey, 62 + name: automations.name, 63 + description: automations.description, 64 + lexicon: automations.lexicon, 65 + handle: users.handle, 66 + }) 67 + .from(automations) 68 + .innerJoin(users, eq(users.did, automations.did)) 69 + .where(whereClause) 70 + .orderBy(desc(automations.indexedAt), desc(automations.uri)) 71 + .limit(params.limit); 72 + }
+6
lib/config.ts
··· 44 44 secretsKey, 45 45 nsidAllowlist: env("NSID_ALLOWLIST", "").split(",").filter(Boolean), 46 46 nsidBlocklist: env("NSID_BLOCKLIST", "").split(",").filter(Boolean), 47 + // Hand-picked AT URIs (at://did/run.airglow.automation/rkey), comma-separated. 48 + // These appear on the homepage and at the top of the gallery tagged "Featured". 49 + featuredAutomations: env("FEATURED_AUTOMATIONS", "") 50 + .split(",") 51 + .map((s) => s.trim()) 52 + .filter(Boolean), 47 53 } as const;