Webhooks for the AT Protocol

fix: better public profiles

exosphere.site e23597df 1c5f9ac1

verified
+218 -10
+7 -7
README.md
··· 114 114 115 115 Airglow is designed to be easy to self-host. Configuration is done via environment variables (see `.env.example`): 116 116 117 - | Variable | Purpose | 118 - | --- | --- | 119 - | `PUBLIC_URL` | Public-facing base URL of the instance | 120 - | `DATABASE_PATH` | Path to the SQLite database file | 121 - | `JETSTREAM_URL` | Jetstream WebSocket endpoint | 122 - | `COOKIE_SECRET` | Secret for session cookies (min 32 chars) | 123 - | `NSID_ALLOWLIST` | Comma-separated NSIDs to allow (empty = allow all) | 117 + | Variable | Purpose | 118 + | ---------------- | --------------------------------------------------- | 119 + | `PUBLIC_URL` | Public-facing base URL of the instance | 120 + | `DATABASE_PATH` | Path to the SQLite database file | 121 + | `JETSTREAM_URL` | Jetstream WebSocket endpoint | 122 + | `COOKIE_SECRET` | Secret for session cookies (min 32 chars) | 123 + | `NSID_ALLOWLIST` | Comma-separated NSIDs to allow (empty = allow all) | 124 124 | `NSID_BLOCKLIST` | Comma-separated NSIDs to block (empty = block none) | 125 125 126 126 Instance operators can configure `NSID_ALLOWLIST` and `NSID_BLOCKLIST` 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.
+173
app/routes/lexicons/[nsid].tsx
··· 1 + import { createRoute } from "honox/factory"; 2 + import { eq } from "drizzle-orm"; 3 + import { ArrowLeft, Eye, Zap } from "../../icons.js"; 4 + import { getSessionUser } from "@/auth/middleware.js"; 5 + import { db } from "@/db/index.js"; 6 + import { automations, users } from "@/db/schema.js"; 7 + import { isValidNsid, nsidToAuthority, resolve } from "@/lexicons/resolver.js"; 8 + import { getCached, setCache } from "@/lexicons/cache.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 { Table } from "../../components/Table/index.js"; 17 + import { InlineCode } from "../../components/CodeBlock/index.js"; 18 + import { Stack } from "../../components/Layout/Stack/index.js"; 19 + import ThemeToggle from "../../islands/ThemeToggle.js"; 20 + import { centerTextSm, inlineCluster } from "../../styles/utilities.css.js"; 21 + import * as s from "../../styles/pages/profile.css.js"; 22 + 23 + export default createRoute(async (c) => { 24 + const viewer = await getSessionUser(c); 25 + const nsid = c.req.param("nsid")!; 26 + 27 + if (!isValidNsid(nsid)) { 28 + c.status(404); 29 + return c.render( 30 + <AppShell header={<Header user={viewer} actions={<ThemeToggle />} />}> 31 + <Container> 32 + <div class={centerTextSm}> 33 + <h1>Not Found</h1> 34 + <p>Invalid lexicon NSID.</p> 35 + <Button href="/" variant="secondary" size="sm"> 36 + Back to home 37 + </Button> 38 + </div> 39 + </Container> 40 + </AppShell>, 41 + { title: "Not Found — Airglow" }, 42 + ); 43 + } 44 + 45 + // Try to resolve the lexicon schema for description 46 + let description: string | undefined; 47 + const cached = await getCached(nsid); 48 + if (cached) { 49 + description = cached.description; 50 + } else { 51 + const schema = await resolve(nsid); 52 + if (schema) { 53 + description = schema.description; 54 + await setCache(schema); 55 + } 56 + } 57 + 58 + // Find all automations subscribed to this lexicon 59 + const autos = await db.query.automations.findMany({ 60 + where: eq(automations.lexicon, nsid), 61 + }); 62 + 63 + // Resolve owner handles for each automation 64 + const ownerDids = [...new Set(autos.map((a) => a.did))]; 65 + const ownerRows = await Promise.all( 66 + ownerDids.map((did) => db.query.users.findFirst({ where: eq(users.did, did) })), 67 + ); 68 + const handleByDid = new Map<string, string>(); 69 + for (const row of ownerRows) { 70 + if (row) handleByDid.set(row.did, row.handle); 71 + } 72 + 73 + const authority = nsidToAuthority(nsid); 74 + 75 + return c.render( 76 + <AppShell header={<Header user={viewer} actions={<ThemeToggle />} />}> 77 + <Container> 78 + <PageHeader 79 + title={nsid} 80 + description={description} 81 + actions={ 82 + <div class={inlineCluster}> 83 + <Button href={`/u/${authority}`} variant="ghost" size="sm"> 84 + <ArrowLeft size={14} /> @{authority} 85 + </Button> 86 + </div> 87 + } 88 + /> 89 + 90 + <Stack gap={6}> 91 + {autos.length > 0 ? ( 92 + <section class={s.section}> 93 + <h2 class={s.sectionTitle}> 94 + <Zap size={18} /> Automations using this lexicon ({autos.length}) 95 + </h2> 96 + <Table> 97 + <thead> 98 + <tr> 99 + <th>Name</th> 100 + <th>By</th> 101 + <th>Operations</th> 102 + <th>Actions</th> 103 + <th>Status</th> 104 + <th></th> 105 + </tr> 106 + </thead> 107 + <tbody> 108 + {autos.map((auto) => { 109 + const ownerHandle = handleByDid.get(auto.did); 110 + return ( 111 + <tr key={auto.uri}> 112 + <td> 113 + {ownerHandle ? ( 114 + <a href={`/u/${ownerHandle}/${auto.rkey}`}>{auto.name}</a> 115 + ) : ( 116 + auto.name 117 + )} 118 + </td> 119 + <td> 120 + {ownerHandle ? ( 121 + <a href={`/u/${ownerHandle}`}>@{ownerHandle}</a> 122 + ) : ( 123 + <InlineCode>{auto.did}</InlineCode> 124 + )} 125 + </td> 126 + <td> 127 + {auto.operations.map((op, i) => ( 128 + <> 129 + {i > 0 && ", "} 130 + <InlineCode>{op}</InlineCode> 131 + </> 132 + ))} 133 + </td> 134 + <td> 135 + {auto.actions.length} action{auto.actions.length !== 1 ? "s" : ""} 136 + </td> 137 + <td> 138 + <Badge 139 + variant={!auto.active ? "neutral" : auto.dryRun ? "warning" : "success"} 140 + > 141 + {!auto.active ? "Inactive" : auto.dryRun ? "Dry Run" : "Active"} 142 + </Badge> 143 + </td> 144 + <td> 145 + {ownerHandle && ( 146 + <Button 147 + href={`/u/${ownerHandle}/${auto.rkey}`} 148 + variant="ghost" 149 + size="sm" 150 + > 151 + <Eye size={14} /> View 152 + </Button> 153 + )} 154 + </td> 155 + </tr> 156 + ); 157 + })} 158 + </tbody> 159 + </Table> 160 + </section> 161 + ) : ( 162 + <Card variant="flat"> 163 + <div class={centerTextSm}> 164 + <p>No automations are using this lexicon yet.</p> 165 + </div> 166 + </Card> 167 + )} 168 + </Stack> 169 + </Container> 170 + </AppShell>, 171 + { title: `${nsid} — Airglow` }, 172 + ); 173 + });
+3 -1
app/routes/u/[handle]/[rkey].tsx
··· 129 129 <DescriptionList> 130 130 <dt>Lexicon</dt> 131 131 <dd> 132 - <InlineCode>{auto.lexicon}</InlineCode> 132 + <a href={`/lexicons/${auto.lexicon}`}> 133 + <InlineCode>{auto.lexicon}</InlineCode> 134 + </a> 133 135 </dd> 134 136 <dt>Operations</dt> 135 137 <dd>
+6 -2
app/routes/u/[handle]/index.tsx
··· 128 128 <a href={`/u/${handle}/${auto.rkey}`}>{auto.name}</a> 129 129 </td> 130 130 <td> 131 - <InlineCode>{auto.lexicon}</InlineCode> 131 + <a href={`/lexicons/${auto.lexicon}`}> 132 + <InlineCode>{auto.lexicon}</InlineCode> 133 + </a> 132 134 </td> 133 135 <td> 134 136 {auto.operations.map((op, i) => ( ··· 175 177 {lexicons.map((nsid) => ( 176 178 <tr key={nsid}> 177 179 <td> 178 - <InlineCode>{nsid}</InlineCode> 180 + <a href={`/lexicons/${nsid}`}> 181 + <InlineCode>{nsid}</InlineCode> 182 + </a> 179 183 </td> 180 184 </tr> 181 185 ))}
+27
lib/auth/client.ts
··· 2 2 import { createRequire } from "node:module"; 3 3 import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; 4 4 import { dirname, resolve } from "node:path"; 5 + import { resolveTxt } from "node:dns/promises"; 5 6 6 7 // Load via require() — HonoX forces Vite to ESM-transform all node_modules 7 8 // during SSR, which breaks CJS packages. require() bypasses Vite's transform. ··· 54 55 // fall through 55 56 } 56 57 } 58 + 59 + if (!pdsUrl) { 60 + // Production: resolve via DNS TXT _atproto.handle or HTTPS .well-known 61 + try { 62 + const records = await resolveTxt(`_atproto.${handle}`); 63 + for (const record of records) { 64 + const txt = record.join(""); 65 + if (txt.startsWith("did=")) return txt.slice(4); 66 + } 67 + } catch { 68 + // fall through to HTTPS 69 + } 70 + 71 + try { 72 + const res = await fetch(`https://${handle}/.well-known/atproto-did`, { 73 + signal: AbortSignal.timeout(5_000), 74 + }); 75 + if (res.ok) { 76 + const did = (await res.text()).trim(); 77 + if (did.startsWith("did:")) return did; 78 + } 79 + } catch { 80 + // fall through 81 + } 82 + } 83 + 57 84 return handle; 58 85 } 59 86
+2
lib/lexicons/schema-tree.ts
··· 14 14 15 15 import type { 16 16 StringNode, 17 + IntegerNode, 18 + BooleanNode, 17 19 ObjectNode, 18 20 ArrayNode, 19 21 SchemaNode,