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: subscription management

Hugo f789411d cc13b499

+899 -2
+125
app/islands/DeliveryLog.tsx
··· 1 + import { useState, useCallback } from "hono/jsx"; 2 + 3 + type LogEntry = { 4 + id: number; 5 + eventTimeUs: number; 6 + statusCode: number | null; 7 + error: string | null; 8 + attempt: number; 9 + createdAt: number; 10 + }; 11 + 12 + type Props = { 13 + rkey: string; 14 + active: boolean; 15 + initialLogs: LogEntry[]; 16 + }; 17 + 18 + export default function DeliveryLog({ rkey, active, initialLogs }: Props) { 19 + const [isActive, setIsActive] = useState(active); 20 + const [logs, setLogs] = useState(initialLogs); 21 + const [loading, setLoading] = useState(false); 22 + const [error, setError] = useState(""); 23 + 24 + const toggleActive = useCallback(async () => { 25 + setLoading(true); 26 + setError(""); 27 + try { 28 + const res = await fetch(`/api/subscriptions/${rkey}`, { 29 + method: "PATCH", 30 + headers: { "Content-Type": "application/json" }, 31 + body: JSON.stringify({ active: !isActive }), 32 + }); 33 + if (!res.ok) { 34 + const data = await res.json(); 35 + setError(data.error || "Failed to update"); 36 + } else { 37 + setIsActive(!isActive); 38 + } 39 + } catch { 40 + setError("Request failed"); 41 + } finally { 42 + setLoading(false); 43 + } 44 + }, [rkey, isActive]); 45 + 46 + const handleDelete = useCallback(async () => { 47 + if (!confirm("Delete this subscription? This cannot be undone.")) return; 48 + setLoading(true); 49 + setError(""); 50 + try { 51 + const res = await fetch(`/api/subscriptions/${rkey}`, { 52 + method: "DELETE", 53 + }); 54 + if (!res.ok) { 55 + const data = await res.json(); 56 + setError(data.error || "Failed to delete"); 57 + } else { 58 + window.location.href = "/dashboard"; 59 + } 60 + } catch { 61 + setError("Request failed"); 62 + } finally { 63 + setLoading(false); 64 + } 65 + }, [rkey]); 66 + 67 + const refreshLogs = useCallback(async () => { 68 + try { 69 + const res = await fetch(`/api/subscriptions/${rkey}`); 70 + if (res.ok) { 71 + const data = await res.json(); 72 + setLogs(data.deliveryLogs || []); 73 + } 74 + } catch { 75 + // ignore 76 + } 77 + }, [rkey]); 78 + 79 + return ( 80 + <div> 81 + <p> 82 + <button type="button" onClick={toggleActive} disabled={loading}> 83 + {isActive ? "Deactivate" : "Activate"} 84 + </button>{" "} 85 + <button type="button" onClick={handleDelete} disabled={loading}> 86 + Delete 87 + </button> 88 + </p> 89 + 90 + {error && <p>{error}</p>} 91 + 92 + <h3> 93 + Delivery Logs{" "} 94 + <button type="button" onClick={refreshLogs}> 95 + Refresh 96 + </button> 97 + </h3> 98 + 99 + {logs.length === 0 ? ( 100 + <p>No deliveries yet.</p> 101 + ) : ( 102 + <table> 103 + <thead> 104 + <tr> 105 + <th>Time</th> 106 + <th>Status</th> 107 + <th>Attempt</th> 108 + <th>Error</th> 109 + </tr> 110 + </thead> 111 + <tbody> 112 + {logs.map((log) => ( 113 + <tr key={log.id}> 114 + <td>{new Date(log.createdAt).toLocaleString()}</td> 115 + <td>{log.statusCode ?? "—"}</td> 116 + <td>{log.attempt}</td> 117 + <td>{log.error || "—"}</td> 118 + </tr> 119 + ))} 120 + </tbody> 121 + </table> 122 + )} 123 + </div> 124 + ); 125 + }
+194
app/islands/SubscriptionForm.tsx
··· 1 + import { useState, useCallback, useRef } from "hono/jsx"; 2 + 3 + type Field = { 4 + path: string; 5 + type: string; 6 + description?: string; 7 + }; 8 + 9 + type Condition = { 10 + field: string; 11 + value: string; 12 + }; 13 + 14 + export default function SubscriptionForm() { 15 + const [lexicon, setLexicon] = useState(""); 16 + const [fields, setFields] = useState<Field[]>([]); 17 + const [fieldsLoading, setFieldsLoading] = useState(false); 18 + const [fieldsError, setFieldsError] = useState(""); 19 + const [conditions, setConditions] = useState<Condition[]>([]); 20 + const [callbackUrl, setCallbackUrl] = useState(""); 21 + const [submitting, setSubmitting] = useState(false); 22 + const [error, setError] = useState(""); 23 + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 24 + 25 + const fetchFields = useCallback((nsid: string) => { 26 + if (debounceRef.current) clearTimeout(debounceRef.current); 27 + // Clear conditions immediately — old field paths are stale 28 + setConditions([]); 29 + if (!nsid) { 30 + setFields([]); 31 + setFieldsError(""); 32 + return; 33 + } 34 + debounceRef.current = setTimeout(async () => { 35 + setFieldsLoading(true); 36 + setFieldsError(""); 37 + try { 38 + const res = await fetch(`/api/lexicons/${encodeURIComponent(nsid)}`); 39 + const data = await res.json(); 40 + if (!res.ok) { 41 + setFieldsError(data.error || "Failed to load fields"); 42 + setFields([]); 43 + } else { 44 + setFields(data.fields || []); 45 + } 46 + } catch { 47 + setFieldsError("Failed to fetch lexicon fields"); 48 + setFields([]); 49 + } finally { 50 + setFieldsLoading(false); 51 + } 52 + }, 400); 53 + }, []); 54 + 55 + const addCondition = useCallback(() => { 56 + setConditions((prev) => [...prev, { field: "", value: "" }]); 57 + }, []); 58 + 59 + const removeCondition = useCallback((index: number) => { 60 + setConditions((prev) => prev.filter((_, i) => i !== index)); 61 + }, []); 62 + 63 + const updateCondition = useCallback( 64 + (index: number, key: "field" | "value", val: string) => { 65 + setConditions((prev) => 66 + prev.map((c, i) => (i === index ? { ...c, [key]: val } : c)), 67 + ); 68 + }, 69 + [], 70 + ); 71 + 72 + const handleSubmit = useCallback( 73 + async (e: Event) => { 74 + e.preventDefault(); 75 + setError(""); 76 + setSubmitting(true); 77 + try { 78 + const res = await fetch("/api/subscriptions", { 79 + method: "POST", 80 + headers: { "Content-Type": "application/json" }, 81 + body: JSON.stringify({ 82 + lexicon, 83 + callbackUrl, 84 + conditions: conditions 85 + .filter((c) => c.field && c.value) 86 + .map((c) => ({ field: c.field, operator: "eq", value: c.value })), 87 + }), 88 + }); 89 + const data = await res.json(); 90 + if (!res.ok) { 91 + setError(data.error || "Failed to create subscription"); 92 + } else { 93 + window.location.href = `/dashboard/subscriptions/${data.rkey}`; 94 + } 95 + } catch { 96 + setError("Request failed"); 97 + } finally { 98 + setSubmitting(false); 99 + } 100 + }, 101 + [lexicon, callbackUrl, conditions], 102 + ); 103 + 104 + return ( 105 + <form onSubmit={handleSubmit}> 106 + <fieldset disabled={submitting}> 107 + <div> 108 + <label for="lexicon">Lexicon NSID</label> 109 + <input 110 + id="lexicon" 111 + type="text" 112 + placeholder="e.g. sh.tangled.feed.star" 113 + value={lexicon} 114 + onInput={(e: Event) => { 115 + const val = (e.target as HTMLInputElement).value; 116 + setLexicon(val); 117 + fetchFields(val); 118 + }} 119 + required 120 + /> 121 + {fieldsLoading && <span>Loading fields...</span>} 122 + {fieldsError && <span>{fieldsError}</span>} 123 + </div> 124 + 125 + <div> 126 + <label for="callbackUrl">Callback URL</label> 127 + <input 128 + id="callbackUrl" 129 + type="url" 130 + placeholder="https://example.com/hooks/events" 131 + value={callbackUrl} 132 + onInput={(e: Event) => 133 + setCallbackUrl((e.target as HTMLInputElement).value) 134 + } 135 + required 136 + /> 137 + </div> 138 + 139 + {fields.length > 0 && ( 140 + <div> 141 + <h3>Conditions</h3> 142 + <p>Filter events by field values. All conditions must match (AND).</p> 143 + {conditions.map((cond, i) => ( 144 + <div key={i}> 145 + <select 146 + value={cond.field} 147 + onChange={(e: Event) => 148 + updateCondition( 149 + i, 150 + "field", 151 + (e.target as HTMLSelectElement).value, 152 + ) 153 + } 154 + > 155 + <option value="">Select field...</option> 156 + {fields.map((f) => ( 157 + <option key={f.path} value={f.path}> 158 + {f.path} 159 + {f.description ? ` — ${f.description}` : ""} 160 + </option> 161 + ))} 162 + </select> 163 + <input 164 + type="text" 165 + placeholder="Value" 166 + value={cond.value} 167 + onInput={(e: Event) => 168 + updateCondition( 169 + i, 170 + "value", 171 + (e.target as HTMLInputElement).value, 172 + ) 173 + } 174 + /> 175 + <button type="button" onClick={() => removeCondition(i)}> 176 + Remove 177 + </button> 178 + </div> 179 + ))} 180 + <button type="button" onClick={addCondition}> 181 + Add condition 182 + </button> 183 + </div> 184 + )} 185 + 186 + {error && <p>{error}</p>} 187 + 188 + <button type="submit"> 189 + {submitting ? "Creating..." : "Create subscription"} 190 + </button> 191 + </fieldset> 192 + </form> 193 + ); 194 + }
+134
app/routes/api/subscriptions/[rkey].ts
··· 1 + import { createRoute } from "honox/factory"; 2 + import { eq, and, desc } from "drizzle-orm"; 3 + import { db } from "@/db/index.js"; 4 + import { subscriptions, deliveryLogs } from "@/db/schema.js"; 5 + import { getRecord, putRecord, deleteRecord } from "@/subscriptions/pds.js"; 6 + import { verifyCallback } from "@/subscriptions/verify.js"; 7 + 8 + function findSubscription(did: string, rkey: string) { 9 + return db.query.subscriptions.findFirst({ 10 + where: and(eq(subscriptions.did, did), eq(subscriptions.rkey, rkey)), 11 + }); 12 + } 13 + 14 + export const GET = createRoute(async (c) => { 15 + const user = c.get("user"); 16 + const rkey = c.req.param("rkey")!; 17 + 18 + const sub = await findSubscription(user.did, rkey); 19 + if (!sub) return c.json({ error: "Subscription not found" }, 404); 20 + 21 + const logs = await db.query.deliveryLogs.findMany({ 22 + where: eq(deliveryLogs.subscriptionUri, sub.uri), 23 + orderBy: desc(deliveryLogs.createdAt), 24 + limit: 50, 25 + }); 26 + 27 + return c.json({ 28 + uri: sub.uri, 29 + rkey: sub.rkey, 30 + lexicon: sub.lexicon, 31 + callbackUrl: sub.callbackUrl, 32 + conditions: sub.conditions, 33 + active: sub.active, 34 + secret: sub.secret, 35 + indexedAt: sub.indexedAt.getTime(), 36 + deliveryLogs: logs.map((l) => ({ 37 + id: l.id, 38 + eventTimeUs: l.eventTimeUs, 39 + statusCode: l.statusCode, 40 + error: l.error, 41 + attempt: l.attempt, 42 + createdAt: l.createdAt.getTime(), 43 + })), 44 + }); 45 + }); 46 + 47 + export const PATCH = createRoute(async (c) => { 48 + const user = c.get("user"); 49 + const rkey = c.req.param("rkey")!; 50 + 51 + const sub = await findSubscription(user.did, rkey); 52 + if (!sub) return c.json({ error: "Subscription not found" }, 404); 53 + 54 + const body = await c.req.json<{ 55 + callbackUrl?: string; 56 + conditions?: Array<{ field: string; operator?: string; value: string }>; 57 + active?: boolean; 58 + }>(); 59 + 60 + if (body.callbackUrl !== undefined && !body.callbackUrl) { 61 + return c.json({ error: "callbackUrl cannot be empty" }, 400); 62 + } 63 + const callbackUrl = body.callbackUrl ?? sub.callbackUrl; 64 + const conditions = body.conditions 65 + ? body.conditions 66 + .filter((cond) => cond.field && cond.value) 67 + .map((cond) => ({ 68 + field: cond.field, 69 + operator: cond.operator ?? "eq", 70 + value: cond.value, 71 + })) 72 + : sub.conditions; 73 + if (conditions.length > 20) { 74 + return c.json({ error: "Maximum 20 conditions allowed" }, 400); 75 + } 76 + const active = body.active ?? sub.active; 77 + 78 + // Re-verify callback if URL changed or reactivating 79 + if (body.callbackUrl || (body.active === true && !sub.active)) { 80 + const verification = await verifyCallback(callbackUrl, sub.lexicon); 81 + if (!verification.ok) { 82 + return c.json({ error: verification.error }, 422); 83 + } 84 + } 85 + 86 + // Read existing PDS record to preserve createdAt 87 + const existing = await getRecord(user.did, rkey); 88 + const createdAt = 89 + (existing?.createdAt as string) || sub.indexedAt.toISOString(); 90 + 91 + // Update on PDS 92 + try { 93 + await putRecord(user.did, rkey, { 94 + lexicon: sub.lexicon, 95 + callbackUrl, 96 + conditions, 97 + active, 98 + createdAt, 99 + }); 100 + } catch (err) { 101 + console.error("Failed to update PDS record:", err); 102 + return c.json({ error: "Failed to update subscription on PDS" }, 502); 103 + } 104 + 105 + // Update local index 106 + const now = new Date(); 107 + await db 108 + .update(subscriptions) 109 + .set({ callbackUrl, conditions, active, indexedAt: now }) 110 + .where(eq(subscriptions.uri, sub.uri)); 111 + 112 + return c.json({ ok: true }); 113 + }); 114 + 115 + export const DELETE = createRoute(async (c) => { 116 + const user = c.get("user"); 117 + const rkey = c.req.param("rkey")!; 118 + 119 + const sub = await findSubscription(user.did, rkey); 120 + if (!sub) return c.json({ error: "Subscription not found" }, 404); 121 + 122 + // Delete from PDS 123 + try { 124 + await deleteRecord(user.did, rkey); 125 + } catch (err) { 126 + console.error("Failed to delete PDS record:", err); 127 + return c.json({ error: "Failed to delete subscription from PDS" }, 502); 128 + } 129 + 130 + // Remove from local index (cascade deletes delivery logs) 131 + await db.delete(subscriptions).where(eq(subscriptions.uri, sub.uri)); 132 + 133 + return c.json({ ok: true }); 134 + });
+11
app/routes/api/subscriptions/_middleware.ts
··· 1 + import { createMiddleware } from "hono/factory"; 2 + import { getSessionUser } from "@/auth/middleware.js"; 3 + 4 + export default [ 5 + createMiddleware(async (c, next) => { 6 + const user = await getSessionUser(c); 7 + if (!user) return c.json({ error: "Unauthorized" }, 401); 8 + c.set("user", user); 9 + return next(); 10 + }), 11 + ];
+116
app/routes/api/subscriptions/index.ts
··· 1 + import { createRoute } from "honox/factory"; 2 + import { eq } from "drizzle-orm"; 3 + import { nanoid } from "nanoid"; 4 + import { db } from "@/db/index.js"; 5 + import { subscriptions } from "@/db/schema.js"; 6 + import { config } from "@/config.js"; 7 + import { isValidNsid, isNsidAllowed } from "@/lexicons/resolver.js"; 8 + import { verifyCallback } from "@/subscriptions/verify.js"; 9 + import { createRecord, deleteRecord } from "@/subscriptions/pds.js"; 10 + 11 + export const GET = createRoute(async (c) => { 12 + const user = c.get("user"); 13 + const rows = await db.query.subscriptions.findMany({ 14 + where: eq(subscriptions.did, user.did), 15 + }); 16 + return c.json( 17 + rows.map((r) => ({ 18 + uri: r.uri, 19 + rkey: r.rkey, 20 + lexicon: r.lexicon, 21 + callbackUrl: r.callbackUrl, 22 + conditions: r.conditions, 23 + active: r.active, 24 + indexedAt: r.indexedAt.getTime(), 25 + })), 26 + ); 27 + }); 28 + 29 + export const POST = createRoute(async (c) => { 30 + const user = c.get("user"); 31 + const body = await c.req.json<{ 32 + lexicon: string; 33 + callbackUrl: string; 34 + conditions?: Array<{ field: string; operator?: string; value: string }>; 35 + active?: boolean; 36 + }>(); 37 + 38 + // Validate lexicon NSID 39 + if (!body.lexicon || !isValidNsid(body.lexicon)) { 40 + return c.json({ error: "Invalid lexicon NSID" }, 400); 41 + } 42 + if (!isNsidAllowed(body.lexicon, config.nsidAllowlist, config.nsidBlocklist)) { 43 + return c.json({ error: "This lexicon is not allowed on this instance" }, 403); 44 + } 45 + 46 + // Validate callback URL 47 + if (!body.callbackUrl) { 48 + return c.json({ error: "callbackUrl is required" }, 400); 49 + } 50 + try { 51 + new URL(body.callbackUrl); 52 + } catch { 53 + return c.json({ error: "Invalid callback URL" }, 400); 54 + } 55 + 56 + // Normalize and validate conditions 57 + const conditions = (body.conditions ?? []) 58 + .filter((cond) => cond.field && cond.value) 59 + .map((cond) => ({ 60 + field: cond.field, 61 + operator: cond.operator ?? "eq", 62 + value: cond.value, 63 + })); 64 + if (conditions.length > 20) { 65 + return c.json({ error: "Maximum 20 conditions allowed" }, 400); 66 + } 67 + 68 + // Verify callback endpoint 69 + const verification = await verifyCallback(body.callbackUrl, body.lexicon); 70 + if (!verification.ok) { 71 + return c.json({ error: verification.error }, 422); 72 + } 73 + 74 + // Write record to PDS 75 + const active = body.active !== false; 76 + const now = new Date(); 77 + let uri: string; 78 + let rkey: string; 79 + try { 80 + const result = await createRecord(user.did, { 81 + lexicon: body.lexicon, 82 + callbackUrl: body.callbackUrl, 83 + conditions, 84 + active, 85 + createdAt: now.toISOString(), 86 + }); 87 + uri = result.uri; 88 + rkey = result.rkey; 89 + } catch (err) { 90 + console.error("Failed to create PDS record:", err); 91 + return c.json({ error: "Failed to write subscription to PDS" }, 502); 92 + } 93 + 94 + // Index locally with HMAC secret 95 + const secret = nanoid(32); 96 + try { 97 + await db.insert(subscriptions).values({ 98 + uri, 99 + did: user.did, 100 + rkey, 101 + lexicon: body.lexicon, 102 + callbackUrl: body.callbackUrl, 103 + conditions, 104 + secret, 105 + active, 106 + indexedAt: now, 107 + }); 108 + } catch (err) { 109 + // Rollback PDS record if local indexing fails 110 + try { await deleteRecord(user.did, rkey); } catch { /* best-effort */ } 111 + console.error("Failed to index subscription locally:", err); 112 + return c.json({ error: "Failed to save subscription" }, 500); 113 + } 114 + 115 + return c.json({ uri, rkey, secret }, 201); 116 + });
+44 -2
app/routes/dashboard/index.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 + import { eq } from "drizzle-orm"; 3 + import { db } from "@/db/index.js"; 4 + import { subscriptions } from "@/db/schema.js"; 2 5 3 - export default createRoute((c) => { 6 + export default createRoute(async (c) => { 4 7 const user = c.get("user"); 8 + const subs = await db.query.subscriptions.findMany({ 9 + where: eq(subscriptions.did, user.did), 10 + }); 5 11 6 12 return c.render( 7 13 <div> ··· 16 22 </header> 17 23 <section> 18 24 <h2>Subscriptions</h2> 19 - <p>No subscriptions yet.</p> 25 + <p> 26 + <a href="/dashboard/subscriptions/new">New subscription</a> 27 + </p> 28 + {subs.length === 0 ? ( 29 + <p>No subscriptions yet.</p> 30 + ) : ( 31 + <table> 32 + <thead> 33 + <tr> 34 + <th>Lexicon</th> 35 + <th>Callback URL</th> 36 + <th>Conditions</th> 37 + <th>Status</th> 38 + <th></th> 39 + </tr> 40 + </thead> 41 + <tbody> 42 + {subs.map((sub) => ( 43 + <tr key={sub.uri}> 44 + <td> 45 + <a href={`/dashboard/subscriptions/${sub.rkey}`}> 46 + <code>{sub.lexicon}</code> 47 + </a> 48 + </td> 49 + <td> 50 + <code>{sub.callbackUrl}</code> 51 + </td> 52 + <td>{sub.conditions.length}</td> 53 + <td>{sub.active ? "Active" : "Inactive"}</td> 54 + <td> 55 + <a href={`/dashboard/subscriptions/${sub.rkey}`}>View</a> 56 + </td> 57 + </tr> 58 + ))} 59 + </tbody> 60 + </table> 61 + )} 20 62 </section> 21 63 </div>, 22 64 { title: "Dashboard — Airglow" },
+93
app/routes/dashboard/subscriptions/[rkey].tsx
··· 1 + import { createRoute } from "honox/factory"; 2 + import { eq, and, desc } from "drizzle-orm"; 3 + import { db } from "@/db/index.js"; 4 + import { subscriptions, deliveryLogs } from "@/db/schema.js"; 5 + import DeliveryLog from "../../../islands/DeliveryLog.js"; 6 + 7 + export default createRoute(async (c) => { 8 + const user = c.get("user"); 9 + const rkey = c.req.param("rkey")!; 10 + 11 + const sub = await db.query.subscriptions.findFirst({ 12 + where: and(eq(subscriptions.did, user.did), eq(subscriptions.rkey, rkey)), 13 + }); 14 + if (!sub) { 15 + c.status(404); 16 + return c.render( 17 + <div> 18 + <h1>Not found</h1> 19 + <p> 20 + <a href="/dashboard">&larr; Back to dashboard</a> 21 + </p> 22 + </div>, 23 + { title: "Not Found — Airglow" }, 24 + ); 25 + } 26 + 27 + const logs = await db.query.deliveryLogs.findMany({ 28 + where: eq(deliveryLogs.subscriptionUri, sub.uri), 29 + orderBy: desc(deliveryLogs.createdAt), 30 + limit: 50, 31 + }); 32 + 33 + return c.render( 34 + <div> 35 + <h1>Subscription</h1> 36 + <p> 37 + <a href="/dashboard">&larr; Back to dashboard</a> 38 + </p> 39 + 40 + <dl> 41 + <dt>Lexicon</dt> 42 + <dd> 43 + <code>{sub.lexicon}</code> 44 + </dd> 45 + <dt>Callback URL</dt> 46 + <dd> 47 + <code>{sub.callbackUrl}</code> 48 + </dd> 49 + <dt>Status</dt> 50 + <dd>{sub.active ? "Active" : "Inactive"}</dd> 51 + <dt>HMAC Secret</dt> 52 + <dd> 53 + <code>{sub.secret}</code> 54 + </dd> 55 + <dt>AT URI</dt> 56 + <dd> 57 + <code>{sub.uri}</code> 58 + </dd> 59 + </dl> 60 + 61 + {sub.conditions.length > 0 && ( 62 + <div> 63 + <h2>Conditions</h2> 64 + <ul> 65 + {sub.conditions.map((cond, i) => ( 66 + <li key={i}> 67 + <code>{cond.field}</code> {cond.operator}{" "} 68 + <code>{cond.value}</code> 69 + </li> 70 + ))} 71 + </ul> 72 + </div> 73 + )} 74 + 75 + <div> 76 + <h2>Actions</h2> 77 + <DeliveryLog 78 + rkey={sub.rkey} 79 + active={sub.active} 80 + initialLogs={logs.map((l) => ({ 81 + id: l.id, 82 + eventTimeUs: l.eventTimeUs, 83 + statusCode: l.statusCode, 84 + error: l.error, 85 + attempt: l.attempt, 86 + createdAt: l.createdAt.getTime(), 87 + }))} 88 + /> 89 + </div> 90 + </div>, 91 + { title: `${sub.lexicon} — Airglow` }, 92 + ); 93 + });
+15
app/routes/dashboard/subscriptions/new.tsx
··· 1 + import { createRoute } from "honox/factory"; 2 + import SubscriptionForm from "../../../islands/SubscriptionForm.js"; 3 + 4 + export default createRoute((c) => { 5 + return c.render( 6 + <div> 7 + <h1>New Subscription</h1> 8 + <p> 9 + <a href="/dashboard">&larr; Back to dashboard</a> 10 + </p> 11 + <SubscriptionForm /> 12 + </div>, 13 + { title: "New Subscription — Airglow" }, 14 + ); 15 + });
+101
lib/subscriptions/pds.ts
··· 1 + import { getOAuthClient } from "../auth/client.js"; 2 + 3 + const COLLECTION = "app.rglw.subscription"; 4 + 5 + // AT Protocol TID: base32-sort encoded (timestamp_us << 10 | clock_id) 6 + const S32 = "234567abcdefghijklmnopqrstuvwxyz"; 7 + export function generateTid(): string { 8 + const timestampUs = BigInt(Date.now()) * 1000n; 9 + const clockId = BigInt(Math.floor(Math.random() * 1024)); 10 + let tid = (timestampUs << 10n) | clockId; 11 + let s = ""; 12 + while (tid > 0n) { 13 + s = S32[Number(tid % 32n)] + s; 14 + tid /= 32n; 15 + } 16 + return s.padStart(13, "2"); 17 + } 18 + 19 + type SubscriptionRecord = { 20 + lexicon: string; 21 + callbackUrl: string; 22 + conditions: Array<{ field: string; operator: string; value: string }>; 23 + active: boolean; 24 + createdAt: string; 25 + }; 26 + 27 + async function pdsCall( 28 + did: string, 29 + nsid: string, 30 + body: Record<string, unknown>, 31 + ): Promise<Record<string, unknown>> { 32 + const client = await getOAuthClient(); 33 + const session = await client.restore(did); 34 + const res = await session.fetchHandler(`/xrpc/${nsid}`, { 35 + method: "POST", 36 + headers: { "Content-Type": "application/json" }, 37 + body: JSON.stringify(body), 38 + }); 39 + if (!res.ok) { 40 + const text = await res.text(); 41 + throw new Error(`PDS ${nsid} failed (${res.status}): ${text}`); 42 + } 43 + return (await res.json()) as Record<string, unknown>; 44 + } 45 + 46 + export async function createRecord( 47 + did: string, 48 + record: SubscriptionRecord, 49 + ): Promise<{ uri: string; rkey: string }> { 50 + const rkey = generateTid(); 51 + const data = await pdsCall(did, "com.atproto.repo.createRecord", { 52 + repo: did, 53 + collection: COLLECTION, 54 + rkey, 55 + record: { $type: COLLECTION, ...record }, 56 + }); 57 + return { uri: data.uri as string, rkey }; 58 + } 59 + 60 + export async function getRecord( 61 + did: string, 62 + rkey: string, 63 + ): Promise<Record<string, unknown> | null> { 64 + const client = await getOAuthClient(); 65 + const session = await client.restore(did); 66 + const params = new URLSearchParams({ 67 + repo: did, 68 + collection: COLLECTION, 69 + rkey, 70 + }); 71 + const res = await session.fetchHandler(`/xrpc/com.atproto.repo.getRecord?${params}`, { 72 + method: "GET", 73 + }); 74 + if (!res.ok) return null; 75 + const data = (await res.json()) as { value?: Record<string, unknown> }; 76 + return data.value ?? null; 77 + } 78 + 79 + export async function putRecord( 80 + did: string, 81 + rkey: string, 82 + record: SubscriptionRecord, 83 + ): Promise<void> { 84 + await pdsCall(did, "com.atproto.repo.putRecord", { 85 + repo: did, 86 + collection: COLLECTION, 87 + rkey, 88 + record: { $type: COLLECTION, ...record }, 89 + }); 90 + } 91 + 92 + export async function deleteRecord( 93 + did: string, 94 + rkey: string, 95 + ): Promise<void> { 96 + await pdsCall(did, "com.atproto.repo.deleteRecord", { 97 + repo: did, 98 + collection: COLLECTION, 99 + rkey, 100 + }); 101 + }
+66
lib/subscriptions/verify.ts
··· 1 + type AirglowManifest = { 2 + callbacks: Array<{ 3 + path: string; 4 + lexicons: string[]; 5 + }>; 6 + }; 7 + 8 + /** 9 + * Verify that a callback URL accepts the given lexicon. 10 + * Fetches the .well-known/airglow manifest from the callback's origin 11 + * and checks that the path is listed with the correct lexicon. 12 + */ 13 + export async function verifyCallback( 14 + callbackUrl: string, 15 + lexicon: string, 16 + ): Promise<{ ok: true } | { ok: false; error: string }> { 17 + let url: URL; 18 + try { 19 + url = new URL(callbackUrl); 20 + } catch { 21 + return { ok: false, error: "Invalid callback URL" }; 22 + } 23 + 24 + const manifestUrl = `${url.origin}/.well-known/airglow`; 25 + 26 + let manifest: AirglowManifest; 27 + try { 28 + const res = await fetch(manifestUrl, { 29 + headers: { Accept: "application/json" }, 30 + signal: AbortSignal.timeout(10_000), 31 + }); 32 + if (!res.ok) { 33 + return { 34 + ok: false, 35 + error: `Callback server returned ${res.status} for ${manifestUrl}`, 36 + }; 37 + } 38 + manifest = (await res.json()) as AirglowManifest; 39 + } catch { 40 + return { 41 + ok: false, 42 + error: `Could not reach callback server at ${manifestUrl}`, 43 + }; 44 + } 45 + 46 + if (!Array.isArray(manifest.callbacks)) { 47 + return { ok: false, error: "Invalid manifest: missing callbacks array" }; 48 + } 49 + 50 + const entry = manifest.callbacks.find((cb) => cb.path === url.pathname); 51 + if (!entry) { 52 + return { 53 + ok: false, 54 + error: `Path ${url.pathname} not found in callback manifest`, 55 + }; 56 + } 57 + 58 + if (!entry.lexicons?.includes(lexicon)) { 59 + return { 60 + ok: false, 61 + error: `Callback at ${url.pathname} does not accept lexicon ${lexicon}`, 62 + }; 63 + } 64 + 65 + return { ok: true }; 66 + }