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: record creation subscription

Hugo ffd335b1 f8390e68

+2538 -385
+10 -10
CLAUDE.md
··· 9 9 - **UI**: hono/jsx (SSR + client hydration) 10 10 - **Styling**: @vanilla-extract/css 11 11 - **Database**: SQLite via bun:sqlite + Drizzle ORM 12 - - **Toolchain**: Vite+ (`vp`) — wraps Vite 8, Vitest, Oxlint, Oxfmt 12 + - **Toolchain**: Vite+ (`vp`) wraps Vite 8, Vitest, Oxlint, Oxfmt 13 13 - **Lexicons**: managed with `goat lex`, stored in `lexicons/` 14 14 15 15 ## Commands 16 16 17 - - `vp dev` — start dev server 18 - - `vp build --mode client` — build client assets 19 - - `vp check` — lint, format, type-check 20 - - `vp test` — run tests 21 - - `bun run start` — run production server 22 - - `bun run db:generate` — generate Drizzle migrations 23 - - `bun run db:migrate` — run migrations 24 - - `goat lex lint lexicons/` — lint lexicon schemas 17 + - `vp dev`: start dev server 18 + - `vp build --mode client`: build client assets 19 + - `vp check`: lint, format, type-check 20 + - `vp test`: run tests 21 + - `bun run start`: run production server 22 + - `bun run db:generate`: generate Drizzle migrations 23 + - `bun run db:migrate`: run migrations 24 + - `goat lex lint lexicons/`: lint lexicon schemas 25 25 26 26 ## Conventions 27 27 28 28 - Use `bun` instead of `node`, `npm`, etc. 29 29 - Bun auto-loads .env — no dotenv needed 30 - - Use `bun:sqlite` for SQLite — not better-sqlite3 30 + - Use `bun:sqlite` for SQLite, not better-sqlite3 31 31 - Routes go in `app/routes/`, islands in `app/islands/` 32 32 - Shared non-interactive components go in `app/components/` 33 33 - Backend logic goes in `lib/`
+1 -1
app/client.ts
··· 8 8 import "./styles/pages/landing.css.js"; 9 9 import "./styles/pages/login.css.js"; 10 10 11 - createClient(); 11 + void createClient();
+2 -13
app/components/Input/index.tsx
··· 1 - import type { Child } from "hono/jsx"; 2 1 import * as s from "./styles.css.ts"; 3 2 4 3 type InputProps = { ··· 9 8 [key: string]: unknown; 10 9 }; 11 10 12 - export function Input({ 13 - label: labelText, 14 - hint, 15 - error, 16 - id, 17 - ...rest 18 - }: InputProps) { 11 + export function Input({ label: labelText, hint, error, id, ...rest }: InputProps) { 19 12 const inputId = id || (labelText ? labelText.toLowerCase().replace(/\s+/g, "-") : undefined); 20 13 21 14 return ( ··· 25 18 {labelText} 26 19 </label> 27 20 )} 28 - <input 29 - id={inputId} 30 - class={`${s.input}${error ? ` ${s.inputError}` : ""}`} 31 - {...rest} 32 - /> 21 + <input id={inputId} class={`${s.input}${error ? ` ${s.inputError}` : ""}`} {...rest} /> 33 22 {hint && !error && <span class={s.hint}>{hint}</span>} 34 23 {error && <span class={s.errorText}>{error}</span>} 35 24 </div>
+1 -7
app/components/Layout/AppShell/index.tsx
··· 1 1 import type { Child } from "hono/jsx"; 2 2 import * as s from "./styles.css.ts"; 3 3 4 - export function AppShell({ 5 - header, 6 - children, 7 - }: { 8 - header: Child; 9 - children: Child; 10 - }) { 4 + export function AppShell({ header, children }: { header: Child; children: Child }) { 11 5 return ( 12 6 <div class={s.shell}> 13 7 {header}
+1 -7
app/components/Layout/Cluster/index.tsx
··· 4 4 5 5 type SpaceKey = keyof typeof space; 6 6 7 - export function Cluster({ 8 - gap = 3, 9 - children, 10 - }: { 11 - gap?: SpaceKey; 12 - children: Child; 13 - }) { 7 + export function Cluster({ gap = 3, children }: { gap?: SpaceKey; children: Child }) { 14 8 return <div class={cluster[gap]}>{children}</div>; 15 9 }
+1 -7
app/components/Layout/Header/index.tsx
··· 1 1 import type { Child } from "hono/jsx"; 2 2 import * as s from "./styles.css.ts"; 3 3 4 - export function Header({ 5 - user, 6 - actions, 7 - }: { 8 - user?: { handle: string } | null; 9 - actions?: Child; 10 - }) { 4 + export function Header({ user, actions }: { user?: { handle: string } | null; actions?: Child }) { 11 5 return ( 12 6 <header class={s.header}> 13 7 <div class={s.inner}>
+1 -7
app/components/Layout/Stack/index.tsx
··· 4 4 5 5 type SpaceKey = keyof typeof space; 6 6 7 - export function Stack({ 8 - gap = 4, 9 - children, 10 - }: { 11 - gap?: SpaceKey; 12 - children: Child; 13 - }) { 7 + export function Stack({ gap = 4, children }: { gap?: SpaceKey; children: Child }) { 14 8 return <div class={stack[gap]}>{children}</div>; 15 9 }
+1
app/env.d.ts
··· 1 + /// <reference types="vite-plus/client" />
+3 -15
app/islands/DeliveryLog.tsx
··· 80 80 return ( 81 81 <div class={s.wrapper}> 82 82 <div class={s.actions}> 83 - <button 84 - type="button" 85 - class={s.toggleBtn} 86 - onClick={toggleActive} 87 - disabled={loading} 88 - > 83 + <button type="button" class={s.toggleBtn} onClick={toggleActive} disabled={loading}> 89 84 {isActive ? "Deactivate" : "Activate"} 90 85 </button> 91 - <button 92 - type="button" 93 - class={s.deleteBtn} 94 - onClick={handleDelete} 95 - disabled={loading} 96 - > 86 + <button type="button" class={s.deleteBtn} onClick={handleDelete} disabled={loading}> 97 87 Delete 98 88 </button> 99 89 </div> ··· 123 113 <tbody> 124 114 {logs.map((log) => ( 125 115 <tr key={log.id}> 126 - <td class={s.td}> 127 - {new Date(log.createdAt).toLocaleString()} 128 - </td> 116 + <td class={s.td}>{new Date(log.createdAt).toLocaleString()}</td> 129 117 <td class={s.td}>{log.statusCode ?? "\u2014"}</td> 130 118 <td class={s.td}>{log.attempt}</td> 131 119 <td class={s.td}>{log.error || "\u2014"}</td>
+171
app/islands/RecordFormBuilder.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 } from "../styles/tokens/typography.ts"; 5 + import { radii } from "../styles/tokens/radii.ts"; 6 + 7 + export const root = style({ 8 + display: "flex", 9 + flexDirection: "column", 10 + gap: space[4], 11 + }); 12 + 13 + export const fieldGroup = style({ 14 + display: "flex", 15 + flexDirection: "column", 16 + gap: space[1], 17 + }); 18 + 19 + export const label = style({ 20 + fontSize: fontSize.sm, 21 + fontWeight: fontWeight.medium, 22 + color: vars.color.text, 23 + }); 24 + 25 + export const requiredMark = style({ 26 + color: vars.color.error, 27 + marginInlineStart: space[1], 28 + }); 29 + 30 + export const input = style({ 31 + width: "100%", 32 + paddingBlock: space[2], 33 + paddingInline: space[3], 34 + fontSize: fontSize.base, 35 + color: vars.color.text, 36 + backgroundColor: vars.color.bg, 37 + border: `1px solid ${vars.color.border}`, 38 + borderRadius: radii.md, 39 + "::placeholder": { 40 + color: vars.color.textMuted, 41 + }, 42 + ":focus": { 43 + borderColor: vars.color.accent, 44 + outline: "none", 45 + boxShadow: `0 0 0 3px ${vars.focus.ring}`, 46 + }, 47 + }); 48 + 49 + export const select = style({ 50 + width: "100%", 51 + paddingBlock: space[2], 52 + paddingInline: space[3], 53 + paddingInlineEnd: space[7], 54 + fontSize: fontSize.base, 55 + color: vars.color.text, 56 + backgroundColor: vars.color.bg, 57 + border: `1px solid ${vars.color.border}`, 58 + borderRadius: radii.md, 59 + appearance: "none", 60 + backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")`, 61 + backgroundRepeat: "no-repeat", 62 + backgroundPosition: "right 12px center", 63 + cursor: "pointer", 64 + ":focus": { 65 + borderColor: vars.color.accent, 66 + outline: "none", 67 + boxShadow: `0 0 0 3px ${vars.focus.ring}`, 68 + }, 69 + }); 70 + 71 + export const hint = style({ 72 + fontSize: fontSize.xs, 73 + color: vars.color.textMuted, 74 + }); 75 + 76 + export const nestedFieldset = style({ 77 + border: `1px solid ${vars.color.borderSubtle}`, 78 + borderRadius: radii.md, 79 + paddingBlock: space[3], 80 + paddingInline: space[4], 81 + display: "flex", 82 + flexDirection: "column", 83 + gap: space[3], 84 + }); 85 + 86 + export const legendText = style({ 87 + fontSize: fontSize.sm, 88 + fontWeight: fontWeight.medium, 89 + color: vars.color.text, 90 + paddingInline: space[1], 91 + }); 92 + 93 + export const arraySection = style({ 94 + display: "flex", 95 + flexDirection: "column", 96 + gap: space[2], 97 + }); 98 + 99 + export const arrayItem = style({ 100 + display: "flex", 101 + gap: space[2], 102 + alignItems: "flex-start", 103 + paddingInlineStart: space[3], 104 + borderInlineStart: `2px solid ${vars.color.borderSubtle}`, 105 + }); 106 + 107 + export const arrayItemContent = style({ 108 + flex: 1, 109 + }); 110 + 111 + export const removeBtn = style({ 112 + paddingBlock: space[1], 113 + paddingInline: space[2], 114 + fontSize: fontSize.xs, 115 + color: vars.color.error, 116 + backgroundColor: "transparent", 117 + border: `1px solid ${vars.color.border}`, 118 + borderRadius: radii.md, 119 + cursor: "pointer", 120 + whiteSpace: "nowrap", 121 + ":hover": { 122 + backgroundColor: vars.color.errorSubtle, 123 + borderColor: vars.color.error, 124 + }, 125 + }); 126 + 127 + export const addBtn = style({ 128 + alignSelf: "flex-start", 129 + paddingBlock: space[1], 130 + paddingInline: space[3], 131 + fontSize: fontSize.sm, 132 + fontWeight: fontWeight.medium, 133 + color: vars.color.accent, 134 + backgroundColor: "transparent", 135 + border: `1px solid ${vars.color.border}`, 136 + borderRadius: radii.md, 137 + cursor: "pointer", 138 + ":hover": { 139 + backgroundColor: vars.color.accentSubtle, 140 + borderColor: vars.color.accent, 141 + }, 142 + }); 143 + 144 + export const textarea = style({ 145 + width: "100%", 146 + minBlockSize: "80px", 147 + paddingBlock: space[2], 148 + paddingInline: space[3], 149 + fontSize: fontSize.sm, 150 + fontFamily: "monospace", 151 + color: vars.color.text, 152 + backgroundColor: vars.color.bg, 153 + border: `1px solid ${vars.color.border}`, 154 + borderRadius: radii.md, 155 + resize: "vertical", 156 + "::placeholder": { 157 + color: vars.color.textMuted, 158 + }, 159 + ":focus": { 160 + borderColor: vars.color.accent, 161 + outline: "none", 162 + boxShadow: `0 0 0 3px ${vars.focus.ring}`, 163 + }, 164 + }); 165 + 166 + export const placeholderHelp = style({ 167 + fontSize: fontSize.xs, 168 + color: vars.color.textMuted, 169 + fontFamily: "monospace", 170 + lineHeight: 1.6, 171 + });
+584
app/islands/RecordFormBuilder.tsx
··· 1 + import { useState, useEffect, useCallback } from "hono/jsx"; 2 + import type { 3 + SchemaNode, 4 + RecordSchema, 5 + ObjectNode, 6 + ArrayNode, 7 + } from "../../lib/lexicons/schema-tree.js"; 8 + import * as s from "./RecordFormBuilder.css.ts"; 9 + 10 + type FormState = Record<string, unknown>; 11 + 12 + type Props = { 13 + schema: RecordSchema | null; 14 + loading: boolean; 15 + error: string; 16 + placeholders: string[]; 17 + onChange: (jsonTemplate: string) => void; 18 + }; 19 + 20 + // --------------------------------------------------------------------------- 21 + // State initialization 22 + // --------------------------------------------------------------------------- 23 + 24 + function initState(properties: Record<string, SchemaNode>): FormState { 25 + const state: FormState = {}; 26 + for (const [key, node] of Object.entries(properties)) { 27 + if (node.type === "string" && node.format === "datetime") { 28 + state[key] = "{{now}}"; 29 + } else if (node.type === "object") { 30 + state[key] = initState(node.properties); 31 + } else if (node.type === "string" && node.default != null) { 32 + state[key] = node.default; 33 + } else if (node.type === "integer" && node.default != null) { 34 + state[key] = String(node.default); 35 + } else if (node.type === "boolean" && node.default != null) { 36 + state[key] = String(node.default); 37 + } 38 + } 39 + return state; 40 + } 41 + 42 + // --------------------------------------------------------------------------- 43 + // Serialization: form state → JSON template string 44 + // --------------------------------------------------------------------------- 45 + 46 + /** Safely convert unknown form value to string. */ 47 + function toStr(value: unknown): string { 48 + if (value == null) return ""; 49 + if (typeof value === "string") return value; 50 + if (typeof value === "number" || typeof value === "boolean") return value.toString(); 51 + return JSON.stringify(value); 52 + } 53 + 54 + function serializeNode(node: SchemaNode, value: unknown): unknown { 55 + switch (node.type) { 56 + case "string": 57 + return value != null ? toStr(value) : ""; 58 + 59 + case "integer": { 60 + const str = toStr(value); 61 + if (str === "") return undefined; 62 + if (!str.includes("{{") && /^-?\d+$/.test(str)) return parseInt(str, 10); 63 + return str; 64 + } 65 + 66 + case "boolean": { 67 + const str = toStr(value); 68 + if (str === "") return undefined; 69 + if (str === "true") return true; 70 + if (str === "false") return false; 71 + return str; 72 + } 73 + 74 + case "object": { 75 + const obj: Record<string, unknown> = {}; 76 + const formState = (value as FormState) ?? {}; 77 + for (const [key, propNode] of Object.entries(node.properties)) { 78 + const v = serializeNode(propNode, formState[key]); 79 + if (v !== undefined && v !== "") { 80 + obj[key] = v; 81 + } 82 + } 83 + return Object.keys(obj).length > 0 ? obj : undefined; 84 + } 85 + 86 + case "array": { 87 + const items = (value as unknown[]) ?? []; 88 + const result = items 89 + .map((item) => serializeNode(node.items, item)) 90 + .filter((v) => v !== undefined); 91 + return result.length > 0 ? result : undefined; 92 + } 93 + 94 + case "unknown": { 95 + const str = toStr(value).trim(); 96 + if (!str) return undefined; 97 + try { 98 + return JSON.parse(str); 99 + } catch { 100 + return str; 101 + } 102 + } 103 + 104 + default: 105 + return undefined; 106 + } 107 + } 108 + 109 + function serialize(schema: RecordSchema, state: FormState): string { 110 + const obj: Record<string, unknown> = {}; 111 + for (const [key, node] of Object.entries(schema.properties)) { 112 + const v = serializeNode(node, state[key]); 113 + if (v !== undefined && v !== "") { 114 + obj[key] = v; 115 + } 116 + } 117 + return JSON.stringify(obj, null, 2); 118 + } 119 + 120 + // --------------------------------------------------------------------------- 121 + // Field renderers 122 + // --------------------------------------------------------------------------- 123 + 124 + function FieldRenderer({ 125 + name, 126 + node, 127 + value, 128 + required, 129 + onValueChange, 130 + placeholders, 131 + }: { 132 + name: string; 133 + node: SchemaNode; 134 + value: unknown; 135 + required: boolean; 136 + onValueChange: (val: unknown) => void; 137 + placeholders: string[]; 138 + }) { 139 + switch (node.type) { 140 + case "string": 141 + return ( 142 + <StringField 143 + name={name} 144 + node={node} 145 + value={toStr(value)} 146 + required={required} 147 + onValueChange={onValueChange} 148 + /> 149 + ); 150 + 151 + case "integer": 152 + return ( 153 + <IntegerField 154 + name={name} 155 + node={node} 156 + value={toStr(value)} 157 + required={required} 158 + onValueChange={onValueChange} 159 + /> 160 + ); 161 + 162 + case "boolean": 163 + return ( 164 + <BooleanField 165 + name={name} 166 + node={node} 167 + value={toStr(value)} 168 + required={required} 169 + onValueChange={onValueChange} 170 + /> 171 + ); 172 + 173 + case "object": 174 + return ( 175 + <ObjectField 176 + name={name} 177 + node={node} 178 + value={(value as FormState) ?? {}} 179 + required={required} 180 + onValueChange={onValueChange} 181 + placeholders={placeholders} 182 + /> 183 + ); 184 + 185 + case "array": 186 + return ( 187 + <ArrayField 188 + name={name} 189 + node={node} 190 + value={(value as unknown[]) ?? []} 191 + required={required} 192 + onValueChange={onValueChange} 193 + placeholders={placeholders} 194 + /> 195 + ); 196 + 197 + case "unknown": 198 + return ( 199 + <UnknownField 200 + name={name} 201 + node={node} 202 + value={toStr(value)} 203 + required={required} 204 + onValueChange={onValueChange} 205 + /> 206 + ); 207 + 208 + default: 209 + return null; 210 + } 211 + } 212 + 213 + function FieldLabel({ 214 + name, 215 + required, 216 + htmlFor, 217 + }: { 218 + name: string; 219 + required: boolean; 220 + htmlFor?: string; 221 + }) { 222 + return ( 223 + <label class={s.label} for={htmlFor}> 224 + {name} 225 + {required && <span class={s.requiredMark}>*</span>} 226 + </label> 227 + ); 228 + } 229 + 230 + function StringField({ 231 + name, 232 + node, 233 + value, 234 + required, 235 + onValueChange, 236 + }: { 237 + name: string; 238 + node: SchemaNode & { type: "string" }; 239 + value: string; 240 + required: boolean; 241 + onValueChange: (val: string) => void; 242 + }) { 243 + const id = `field-${name}`; 244 + 245 + if (node.knownValues && node.knownValues.length > 0) { 246 + const isCustom = value !== "" && !node.knownValues.includes(value) && !value.startsWith("{{"); 247 + const selectValue = isCustom ? "__custom__" : value; 248 + 249 + return ( 250 + <div class={s.fieldGroup}> 251 + <FieldLabel name={name} required={required} htmlFor={id} /> 252 + <select 253 + id={id} 254 + class={s.select} 255 + value={selectValue} 256 + onChange={(e: Event) => { 257 + const v = (e.target as HTMLSelectElement).value; 258 + onValueChange(v === "__custom__" ? "" : v); 259 + }} 260 + > 261 + <option value="">Select...</option> 262 + {node.knownValues.map((kv) => ( 263 + <option key={kv} value={kv}> 264 + {kv} 265 + </option> 266 + ))} 267 + <option value="__custom__">Custom / placeholder...</option> 268 + </select> 269 + {(selectValue === "__custom__" || isCustom) && ( 270 + <input 271 + class={s.input} 272 + type="text" 273 + placeholder={"e.g. {{event.did}}"} 274 + value={value} 275 + onInput={(e: Event) => onValueChange((e.target as HTMLInputElement).value)} 276 + /> 277 + )} 278 + {node.description && <span class={s.hint}>{node.description}</span>} 279 + </div> 280 + ); 281 + } 282 + 283 + const formatHints: Record<string, string> = { 284 + datetime: "ISO datetime, e.g. {{now}}", 285 + "at-uri": "AT URI, e.g. at://did/collection/rkey", 286 + uri: "URI", 287 + did: "DID identifier", 288 + handle: "Handle, e.g. user.bsky.social", 289 + cid: "CID hash", 290 + }; 291 + 292 + return ( 293 + <div class={s.fieldGroup}> 294 + <FieldLabel name={name} required={required} htmlFor={id} /> 295 + <input 296 + id={id} 297 + class={s.input} 298 + type="text" 299 + placeholder={node.format ? (formatHints[node.format] ?? node.format) : undefined} 300 + value={value} 301 + onInput={(e: Event) => onValueChange((e.target as HTMLInputElement).value)} 302 + /> 303 + {node.description && <span class={s.hint}>{node.description}</span>} 304 + {node.maxLength != null && <span class={s.hint}>Max length: {node.maxLength}</span>} 305 + </div> 306 + ); 307 + } 308 + 309 + function IntegerField({ 310 + name, 311 + node, 312 + value, 313 + required, 314 + onValueChange, 315 + }: { 316 + name: string; 317 + node: SchemaNode & { type: "integer" }; 318 + value: string; 319 + required: boolean; 320 + onValueChange: (val: string) => void; 321 + }) { 322 + const id = `field-${name}`; 323 + const constraints: string[] = []; 324 + if (node.minimum != null) constraints.push(`Min: ${node.minimum}`); 325 + if (node.maximum != null) constraints.push(`Max: ${node.maximum}`); 326 + 327 + return ( 328 + <div class={s.fieldGroup}> 329 + <FieldLabel name={name} required={required} htmlFor={id} /> 330 + <input 331 + id={id} 332 + class={s.input} 333 + type="text" 334 + placeholder="Number or {{placeholder}}" 335 + value={value} 336 + onInput={(e: Event) => onValueChange((e.target as HTMLInputElement).value)} 337 + /> 338 + {node.description && <span class={s.hint}>{node.description}</span>} 339 + {constraints.length > 0 && <span class={s.hint}>{constraints.join(", ")}</span>} 340 + </div> 341 + ); 342 + } 343 + 344 + function BooleanField({ 345 + name, 346 + node, 347 + value, 348 + required, 349 + onValueChange, 350 + }: { 351 + name: string; 352 + node: SchemaNode & { type: "boolean" }; 353 + value: string; 354 + required: boolean; 355 + onValueChange: (val: string) => void; 356 + }) { 357 + const id = `field-${name}`; 358 + const isPlaceholder = value !== "" && value !== "true" && value !== "false"; 359 + 360 + return ( 361 + <div class={s.fieldGroup}> 362 + <FieldLabel name={name} required={required} htmlFor={id} /> 363 + <select 364 + id={id} 365 + class={s.select} 366 + value={isPlaceholder ? "__placeholder__" : value} 367 + onChange={(e: Event) => { 368 + const v = (e.target as HTMLSelectElement).value; 369 + onValueChange(v === "__placeholder__" ? "" : v); 370 + }} 371 + > 372 + <option value="">Select...</option> 373 + <option value="true">true</option> 374 + <option value="false">false</option> 375 + <option value="__placeholder__">Placeholder...</option> 376 + </select> 377 + {isPlaceholder && ( 378 + <input 379 + class={s.input} 380 + type="text" 381 + placeholder={"e.g. {{event.commit.record.active}}"} 382 + value={value} 383 + onInput={(e: Event) => onValueChange((e.target as HTMLInputElement).value)} 384 + /> 385 + )} 386 + {node.description && <span class={s.hint}>{node.description}</span>} 387 + </div> 388 + ); 389 + } 390 + 391 + function ObjectField({ 392 + name, 393 + node, 394 + value, 395 + required, 396 + onValueChange, 397 + placeholders, 398 + }: { 399 + name: string; 400 + node: ObjectNode; 401 + value: FormState; 402 + required: boolean; 403 + onValueChange: (val: FormState) => void; 404 + placeholders: string[]; 405 + }) { 406 + return ( 407 + <fieldset class={s.nestedFieldset}> 408 + <legend class={s.legendText}> 409 + {name} 410 + {required && <span class={s.requiredMark}>*</span>} 411 + </legend> 412 + {node.description && <span class={s.hint}>{node.description}</span>} 413 + {Object.entries(node.properties).map(([key, propNode]) => ( 414 + <FieldRenderer 415 + key={key} 416 + name={key} 417 + node={propNode} 418 + value={value[key]} 419 + required={node.required.includes(key)} 420 + onValueChange={(v) => onValueChange({ ...value, [key]: v })} 421 + placeholders={placeholders} 422 + /> 423 + ))} 424 + </fieldset> 425 + ); 426 + } 427 + 428 + function ArrayField({ 429 + name, 430 + node, 431 + value, 432 + required, 433 + onValueChange, 434 + placeholders, 435 + }: { 436 + name: string; 437 + node: ArrayNode; 438 + value: unknown[]; 439 + required: boolean; 440 + onValueChange: (val: unknown[]) => void; 441 + placeholders: string[]; 442 + }) { 443 + const canAdd = node.maxLength == null || value.length < node.maxLength; 444 + 445 + const addItem = () => { 446 + const initial = node.items.type === "object" ? initState(node.items.properties) : ""; 447 + onValueChange([...value, initial]); 448 + }; 449 + 450 + const removeItem = (index: number) => { 451 + onValueChange(value.filter((_, i) => i !== index)); 452 + }; 453 + 454 + const updateItem = (index: number, newVal: unknown) => { 455 + onValueChange(value.map((v, i) => (i === index ? newVal : v))); 456 + }; 457 + 458 + return ( 459 + <div class={s.fieldGroup}> 460 + <FieldLabel name={name} required={required} /> 461 + {node.description && <span class={s.hint}>{node.description}</span>} 462 + <div class={s.arraySection}> 463 + {value.map((item, i) => ( 464 + <div key={i} class={s.arrayItem}> 465 + <div class={s.arrayItemContent}> 466 + <FieldRenderer 467 + name={`${name}[${i}]`} 468 + node={node.items} 469 + value={item} 470 + required={false} 471 + onValueChange={(v) => updateItem(i, v)} 472 + placeholders={placeholders} 473 + /> 474 + </div> 475 + <button type="button" class={s.removeBtn} onClick={() => removeItem(i)}> 476 + Remove 477 + </button> 478 + </div> 479 + ))} 480 + {canAdd && ( 481 + <button type="button" class={s.addBtn} onClick={addItem}> 482 + + Add {name} item 483 + </button> 484 + )} 485 + </div> 486 + {node.maxLength != null && <span class={s.hint}>Max items: {node.maxLength}</span>} 487 + </div> 488 + ); 489 + } 490 + 491 + function UnknownField({ 492 + name, 493 + node, 494 + value, 495 + required, 496 + onValueChange, 497 + }: { 498 + name: string; 499 + node: SchemaNode & { type: "unknown" }; 500 + value: string; 501 + required: boolean; 502 + onValueChange: (val: string) => void; 503 + }) { 504 + const id = `field-${name}`; 505 + return ( 506 + <div class={s.fieldGroup}> 507 + <FieldLabel name={name} required={required} htmlFor={id} /> 508 + <textarea 509 + id={id} 510 + class={s.textarea} 511 + placeholder="Enter raw JSON value" 512 + value={value} 513 + onInput={(e: Event) => onValueChange((e.target as HTMLTextAreaElement).value)} 514 + /> 515 + {node.description && <span class={s.hint}>{node.description}</span>} 516 + <span class={s.hint}>Complex type: enter JSON directly</span> 517 + </div> 518 + ); 519 + } 520 + 521 + // --------------------------------------------------------------------------- 522 + // Main component 523 + // --------------------------------------------------------------------------- 524 + 525 + export default function RecordFormBuilder({ 526 + schema, 527 + loading, 528 + error, 529 + placeholders, 530 + onChange, 531 + }: Props) { 532 + const [state, setState] = useState<FormState>({}); 533 + 534 + useEffect(() => { 535 + if (schema) { 536 + const initial = initState(schema.properties); 537 + setState(initial); 538 + onChange(serialize(schema, initial)); 539 + } 540 + }, [schema]); 541 + 542 + const handleFieldChange = useCallback( 543 + (key: string, value: unknown) => { 544 + if (!schema) return; 545 + setState((prev) => { 546 + const next = { ...prev, [key]: value }; 547 + onChange(serialize(schema, next)); 548 + return next; 549 + }); 550 + }, 551 + [schema, onChange], 552 + ); 553 + 554 + if (loading) { 555 + return <span class={s.hint}>Loading target collection schema...</span>; 556 + } 557 + 558 + if (error) { 559 + return <span class={s.hint}>{error}</span>; 560 + } 561 + 562 + if (!schema) return null; 563 + 564 + return ( 565 + <div class={s.root}> 566 + {Object.entries(schema.properties).map(([key, node]) => ( 567 + <FieldRenderer 568 + key={key} 569 + name={key} 570 + node={node} 571 + value={state[key]} 572 + required={schema.required.includes(key)} 573 + onValueChange={(v) => handleFieldChange(key, v)} 574 + placeholders={placeholders} 575 + /> 576 + ))} 577 + {placeholders.length > 0 && ( 578 + <div class={s.placeholderHelp}> 579 + Available placeholders: {placeholders.map((p) => `{{${p}}}`).join(", ")} 580 + </div> 581 + )} 582 + </div> 583 + ); 584 + }
+31 -2
app/islands/SubscriptionForm.css.ts
··· 1 - import { style, globalStyle } from "@vanilla-extract/css"; 1 + import { style } from "@vanilla-extract/css"; 2 2 import { vars } from "../styles/theme.css.ts"; 3 3 import { space } from "../styles/tokens/spacing.ts"; 4 4 import { fontSize, fontWeight } from "../styles/tokens/typography.ts"; ··· 91 91 }); 92 92 93 93 export const conditionField = style({ 94 - flex: 1, 94 + flex: 2, 95 95 }); 96 96 97 97 export const conditionValue = style({ ··· 176 176 border: "none", 177 177 padding: 0, 178 178 }); 179 + 180 + export const textarea = style({ 181 + width: "100%", 182 + minBlockSize: "120px", 183 + paddingBlock: space[2], 184 + paddingInline: space[3], 185 + fontSize: fontSize.sm, 186 + fontFamily: "monospace", 187 + color: vars.color.text, 188 + backgroundColor: vars.color.bg, 189 + border: `1px solid ${vars.color.border}`, 190 + borderRadius: radii.md, 191 + resize: "vertical", 192 + "::placeholder": { 193 + color: vars.color.textMuted, 194 + }, 195 + ":focus": { 196 + borderColor: vars.color.accent, 197 + outline: "none", 198 + boxShadow: `0 0 0 3px ${vars.focus.ring}`, 199 + }, 200 + }); 201 + 202 + export const placeholderHelp = style({ 203 + fontSize: fontSize.xs, 204 + color: vars.color.textMuted, 205 + fontFamily: "monospace", 206 + lineHeight: 1.6, 207 + });
+250 -67
app/islands/SubscriptionForm.tsx
··· 1 1 import { useState, useCallback, useRef } from "hono/jsx"; 2 + import type { RecordSchema } from "../../lib/lexicons/schema-tree.js"; 3 + import RecordFormBuilder from "./RecordFormBuilder.js"; 2 4 import * as s from "./SubscriptionForm.css.ts"; 3 5 4 6 type Field = { ··· 12 14 value: string; 13 15 }; 14 16 17 + type SubType = "webhook" | "record"; 18 + 15 19 const NSID_RE = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*){2,}$/; 16 20 17 - function getInitialLexicon(): string { 21 + const BUILTIN_PLACEHOLDERS = [ 22 + "event.did", 23 + "event.commit.collection", 24 + "event.commit.rkey", 25 + "event.commit.cid", 26 + "event.commit.operation", 27 + "now", 28 + ]; 29 + 30 + function getInitialParam(key: string): string { 18 31 if (typeof window === "undefined") return ""; 19 - return new URLSearchParams(window.location.search).get("lexicon") ?? ""; 32 + return new URLSearchParams(window.location.search).get(key) ?? ""; 20 33 } 21 34 22 - export default function SubscriptionForm() { 23 - const initial = getInitialLexicon(); 35 + export default function SubscriptionForm({ type }: { type: SubType }) { 36 + const initial = getInitialParam("lexicon"); 37 + const initialTarget = getInitialParam("targetCollection"); 24 38 const [lexicon, setLexicon] = useState(initial); 25 39 const [fields, setFields] = useState<Field[]>([]); 26 40 const [fieldsLoading, setFieldsLoading] = useState(false); 27 41 const [fieldsError, setFieldsError] = useState(""); 28 42 const [conditions, setConditions] = useState<Condition[]>([]); 29 43 const [callbackUrl, setCallbackUrl] = useState(""); 44 + const [targetCollection, setTargetCollection] = useState(initialTarget); 45 + const [recordTemplate, setRecordTemplate] = useState(""); 46 + const [targetSchema, setTargetSchema] = useState<RecordSchema | null>(null); 47 + const [targetSchemaLoading, setTargetSchemaLoading] = useState(false); 48 + const [targetSchemaError, setTargetSchemaError] = useState(""); 49 + const [nsidSuggestions, setNsidSuggestions] = useState<string[]>([]); 30 50 const [submitting, setSubmitting] = useState(false); 31 51 const [error, setError] = useState(""); 32 52 const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 53 + const targetDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 54 + const suggestDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 55 + const lastSuggestPrefix = useRef(""); 33 56 const initialFetched = useRef(false); 34 57 35 58 const fetchFields = useCallback((nsid: string, updateUrl = true) => { 36 59 if (debounceRef.current) clearTimeout(debounceRef.current); 37 - // Clear conditions immediately — old field paths are stale 38 60 setConditions([]); 39 61 if (!nsid) { 40 62 setFields([]); ··· 77 99 }, 400); 78 100 }, []); 79 101 80 - // Fetch fields for initial lexicon from URL query param 81 - if (!initialFetched.current && initial) { 102 + const fetchTargetSchema = useCallback((nsid: string, updateUrl = true) => { 103 + if (targetDebounceRef.current) clearTimeout(targetDebounceRef.current); 104 + if (!nsid) { 105 + setTargetSchema(null); 106 + setTargetSchemaError(""); 107 + setRecordTemplate(""); 108 + if (updateUrl) { 109 + const url = new URL(window.location.href); 110 + url.searchParams.delete("targetCollection"); 111 + history.replaceState(null, "", url); 112 + } 113 + return; 114 + } 115 + if (updateUrl) { 116 + const url = new URL(window.location.href); 117 + url.searchParams.set("targetCollection", nsid); 118 + history.replaceState(null, "", url); 119 + } 120 + if (!NSID_RE.test(nsid)) { 121 + setTargetSchema(null); 122 + setTargetSchemaError(""); 123 + setRecordTemplate(""); 124 + return; 125 + } 126 + targetDebounceRef.current = setTimeout(async () => { 127 + setTargetSchemaLoading(true); 128 + setTargetSchemaError(""); 129 + try { 130 + const res = await fetch(`/api/lexicons/${encodeURIComponent(nsid)}?schema=record`); 131 + const data = await res.json(); 132 + if (!res.ok) { 133 + setTargetSchemaError(data.error || "Failed to load schema"); 134 + setTargetSchema(null); 135 + } else { 136 + setTargetSchema(data.record ?? null); 137 + if (!data.record) { 138 + setTargetSchemaError("No record schema found for this collection"); 139 + } 140 + } 141 + } catch { 142 + setTargetSchemaError("Failed to fetch target collection schema"); 143 + setTargetSchema(null); 144 + } finally { 145 + setTargetSchemaLoading(false); 146 + } 147 + }, 400); 148 + }, []); 149 + 150 + const fetchSuggestions = useCallback((value: string) => { 151 + if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); 152 + 153 + // Extract prefix: keep everything up to and including the last dot 154 + const dotIndex = value.lastIndexOf("."); 155 + const prefix = dotIndex > 0 ? value.slice(0, dotIndex + 1) : ""; 156 + 157 + // Need at least 2 segments (e.g., "app.bsky.") 158 + if (!prefix || prefix.split(".").filter(Boolean).length < 2) { 159 + return; 160 + } 161 + 162 + // Don't re-fetch for the same prefix 163 + if (prefix === lastSuggestPrefix.current) return; 164 + 165 + suggestDebounceRef.current = setTimeout(async () => { 166 + lastSuggestPrefix.current = prefix; 167 + try { 168 + const res = await fetch(`/api/lexicons/suggest?prefix=${encodeURIComponent(prefix)}`); 169 + if (res.ok) { 170 + const data = await res.json(); 171 + setNsidSuggestions(data.suggestions ?? []); 172 + } 173 + } catch { 174 + // ignore 175 + } 176 + }, 300); 177 + }, []); 178 + 179 + if (!initialFetched.current && (initial || initialTarget)) { 82 180 initialFetched.current = true; 83 - fetchFields(initial, false); 181 + if (initial) fetchFields(initial, false); 182 + if (initialTarget) fetchTargetSchema(initialTarget, false); 84 183 } 85 184 86 185 const addCondition = useCallback(() => { ··· 91 190 setConditions((prev) => prev.filter((_, i) => i !== index)); 92 191 }, []); 93 192 94 - const updateCondition = useCallback( 95 - (index: number, key: "field" | "value", val: string) => { 96 - setConditions((prev) => 97 - prev.map((c, i) => (i === index ? { ...c, [key]: val } : c)), 98 - ); 99 - }, 100 - [], 101 - ); 193 + const updateCondition = useCallback((index: number, key: "field" | "value", val: string) => { 194 + setConditions((prev) => prev.map((c, i) => (i === index ? { ...c, [key]: val } : c))); 195 + }, []); 102 196 103 197 const handleSubmit = useCallback( 104 198 async (e: Event) => { ··· 106 200 setError(""); 107 201 setSubmitting(true); 108 202 try { 203 + const payload: Record<string, unknown> = { 204 + type, 205 + lexicon, 206 + conditions: conditions 207 + .filter((c) => c.field && c.value) 208 + .map((c) => ({ field: c.field, operator: "eq", value: c.value })), 209 + }; 210 + 211 + if (type === "webhook") { 212 + payload.callbackUrl = callbackUrl; 213 + } else { 214 + payload.targetCollection = targetCollection; 215 + payload.recordTemplate = recordTemplate; 216 + } 217 + 109 218 const res = await fetch("/api/subscriptions", { 110 219 method: "POST", 111 220 headers: { "Content-Type": "application/json" }, 112 - body: JSON.stringify({ 113 - lexicon, 114 - callbackUrl, 115 - conditions: conditions 116 - .filter((c) => c.field && c.value) 117 - .map((c) => ({ field: c.field, operator: "eq", value: c.value })), 118 - }), 221 + body: JSON.stringify(payload), 119 222 }); 120 223 const data = await res.json(); 121 224 if (!res.ok) { ··· 129 232 setSubmitting(false); 130 233 } 131 234 }, 132 - [lexicon, callbackUrl, conditions], 235 + [type, lexicon, callbackUrl, targetCollection, recordTemplate, conditions], 133 236 ); 134 237 238 + const allPlaceholders = [ 239 + ...BUILTIN_PLACEHOLDERS, 240 + ...fields.map((f) => `event.commit.record.${f.path}`), 241 + ]; 242 + 135 243 return ( 136 244 <form onSubmit={handleSubmit} class={s.form}> 137 245 <fieldset disabled={submitting} class={s.resetFieldset}> ··· 144 252 id="lexicon" 145 253 class={s.input} 146 254 type="text" 255 + list="nsid-suggestions" 147 256 placeholder="e.g. sh.tangled.feed.star" 148 257 value={lexicon} 149 258 onInput={(e: Event) => { 150 259 const val = (e.target as HTMLInputElement).value; 151 260 setLexicon(val); 152 261 fetchFields(val); 262 + fetchSuggestions(val); 153 263 }} 154 264 required 155 265 /> ··· 157 267 {fieldsError && <span class={s.errorText}>{fieldsError}</span>} 158 268 </div> 159 269 160 - <div class={s.fieldGroup}> 161 - <label class={s.label} for="callbackUrl"> 162 - Callback URL 163 - </label> 164 - <input 165 - id="callbackUrl" 166 - class={s.input} 167 - type="url" 168 - placeholder="https://example.com/hooks/events" 169 - value={callbackUrl} 170 - onInput={(e: Event) => 171 - setCallbackUrl((e.target as HTMLInputElement).value) 172 - } 173 - required 174 - /> 175 - </div> 176 - 177 270 {fields.length > 0 && ( 178 271 <div class={s.conditionsSection}> 179 272 <div> 180 273 <h3>Conditions</h3> 181 274 <p class={s.hint}> 182 - Filter events by field values. All conditions must match 183 - (AND). 275 + Filter events by field values. All conditions must match (AND). 184 276 </p> 185 277 </div> 186 278 {conditions.map((cond, i) => ( ··· 190 282 class={s.select} 191 283 value={cond.field} 192 284 onChange={(e: Event) => 193 - updateCondition( 194 - i, 195 - "field", 196 - (e.target as HTMLSelectElement).value, 197 - ) 285 + updateCondition(i, "field", (e.target as HTMLSelectElement).value) 198 286 } 199 287 > 200 288 <option value="">Select field...</option> 201 289 {fields.map((f) => ( 202 290 <option key={f.path} value={f.path}> 203 291 {f.path} 204 - {f.description ? ` — ${f.description}` : ""} 205 292 </option> 206 293 ))} 207 294 </select> 295 + {cond.field && fields.find((f) => f.path === cond.field)?.description && ( 296 + <span class={s.hint}> 297 + {fields.find((f) => f.path === cond.field)!.description} 298 + </span> 299 + )} 208 300 </div> 209 301 <div class={s.conditionValue}> 210 - <input 211 - class={s.input} 212 - type="text" 213 - placeholder="Value" 214 - value={cond.value} 215 - onInput={(e: Event) => 216 - updateCondition( 217 - i, 218 - "value", 219 - (e.target as HTMLInputElement).value, 220 - ) 221 - } 222 - /> 302 + {fields.find((f) => f.path === cond.field)?.type === "boolean" ? ( 303 + <select 304 + class={s.select} 305 + value={cond.value} 306 + onChange={(e: Event) => 307 + updateCondition(i, "value", (e.target as HTMLSelectElement).value) 308 + } 309 + > 310 + <option value="">Select...</option> 311 + <option value="true">true</option> 312 + <option value="false">false</option> 313 + </select> 314 + ) : ( 315 + <input 316 + class={s.input} 317 + type="text" 318 + placeholder="Value" 319 + value={cond.value} 320 + onInput={(e: Event) => 321 + updateCondition(i, "value", (e.target as HTMLInputElement).value) 322 + } 323 + /> 324 + )} 223 325 </div> 224 - <button 225 - type="button" 226 - class={s.removeBtn} 227 - onClick={() => removeCondition(i)} 228 - > 326 + <button type="button" class={s.removeBtn} onClick={() => removeCondition(i)}> 229 327 Remove 230 328 </button> 231 329 </div> ··· 236 334 </div> 237 335 )} 238 336 337 + {type === "webhook" && ( 338 + <div class={s.fieldGroup}> 339 + <label class={s.label} for="callbackUrl"> 340 + Callback URL 341 + </label> 342 + <input 343 + id="callbackUrl" 344 + class={s.input} 345 + type="url" 346 + placeholder="https://example.com/hooks/events" 347 + value={callbackUrl} 348 + onInput={(e: Event) => setCallbackUrl((e.target as HTMLInputElement).value)} 349 + required 350 + /> 351 + </div> 352 + )} 353 + 354 + {type === "record" && ( 355 + <> 356 + <div class={s.fieldGroup}> 357 + <label class={s.label} for="targetCollection"> 358 + Target Collection 359 + </label> 360 + <input 361 + id="targetCollection" 362 + class={s.input} 363 + type="text" 364 + list="nsid-suggestions" 365 + placeholder="e.g. app.bsky.feed.like" 366 + value={targetCollection} 367 + onInput={(e: Event) => { 368 + const val = (e.target as HTMLInputElement).value; 369 + setTargetCollection(val); 370 + fetchTargetSchema(val); 371 + fetchSuggestions(val); 372 + }} 373 + required 374 + /> 375 + <span class={s.hint}>NSID of the collection to create a record in</span> 376 + </div> 377 + 378 + {(targetSchema || targetSchemaLoading) && ( 379 + <RecordFormBuilder 380 + schema={targetSchema} 381 + loading={targetSchemaLoading} 382 + error={targetSchemaError} 383 + placeholders={allPlaceholders} 384 + onChange={setRecordTemplate} 385 + /> 386 + )} 387 + 388 + {!targetSchema && !targetSchemaLoading && targetCollection && ( 389 + <div class={s.fieldGroup}> 390 + <label class={s.label} for="recordTemplate"> 391 + Record Template 392 + </label> 393 + {targetSchemaError && <span class={s.errorText}>{targetSchemaError}</span>} 394 + <textarea 395 + id="recordTemplate" 396 + class={s.textarea} 397 + placeholder={ 398 + '{\n "subject": {\n "uri": "at://{{event.did}}/{{event.commit.collection}}/{{event.commit.rkey}}",\n "cid": "{{event.commit.cid}}"\n },\n "createdAt": "{{now}}"\n}' 399 + } 400 + value={recordTemplate} 401 + onInput={(e: Event) => 402 + setRecordTemplate((e.target as HTMLTextAreaElement).value) 403 + } 404 + required 405 + /> 406 + {allPlaceholders.length > 0 && ( 407 + <div class={s.placeholderHelp}> 408 + Available: {allPlaceholders.map((p) => `{{${p}}}`).join(", ")} 409 + </div> 410 + )} 411 + </div> 412 + )} 413 + </> 414 + )} 415 + 239 416 {error && <div class={s.alertError}>{error}</div>} 240 417 241 418 <button type="submit" class={s.submitBtn}> 242 419 {submitting ? "Creating..." : "Create subscription"} 243 420 </button> 421 + 422 + <datalist id="nsid-suggestions"> 423 + {nsidSuggestions.map((nsid) => ( 424 + <option key={nsid} value={nsid} /> 425 + ))} 426 + </datalist> 244 427 </div> 245 428 </fieldset> 246 429 </form>
+7
app/routes/api/lexicons/[nsid].ts
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { getCached, setCache } from "@/lexicons/cache.js"; 3 3 import { isValidNsid, isNsidAllowed, resolve } from "@/lexicons/resolver.js"; 4 + import { buildRecordSchema, type RecordSchema } from "@/lexicons/schema-tree.js"; 4 5 import { config } from "@/config.js"; 5 6 6 7 export const GET = createRoute(async (c) => { ··· 22 23 await setCache(schema); 23 24 } 24 25 26 + let record: RecordSchema | undefined; 27 + if (c.req.query("schema") === "record") { 28 + record = (await buildRecordSchema(schema.raw)) ?? undefined; 29 + } 30 + 25 31 return c.json({ 26 32 nsid: schema.nsid, 27 33 description: schema.description, ··· 29 35 { path: "repo", type: "string", description: "DID of the repo that emitted the event" }, 30 36 ...schema.fields, 31 37 ], 38 + ...(record ? { record } : {}), 32 39 }); 33 40 });
+167
app/routes/api/lexicons/suggest.ts
··· 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"; 8 + 9 + /** 10 + * Suggest lexicon NSIDs matching a prefix. 11 + * Sources: local lexicons/ directory, SQLite cache, AT Protocol DNS + listRecords. 12 + */ 13 + export const GET = createRoute(async (c) => { 14 + const prefix = (c.req.query("prefix") ?? "").toLowerCase().trim(); 15 + if (!prefix || prefix.length < 3) { 16 + return c.json({ suggestions: [] }); 17 + } 18 + 19 + const results = new Set<string>(); 20 + 21 + // 1. Local lexicons/ directory — walk matching path segments 22 + collectLocalNsids(prefix, results); 23 + 24 + // 2. SQLite cache — search by prefix 25 + await collectCachedNsids(prefix, results); 26 + 27 + // 3. AT Protocol DNS + listRecords — resolve authority, list published lexicons 28 + await collectRemoteNsids(prefix, results); 29 + 30 + const suggestions = [...results].filter((nsid) => nsid.startsWith(prefix)).sort(); 31 + 32 + return c.json({ suggestions }); 33 + }); 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/app/rglw/subscription.json → app.rglw.subscription 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 + }
+78 -19
app/routes/api/subscriptions/[rkey].ts
··· 2 2 import { eq, and, desc } from "drizzle-orm"; 3 3 import { db } from "@/db/index.js"; 4 4 import { subscriptions, deliveryLogs } from "@/db/schema.js"; 5 + import { config } from "@/config.js"; 6 + import { isValidNsid, isNsidAllowed } from "@/lexicons/resolver.js"; 5 7 import { getRecord, putRecord, deleteRecord } from "@/subscriptions/pds.js"; 6 8 import { verifyCallback } from "@/subscriptions/verify.js"; 9 + import { validateTemplate } from "@/actions/template.js"; 7 10 import { notifySubscriptionChange } from "@/jetstream/consumer.js"; 8 11 9 12 function findSubscription(did: string, rkey: string) { ··· 28 31 return c.json({ 29 32 uri: sub.uri, 30 33 rkey: sub.rkey, 34 + type: sub.type, 31 35 lexicon: sub.lexicon, 32 36 callbackUrl: sub.callbackUrl, 37 + targetCollection: sub.targetCollection, 38 + recordTemplate: sub.recordTemplate, 33 39 conditions: sub.conditions, 34 40 active: sub.active, 35 41 secret: sub.secret, ··· 54 60 55 61 const body = await c.req.json<{ 56 62 callbackUrl?: string; 63 + targetCollection?: string; 64 + recordTemplate?: string; 57 65 conditions?: Array<{ field: string; operator?: string; value: string }>; 58 66 active?: boolean; 59 67 }>(); 60 68 61 - if (body.callbackUrl !== undefined && !body.callbackUrl) { 62 - return c.json({ error: "callbackUrl cannot be empty" }, 400); 63 - } 64 - const callbackUrl = body.callbackUrl ?? sub.callbackUrl; 65 69 const conditions = body.conditions 66 70 ? body.conditions 67 71 .filter((cond) => cond.field && cond.value) ··· 76 80 } 77 81 const active = body.active ?? sub.active; 78 82 79 - // Re-verify callback if URL changed or reactivating 80 - if (body.callbackUrl || (body.active === true && !sub.active)) { 81 - const verification = await verifyCallback(callbackUrl, sub.lexicon); 82 - if (!verification.ok) { 83 - return c.json({ error: verification.error }, 422); 83 + // Type-specific validation and field resolution 84 + let callbackUrl = sub.callbackUrl; 85 + let targetCollection = sub.targetCollection; 86 + let recordTemplate = sub.recordTemplate; 87 + 88 + if (sub.type === "webhook") { 89 + if (body.callbackUrl !== undefined && !body.callbackUrl) { 90 + return c.json({ error: "callbackUrl cannot be empty" }, 400); 91 + } 92 + callbackUrl = body.callbackUrl ?? sub.callbackUrl; 93 + 94 + // Re-verify callback if URL changed or reactivating 95 + if (body.callbackUrl || (body.active === true && !sub.active)) { 96 + const verification = await verifyCallback(callbackUrl!, sub.lexicon); 97 + if (!verification.ok) { 98 + return c.json({ error: verification.error }, 422); 99 + } 100 + } 101 + } else if (sub.type === "record") { 102 + if (body.targetCollection !== undefined) { 103 + if (!body.targetCollection) { 104 + return c.json({ error: "targetCollection cannot be empty" }, 400); 105 + } 106 + if (!isValidNsid(body.targetCollection)) { 107 + return c.json({ error: "Invalid target collection NSID" }, 400); 108 + } 109 + if (!isNsidAllowed(body.targetCollection, config.nsidAllowlist, config.nsidBlocklist)) { 110 + return c.json({ error: "This target collection is not allowed on this instance" }, 403); 111 + } 112 + targetCollection = body.targetCollection; 113 + } 114 + if (body.recordTemplate !== undefined) { 115 + if (!body.recordTemplate) { 116 + return c.json({ error: "recordTemplate cannot be empty" }, 400); 117 + } 118 + const templateValidation = validateTemplate(body.recordTemplate); 119 + if (!templateValidation.valid) { 120 + return c.json({ error: templateValidation.error }, 400); 121 + } 122 + recordTemplate = body.recordTemplate; 84 123 } 85 124 } 86 125 87 126 // Read existing PDS record to preserve createdAt 88 127 const existing = await getRecord(user.did, rkey); 89 - const createdAt = 90 - (existing?.createdAt as string) || sub.indexedAt.toISOString(); 128 + const createdAt = (existing?.createdAt as string) || sub.indexedAt.toISOString(); 91 129 92 130 // Update on PDS 131 + const pdsRecord = 132 + sub.type === "webhook" 133 + ? { 134 + type: "webhook" as const, 135 + lexicon: sub.lexicon, 136 + callbackUrl: callbackUrl!, 137 + conditions, 138 + active, 139 + createdAt, 140 + } 141 + : { 142 + type: "record" as const, 143 + lexicon: sub.lexicon, 144 + targetCollection: targetCollection!, 145 + recordTemplate: recordTemplate!, 146 + conditions, 147 + active, 148 + createdAt, 149 + }; 150 + 93 151 try { 94 - await putRecord(user.did, rkey, { 95 - lexicon: sub.lexicon, 96 - callbackUrl, 97 - conditions, 98 - active, 99 - createdAt, 100 - }); 152 + await putRecord(user.did, rkey, pdsRecord); 101 153 } catch (err) { 102 154 console.error("Failed to update PDS record:", err); 103 155 return c.json({ error: "Failed to update subscription on PDS" }, 502); ··· 107 159 const now = new Date(); 108 160 await db 109 161 .update(subscriptions) 110 - .set({ callbackUrl, conditions, active, indexedAt: now }) 162 + .set({ 163 + callbackUrl, 164 + targetCollection, 165 + recordTemplate, 166 + conditions, 167 + active, 168 + indexedAt: now, 169 + }) 111 170 .where(eq(subscriptions.uri, sub.uri)); 112 171 113 172 notifySubscriptionChange();
+87 -27
app/routes/api/subscriptions/index.ts
··· 7 7 import { isValidNsid, isNsidAllowed } from "@/lexicons/resolver.js"; 8 8 import { verifyCallback } from "@/subscriptions/verify.js"; 9 9 import { createRecord, deleteRecord } from "@/subscriptions/pds.js"; 10 + import { validateTemplate } from "@/actions/template.js"; 10 11 import { notifySubscriptionChange } from "@/jetstream/consumer.js"; 11 12 12 13 export const GET = createRoute(async (c) => { ··· 18 19 rows.map((r) => ({ 19 20 uri: r.uri, 20 21 rkey: r.rkey, 22 + type: r.type, 21 23 lexicon: r.lexicon, 22 24 callbackUrl: r.callbackUrl, 25 + targetCollection: r.targetCollection, 23 26 conditions: r.conditions, 24 27 active: r.active, 25 28 indexedAt: r.indexedAt.getTime(), ··· 30 33 export const POST = createRoute(async (c) => { 31 34 const user = c.get("user"); 32 35 const body = await c.req.json<{ 36 + type?: "webhook" | "record"; 33 37 lexicon: string; 34 - callbackUrl: string; 38 + callbackUrl?: string; 39 + targetCollection?: string; 40 + recordTemplate?: string; 35 41 conditions?: Array<{ field: string; operator?: string; value: string }>; 36 42 active?: boolean; 37 43 }>(); 38 44 45 + const type = body.type ?? "webhook"; 46 + 39 47 // Validate lexicon NSID 40 48 if (!body.lexicon || !isValidNsid(body.lexicon)) { 41 49 return c.json({ error: "Invalid lexicon NSID" }, 400); ··· 44 52 return c.json({ error: "This lexicon is not allowed on this instance" }, 403); 45 53 } 46 54 47 - // Validate callback URL 48 - if (!body.callbackUrl) { 49 - return c.json({ error: "callbackUrl is required" }, 400); 50 - } 51 - try { 52 - new URL(body.callbackUrl); 53 - } catch { 54 - return c.json({ error: "Invalid callback URL" }, 400); 55 - } 56 - 57 55 // Normalize and validate conditions 58 56 const conditions = (body.conditions ?? []) 59 57 .filter((cond) => cond.field && cond.value) ··· 66 64 return c.json({ error: "Maximum 20 conditions allowed" }, 400); 67 65 } 68 66 69 - // Verify callback endpoint 70 - const verification = await verifyCallback(body.callbackUrl, body.lexicon); 71 - if (!verification.ok) { 72 - return c.json({ error: verification.error }, 422); 67 + // Type-specific validation 68 + let callbackUrl: string | null = null; 69 + let secret: string | null = null; 70 + let targetCollection: string | null = null; 71 + let recordTemplate: string | null = null; 72 + 73 + if (type === "webhook") { 74 + if (!body.callbackUrl) { 75 + return c.json({ error: "callbackUrl is required for webhook subscriptions" }, 400); 76 + } 77 + try { 78 + new URL(body.callbackUrl); 79 + } catch { 80 + return c.json({ error: "Invalid callback URL" }, 400); 81 + } 82 + 83 + const verification = await verifyCallback(body.callbackUrl, body.lexicon); 84 + if (!verification.ok) { 85 + return c.json({ error: verification.error }, 422); 86 + } 87 + 88 + callbackUrl = body.callbackUrl; 89 + secret = nanoid(32); 90 + } else if (type === "record") { 91 + if (!body.targetCollection) { 92 + return c.json({ error: "targetCollection is required for record subscriptions" }, 400); 93 + } 94 + if (!isValidNsid(body.targetCollection)) { 95 + return c.json({ error: "Invalid target collection NSID" }, 400); 96 + } 97 + if (!isNsidAllowed(body.targetCollection, config.nsidAllowlist, config.nsidBlocklist)) { 98 + return c.json({ error: "This target collection is not allowed on this instance" }, 403); 99 + } 100 + if (!body.recordTemplate) { 101 + return c.json({ error: "recordTemplate is required for record subscriptions" }, 400); 102 + } 103 + const templateValidation = validateTemplate(body.recordTemplate); 104 + if (!templateValidation.valid) { 105 + return c.json({ error: templateValidation.error }, 400); 106 + } 107 + 108 + targetCollection = body.targetCollection; 109 + recordTemplate = body.recordTemplate; 110 + } else { 111 + return c.json({ error: "Invalid subscription type" }, 400); 73 112 } 74 113 75 114 // Write record to PDS ··· 77 116 const now = new Date(); 78 117 let uri: string; 79 118 let rkey: string; 119 + 120 + const pdsRecord = 121 + type === "webhook" 122 + ? { 123 + type: "webhook" as const, 124 + lexicon: body.lexicon, 125 + callbackUrl: callbackUrl!, 126 + conditions, 127 + active, 128 + createdAt: now.toISOString(), 129 + } 130 + : { 131 + type: "record" as const, 132 + lexicon: body.lexicon, 133 + targetCollection: targetCollection!, 134 + recordTemplate: recordTemplate!, 135 + conditions, 136 + active, 137 + createdAt: now.toISOString(), 138 + }; 139 + 80 140 try { 81 - const result = await createRecord(user.did, { 82 - lexicon: body.lexicon, 83 - callbackUrl: body.callbackUrl, 84 - conditions, 85 - active, 86 - createdAt: now.toISOString(), 87 - }); 141 + const result = await createRecord(user.did, pdsRecord); 88 142 uri = result.uri; 89 143 rkey = result.rkey; 90 144 } catch (err) { ··· 92 146 return c.json({ error: "Failed to write subscription to PDS" }, 502); 93 147 } 94 148 95 - // Index locally with HMAC secret 96 - const secret = nanoid(32); 149 + // Index locally 97 150 try { 98 151 await db.insert(subscriptions).values({ 99 152 uri, 100 153 did: user.did, 101 154 rkey, 155 + type, 102 156 lexicon: body.lexicon, 103 - callbackUrl: body.callbackUrl, 157 + callbackUrl, 104 158 conditions, 105 159 secret, 160 + targetCollection, 161 + recordTemplate, 106 162 active, 107 163 indexedAt: now, 108 164 }); 109 165 } catch (err) { 110 166 // Rollback PDS record if local indexing fails 111 - try { await deleteRecord(user.did, rkey); } catch { /* best-effort */ } 167 + try { 168 + await deleteRecord(user.did, rkey); 169 + } catch { 170 + /* best-effort */ 171 + } 112 172 console.error("Failed to index subscription locally:", err); 113 173 return c.json({ error: "Failed to save subscription" }, 500); 114 174 } 115 175 116 176 notifySubscriptionChange(); 117 - return c.json({ uri, rkey, secret }, 201); 177 + return c.json({ uri, rkey, ...(secret ? { secret } : {}) }, 201); 118 178 });
+1 -4
app/routes/auth/callback.tsx
··· 41 41 // Check for OAuth error response 42 42 if (params.has("error")) { 43 43 const description = params.get("error_description") || params.get("error") || "Unknown error"; 44 - return c.render( 45 - <ErrorPage message={description} />, 46 - { title: "Error — Airglow" }, 47 - ); 44 + return c.render(<ErrorPage message={description} />, { title: "Error — Airglow" }); 48 45 } 49 46 50 47 try {
+8 -1
app/routes/auth/login.tsx
··· 8 8 import { Alert } from "../../components/Alert/index.js"; 9 9 import { Stack } from "../../components/Layout/Stack/index.js"; 10 10 import ThemeToggle from "../../islands/ThemeToggle.js"; 11 - import { loginWrapper, loginCard, formGroup, loginInput, loginLabel, loginButton } from "../../styles/pages/login.css.js"; 11 + import { 12 + loginWrapper, 13 + loginCard, 14 + formGroup, 15 + loginInput, 16 + loginLabel, 17 + loginButton, 18 + } from "../../styles/pages/login.css.js"; 12 19 13 20 export default createRoute(async (c) => { 14 21 const user = await getSessionUser(c);
+10 -10
app/routes/dashboard/index.tsx
··· 21 21 }); 22 22 23 23 return c.render( 24 - <AppShell 25 - header={<Header user={user} actions={<ThemeToggle />} />} 26 - > 24 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 27 25 <Container> 28 26 <PageHeader 29 27 title="Subscriptions" ··· 51 49 <thead> 52 50 <tr> 53 51 <th>Lexicon</th> 54 - <th>Callback URL</th> 52 + <th>Action</th> 55 53 <th>Conditions</th> 56 54 <th>Status</th> 57 55 <th></th> ··· 66 64 </a> 67 65 </td> 68 66 <td> 69 - <InlineCode>{sub.callbackUrl}</InlineCode> 67 + {sub.type === "record" ? ( 68 + <> 69 + Create <InlineCode>{sub.targetCollection}</InlineCode> 70 + </> 71 + ) : ( 72 + <InlineCode>{sub.callbackUrl}</InlineCode> 73 + )} 70 74 </td> 71 75 <td>{sub.conditions.length}</td> 72 76 <td> ··· 75 79 </Badge> 76 80 </td> 77 81 <td> 78 - <Button 79 - href={`/dashboard/subscriptions/${sub.rkey}`} 80 - variant="ghost" 81 - size="sm" 82 - > 82 + <Button href={`/dashboard/subscriptions/${sub.rkey}`} variant="ghost" size="sm"> 83 83 View 84 84 </Button> 85 85 </td>
+27 -17
app/routes/dashboard/subscriptions/[rkey].tsx
··· 10 10 import { Badge } from "../../../components/Badge/index.js"; 11 11 import { Button } from "../../../components/Button/index.js"; 12 12 import { DescriptionList } from "../../../components/DescriptionList/index.js"; 13 - import { InlineCode } from "../../../components/CodeBlock/index.js"; 13 + import { CodeBlock, InlineCode } from "../../../components/CodeBlock/index.js"; 14 14 import { Stack } from "../../../components/Layout/Stack/index.js"; 15 15 import ThemeToggle from "../../../islands/ThemeToggle.js"; 16 16 import DeliveryLog from "../../../islands/DeliveryLog.js"; ··· 26 26 if (!sub) { 27 27 c.status(404); 28 28 return c.render( 29 - <AppShell 30 - header={<Header user={user} actions={<ThemeToggle />} />} 31 - > 29 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 32 30 <Container> 33 31 <PageHeader 34 32 title="Not Found" ··· 52 50 }); 53 51 54 52 return c.render( 55 - <AppShell 56 - header={<Header user={user} actions={<ThemeToggle />} />} 57 - > 53 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 58 54 <Container> 59 55 <PageHeader 60 56 title={sub.lexicon} ··· 77 73 <dd> 78 74 <InlineCode>{sub.lexicon}</InlineCode> 79 75 </dd> 80 - <dt>Callback URL</dt> 81 - <dd> 82 - <InlineCode>{sub.callbackUrl}</InlineCode> 83 - </dd> 76 + {sub.type === "record" ? ( 77 + <> 78 + <dt>Target Collection</dt> 79 + <dd> 80 + <InlineCode>{sub.targetCollection}</InlineCode> 81 + </dd> 82 + <dt>Record Template</dt> 83 + <dd> 84 + <CodeBlock>{sub.recordTemplate}</CodeBlock> 85 + </dd> 86 + </> 87 + ) : ( 88 + <> 89 + <dt>Callback URL</dt> 90 + <dd> 91 + <InlineCode>{sub.callbackUrl}</InlineCode> 92 + </dd> 93 + <dt>HMAC Secret</dt> 94 + <dd> 95 + <InlineCode>{sub.secret}</InlineCode> 96 + </dd> 97 + </> 98 + )} 84 99 <dt>Status</dt> 85 100 <dd> 86 101 <Badge variant={sub.active ? "success" : "neutral"}> 87 102 {sub.active ? "Active" : "Inactive"} 88 103 </Badge> 89 - </dd> 90 - <dt>HMAC Secret</dt> 91 - <dd> 92 - <InlineCode>{sub.secret}</InlineCode> 93 104 </dd> 94 105 <dt>AT URI</dt> 95 106 <dd> ··· 105 116 <ul class={plainList}> 106 117 {sub.conditions.map((cond, i) => ( 107 118 <li key={i}> 108 - <InlineCode>{cond.field}</InlineCode>{" "} 109 - {cond.operator}{" "} 119 + <InlineCode>{cond.field}</InlineCode> {cond.operator}{" "} 110 120 <InlineCode>{cond.value}</InlineCode> 111 121 </li> 112 122 ))}
+22 -7
app/routes/dashboard/subscriptions/new.tsx
··· 5 5 import { PageHeader } from "../../../components/Layout/PageHeader/index.js"; 6 6 import { Card } from "../../../components/Card/index.js"; 7 7 import { Button } from "../../../components/Button/index.js"; 8 + import { Stack } from "../../../components/Layout/Stack/index.js"; 8 9 import ThemeToggle from "../../../islands/ThemeToggle.js"; 9 - import SubscriptionForm from "../../../islands/SubscriptionForm.js"; 10 10 11 11 export default createRoute((c) => { 12 12 const user = c.get("user"); 13 13 14 14 return c.render( 15 - <AppShell 16 - header={<Header user={user} actions={<ThemeToggle />} />} 17 - > 15 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 18 16 <Container> 19 17 <PageHeader 20 18 title="New Subscription" ··· 24 22 </Button> 25 23 } 26 24 /> 27 - <Card variant="flat"> 28 - <SubscriptionForm /> 29 - </Card> 25 + <Stack gap={4}> 26 + <Card variant="flat"> 27 + <Stack gap={2}> 28 + <h3>Webhook</h3> 29 + <p>Forward matching events to a callback URL via HTTP POST.</p> 30 + <Button href="/dashboard/subscriptions/new/webhook" size="sm"> 31 + Create webhook 32 + </Button> 33 + </Stack> 34 + </Card> 35 + <Card variant="flat"> 36 + <Stack gap={2}> 37 + <h3>Record</h3> 38 + <p>Create a record on your PDS when a matching event occurs.</p> 39 + <Button href="/dashboard/subscriptions/new/record" size="sm"> 40 + Create record action 41 + </Button> 42 + </Stack> 43 + </Card> 44 + </Stack> 30 45 </Container> 31 46 </AppShell>, 32 47 { title: "New Subscription — Airglow" },
+32
app/routes/dashboard/subscriptions/new/record.tsx
··· 1 + import { createRoute } from "honox/factory"; 2 + import { AppShell } from "../../../../components/Layout/AppShell/index.js"; 3 + import { Header } from "../../../../components/Layout/Header/index.js"; 4 + import { Container } from "../../../../components/Layout/Container/index.js"; 5 + import { PageHeader } from "../../../../components/Layout/PageHeader/index.js"; 6 + import { Card } from "../../../../components/Card/index.js"; 7 + import { Button } from "../../../../components/Button/index.js"; 8 + import ThemeToggle from "../../../../islands/ThemeToggle.js"; 9 + import SubscriptionForm from "../../../../islands/SubscriptionForm.js"; 10 + 11 + export default createRoute((c) => { 12 + const user = c.get("user"); 13 + 14 + return c.render( 15 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 16 + <Container> 17 + <PageHeader 18 + title="New Record Subscription" 19 + actions={ 20 + <Button href="/dashboard/subscriptions/new" variant="ghost" size="sm"> 21 + &larr; Back 22 + </Button> 23 + } 24 + /> 25 + <Card variant="flat"> 26 + <SubscriptionForm type="record" /> 27 + </Card> 28 + </Container> 29 + </AppShell>, 30 + { title: "New Record Action — Airglow" }, 31 + ); 32 + });
+32
app/routes/dashboard/subscriptions/new/webhook.tsx
··· 1 + import { createRoute } from "honox/factory"; 2 + import { AppShell } from "../../../../components/Layout/AppShell/index.js"; 3 + import { Header } from "../../../../components/Layout/Header/index.js"; 4 + import { Container } from "../../../../components/Layout/Container/index.js"; 5 + import { PageHeader } from "../../../../components/Layout/PageHeader/index.js"; 6 + import { Card } from "../../../../components/Card/index.js"; 7 + import { Button } from "../../../../components/Button/index.js"; 8 + import ThemeToggle from "../../../../islands/ThemeToggle.js"; 9 + import SubscriptionForm from "../../../../islands/SubscriptionForm.js"; 10 + 11 + export default createRoute((c) => { 12 + const user = c.get("user"); 13 + 14 + return c.render( 15 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 16 + <Container> 17 + <PageHeader 18 + title="New Webhook Subscription" 19 + actions={ 20 + <Button href="/dashboard/subscriptions/new" variant="ghost" size="sm"> 21 + &larr; Back 22 + </Button> 23 + } 24 + /> 25 + <Card variant="flat"> 26 + <SubscriptionForm type="webhook" /> 27 + </Card> 28 + </Container> 29 + </AppShell>, 30 + { title: "New Webhook — Airglow" }, 31 + ); 32 + });
+12 -16
app/routes/index.tsx
··· 16 16 <section class={s.hero}> 17 17 <h1 class={s.heroTitle}>Webhooks for the AT Protocol</h1> 18 18 <p class={s.heroSubtitle}> 19 - Subscribe to events across the AT Protocol network and receive 20 - real-time webhook deliveries. Filter by lexicon, match conditions, 21 - and track every delivery. 19 + Subscribe to events across the AT Protocol network and receive real-time webhook 20 + deliveries. Filter by lexicon, match conditions, and track every delivery. 22 21 </p> 23 22 {user ? ( 24 23 <Button href="/dashboard" size="lg"> ··· 35 34 <div class={s.featureCard}> 36 35 <h3 class={s.featureTitle}>Real-time Webhooks</h3> 37 36 <p class={s.featureDesc}> 38 - Receive HTTP POST callbacks instantly when matching events occur on 39 - the AT Protocol network via Jetstream. 37 + Receive HTTP POST callbacks instantly when matching events occur on the AT Protocol 38 + network via Jetstream. 40 39 </p> 41 40 </div> 42 41 <div class={s.featureCard}> 43 42 <h3 class={s.featureTitle}>Lexicon Filtering</h3> 44 43 <p class={s.featureDesc}> 45 - Subscribe to specific record types by NSID. Add field-level 46 - conditions to match exactly the events you need. 44 + Subscribe to specific record types by NSID. Add field-level conditions to match 45 + exactly the events you need. 47 46 </p> 48 47 </div> 49 48 <div class={s.featureCard}> 50 49 <h3 class={s.featureTitle}>Delivery Tracking</h3> 51 50 <p class={s.featureDesc}> 52 - Full delivery log with status codes, retry attempts, and error 53 - details. Know exactly what happened with every event. 51 + Full delivery log with status codes, retry attempts, and error details. Know exactly 52 + what happened with every event. 54 53 </p> 55 54 </div> 56 55 <div class={s.featureCard}> 57 56 <h3 class={s.featureTitle}>HMAC Signing</h3> 58 57 <p class={s.featureDesc}> 59 - Every webhook is signed with a per-subscription HMAC secret so 60 - your callback can verify authenticity. 58 + Every webhook is signed with a per-subscription HMAC secret so your callback can 59 + verify authenticity. 61 60 </p> 62 61 </div> 63 62 </section> ··· 68 67 <li class={s.step}> 69 68 <div class={s.stepNumber}>1</div> 70 69 <h3 class={s.stepTitle}>Sign in</h3> 71 - <p class={s.stepDesc}> 72 - Authenticate with your AT Protocol identity via OAuth. 73 - </p> 70 + <p class={s.stepDesc}>Authenticate with your AT Protocol identity via OAuth.</p> 74 71 </li> 75 72 <li class={s.step}> 76 73 <div class={s.stepNumber}>2</div> ··· 83 80 <div class={s.stepNumber}>3</div> 84 81 <h3 class={s.stepTitle}>Receive</h3> 85 82 <p class={s.stepDesc}> 86 - Get signed webhook deliveries in real time with automatic 87 - retries. 83 + Get signed webhook deliveries in real time with automatic retries. 88 84 </p> 89 85 </li> 90 86 </ol>
+5 -3
app/server.ts
··· 2 2 import { getOAuthClient } from "@/auth/client.js"; 3 3 import { startJetstream } from "@/jetstream/consumer.js"; 4 4 import { dispatch } from "@/webhooks/dispatcher.js"; 5 + import { executeAction } from "@/actions/executor.js"; 5 6 6 7 const app = createApp(); 7 8 8 - // Start Jetstream consumer — delivers matched events to callback URLs 9 + // Start Jetstream consumer — routes matched events to the appropriate handler 9 10 startJetstream((match) => { 10 - dispatch(match).catch((err) => { 11 - console.error("Webhook delivery error:", err); 11 + const handler = match.subscription.type === "record" ? executeAction : dispatch; 12 + handler(match).catch((err) => { 13 + console.error(`${match.subscription.type} delivery error:`, err); 12 14 }); 13 15 }); 14 16
+3 -17
app/styles/sprinkles.css.ts
··· 42 42 shorthands: { 43 43 paddingBlock: ["paddingBlockStart", "paddingBlockEnd"], 44 44 paddingInline: ["paddingInlineStart", "paddingInlineEnd"], 45 - padding: [ 46 - "paddingBlockStart", 47 - "paddingBlockEnd", 48 - "paddingInlineStart", 49 - "paddingInlineEnd", 50 - ], 45 + padding: ["paddingBlockStart", "paddingBlockEnd", "paddingInlineStart", "paddingInlineEnd"], 51 46 marginBlock: ["marginBlockStart", "marginBlockEnd"], 52 47 marginInline: ["marginInlineStart", "marginInlineEnd"], 53 - margin: [ 54 - "marginBlockStart", 55 - "marginBlockEnd", 56 - "marginInlineStart", 57 - "marginInlineEnd", 58 - ], 48 + margin: ["marginBlockStart", "marginBlockEnd", "marginInlineStart", "marginInlineEnd"], 59 49 }, 60 50 }); 61 51 ··· 96 86 }, 97 87 }); 98 88 99 - export const sprinkles = createSprinkles( 100 - layoutProperties, 101 - colorProperties, 102 - typographyProperties, 103 - ); 89 + export const sprinkles = createSprinkles(layoutProperties, colorProperties, typographyProperties); 104 90 105 91 export type Sprinkles = Parameters<typeof sprinkles>[0];
+1 -5
app/styles/theme.css.ts
··· 1 - import { 2 - createGlobalThemeContract, 3 - createGlobalTheme, 4 - globalStyle, 5 - } from "@vanilla-extract/css"; 1 + import { createGlobalThemeContract, createGlobalTheme, globalStyle } from "@vanilla-extract/css"; 6 2 import { darkColors, lightColors } from "./tokens/colors.ts"; 7 3 import { darkShadows, lightShadows } from "./tokens/shadows.ts"; 8 4
+1 -7
app/styles/tokens/index.ts
··· 1 1 export { darkColors, lightColors } from "./colors.ts"; 2 2 export { space } from "./spacing.ts"; 3 - export { 4 - fontFamily, 5 - fontSize, 6 - fontWeight, 7 - lineHeight, 8 - letterSpacing, 9 - } from "./typography.ts"; 3 + export { fontFamily, fontSize, fontWeight, lineHeight, letterSpacing } from "./typography.ts"; 10 4 export { lightShadows, darkShadows } from "./shadows.ts"; 11 5 export { radii } from "./radii.ts"; 12 6 export { breakpoints } from "./breakpoints.ts";
+17 -7
docs/dev-server.md
··· 19 19 `vp dev` (Vite+) starts a Vite dev server. HonoX registers a middleware via `configureServer` that intercepts all requests and runs them through the Hono app using Vite's SSR module runner. Route files in `app/routes/` are discovered and registered automatically. 20 20 21 21 The key constraint: **all server-side code executes in Node**, not Bun. This means: 22 + 22 23 - `bun:sqlite` is not available (see [sqlite-bun-compat.md](./sqlite-bun-compat.md)) 23 24 - CJS packages must be loaded via `createRequire()` (see [oauth.md](./oauth.md)) 24 25 - `.env` must be loaded manually for Node ··· 28 29 HonoX's Vite plugin unconditionally sets: 29 30 30 31 ```ts 31 - { ssr: { noExternal: true } } 32 + { 33 + ssr: { 34 + noExternal: true; 35 + } 36 + } 32 37 ``` 33 38 34 39 This tells Vite to ESM-transform **all** `node_modules` during SSR instead of letting Node load them natively. This is required for HonoX's file-based routing to work (it needs to process route imports through Vite's module graph). But it breaks CJS packages that use `module.exports` or `require()`. 35 40 36 41 Workarounds: 42 + 37 43 - **Vite aliases** redirect `bun:sqlite` and `better-sqlite3` to a `createRequire()` shim 38 44 - **`createRequire()`** loads `@atproto/oauth-client-node` bypassing Vite's transform 39 45 ··· 62 68 ### Why `http.request()` and not `fetch()` 63 69 64 70 Node's `fetch()` follows the Fetch spec and silently strips `sec-fetch-*` headers. The PDS validates these headers: 71 + 65 72 - `sec-fetch-site: same-origin` — required for the PDS to accept the request 66 73 - `sec-fetch-mode: navigate` — required for page loads (the browser sends this naturally) 67 74 ··· 70 77 ### Response rewriting 71 78 72 79 Text responses (HTML, JSON, JavaScript) have all occurrences of the PDS issuer URL (e.g. `https://pds.dev`) replaced with the app origin (`http://127.0.0.1:5175`). This ensures: 80 + 73 81 - The PDS OAuth SPA's internal API calls go through the proxy 74 82 - Redirect URLs in JSON responses point to the proxy 75 83 - Location headers in 3xx responses are rewritten 76 84 77 85 Additionally: 86 + 78 87 - `Strict-Transport-Security` headers are stripped (we're on HTTP) 79 88 - `upgrade-insecure-requests` is removed from CSP 80 89 - `Secure` flag is stripped from cookies ··· 82 91 ## Production 83 92 84 93 In production, none of these workarounds apply: 94 + 85 95 - `bun run start` runs the Hono app directly under Bun 86 96 - `bun:sqlite` resolves natively 87 97 - CJS packages are loaded by Bun's module system ··· 90 100 91 101 ## Key configuration 92 102 93 - | Setting | Dev | Production | 94 - |---|---|---| 95 - | Runtime | Node (via Vite) | Bun | 96 - | SQLite driver | `better-sqlite3` (via alias) | `bun:sqlite` | 97 - | PDS access | Vite proxy + URL rewriting | Direct HTTPS | 98 - | `.env` loading | Manual loader in `lib/config.ts` | Bun auto-loads | 103 + | Setting | Dev | Production | 104 + | ----------------- | --------------------------------- | --------------------------------- | 105 + | Runtime | Node (via Vite) | Bun | 106 + | SQLite driver | `better-sqlite3` (via alias) | `bun:sqlite` | 107 + | PDS access | Vite proxy + URL rewriting | Direct HTTPS | 108 + | `.env` loading | Manual loader in `lib/config.ts` | Bun auto-loads | 99 109 | OAuth client type | Loopback (`http://localhost?...`) | Confidential (HTTPS metadata URL) |
+13 -13
docs/oauth.md
··· 44 44 ```ts 45 45 import { createRequire } from "node:module"; 46 46 const require = createRequire(import.meta.url); 47 - const { NodeOAuthClient, JoseKey, requestLocalLock } = require( 48 - "@atproto/oauth-client-node", 49 - ) as typeof import("@atproto/oauth-client-node"); 47 + const { NodeOAuthClient, JoseKey, requestLocalLock } = 48 + require("@atproto/oauth-client-node") as typeof import("@atproto/oauth-client-node"); 50 49 ``` 51 50 52 51 `createRequire()` gives us a CJS-compatible `require()` in an ESM module. This bypasses Vite's transform pipeline entirely. Type safety is preserved via `import type` and the `as typeof import(...)` cast. ··· 81 80 6. Strips `Secure` flag from cookies 82 81 83 82 The login handler rewrites the authorize URL for the browser: 83 + 84 84 ```ts 85 85 const redirectUrl = rewritePdsUrl(url.toString(), "browser"); 86 86 // https://pds.dev/oauth/authorize?... → http://127.0.0.1:5175/oauth/authorize?... ··· 107 107 108 108 ## Key files 109 109 110 - | File | Role | 111 - |---|---| 112 - | `lib/auth/client.ts` | OAuth client singleton, key management, handle resolution, URL rewriting | 113 - | `lib/auth/storage.ts` | SQLite-backed stores for OAuth state and sessions | 114 - | `lib/auth/middleware.ts` | Cookie-based session middleware (`getSessionUser`, `requireAuth`) | 115 - | `app/routes/auth/login.tsx` | Login form + POST handler (initiates OAuth) | 116 - | `app/routes/auth/callback.tsx` | OAuth callback (token exchange, user upsert, cookie) | 117 - | `app/routes/auth/signout.ts` | Clears session cookie | 118 - | `app/routes/dashboard/_middleware.ts` | Auth guard for dashboard routes | 119 - | `vite.config.ts` | PDS proxy plugin for local dev | 110 + | File | Role | 111 + | ------------------------------------- | ------------------------------------------------------------------------ | 112 + | `lib/auth/client.ts` | OAuth client singleton, key management, handle resolution, URL rewriting | 113 + | `lib/auth/storage.ts` | SQLite-backed stores for OAuth state and sessions | 114 + | `lib/auth/middleware.ts` | Cookie-based session middleware (`getSessionUser`, `requireAuth`) | 115 + | `app/routes/auth/login.tsx` | Login form + POST handler (initiates OAuth) | 116 + | `app/routes/auth/callback.tsx` | OAuth callback (token exchange, user upsert, cookie) | 117 + | `app/routes/auth/signout.ts` | Clears session cookie | 118 + | `app/routes/dashboard/_middleware.ts` | Auth guard for dashboard routes | 119 + | `vite.config.ts` | PDS proxy plugin for local dev |
+9 -7
docs/sqlite-bun-compat.md
··· 67 67 The obvious fix would be to tell Vite to externalize `bun:sqlite`: 68 68 69 69 ```ts 70 - ssr: { external: ["bun:sqlite"] } 70 + ssr: { 71 + external: ["bun:sqlite"]; 72 + } 71 73 ``` 72 74 73 75 This doesn't work because HonoX's Vite plugin unconditionally sets `ssr: { noExternal: true }`, which overrides any `external` setting. Attempts to override this via `configResolved` also failed — the config is effectively locked by HonoX. ··· 89 91 90 92 ## Key files 91 93 92 - | File | Role | 93 - |---|---| 94 - | `lib/db/sqlite-compat.ts` | `createRequire()` shim for `better-sqlite3` | 95 - | `lib/db/index.ts` | Database connection (imports `bun:sqlite`, aliased in dev) | 96 - | `vite.config.ts` | Aliases and PDS proxy plugin | 97 - | `lib/config.ts` | `.env` loader for Node SSR context | 94 + | File | Role | 95 + | ------------------------- | ---------------------------------------------------------- | 96 + | `lib/db/sqlite-compat.ts` | `createRequire()` shim for `better-sqlite3` | 97 + | `lib/db/index.ts` | Database connection (imports `bun:sqlite`, aliased in dev) | 98 + | `vite.config.ts` | Aliases and PDS proxy plugin | 99 + | `lib/config.ts` | `.env` loader for Node SSR context |
+19 -2
lexicons/app/rglw/subscription.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["lexicon", "callbackUrl", "createdAt"], 11 + "required": ["lexicon", "createdAt"], 12 12 "properties": { 13 13 "lexicon": { 14 14 "type": "string", 15 15 "description": "NSID of the collection to subscribe to.", 16 16 "maxLength": 256 17 17 }, 18 + "type": { 19 + "type": "string", 20 + "description": "Subscription type: 'webhook' delivers via HTTP POST, 'record' creates a record on the subscriber's PDS.", 21 + "knownValues": ["webhook", "record"], 22 + "default": "webhook", 23 + "maxLength": 32 24 + }, 18 25 "callbackUrl": { 19 26 "type": "string", 20 27 "format": "uri", 21 - "description": "URL to receive webhook POST requests.", 28 + "description": "For webhook subscriptions: URL to receive webhook POST requests.", 22 29 "maxLength": 2048 30 + }, 31 + "targetCollection": { 32 + "type": "string", 33 + "description": "For record subscriptions: NSID of the collection to create the record in.", 34 + "maxLength": 256 35 + }, 36 + "recordTemplate": { 37 + "type": "string", 38 + "description": "For record subscriptions: JSON template with {{placeholder}} expressions resolved from event data.", 39 + "maxLength": 10240 23 40 }, 24 41 "conditions": { 25 42 "type": "array",
+113
lib/actions/executor.ts
··· 1 + import { db } from "../db/index.js"; 2 + import { deliveryLogs } from "../db/schema.js"; 3 + import { createArbitraryRecord } from "../subscriptions/pds.js"; 4 + import { renderTemplate } from "./template.js"; 5 + import type { MatchedEvent } from "../jetstream/consumer.js"; 6 + 7 + const RETRY_DELAYS = [5_000, 30_000]; 8 + 9 + async function execute(match: MatchedEvent): Promise<{ statusCode: number; error?: string }> { 10 + const { subscription, event } = match; 11 + 12 + let record: Record<string, unknown>; 13 + try { 14 + record = renderTemplate(subscription.recordTemplate!, event); 15 + } catch (err) { 16 + return { 17 + statusCode: 0, 18 + error: `Template error: ${err instanceof Error ? err.message : String(err)}`, 19 + }; 20 + } 21 + 22 + try { 23 + await createArbitraryRecord(subscription.did, subscription.targetCollection!, record); 24 + return { statusCode: 200 }; 25 + } catch (err) { 26 + const message = err instanceof Error ? err.message : String(err); 27 + // Extract HTTP status from PDS error message if available 28 + const statusMatch = message.match(/\((\d{3})\)/); 29 + const statusCode = statusMatch ? Number(statusMatch[1]) : 0; 30 + return { statusCode, error: message }; 31 + } 32 + } 33 + 34 + async function logDelivery( 35 + subscriptionUri: string, 36 + eventTimeUs: number, 37 + payload: string | null, 38 + statusCode: number, 39 + error: string | null, 40 + attempt: number, 41 + ) { 42 + await db.insert(deliveryLogs).values({ 43 + subscriptionUri, 44 + eventTimeUs, 45 + payload, 46 + statusCode, 47 + error, 48 + attempt, 49 + createdAt: new Date(), 50 + }); 51 + } 52 + 53 + function isSuccess(code: number): boolean { 54 + return code === 200; 55 + } 56 + 57 + function isRetryable(code: number): boolean { 58 + // Retry on server errors or network failures 59 + // Do NOT retry on 401/403 (auth) or template errors (deterministic) 60 + return code >= 500 || code === 0; 61 + } 62 + 63 + function scheduleRetry(match: MatchedEvent, retryIndex: number) { 64 + if (retryIndex >= RETRY_DELAYS.length) return; 65 + 66 + setTimeout(async () => { 67 + try { 68 + const result = await execute(match); 69 + const body = JSON.stringify({ 70 + targetCollection: match.subscription.targetCollection, 71 + recordTemplate: match.subscription.recordTemplate, 72 + }); 73 + 74 + await logDelivery( 75 + match.subscription.uri, 76 + match.event.time_us, 77 + isSuccess(result.statusCode) ? null : body, 78 + result.statusCode, 79 + result.error ?? null, 80 + retryIndex + 2, 81 + ); 82 + 83 + if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) { 84 + scheduleRetry(match, retryIndex + 1); 85 + } 86 + } catch (err) { 87 + console.error("Action retry error:", err); 88 + } 89 + }, RETRY_DELAYS[retryIndex]); 90 + } 91 + 92 + /** Execute a record action for a matched event. */ 93 + export async function executeAction(match: MatchedEvent) { 94 + const result = await execute(match); 95 + 96 + const body = JSON.stringify({ 97 + targetCollection: match.subscription.targetCollection, 98 + recordTemplate: match.subscription.recordTemplate, 99 + }); 100 + 101 + await logDelivery( 102 + match.subscription.uri, 103 + match.event.time_us, 104 + isSuccess(result.statusCode) ? null : body, 105 + result.statusCode, 106 + result.error ?? null, 107 + 1, 108 + ); 109 + 110 + if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) { 111 + scheduleRetry(match, 0); 112 + } 113 + }
+91
lib/actions/template.ts
··· 1 + import type { JetstreamEvent } from "../jetstream/matcher.js"; 2 + 3 + const PLACEHOLDER_RE = /\{\{([^}]+)\}\}/g; 4 + 5 + /** 6 + * Resolve a placeholder path against a Jetstream event. 7 + * Supports: 8 + * - "now" → current ISO datetime 9 + * - "event.did", "event.time_us", "event.kind" 10 + * - "event.commit.operation", "event.commit.collection", "event.commit.rkey", "event.commit.cid" 11 + * - "event.commit.record.<dotted.path>" → nested record field 12 + */ 13 + function resolvePlaceholder(path: string, event: JetstreamEvent): unknown { 14 + if (path === "now") return new Date().toISOString(); 15 + 16 + if (!path.startsWith("event.")) return undefined; 17 + const rest = path.slice("event.".length); 18 + 19 + // Walk dot path into event 20 + let value: unknown = event; 21 + for (const key of rest.split(".")) { 22 + if (value == null || typeof value !== "object") return undefined; 23 + value = (value as Record<string, unknown>)[key]; 24 + } 25 + return value; 26 + } 27 + 28 + /** Validate template syntax at creation time. */ 29 + export function validateTemplate( 30 + template: string, 31 + ): { valid: true; placeholders: string[] } | { valid: false; error: string } { 32 + // Check that the template is valid JSON (ignoring placeholders) 33 + const placeholders: string[] = []; 34 + const stripped = template.replace(PLACEHOLDER_RE, (_, path: string) => { 35 + placeholders.push(path.trim()); 36 + return '"__placeholder__"'; 37 + }); 38 + 39 + try { 40 + const parsed = JSON.parse(stripped); 41 + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { 42 + return { valid: false, error: "Template must be a JSON object" }; 43 + } 44 + } catch { 45 + return { valid: false, error: "Template is not valid JSON" }; 46 + } 47 + 48 + if (placeholders.length === 0) { 49 + return { valid: false, error: "Template must contain at least one {{placeholder}}" }; 50 + } 51 + 52 + for (const p of placeholders) { 53 + if (p !== "now" && !p.startsWith("event.")) { 54 + return { valid: false, error: `Invalid placeholder: {{${p}}}` }; 55 + } 56 + } 57 + 58 + return { valid: true, placeholders }; 59 + } 60 + 61 + /** Render a template by resolving all {{placeholder}} expressions against event data. */ 62 + export function renderTemplate(template: string, event: JetstreamEvent): Record<string, unknown> { 63 + const rendered = template.replace(PLACEHOLDER_RE, (match, path: string) => { 64 + const value = resolvePlaceholder(path.trim(), event); 65 + if (value === undefined) return ""; 66 + 67 + // If the placeholder is the entire JSON value (between quotes), return raw 68 + // Otherwise return as string for interpolation within a larger string 69 + if (typeof value === "string") { 70 + // Escape for JSON string context 71 + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); 72 + } 73 + if (typeof value === "number" || typeof value === "boolean") { 74 + return String(value); 75 + } 76 + // For objects/arrays, stringify (without outer quotes) 77 + return JSON.stringify(value); 78 + }); 79 + 80 + try { 81 + const parsed = JSON.parse(rendered); 82 + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { 83 + throw new Error("Rendered template is not a JSON object"); 84 + } 85 + return parsed as Record<string, unknown>; 86 + } catch (err) { 87 + throw new Error( 88 + `Failed to parse rendered template: ${err instanceof Error ? err.message : String(err)}`, 89 + ); 90 + } 91 + }
+2 -3
lib/auth/client.ts
··· 6 6 // Load via require() — HonoX forces Vite to ESM-transform all node_modules 7 7 // during SSR, which breaks CJS packages. require() bypasses Vite's transform. 8 8 const require = createRequire(import.meta.url); 9 - const { JoseKey, NodeOAuthClient, requestLocalLock } = require( 10 - "@atproto/oauth-client-node", 11 - ) as typeof import("@atproto/oauth-client-node"); 9 + const { JoseKey, NodeOAuthClient, requestLocalLock } = 10 + require("@atproto/oauth-client-node") as typeof import("@atproto/oauth-client-node"); 12 11 import { config } from "../config.js"; 13 12 import { sessionStore, stateStore } from "./storage.js"; 14 13
+1 -1
lib/config.ts
··· 2 2 3 3 // Bun auto-loads .env, but Vite SSR runs on Node which doesn't. 4 4 // Load missing vars so config works in both contexts. 5 - if (typeof globalThis.Bun === "undefined" && existsSync(".env")) { 5 + if (!("Bun" in globalThis) && existsSync(".env")) { 6 6 for (const line of readFileSync(".env", "utf-8").split("\n")) { 7 7 const m = line.match(/^([^#=\s]+)=(.*)$/); 8 8 if (m?.[1] && !(m[1] in process.env)) process.env[m[1]] = m[2];
+20
lib/db/migrations/0001_early_kronos.sql
··· 1 + PRAGMA foreign_keys=OFF;--> statement-breakpoint 2 + CREATE TABLE `__new_subscriptions` ( 3 + `uri` text PRIMARY KEY NOT NULL, 4 + `did` text NOT NULL, 5 + `rkey` text NOT NULL, 6 + `type` text DEFAULT 'webhook' NOT NULL, 7 + `lexicon` text NOT NULL, 8 + `callback_url` text, 9 + `conditions` text DEFAULT '[]' NOT NULL, 10 + `secret` text, 11 + `target_collection` text, 12 + `record_template` text, 13 + `active` integer DEFAULT false NOT NULL, 14 + `indexed_at` integer NOT NULL 15 + ); 16 + --> statement-breakpoint 17 + INSERT INTO `__new_subscriptions`("uri", "did", "rkey", "type", "lexicon", "callback_url", "conditions", "secret", "target_collection", "record_template", "active", "indexed_at") SELECT "uri", "did", "rkey", "type", "lexicon", "callback_url", "conditions", "secret", "target_collection", "record_template", "active", "indexed_at" FROM `subscriptions`;--> statement-breakpoint 18 + DROP TABLE `subscriptions`;--> statement-breakpoint 19 + ALTER TABLE `__new_subscriptions` RENAME TO `subscriptions`;--> statement-breakpoint 20 + PRAGMA foreign_keys=ON;
+329
lib/db/migrations/meta/0001_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "9fe2d7e1-920a-4929-981d-99c94549c02e", 5 + "prevId": "4cb923a7-52a2-4515-861e-3ec2feefbeb3", 6 + "tables": { 7 + "delivery_logs": { 8 + "name": "delivery_logs", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "integer", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": true 16 + }, 17 + "subscription_uri": { 18 + "name": "subscription_uri", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "event_time_us": { 25 + "name": "event_time_us", 26 + "type": "integer", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "payload": { 32 + "name": "payload", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": false, 36 + "autoincrement": false 37 + }, 38 + "status_code": { 39 + "name": "status_code", 40 + "type": "integer", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "error": { 46 + "name": "error", 47 + "type": "text", 48 + "primaryKey": false, 49 + "notNull": false, 50 + "autoincrement": false 51 + }, 52 + "attempt": { 53 + "name": "attempt", 54 + "type": "integer", 55 + "primaryKey": false, 56 + "notNull": true, 57 + "autoincrement": false, 58 + "default": 1 59 + }, 60 + "created_at": { 61 + "name": "created_at", 62 + "type": "integer", 63 + "primaryKey": false, 64 + "notNull": true, 65 + "autoincrement": false 66 + } 67 + }, 68 + "indexes": {}, 69 + "foreignKeys": { 70 + "delivery_logs_subscription_uri_subscriptions_uri_fk": { 71 + "name": "delivery_logs_subscription_uri_subscriptions_uri_fk", 72 + "tableFrom": "delivery_logs", 73 + "tableTo": "subscriptions", 74 + "columnsFrom": ["subscription_uri"], 75 + "columnsTo": ["uri"], 76 + "onDelete": "cascade", 77 + "onUpdate": "no action" 78 + } 79 + }, 80 + "compositePrimaryKeys": {}, 81 + "uniqueConstraints": {}, 82 + "checkConstraints": {} 83 + }, 84 + "lexicon_cache": { 85 + "name": "lexicon_cache", 86 + "columns": { 87 + "nsid": { 88 + "name": "nsid", 89 + "type": "text", 90 + "primaryKey": true, 91 + "notNull": true, 92 + "autoincrement": false 93 + }, 94 + "schema": { 95 + "name": "schema", 96 + "type": "text", 97 + "primaryKey": false, 98 + "notNull": true, 99 + "autoincrement": false 100 + }, 101 + "fetched_at": { 102 + "name": "fetched_at", 103 + "type": "integer", 104 + "primaryKey": false, 105 + "notNull": true, 106 + "autoincrement": false 107 + } 108 + }, 109 + "indexes": {}, 110 + "foreignKeys": {}, 111 + "compositePrimaryKeys": {}, 112 + "uniqueConstraints": {}, 113 + "checkConstraints": {} 114 + }, 115 + "oauth_sessions": { 116 + "name": "oauth_sessions", 117 + "columns": { 118 + "key": { 119 + "name": "key", 120 + "type": "text", 121 + "primaryKey": true, 122 + "notNull": true, 123 + "autoincrement": false 124 + }, 125 + "value": { 126 + "name": "value", 127 + "type": "text", 128 + "primaryKey": false, 129 + "notNull": true, 130 + "autoincrement": false 131 + }, 132 + "expires_at": { 133 + "name": "expires_at", 134 + "type": "integer", 135 + "primaryKey": false, 136 + "notNull": false, 137 + "autoincrement": false 138 + } 139 + }, 140 + "indexes": {}, 141 + "foreignKeys": {}, 142 + "compositePrimaryKeys": {}, 143 + "uniqueConstraints": {}, 144 + "checkConstraints": {} 145 + }, 146 + "oauth_states": { 147 + "name": "oauth_states", 148 + "columns": { 149 + "key": { 150 + "name": "key", 151 + "type": "text", 152 + "primaryKey": true, 153 + "notNull": true, 154 + "autoincrement": false 155 + }, 156 + "value": { 157 + "name": "value", 158 + "type": "text", 159 + "primaryKey": false, 160 + "notNull": true, 161 + "autoincrement": false 162 + }, 163 + "expires_at": { 164 + "name": "expires_at", 165 + "type": "integer", 166 + "primaryKey": false, 167 + "notNull": false, 168 + "autoincrement": false 169 + } 170 + }, 171 + "indexes": {}, 172 + "foreignKeys": {}, 173 + "compositePrimaryKeys": {}, 174 + "uniqueConstraints": {}, 175 + "checkConstraints": {} 176 + }, 177 + "subscriptions": { 178 + "name": "subscriptions", 179 + "columns": { 180 + "uri": { 181 + "name": "uri", 182 + "type": "text", 183 + "primaryKey": true, 184 + "notNull": true, 185 + "autoincrement": false 186 + }, 187 + "did": { 188 + "name": "did", 189 + "type": "text", 190 + "primaryKey": false, 191 + "notNull": true, 192 + "autoincrement": false 193 + }, 194 + "rkey": { 195 + "name": "rkey", 196 + "type": "text", 197 + "primaryKey": false, 198 + "notNull": true, 199 + "autoincrement": false 200 + }, 201 + "type": { 202 + "name": "type", 203 + "type": "text", 204 + "primaryKey": false, 205 + "notNull": true, 206 + "autoincrement": false, 207 + "default": "'webhook'" 208 + }, 209 + "lexicon": { 210 + "name": "lexicon", 211 + "type": "text", 212 + "primaryKey": false, 213 + "notNull": true, 214 + "autoincrement": false 215 + }, 216 + "callback_url": { 217 + "name": "callback_url", 218 + "type": "text", 219 + "primaryKey": false, 220 + "notNull": false, 221 + "autoincrement": false 222 + }, 223 + "conditions": { 224 + "name": "conditions", 225 + "type": "text", 226 + "primaryKey": false, 227 + "notNull": true, 228 + "autoincrement": false, 229 + "default": "'[]'" 230 + }, 231 + "secret": { 232 + "name": "secret", 233 + "type": "text", 234 + "primaryKey": false, 235 + "notNull": false, 236 + "autoincrement": false 237 + }, 238 + "target_collection": { 239 + "name": "target_collection", 240 + "type": "text", 241 + "primaryKey": false, 242 + "notNull": false, 243 + "autoincrement": false 244 + }, 245 + "record_template": { 246 + "name": "record_template", 247 + "type": "text", 248 + "primaryKey": false, 249 + "notNull": false, 250 + "autoincrement": false 251 + }, 252 + "active": { 253 + "name": "active", 254 + "type": "integer", 255 + "primaryKey": false, 256 + "notNull": true, 257 + "autoincrement": false, 258 + "default": false 259 + }, 260 + "indexed_at": { 261 + "name": "indexed_at", 262 + "type": "integer", 263 + "primaryKey": false, 264 + "notNull": true, 265 + "autoincrement": false 266 + } 267 + }, 268 + "indexes": {}, 269 + "foreignKeys": {}, 270 + "compositePrimaryKeys": {}, 271 + "uniqueConstraints": {}, 272 + "checkConstraints": {} 273 + }, 274 + "users": { 275 + "name": "users", 276 + "columns": { 277 + "id": { 278 + "name": "id", 279 + "type": "integer", 280 + "primaryKey": true, 281 + "notNull": true, 282 + "autoincrement": true 283 + }, 284 + "did": { 285 + "name": "did", 286 + "type": "text", 287 + "primaryKey": false, 288 + "notNull": true, 289 + "autoincrement": false 290 + }, 291 + "handle": { 292 + "name": "handle", 293 + "type": "text", 294 + "primaryKey": false, 295 + "notNull": true, 296 + "autoincrement": false 297 + }, 298 + "created_at": { 299 + "name": "created_at", 300 + "type": "integer", 301 + "primaryKey": false, 302 + "notNull": true, 303 + "autoincrement": false 304 + } 305 + }, 306 + "indexes": { 307 + "users_did_unique": { 308 + "name": "users_did_unique", 309 + "columns": ["did"], 310 + "isUnique": true 311 + } 312 + }, 313 + "foreignKeys": {}, 314 + "compositePrimaryKeys": {}, 315 + "uniqueConstraints": {}, 316 + "checkConstraints": {} 317 + } 318 + }, 319 + "views": {}, 320 + "enums": {}, 321 + "_meta": { 322 + "schemas": {}, 323 + "tables": {}, 324 + "columns": {} 325 + }, 326 + "internal": { 327 + "indexes": {} 328 + } 329 + }
+7
lib/db/migrations/meta/_journal.json
··· 8 8 "when": 1775226502108, 9 9 "tag": "0000_lethal_titania", 10 10 "breakpoints": true 11 + }, 12 + { 13 + "idx": 1, 14 + "version": "6", 15 + "when": 1775479222795, 16 + "tag": "0001_early_kronos", 17 + "breakpoints": true 11 18 } 12 19 ] 13 20 }
+5 -2
lib/db/schema.ts
··· 13 13 uri: text("uri").primaryKey(), // at://did/app.rglw.subscription/rkey 14 14 did: text("did").notNull(), 15 15 rkey: text("rkey").notNull(), 16 + type: text("type").notNull().default("webhook"), // "webhook" | "record" 16 17 lexicon: text("lexicon").notNull(), // NSID being watched 17 - callbackUrl: text("callback_url").notNull(), 18 + callbackUrl: text("callback_url"), // webhook only 18 19 conditions: text("conditions", { mode: "json" }) 19 20 .notNull() 20 21 .$type<Array<{ field: string; operator: string; value: string }>>() 21 22 .default([]), 22 - secret: text("secret").notNull(), // HMAC secret, instance-local only 23 + secret: text("secret"), // HMAC secret, instance-local, webhook only 24 + targetCollection: text("target_collection"), // record only — NSID to create 25 + recordTemplate: text("record_template"), // record only — JSON template 23 26 active: integer("active", { mode: "boolean" }).notNull().default(false), 24 27 indexedAt: integer("indexed_at", { mode: "timestamp_ms" }).notNull(), 25 28 });
+1
lib/env.d.ts
··· 1 + /// <reference types="bun" />
+4 -13
lib/jetstream/matcher.ts
··· 25 25 * - "repo" → event.did 26 26 * - "record.foo.bar" → event.commit.record.foo.bar 27 27 */ 28 - function resolveField( 29 - event: JetstreamEvent, 30 - field: string, 31 - ): string | undefined { 28 + function resolveField(event: JetstreamEvent, field: string): string | undefined { 32 29 if (field === "repo") return event.did; 33 30 34 31 if (field.startsWith("record.") && event.commit?.record) { ··· 39 36 value = (value as Record<string, unknown>)[key]; 40 37 } 41 38 if (value == null) return undefined; 42 - return typeof value === "string" ? value : String(value); 39 + return typeof value === "string" ? value : JSON.stringify(value); 43 40 } 44 41 45 42 return undefined; 46 43 } 47 44 48 - function evaluateCondition( 49 - event: JetstreamEvent, 50 - condition: Condition, 51 - ): boolean { 45 + function evaluateCondition(event: JetstreamEvent, condition: Condition): boolean { 52 46 const actual = resolveField(event, condition.field); 53 47 if (actual === undefined) return false; 54 48 ··· 64 58 * Check if all conditions match the event. 65 59 * Empty conditions = match all events for that collection. 66 60 */ 67 - export function matchConditions( 68 - event: JetstreamEvent, 69 - conditions: Condition[], 70 - ): boolean { 61 + export function matchConditions(event: JetstreamEvent, conditions: Condition[]): boolean { 71 62 if (conditions.length === 0) return true; 72 63 return conditions.every((cond) => evaluateCondition(event, cond)); 73 64 }
+28
lib/lexicons/cache.ts
··· 30 30 set: { schema: json, fetchedAt: now }, 31 31 }); 32 32 } 33 + 34 + /** Get raw JSON from cache without requiring record-type parsing. */ 35 + export async function getCachedRaw(nsid: string): Promise<Record<string, unknown> | null> { 36 + const row = await db.query.lexiconCache.findFirst({ 37 + where: eq(lexiconCache.nsid, nsid), 38 + }); 39 + if (!row) return null; 40 + if (Date.now() - row.fetchedAt.getTime() > TTL_MS) return null; 41 + 42 + try { 43 + return JSON.parse(row.schema) as Record<string, unknown>; 44 + } catch { 45 + return null; 46 + } 47 + } 48 + 49 + /** Cache raw JSON for any lexicon type (not just records). */ 50 + export async function setCacheRaw(nsid: string, raw: Record<string, unknown>): Promise<void> { 51 + const json = JSON.stringify(raw); 52 + const now = new Date(); 53 + await db 54 + .insert(lexiconCache) 55 + .values({ nsid, schema: json, fetchedAt: now }) 56 + .onConflictDoUpdate({ 57 + target: lexiconCache.nsid, 58 + set: { schema: json, fetchedAt: now }, 59 + }); 60 + }
+82 -46
lib/lexicons/resolver.ts
··· 34 34 * Check whether an NSID is allowed by the instance's allowlist/blocklist. 35 35 * Blocklist takes precedence. Supports glob patterns like "app.bsky.*". 36 36 */ 37 - export function isNsidAllowed( 38 - nsid: string, 39 - allowlist: string[], 40 - blocklist: string[], 41 - ): boolean { 37 + export function isNsidAllowed(nsid: string, allowlist: string[], blocklist: string[]): boolean { 42 38 const matches = (pattern: string) => 43 - pattern.endsWith(".*") 44 - ? nsid.startsWith(pattern.slice(0, -1)) 45 - : nsid === pattern; 39 + pattern.endsWith(".*") ? nsid.startsWith(pattern.slice(0, -1)) : nsid === pattern; 46 40 47 41 if (blocklist.some(matches)) return false; 48 42 if (allowlist.length > 0) return allowlist.some(matches); ··· 95 89 /** 96 90 * Parse a lexicon JSON and extract record fields for the condition builder. 97 91 */ 98 - export function parseLexicon( 99 - nsid: string, 100 - json: Record<string, unknown>, 101 - ): LexiconSchema { 92 + export function parseLexicon(nsid: string, json: Record<string, unknown>): LexiconSchema { 102 93 const defs = json.defs as Record<string, any> | undefined; 103 94 if (!defs?.main || defs.main.type !== "record") { 104 95 throw new Error(`Lexicon ${nsid} has no record definition`); 105 96 } 106 97 107 98 const record = defs.main.record; 108 - const fields = record?.properties 109 - ? extractFields(record.properties, defs, "record") 110 - : []; 99 + const fields = record?.properties ? extractFields(record.properties, defs, "record") : []; 111 100 112 101 return { 113 102 nsid, ··· 117 106 }; 118 107 } 119 108 120 - /** Try to resolve a lexicon from the local lexicons/ directory. */ 121 - export function resolveLocal(nsid: string): LexiconSchema | null { 109 + // --------------------------------------------------------------------------- 110 + // Internal helpers: fetch raw JSON without parsing into LexiconSchema 111 + // --------------------------------------------------------------------------- 112 + 113 + function fetchLocalRaw(nsid: string): Record<string, unknown> | null { 122 114 const path = resolvePath("lexicons", ...nsid.split(".")) + ".json"; 123 115 if (!existsSync(path)) return null; 124 116 try { 125 - return parseLexicon(nsid, JSON.parse(readFileSync(path, "utf-8"))); 117 + return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>; 126 118 } catch { 127 119 return null; 128 120 } 129 121 } 130 122 131 - /** Try to fetch a lexicon from the authority domain via HTTP. */ 132 - export async function resolveRemote( 133 - nsid: string, 134 - ): Promise<LexiconSchema | null> { 123 + async function fetchRemoteRaw(nsid: string): Promise<Record<string, unknown> | null> { 135 124 const authority = nsidToAuthority(nsid); 136 125 const segments = nsid.split("."); 137 126 ··· 151 140 const json = (await res.json()) as Record<string, unknown>; 152 141 if (json.lexicon !== 1 || json.id !== nsid) continue; 153 142 154 - return parseLexicon(nsid, json); 143 + return json; 155 144 } catch { 156 145 continue; 157 146 } ··· 160 149 return null; 161 150 } 162 151 163 - /** 164 - * Resolve a lexicon via the official AT Protocol mechanism: 165 - * 1. DNS TXT lookup at _lexicon.{authority} to find the DID 166 - * 2. DID resolution to find the PDS 167 - * 3. Fetch com.atproto.lexicon.schema record from the PDS 168 - * 169 - * See https://atproto.com/specs/lexicon#lexicon-publication-and-resolution 170 - */ 171 - export async function resolveViaAtproto( 172 - nsid: string, 173 - ): Promise<LexiconSchema | null> { 152 + async function fetchViaAtprotoRaw(nsid: string): Promise<Record<string, unknown> | null> { 174 153 const parts = nsid.split("."); 175 - // Authority = all segments except the last, reversed for DNS 176 154 const authorityParts = parts.slice(0, -1).reverse(); 177 155 const dnsName = `_lexicon.${authorityParts.join(".")}`; 178 156 ··· 195 173 // Step 2: DID resolution → PDS endpoint 196 174 let pdsEndpoint: string | null = null; 197 175 try { 198 - const res = await fetch( 199 - `https://plc.directory/${encodeURIComponent(did)}`, 200 - { signal: AbortSignal.timeout(10_000) }, 201 - ); 176 + const res = await fetch(`https://plc.directory/${encodeURIComponent(did)}`, { 177 + signal: AbortSignal.timeout(10_000), 178 + }); 202 179 if (!res.ok) return null; 203 180 const doc = (await res.json()) as { 204 181 service?: Array<{ id: string; serviceEndpoint: string }>; 205 182 }; 206 - pdsEndpoint = 207 - doc.service?.find((s) => s.id === "#atproto_pds")?.serviceEndpoint ?? 208 - null; 183 + pdsEndpoint = doc.service?.find((s) => s.id === "#atproto_pds")?.serviceEndpoint ?? null; 209 184 } catch { 210 185 return null; 211 186 } ··· 230 205 const record = data.value; 231 206 if (!record || record.id !== nsid) return null; 232 207 233 - return parseLexicon(nsid, record); 208 + return record; 209 + } catch { 210 + return null; 211 + } 212 + } 213 + 214 + // --------------------------------------------------------------------------- 215 + // Public: resolve to LexiconSchema (parsed, record-type only) 216 + // --------------------------------------------------------------------------- 217 + 218 + /** Try to resolve a lexicon from the local lexicons/ directory. */ 219 + export function resolveLocal(nsid: string): LexiconSchema | null { 220 + const raw = fetchLocalRaw(nsid); 221 + if (!raw) return null; 222 + try { 223 + return parseLexicon(nsid, raw); 224 + } catch { 225 + return null; 226 + } 227 + } 228 + 229 + /** Try to fetch a lexicon from the authority domain via HTTP. */ 230 + export async function resolveRemote(nsid: string): Promise<LexiconSchema | null> { 231 + const raw = await fetchRemoteRaw(nsid); 232 + if (!raw) return null; 233 + try { 234 + return parseLexicon(nsid, raw); 235 + } catch { 236 + return null; 237 + } 238 + } 239 + 240 + /** 241 + * Resolve a lexicon via the official AT Protocol mechanism: 242 + * 1. DNS TXT lookup at _lexicon.{authority} to find the DID 243 + * 2. DID resolution to find the PDS 244 + * 3. Fetch com.atproto.lexicon.schema record from the PDS 245 + * 246 + * See https://atproto.com/specs/lexicon#lexicon-publication-and-resolution 247 + */ 248 + export async function resolveViaAtproto(nsid: string): Promise<LexiconSchema | null> { 249 + const raw = await fetchViaAtprotoRaw(nsid); 250 + if (!raw) return null; 251 + try { 252 + return parseLexicon(nsid, raw); 234 253 } catch { 235 254 return null; 236 255 } ··· 240 259 * Resolve a lexicon by NSID. 241 260 * Tries: local files → AT Protocol DNS resolution → HTTP authority domain. 242 261 */ 243 - export async function resolve( 244 - nsid: string, 245 - ): Promise<LexiconSchema | null> { 262 + export async function resolve(nsid: string): Promise<LexiconSchema | null> { 246 263 const local = resolveLocal(nsid); 247 264 if (local) return local; 248 265 ··· 251 268 252 269 return resolveRemote(nsid); 253 270 } 271 + 272 + // --------------------------------------------------------------------------- 273 + // Public: resolve to raw JSON (any lexicon type, no record-type check) 274 + // --------------------------------------------------------------------------- 275 + 276 + /** 277 + * Resolve a lexicon by NSID and return the raw JSON. 278 + * Unlike `resolve()`, this works for any lexicon type (object, token, etc.), 279 + * not just record types. Used by the schema tree builder for external refs. 280 + */ 281 + export async function resolveRaw(nsid: string): Promise<Record<string, unknown> | null> { 282 + const local = fetchLocalRaw(nsid); 283 + if (local) return local; 284 + 285 + const atproto = await fetchViaAtprotoRaw(nsid); 286 + if (atproto) return atproto; 287 + 288 + return fetchRemoteRaw(nsid); 289 + }
+210
lib/lexicons/schema-tree.ts
··· 1 + import { getCachedRaw, setCacheRaw } from "./cache.js"; 2 + import { resolveRaw } from "./resolver.js"; 3 + 4 + // --------------------------------------------------------------------------- 5 + // Types 6 + // --------------------------------------------------------------------------- 7 + 8 + export type StringNode = { 9 + type: "string"; 10 + description?: string; 11 + format?: string; 12 + knownValues?: string[]; 13 + maxLength?: number; 14 + minLength?: number; 15 + default?: string; 16 + }; 17 + 18 + export type IntegerNode = { 19 + type: "integer"; 20 + description?: string; 21 + minimum?: number; 22 + maximum?: number; 23 + default?: number; 24 + }; 25 + 26 + export type BooleanNode = { 27 + type: "boolean"; 28 + description?: string; 29 + default?: boolean; 30 + }; 31 + 32 + export type ObjectNode = { 33 + type: "object"; 34 + description?: string; 35 + required: string[]; 36 + properties: Record<string, SchemaNode>; 37 + }; 38 + 39 + export type ArrayNode = { 40 + type: "array"; 41 + description?: string; 42 + items: SchemaNode; 43 + maxLength?: number; 44 + }; 45 + 46 + export type UnknownNode = { 47 + type: "unknown"; 48 + description?: string; 49 + }; 50 + 51 + export type SchemaNode = 52 + | StringNode 53 + | IntegerNode 54 + | BooleanNode 55 + | ObjectNode 56 + | ArrayNode 57 + | UnknownNode; 58 + 59 + export type RecordSchema = { 60 + required: string[]; 61 + properties: Record<string, SchemaNode>; 62 + }; 63 + 64 + // --------------------------------------------------------------------------- 65 + // External ref parsing 66 + // --------------------------------------------------------------------------- 67 + 68 + function parseRef(ref: string): { nsid: string; defName: string } { 69 + const hashIndex = ref.indexOf("#"); 70 + if (hashIndex === -1) return { nsid: ref, defName: "main" }; 71 + return { nsid: ref.slice(0, hashIndex), defName: ref.slice(hashIndex + 1) }; 72 + } 73 + 74 + // --------------------------------------------------------------------------- 75 + // Fetch external lexicon defs (with caching) 76 + // --------------------------------------------------------------------------- 77 + 78 + async function fetchExternalDefs(nsid: string): Promise<Record<string, any> | null> { 79 + const raw = (await getCachedRaw(nsid)) ?? (await resolveRaw(nsid)); 80 + if (!raw) return null; 81 + 82 + // Cache on successful fetch 83 + await setCacheRaw(nsid, raw).catch(() => {}); 84 + 85 + return (raw.defs as Record<string, any>) ?? null; 86 + } 87 + 88 + // --------------------------------------------------------------------------- 89 + // Recursive node builder 90 + // --------------------------------------------------------------------------- 91 + 92 + const MAX_DEPTH = 8; 93 + 94 + async function buildNode( 95 + prop: any, 96 + localDefs: Record<string, any>, 97 + visited: Set<string>, 98 + depth: number, 99 + ): Promise<SchemaNode> { 100 + if (depth > MAX_DEPTH) return { type: "unknown" }; 101 + if (!prop || typeof prop !== "object") return { type: "unknown" }; 102 + 103 + switch (prop.type) { 104 + case "string": { 105 + const node: StringNode = { type: "string" }; 106 + if (prop.description) node.description = prop.description; 107 + if (prop.format) node.format = prop.format; 108 + if (prop.knownValues) node.knownValues = prop.knownValues; 109 + if (prop.maxLength != null) node.maxLength = prop.maxLength; 110 + if (prop.minLength != null) node.minLength = prop.minLength; 111 + if (prop.default != null) node.default = prop.default; 112 + return node; 113 + } 114 + 115 + case "integer": { 116 + const node: IntegerNode = { type: "integer" }; 117 + if (prop.description) node.description = prop.description; 118 + if (prop.minimum != null) node.minimum = prop.minimum; 119 + if (prop.maximum != null) node.maximum = prop.maximum; 120 + if (prop.default != null) node.default = prop.default; 121 + return node; 122 + } 123 + 124 + case "boolean": { 125 + const node: BooleanNode = { type: "boolean" }; 126 + if (prop.description) node.description = prop.description; 127 + if (prop.default != null) node.default = prop.default; 128 + return node; 129 + } 130 + 131 + case "object": { 132 + const required = (prop.required as string[]) ?? []; 133 + const properties: Record<string, SchemaNode> = {}; 134 + if (prop.properties) { 135 + for (const [key, childProp] of Object.entries(prop.properties as Record<string, any>)) { 136 + properties[key] = await buildNode(childProp, localDefs, visited, depth + 1); 137 + } 138 + } 139 + const node: ObjectNode = { type: "object", required, properties }; 140 + if (prop.description) node.description = prop.description; 141 + return node; 142 + } 143 + 144 + case "array": { 145 + if (!prop.items) return { type: "unknown", description: prop.description }; 146 + const items = await buildNode(prop.items, localDefs, visited, depth + 1); 147 + const node: ArrayNode = { type: "array", items }; 148 + if (prop.description) node.description = prop.description; 149 + if (prop.maxLength != null) node.maxLength = prop.maxLength; 150 + return node; 151 + } 152 + 153 + case "ref": { 154 + const ref = prop.ref as string | undefined; 155 + if (!ref) return { type: "unknown" }; 156 + 157 + if (visited.has(ref)) return { type: "unknown" }; 158 + visited.add(ref); 159 + 160 + // Local ref: #defName 161 + if (ref.startsWith("#")) { 162 + const def = localDefs[ref.slice(1)]; 163 + if (!def) return { type: "unknown" }; 164 + return buildNode(def, localDefs, visited, depth + 1); 165 + } 166 + 167 + // External ref: nsid#defName or just nsid (→ main) 168 + const { nsid, defName } = parseRef(ref); 169 + const externalDefs = await fetchExternalDefs(nsid); 170 + if (!externalDefs) return { type: "unknown" }; 171 + 172 + const def = externalDefs[defName]; 173 + if (!def) return { type: "unknown" }; 174 + 175 + // Use the external lexicon's defs for local ref resolution within it 176 + return buildNode(def, externalDefs, visited, depth + 1); 177 + } 178 + 179 + default: 180 + return { type: "unknown", description: prop.description }; 181 + } 182 + } 183 + 184 + // --------------------------------------------------------------------------- 185 + // Public API 186 + // --------------------------------------------------------------------------- 187 + 188 + /** 189 + * Build a form-friendly schema tree from a raw lexicon JSON. 190 + * Resolves local and external refs recursively. 191 + */ 192 + export async function buildRecordSchema( 193 + raw: Record<string, unknown>, 194 + ): Promise<RecordSchema | null> { 195 + const defs = raw.defs as Record<string, any> | undefined; 196 + if (!defs?.main || defs.main.type !== "record") return null; 197 + 198 + const record = defs.main.record; 199 + if (!record?.properties) return null; 200 + 201 + const required = (record.required as string[]) ?? []; 202 + const visited = new Set<string>(); 203 + const properties: Record<string, SchemaNode> = {}; 204 + 205 + for (const [key, prop] of Object.entries(record.properties as Record<string, any>)) { 206 + properties[key] = await buildNode(prop, defs, visited, 0); 207 + } 208 + 209 + return { required, properties }; 210 + }
+29 -6
lib/subscriptions/pds.ts
··· 16 16 return s.padStart(13, "2"); 17 17 } 18 18 19 - type SubscriptionRecord = { 19 + type BaseSubscriptionRecord = { 20 20 lexicon: string; 21 - callbackUrl: string; 22 21 conditions: Array<{ field: string; operator: string; value: string }>; 23 22 active: boolean; 24 23 createdAt: string; 25 24 }; 25 + 26 + type WebhookSubscriptionRecord = BaseSubscriptionRecord & { 27 + type?: "webhook"; 28 + callbackUrl: string; 29 + }; 30 + 31 + type RecordSubscriptionRecord = BaseSubscriptionRecord & { 32 + type: "record"; 33 + targetCollection: string; 34 + recordTemplate: string; 35 + }; 36 + 37 + type SubscriptionRecord = WebhookSubscriptionRecord | RecordSubscriptionRecord; 26 38 27 39 async function pdsCall( 28 40 did: string, ··· 89 101 }); 90 102 } 91 103 92 - export async function deleteRecord( 93 - did: string, 94 - rkey: string, 95 - ): Promise<void> { 104 + export async function deleteRecord(did: string, rkey: string): Promise<void> { 96 105 await pdsCall(did, "com.atproto.repo.deleteRecord", { 97 106 repo: did, 98 107 collection: COLLECTION, 99 108 rkey, 100 109 }); 101 110 } 111 + 112 + /** Create a record in any collection on the user's PDS (for action subscriptions). */ 113 + export async function createArbitraryRecord( 114 + did: string, 115 + collection: string, 116 + record: Record<string, unknown>, 117 + ): Promise<{ uri: string; cid: string }> { 118 + const data = await pdsCall(did, "com.atproto.repo.createRecord", { 119 + repo: did, 120 + collection, 121 + record: { $type: collection, ...record }, 122 + }); 123 + return { uri: data.uri as string, cid: data.cid as string }; 124 + }
+5 -12
lib/webhooks/dispatcher.ts
··· 124 124 ); 125 125 126 126 if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) { 127 - scheduleRetry( 128 - subscriptionUri, 129 - callbackUrl, 130 - secret, 131 - eventTimeUs, 132 - body, 133 - retryIndex + 1, 134 - ); 127 + scheduleRetry(subscriptionUri, callbackUrl, secret, eventTimeUs, body, retryIndex + 1); 135 128 } 136 129 } catch (err) { 137 130 console.error("Webhook retry error:", err); ··· 146 139 const body = JSON.stringify(payload); 147 140 148 141 const result = await deliver( 149 - subscription.callbackUrl, 142 + subscription.callbackUrl!, 150 143 body, 151 - subscription.secret, 144 + subscription.secret!, 152 145 subscription.uri, 153 146 ); 154 147 ··· 165 158 if (!isSuccess(result.statusCode) && isRetryable(result.statusCode)) { 166 159 scheduleRetry( 167 160 subscription.uri, 168 - subscription.callbackUrl, 169 - subscription.secret, 161 + subscription.callbackUrl!, 162 + subscription.secret!, 170 163 event.time_us, 171 164 body, 172 165 0,
+3 -4
vite.config.ts
··· 2 2 import { request as httpRequest } from "node:http"; 3 3 import honox from "honox/vite"; 4 4 import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; 5 - import { defineConfig, type Plugin, type ViteDevServer } from "vite"; 5 + import { defineConfig } from "vite-plus"; 6 + import type { Plugin, ViteDevServer } from "vite"; 6 7 7 8 // Collect all CSS from the Vite module graph and serve at /__dev.css 8 9 // This allows a blocking <link> tag in dev mode to prevent FOUC ··· 24 25 if (!result?.code) continue; 25 26 // Vite wraps CSS as: const __vite__css = "...css..." 26 27 // or similar patterns. Extract everything between the first ` = "` and the closing `"` 27 - const match = result.code.match( 28 - /(?:__vite__css|css)\s*=\s*"((?:[^"\\]|\\.)*)"/s, 29 - ); 28 + const match = result.code.match(/(?:__vite__css|css)\s*=\s*"((?:[^"\\]|\\.)*)"/s); 30 29 if (match?.[1]) { 31 30 // Unescape the JS string 32 31 const css = match[1]