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: public profiles

Hugo 67935957 3be5eadd

+676 -145
+4
app/icons.ts
··· 46 46 import SunData from "lucide/icons/sun"; 47 47 import Trash2Data from "lucide/icons/trash-2"; 48 48 import WebhookData from "lucide/icons/webhook"; 49 + import ExternalLinkData from "lucide/icons/external-link"; 50 + import GlobeData from "lucide/icons/globe"; 49 51 import ZapData from "lucide/icons/zap"; 50 52 51 53 export const Activity = icon(ActivityData); ··· 55 57 export const ChevronRight = icon(ChevronRightData); 56 58 export const CircleAlert = icon(CircleAlertData); 57 59 export const Database = icon(DatabaseData); 60 + export const ExternalLink = icon(ExternalLinkData); 58 61 export const Eye = icon(EyeData); 62 + export const Globe = icon(GlobeData); 59 63 export const FilePlus2 = icon(FilePlus2Data); 60 64 export const Filter = icon(FilterData); 61 65 export const FlaskConical = icon(FlaskConicalData);
+1 -140
app/routes/api/lexicons/suggest.ts
··· 1 1 import { createRoute } from "honox/factory"; 2 - import { readdirSync, existsSync } from "node:fs"; 3 - import { resolve as resolvePath } from "node:path"; 4 - import { resolveTxt } from "node:dns/promises"; 5 - import { db } from "@/db/index.js"; 6 - import { lexiconCache } from "@/db/schema.js"; 7 - import { like } from "drizzle-orm"; 2 + import { collectLocalNsids, collectCachedNsids, collectRemoteNsids } from "@/lexicons/discovery.js"; 8 3 9 4 /** 10 5 * Suggest lexicon NSIDs matching a prefix. ··· 31 26 32 27 return c.json({ suggestions }); 33 28 }); 34 - 35 - // --------------------------------------------------------------------------- 36 - // Local lexicons/ directory 37 - // --------------------------------------------------------------------------- 38 - 39 - function collectLocalNsids(prefix: string, results: Set<string>) { 40 - const segments = prefix.split("."); 41 - 42 - // Walk up to the deepest existing directory 43 - let walkDir = resolvePath("lexicons"); 44 - for (const seg of segments) { 45 - if (!seg) break; 46 - const next = resolvePath(walkDir, seg); 47 - if (existsSync(next)) { 48 - walkDir = next; 49 - } else { 50 - break; 51 - } 52 - } 53 - 54 - walkLexiconDir(walkDir, results); 55 - } 56 - 57 - function walkLexiconDir(dir: string, results: Set<string>) { 58 - if (!existsSync(dir)) return; 59 - try { 60 - for (const entry of readdirSync(dir, { withFileTypes: true })) { 61 - if (entry.isDirectory()) { 62 - walkLexiconDir(resolvePath(dir, entry.name), results); 63 - } else if (entry.name.endsWith(".json")) { 64 - // Convert path back to NSID: lexicons/run/airglow/automation.json → run.airglow.automation 65 - const full = resolvePath(dir, entry.name); 66 - const rel = full.slice(full.indexOf("lexicons/") + "lexicons/".length); 67 - const nsid = rel.replace(/\.json$/, "").replace(/\//g, "."); 68 - results.add(nsid); 69 - } 70 - } 71 - } catch { 72 - // ignore read errors 73 - } 74 - } 75 - 76 - // --------------------------------------------------------------------------- 77 - // SQLite cache 78 - // --------------------------------------------------------------------------- 79 - 80 - async function collectCachedNsids(prefix: string, results: Set<string>) { 81 - try { 82 - const rows = await db 83 - .select({ nsid: lexiconCache.nsid }) 84 - .from(lexiconCache) 85 - .where(like(lexiconCache.nsid, `${prefix}%`)) 86 - .limit(50); 87 - for (const row of rows) { 88 - results.add(row.nsid); 89 - } 90 - } catch { 91 - // ignore db errors 92 - } 93 - } 94 - 95 - // --------------------------------------------------------------------------- 96 - // AT Protocol DNS + listRecords 97 - // --------------------------------------------------------------------------- 98 - 99 - async function collectRemoteNsids(prefix: string, results: Set<string>) { 100 - // Need at least 2 segments to form an authority for DNS lookup 101 - const parts = prefix.split(".").filter(Boolean); 102 - if (parts.length < 2) return; 103 - 104 - // The authority is the segments typed so far (reversed for DNS) 105 - const authorityParts = [...parts].reverse(); 106 - const dnsName = `_lexicon.${authorityParts.join(".")}`; 107 - 108 - // Step 1: DNS TXT lookup → DID 109 - let did: string | null = null; 110 - try { 111 - const records = await resolveTxt(dnsName); 112 - for (const record of records) { 113 - const txt = record.join(""); 114 - if (txt.startsWith("did=")) { 115 - did = txt.slice(4); 116 - break; 117 - } 118 - } 119 - } catch { 120 - return; 121 - } 122 - if (!did) return; 123 - 124 - // Step 2: DID → PDS endpoint 125 - let pdsEndpoint: string | null = null; 126 - try { 127 - const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`, { 128 - signal: AbortSignal.timeout(5_000), 129 - }); 130 - if (!res.ok) return; 131 - const doc = (await res.json()) as { 132 - service?: Array<{ id: string; serviceEndpoint: string }>; 133 - }; 134 - pdsEndpoint = doc.service?.find((s) => s.id === "#atproto_pds")?.serviceEndpoint ?? null; 135 - } catch { 136 - return; 137 - } 138 - if (!pdsEndpoint) return; 139 - 140 - // Step 3: List lexicon schema records 141 - try { 142 - const url = new URL(`${pdsEndpoint}/xrpc/com.atproto.repo.listRecords`); 143 - url.searchParams.set("repo", did); 144 - url.searchParams.set("collection", "com.atproto.lexicon.schema"); 145 - url.searchParams.set("limit", "100"); 146 - 147 - const res = await fetch(url, { 148 - headers: { Accept: "application/json" }, 149 - signal: AbortSignal.timeout(5_000), 150 - }); 151 - if (!res.ok) return; 152 - 153 - const data = (await res.json()) as { 154 - records?: Array<{ uri: string; value?: { id?: string } }>; 155 - }; 156 - if (!data.records) return; 157 - 158 - for (const record of data.records) { 159 - const nsid = record.value?.id; 160 - if (typeof nsid === "string") { 161 - results.add(nsid); 162 - } 163 - } 164 - } catch { 165 - // ignore fetch errors 166 - } 167 - }
+4 -1
app/routes/dashboard/automations/[rkey].tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { eq, and, desc } from "drizzle-orm"; 3 - import { ArrowLeft, Copy, Pencil, Filter, Database, Zap } from "../../../icons.js"; 3 + import { ArrowLeft, Copy, ExternalLink, Pencil, Filter, Database, Zap } from "../../../icons.js"; 4 4 import { db } from "@/db/index.js"; 5 5 import { automations, deliveryLogs } from "@/db/schema.js"; 6 6 import { AppShell } from "../../../components/Layout/AppShell/index.js"; ··· 70 70 </Button> 71 71 <Button href={`/dashboard/automations/${rkey}/duplicate`} variant="ghost" size="sm"> 72 72 <Copy size={14} /> Duplicate 73 + </Button> 74 + <Button href={`/u/${user.handle}/${rkey}`} variant="ghost" size="sm"> 75 + <ExternalLink size={14} /> Public page 73 76 </Button> 74 77 <Button href="/dashboard" variant="ghost" size="sm"> 75 78 <ArrowLeft size={14} /> Back
+38 -4
app/routes/dashboard/automations/new.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 + import { eq, and } from "drizzle-orm"; 2 3 import { ArrowLeft } from "../../../icons.js"; 4 + import { db } from "@/db/index.js"; 5 + import { automations } from "@/db/schema.js"; 3 6 import { AppShell } from "../../../components/Layout/AppShell/index.js"; 4 7 import { Header } from "../../../components/Layout/Header/index.js"; 5 8 import { Container } from "../../../components/Layout/Container/index.js"; ··· 9 12 import ThemeToggle from "../../../islands/ThemeToggle.js"; 10 13 import AutomationForm from "../../../islands/AutomationForm.js"; 11 14 12 - export default createRoute((c) => { 15 + export default createRoute(async (c) => { 13 16 const user = c.get("user"); 14 17 18 + // Support cross-user duplication via ?from=<did>&rkey=<rkey> 19 + const fromDid = c.req.query("from"); 20 + const sourceRkey = c.req.query("rkey"); 21 + let initial: Parameters<typeof AutomationForm>[0]["initial"]; 22 + 23 + if (fromDid && sourceRkey) { 24 + const source = await db.query.automations.findFirst({ 25 + where: and(eq(automations.did, fromDid), eq(automations.rkey, sourceRkey)), 26 + }); 27 + if (source) { 28 + initial = { 29 + name: `${source.name} (copy)`, 30 + description: source.description, 31 + lexicon: source.lexicon, 32 + operations: source.operations, 33 + actions: source.actions.map((a) => { 34 + if (a.$type === "webhook") { 35 + // Keep callbackUrl as reference; secret will be regenerated on create 36 + return { ...a, secret: "" }; 37 + } 38 + return a; 39 + }), 40 + fetches: source.fetches, 41 + conditions: source.conditions, 42 + active: false, 43 + }; 44 + } 45 + } 46 + 47 + const isDuplicate = Boolean(initial); 48 + 15 49 return c.render( 16 50 <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 17 51 <Container> 18 52 <PageHeader 19 - title="New Automation" 53 + title={isDuplicate ? "Duplicate Automation" : "New Automation"} 20 54 actions={ 21 55 <Button href="/dashboard" variant="ghost" size="sm"> 22 56 <ArrowLeft size={14} /> Back ··· 24 58 } 25 59 /> 26 60 <Card variant="flat"> 27 - <AutomationForm /> 61 + <AutomationForm initial={initial} /> 28 62 </Card> 29 63 </Container> 30 64 </AppShell>, 31 - { title: "New Automation — Airglow" }, 65 + { title: `${isDuplicate ? "Duplicate" : "New"} Automation — Airglow` }, 32 66 ); 33 67 });
+245
app/routes/u/[handle]/[rkey].tsx
··· 1 + import { createRoute } from "honox/factory"; 2 + import { eq, and } from "drizzle-orm"; 3 + import { ArrowLeft, Copy, Filter, Database, Zap } from "../../../icons.js"; 4 + import { getSessionUser } from "@/auth/middleware.js"; 5 + import { resolveHandle } from "@/auth/client.js"; 6 + import { db } from "@/db/index.js"; 7 + import { users, automations } from "@/db/schema.js"; 8 + import { sanitizeActions } from "@/automations/sanitize.js"; 9 + import { AppShell } from "../../../components/Layout/AppShell/index.js"; 10 + import { Header } from "../../../components/Layout/Header/index.js"; 11 + import { Container } from "../../../components/Layout/Container/index.js"; 12 + import { PageHeader } from "../../../components/Layout/PageHeader/index.js"; 13 + import { Card } from "../../../components/Card/index.js"; 14 + import { Badge } from "../../../components/Badge/index.js"; 15 + import { Button } from "../../../components/Button/index.js"; 16 + import { DescriptionList } from "../../../components/DescriptionList/index.js"; 17 + import { CodeBlock, InlineCode } from "../../../components/CodeBlock/index.js"; 18 + import { Stack } from "../../../components/Layout/Stack/index.js"; 19 + import ThemeToggle from "../../../islands/ThemeToggle.js"; 20 + import { 21 + inlineCluster, 22 + plainList, 23 + textMuted, 24 + centerTextSm, 25 + } from "../../../styles/utilities.css.js"; 26 + 27 + export default createRoute(async (c) => { 28 + const viewer = await getSessionUser(c); 29 + const handle = c.req.param("handle")!; 30 + const rkey = c.req.param("rkey")!; 31 + 32 + // Resolve handle → DID 33 + let profileUser = await db.query.users.findFirst({ 34 + where: eq(users.handle, handle), 35 + }); 36 + 37 + if (!profileUser) { 38 + const did = await resolveHandle(handle); 39 + if (did !== handle) { 40 + profileUser = await db.query.users.findFirst({ 41 + where: eq(users.did, did), 42 + }); 43 + } 44 + } 45 + 46 + if (!profileUser) { 47 + c.status(404); 48 + return c.render( 49 + <AppShell header={<Header user={viewer} actions={<ThemeToggle />} />}> 50 + <Container> 51 + <div class={centerTextSm}> 52 + <h1>Not Found</h1> 53 + <p>This automation does not exist.</p> 54 + <Button href="/" variant="secondary" size="sm"> 55 + Back to home 56 + </Button> 57 + </div> 58 + </Container> 59 + </AppShell>, 60 + { title: "Not Found — Airglow" }, 61 + ); 62 + } 63 + 64 + const auto = await db.query.automations.findFirst({ 65 + where: and(eq(automations.did, profileUser.did), eq(automations.rkey, rkey)), 66 + }); 67 + 68 + if (!auto) { 69 + c.status(404); 70 + return c.render( 71 + <AppShell header={<Header user={viewer} actions={<ThemeToggle />} />}> 72 + <Container> 73 + <div class={centerTextSm}> 74 + <h1>Not Found</h1> 75 + <p>This automation does not exist.</p> 76 + <Button href={`/u/${handle}`} variant="secondary" size="sm"> 77 + <ArrowLeft size={14} /> Back to profile 78 + </Button> 79 + </div> 80 + </Container> 81 + </AppShell>, 82 + { title: "Not Found — Airglow" }, 83 + ); 84 + } 85 + 86 + const publicActions = sanitizeActions(auto.actions); 87 + const isOwner = viewer && viewer.did === profileUser.did; 88 + 89 + return c.render( 90 + <AppShell header={<Header user={viewer} actions={<ThemeToggle />} />}> 91 + <Container> 92 + <PageHeader 93 + title={auto.name} 94 + description={auto.description ?? undefined} 95 + actions={ 96 + <div class={inlineCluster}> 97 + <span> 98 + <Badge variant={!auto.active ? "neutral" : auto.dryRun ? "warning" : "success"}> 99 + {!auto.active ? "Inactive" : auto.dryRun ? "Dry Run" : "Active"} 100 + </Badge> 101 + </span> 102 + {viewer ? ( 103 + isOwner ? ( 104 + <Button href={`/dashboard/automations/${rkey}`} variant="secondary" size="sm"> 105 + Manage 106 + </Button> 107 + ) : ( 108 + <Button 109 + href={`/dashboard/automations/new?from=${encodeURIComponent(profileUser.did)}&rkey=${encodeURIComponent(rkey)}`} 110 + size="sm" 111 + > 112 + <Copy size={14} /> Use this automation 113 + </Button> 114 + ) 115 + ) : ( 116 + <Button href="/auth/login" variant="secondary" size="sm"> 117 + Sign in to use 118 + </Button> 119 + )} 120 + <Button href={`/u/${handle}`} variant="ghost" size="sm"> 121 + <ArrowLeft size={14} /> @{handle} 122 + </Button> 123 + </div> 124 + } 125 + /> 126 + 127 + <Stack gap={6}> 128 + <Card variant="flat"> 129 + <DescriptionList> 130 + <dt>Lexicon</dt> 131 + <dd> 132 + <InlineCode>{auto.lexicon}</InlineCode> 133 + </dd> 134 + <dt>Operations</dt> 135 + <dd> 136 + {auto.operations.map((op, i) => ( 137 + <> 138 + {i > 0 && ", "} 139 + <InlineCode>{op}</InlineCode> 140 + </> 141 + ))} 142 + </dd> 143 + <dt>By</dt> 144 + <dd> 145 + <a href={`/u/${handle}`}>@{handle}</a> 146 + </dd> 147 + </DescriptionList> 148 + </Card> 149 + 150 + {auto.conditions.length > 0 && ( 151 + <Card variant="flat"> 152 + <Stack gap={3}> 153 + <h3 class={inlineCluster}> 154 + <Filter size={18} /> Conditions 155 + </h3> 156 + <ul class={plainList}> 157 + {auto.conditions.map((cond, i) => { 158 + const opLabels: Record<string, string> = { 159 + eq: "equals", 160 + startsWith: "starts with", 161 + endsWith: "ends with", 162 + contains: "contains", 163 + }; 164 + return ( 165 + <li key={i}> 166 + <InlineCode>{cond.field}</InlineCode>{" "} 167 + {opLabels[cond.operator] ?? cond.operator}{" "} 168 + <InlineCode>{cond.value}</InlineCode> 169 + {cond.comment && <span class={textMuted}> — {cond.comment}</span>} 170 + </li> 171 + ); 172 + })} 173 + </ul> 174 + </Stack> 175 + </Card> 176 + )} 177 + 178 + {auto.fetches.length > 0 && ( 179 + <Card variant="flat"> 180 + <Stack gap={3}> 181 + <h3 class={inlineCluster}> 182 + <Database size={18} /> Data Sources 183 + </h3> 184 + <ul class={plainList}> 185 + {auto.fetches.map((f, i) => ( 186 + <li key={i}> 187 + <InlineCode>{f.name}</InlineCode> &larr; <InlineCode>{f.uri}</InlineCode> 188 + {f.comment && <span class={textMuted}> — {f.comment}</span>} 189 + </li> 190 + ))} 191 + </ul> 192 + </Stack> 193 + </Card> 194 + )} 195 + 196 + <Stack gap={3}> 197 + <h3 class={inlineCluster}> 198 + <Zap size={18} /> Actions ({publicActions.length}) 199 + </h3> 200 + {publicActions.map((action, i) => ( 201 + <Card key={i} variant="flat"> 202 + <Stack gap={2}> 203 + <h4> 204 + {action.$type === "webhook" ? "Webhook" : "Record"} {i + 1} 205 + {action.$type === "webhook" && ( 206 + <> 207 + {" "} 208 + <Badge variant={action.verified ? "success" : "neutral"}> 209 + {action.verified ? "Verified" : "Unverified"} 210 + </Badge> 211 + </> 212 + )} 213 + {action.comment && <span class={textMuted}> — {action.comment}</span>} 214 + </h4> 215 + <DescriptionList> 216 + {action.$type === "webhook" ? ( 217 + <> 218 + <dt>Destination</dt> 219 + <dd> 220 + <InlineCode>{action.callbackDomain}</InlineCode> 221 + </dd> 222 + </> 223 + ) : ( 224 + <> 225 + <dt>Target Collection</dt> 226 + <dd> 227 + <InlineCode>{action.targetCollection}</InlineCode> 228 + </dd> 229 + <dt>Record Template</dt> 230 + <dd> 231 + <CodeBlock>{action.recordTemplate}</CodeBlock> 232 + </dd> 233 + </> 234 + )} 235 + </DescriptionList> 236 + </Stack> 237 + </Card> 238 + ))} 239 + </Stack> 240 + </Stack> 241 + </Container> 242 + </AppShell>, 243 + { title: `${auto.name} — @${handle} — Airglow` }, 244 + ); 245 + });
+191
app/routes/u/[handle]/index.tsx
··· 1 + import { createRoute } from "honox/factory"; 2 + import { eq } from "drizzle-orm"; 3 + import { Eye, Zap, Globe } from "../../../icons.js"; 4 + import { getSessionUser } from "@/auth/middleware.js"; 5 + import { resolveHandle } from "@/auth/client.js"; 6 + import { db } from "@/db/index.js"; 7 + import { users, automations } from "@/db/schema.js"; 8 + import { 9 + handleToNsidPrefix, 10 + collectLocalNsids, 11 + collectCachedNsids, 12 + collectRemoteNsids, 13 + } from "@/lexicons/discovery.js"; 14 + import { AppShell } from "../../../components/Layout/AppShell/index.js"; 15 + import { Header } from "../../../components/Layout/Header/index.js"; 16 + import { Container } from "../../../components/Layout/Container/index.js"; 17 + import { PageHeader } from "../../../components/Layout/PageHeader/index.js"; 18 + import { Badge } from "../../../components/Badge/index.js"; 19 + import { Button } from "../../../components/Button/index.js"; 20 + import { Table } from "../../../components/Table/index.js"; 21 + import { InlineCode } from "../../../components/CodeBlock/index.js"; 22 + import { Stack } from "../../../components/Layout/Stack/index.js"; 23 + import ThemeToggle from "../../../islands/ThemeToggle.js"; 24 + import * as s from "../../../styles/pages/profile.css.js"; 25 + import { centerTextSm } from "../../../styles/utilities.css.js"; 26 + 27 + export default createRoute(async (c) => { 28 + const viewer = await getSessionUser(c); 29 + const handle = c.req.param("handle")!; 30 + 31 + // Resolve handle → user in DB 32 + let profileUser = await db.query.users.findFirst({ 33 + where: eq(users.handle, handle), 34 + }); 35 + 36 + if (!profileUser) { 37 + // Try resolving handle → DID, then look up by DID 38 + const did = await resolveHandle(handle); 39 + if (did !== handle) { 40 + profileUser = await db.query.users.findFirst({ 41 + where: eq(users.did, did), 42 + }); 43 + } 44 + } 45 + 46 + // Fetch automations if user exists 47 + const autos = profileUser 48 + ? await db.query.automations.findMany({ 49 + where: eq(automations.did, profileUser.did), 50 + }) 51 + : []; 52 + 53 + // Check if handle is a lexicon authority 54 + const nsidPrefix = handleToNsidPrefix(handle); 55 + const lexiconResults = new Set<string>(); 56 + collectLocalNsids(nsidPrefix, lexiconResults); 57 + await collectCachedNsids(nsidPrefix, lexiconResults); 58 + await collectRemoteNsids(nsidPrefix, lexiconResults); 59 + const lexicons = [...lexiconResults].filter((nsid) => nsid.startsWith(nsidPrefix)).sort(); 60 + 61 + // 404 if nothing to show 62 + if (autos.length === 0 && lexicons.length === 0) { 63 + c.status(404); 64 + return c.render( 65 + <AppShell header={<Header user={viewer} actions={<ThemeToggle />} />}> 66 + <Container> 67 + <div class={centerTextSm}> 68 + <h1>Not Found</h1> 69 + <p>No profile found for @{handle}.</p> 70 + <Button href="/" variant="secondary" size="sm"> 71 + Back to home 72 + </Button> 73 + </div> 74 + </Container> 75 + </AppShell>, 76 + { title: "Not Found — Airglow" }, 77 + ); 78 + } 79 + 80 + // If viewing own profile, redirect hint 81 + const isOwnProfile = viewer && profileUser && viewer.did === profileUser.did; 82 + 83 + return c.render( 84 + <AppShell header={<Header user={viewer} actions={<ThemeToggle />} />}> 85 + <Container> 86 + <PageHeader 87 + title={`@${profileUser?.handle ?? handle}`} 88 + description={ 89 + [ 90 + autos.length > 0 ? `${autos.length} automation${autos.length !== 1 ? "s" : ""}` : "", 91 + lexicons.length > 0 92 + ? `${lexicons.length} lexicon${lexicons.length !== 1 ? "s" : ""}` 93 + : "", 94 + ] 95 + .filter(Boolean) 96 + .join(" · ") || undefined 97 + } 98 + actions={ 99 + isOwnProfile ? ( 100 + <Button href="/dashboard" variant="secondary" size="sm"> 101 + Go to Dashboard 102 + </Button> 103 + ) : undefined 104 + } 105 + /> 106 + 107 + <Stack gap={6}> 108 + {autos.length > 0 && ( 109 + <section class={s.section}> 110 + <h2 class={s.sectionTitle}> 111 + <Zap size={18} /> Automations 112 + </h2> 113 + <Table> 114 + <thead> 115 + <tr> 116 + <th>Name</th> 117 + <th>Lexicon</th> 118 + <th>Operations</th> 119 + <th>Actions</th> 120 + <th>Status</th> 121 + <th></th> 122 + </tr> 123 + </thead> 124 + <tbody> 125 + {autos.map((auto) => ( 126 + <tr key={auto.uri}> 127 + <td> 128 + <a href={`/u/${handle}/${auto.rkey}`}>{auto.name}</a> 129 + </td> 130 + <td> 131 + <InlineCode>{auto.lexicon}</InlineCode> 132 + </td> 133 + <td> 134 + {auto.operations.map((op, i) => ( 135 + <> 136 + {i > 0 && ", "} 137 + <InlineCode>{op}</InlineCode> 138 + </> 139 + ))} 140 + </td> 141 + <td> 142 + {auto.actions.length} action{auto.actions.length !== 1 ? "s" : ""} 143 + </td> 144 + <td> 145 + <Badge 146 + variant={!auto.active ? "neutral" : auto.dryRun ? "warning" : "success"} 147 + > 148 + {!auto.active ? "Inactive" : auto.dryRun ? "Dry Run" : "Active"} 149 + </Badge> 150 + </td> 151 + <td> 152 + <Button href={`/u/${handle}/${auto.rkey}`} variant="ghost" size="sm"> 153 + <Eye size={14} /> View 154 + </Button> 155 + </td> 156 + </tr> 157 + ))} 158 + </tbody> 159 + </Table> 160 + </section> 161 + )} 162 + 163 + {lexicons.length > 0 && ( 164 + <section class={s.section}> 165 + <h2 class={s.sectionTitle}> 166 + <Globe size={18} /> Lexicons 167 + </h2> 168 + <Table> 169 + <thead> 170 + <tr> 171 + <th>NSID</th> 172 + </tr> 173 + </thead> 174 + <tbody> 175 + {lexicons.map((nsid) => ( 176 + <tr key={nsid}> 177 + <td> 178 + <InlineCode>{nsid}</InlineCode> 179 + </td> 180 + </tr> 181 + ))} 182 + </tbody> 183 + </Table> 184 + </section> 185 + )} 186 + </Stack> 187 + </Container> 188 + </AppShell>, 189 + { title: `@${profileUser?.handle ?? handle} — Airglow` }, 190 + ); 191 + });
+18
app/styles/pages/profile.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 + 6 + export const section = style({ 7 + marginBlockEnd: space[6], 8 + }); 9 + 10 + export const sectionTitle = style({ 11 + fontSize: fontSize.base, 12 + fontWeight: fontWeight.semibold, 13 + marginBlockEnd: space[3], 14 + display: "flex", 15 + alignItems: "center", 16 + gap: space[2], 17 + color: vars.color.textSecondary, 18 + });
+26
lib/automations/sanitize.ts
··· 1 + import type { Action } from "../db/schema.ts"; 2 + 3 + export type PublicAction = 4 + | { $type: "webhook"; callbackDomain: string; verified?: boolean; comment?: string } 5 + | { $type: "record"; targetCollection: string; recordTemplate: string; comment?: string }; 6 + 7 + /** Strip instance-local secrets and truncate webhook URLs to domain-only. */ 8 + export function sanitizeActions(actions: Action[]): PublicAction[] { 9 + return actions.map((a) => { 10 + if (a.$type === "webhook") { 11 + let callbackDomain: string; 12 + try { 13 + callbackDomain = new URL(a.callbackUrl).hostname; 14 + } catch { 15 + callbackDomain = "unknown"; 16 + } 17 + return { 18 + $type: "webhook" as const, 19 + callbackDomain, 20 + verified: a.verified, 21 + comment: a.comment, 22 + }; 23 + } 24 + return a; 25 + }); 26 + }
+149
lib/lexicons/discovery.ts
··· 1 + import { readdirSync, existsSync } from "node:fs"; 2 + import { resolve as resolvePath } from "node:path"; 3 + import { resolveTxt } from "node:dns/promises"; 4 + import { db } from "@/db/index.js"; 5 + import { lexiconCache } from "@/db/schema.js"; 6 + import { like } from "drizzle-orm"; 7 + 8 + // --------------------------------------------------------------------------- 9 + // Authority helpers 10 + // --------------------------------------------------------------------------- 11 + 12 + /** Convert a domain handle to an NSID prefix, e.g. "tangled.sh" → "sh.tangled." */ 13 + export function handleToNsidPrefix(handle: string): string { 14 + return handle.split(".").reverse().join(".") + "."; 15 + } 16 + 17 + // --------------------------------------------------------------------------- 18 + // Local lexicons/ directory 19 + // --------------------------------------------------------------------------- 20 + 21 + export function collectLocalNsids(prefix: string, results: Set<string>) { 22 + const segments = prefix.split("."); 23 + 24 + // Walk up to the deepest existing directory 25 + let walkDir = resolvePath("lexicons"); 26 + for (const seg of segments) { 27 + if (!seg) break; 28 + const next = resolvePath(walkDir, seg); 29 + if (existsSync(next)) { 30 + walkDir = next; 31 + } else { 32 + break; 33 + } 34 + } 35 + 36 + walkLexiconDir(walkDir, results); 37 + } 38 + 39 + function walkLexiconDir(dir: string, results: Set<string>) { 40 + if (!existsSync(dir)) return; 41 + try { 42 + for (const entry of readdirSync(dir, { withFileTypes: true })) { 43 + if (entry.isDirectory()) { 44 + walkLexiconDir(resolvePath(dir, entry.name), results); 45 + } else if (entry.name.endsWith(".json")) { 46 + // Convert path back to NSID: lexicons/run/airglow/automation.json → run.airglow.automation 47 + const full = resolvePath(dir, entry.name); 48 + const rel = full.slice(full.indexOf("lexicons/") + "lexicons/".length); 49 + const nsid = rel.replace(/\.json$/, "").replace(/\//g, "."); 50 + results.add(nsid); 51 + } 52 + } 53 + } catch { 54 + // ignore read errors 55 + } 56 + } 57 + 58 + // --------------------------------------------------------------------------- 59 + // SQLite cache 60 + // --------------------------------------------------------------------------- 61 + 62 + export async function collectCachedNsids(prefix: string, results: Set<string>) { 63 + try { 64 + const rows = await db 65 + .select({ nsid: lexiconCache.nsid }) 66 + .from(lexiconCache) 67 + .where(like(lexiconCache.nsid, `${prefix}%`)) 68 + .limit(50); 69 + for (const row of rows) { 70 + results.add(row.nsid); 71 + } 72 + } catch { 73 + // ignore db errors 74 + } 75 + } 76 + 77 + // --------------------------------------------------------------------------- 78 + // AT Protocol DNS + listRecords 79 + // --------------------------------------------------------------------------- 80 + 81 + export async function collectRemoteNsids(prefix: string, results: Set<string>) { 82 + // Need at least 2 segments to form an authority for DNS lookup 83 + const parts = prefix.split(".").filter(Boolean); 84 + if (parts.length < 2) return; 85 + 86 + // The authority is the segments typed so far (reversed for DNS) 87 + const authorityParts = [...parts].reverse(); 88 + const dnsName = `_lexicon.${authorityParts.join(".")}`; 89 + 90 + // Step 1: DNS TXT lookup → DID 91 + let did: string | null = null; 92 + try { 93 + const records = await resolveTxt(dnsName); 94 + for (const record of records) { 95 + const txt = record.join(""); 96 + if (txt.startsWith("did=")) { 97 + did = txt.slice(4); 98 + break; 99 + } 100 + } 101 + } catch { 102 + return; 103 + } 104 + if (!did) return; 105 + 106 + // Step 2: DID → PDS endpoint 107 + let pdsEndpoint: string | null = null; 108 + try { 109 + const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`, { 110 + signal: AbortSignal.timeout(5_000), 111 + }); 112 + if (!res.ok) return; 113 + const doc = (await res.json()) as { 114 + service?: Array<{ id: string; serviceEndpoint: string }>; 115 + }; 116 + pdsEndpoint = doc.service?.find((s) => s.id === "#atproto_pds")?.serviceEndpoint ?? null; 117 + } catch { 118 + return; 119 + } 120 + if (!pdsEndpoint) return; 121 + 122 + // Step 3: List lexicon schema records 123 + try { 124 + const url = new URL(`${pdsEndpoint}/xrpc/com.atproto.repo.listRecords`); 125 + url.searchParams.set("repo", did); 126 + url.searchParams.set("collection", "com.atproto.lexicon.schema"); 127 + url.searchParams.set("limit", "100"); 128 + 129 + const res = await fetch(url, { 130 + headers: { Accept: "application/json" }, 131 + signal: AbortSignal.timeout(5_000), 132 + }); 133 + if (!res.ok) return; 134 + 135 + const data = (await res.json()) as { 136 + records?: Array<{ uri: string; value?: { id?: string } }>; 137 + }; 138 + if (!data.records) return; 139 + 140 + for (const record of data.records) { 141 + const nsid = record.value?.id; 142 + if (typeof nsid === "string") { 143 + results.add(nsid); 144 + } 145 + } 146 + } catch { 147 + // ignore fetch errors 148 + } 149 + }