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: improve subscription creation with notes

Hugo 55e94998 e0796a3b

+386 -458
+2 -1
app/components/CodeBlock/styles.css.ts
··· 13 13 paddingInline: space[4], 14 14 borderRadius: radii.md, 15 15 overflowX: "auto", 16 - whiteSpace: "pre", 16 + whiteSpace: "pre-wrap", 17 + wordBreak: "break-all", 17 18 border: `1px solid ${vars.color.borderSubtle}`, 18 19 }); 19 20
+8 -1
app/islands/SubscriptionForm.css.ts
··· 84 84 backgroundColor: vars.color.bg, 85 85 }); 86 86 87 + export const conditionBlock = style({ 88 + display: "flex", 89 + flexDirection: "column", 90 + gap: space[1], 91 + }); 92 + 87 93 export const conditionRow = style({ 88 94 display: "flex", 89 95 gap: space[2], ··· 183 189 184 190 export const textarea = style({ 185 191 width: "100%", 186 - minBlockSize: "120px", 192 + minBlockSize: "64px", 187 193 paddingBlock: space[2], 188 194 paddingInline: space[3], 189 195 fontSize: fontSize.sm, ··· 322 328 }); 323 329 324 330 export const placeholderCode = style({ 331 + display: "inline-block", 325 332 cursor: "pointer", 326 333 whiteSpace: "nowrap", 327 334 borderRadius: radii.sm,
+184 -100
app/islands/SubscriptionForm.tsx
··· 13 13 field: string; 14 14 operator: string; 15 15 value: string; 16 + comment: string; 16 17 }; 17 18 18 - type FetchDraft = { name: string; uri: string }; 19 + type FetchDraft = { name: string; uri: string; comment: string }; 19 20 20 - type WebhookDraft = { type: "webhook"; callbackUrl: string }; 21 - type RecordDraft = { type: "record"; targetCollection: string; recordTemplate: string }; 21 + type WebhookDraft = { type: "webhook"; callbackUrl: string; comment: string }; 22 + type RecordDraft = { 23 + type: "record"; 24 + targetCollection: string; 25 + recordTemplate: string; 26 + comment: string; 27 + }; 22 28 type ActionDraft = WebhookDraft | RecordDraft; 23 29 24 30 const NSID_RE = /^[a-z][a-z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/; ··· 225 231 226 232 function CopyPlaceholder({ value, children }: { value: string; children?: unknown }) { 227 233 const [copied, setCopied] = useState(false); 234 + const placeholder = `{{${value}}}`; 228 235 const copy = useCallback(() => { 229 - void navigator.clipboard.writeText(value).then(() => { 236 + void navigator.clipboard.writeText(placeholder).then(() => { 230 237 setCopied(true); 231 238 setTimeout(() => setCopied(false), 1200); 232 239 }); ··· 239 246 class={s.placeholderCode} 240 247 onClick={copy} 241 248 title="Click to copy" 249 + style={{ minWidth: `${placeholder.length}ch` }} 242 250 {...(copied ? { "data-copied": "" } : {})} 243 251 > 244 - {copied ? "Copied!" : `{{${value}}}`} 252 + {copied ? "Copied!" : placeholder} 245 253 </span> 246 254 {children} 247 255 </div> ··· 254 262 255 263 export default function SubscriptionForm() { 256 264 const initial = getInitialParam("lexicon"); 265 + const [name, setName] = useState(""); 266 + const [description, setDescription] = useState(""); 257 267 const [lexicon, setLexicon] = useState(initial); 258 268 const [fields, setFields] = useState<Field[]>([]); 259 269 const [fieldsLoading, setFieldsLoading] = useState(false); ··· 339 349 } 340 350 341 351 const addCondition = useCallback(() => { 342 - setConditions((prev) => [...prev, { field: "", operator: "eq", value: "" }]); 352 + setConditions((prev) => [...prev, { field: "", operator: "eq", value: "", comment: "" }]); 343 353 }, []); 344 354 345 355 const removeCondition = useCallback((index: number) => { ··· 347 357 }, []); 348 358 349 359 const updateCondition = useCallback( 350 - (index: number, key: "field" | "operator" | "value", val: string) => { 360 + (index: number, key: "field" | "operator" | "value" | "comment", val: string) => { 351 361 setConditions((prev) => prev.map((c, i) => (i === index ? { ...c, [key]: val } : c))); 352 362 }, 353 363 [], 354 364 ); 355 365 356 366 const addFetch = useCallback(() => { 357 - setFetches((prev) => [...prev, { name: "", uri: "" }]); 367 + setFetches((prev) => [...prev, { name: "", uri: "", comment: "" }]); 358 368 }, []); 359 369 360 370 const removeFetch = useCallback((index: number) => { 361 371 setFetches((prev) => prev.filter((_, i) => i !== index)); 362 372 }, []); 363 373 364 - const updateFetch = useCallback((index: number, key: "name" | "uri", val: string) => { 374 + const updateFetch = useCallback((index: number, key: "name" | "uri" | "comment", val: string) => { 365 375 setFetches((prev) => prev.map((f, i) => (i === index ? { ...f, [key]: val } : f))); 366 376 }, []); 367 377 368 378 const addAction = useCallback((type: "webhook" | "record") => { 369 379 if (type === "webhook") { 370 - setActions((prev) => [...prev, { type: "webhook", callbackUrl: "" }]); 380 + setActions((prev) => [...prev, { type: "webhook", callbackUrl: "", comment: "" }]); 371 381 } else { 372 - setActions((prev) => [...prev, { type: "record", targetCollection: "", recordTemplate: "" }]); 382 + setActions((prev) => [ 383 + ...prev, 384 + { type: "record", targetCollection: "", recordTemplate: "", comment: "" }, 385 + ]); 373 386 } 374 387 }, []); 375 388 ··· 382 395 }, []); 383 396 384 397 const previewPayload = useMemo(() => { 385 - const payload: Record<string, unknown> = { lexicon }; 398 + const payload: Record<string, unknown> = { name, lexicon }; 399 + if (description.trim()) payload.description = description.trim(); 386 400 const filteredFetches = fetches.filter((f) => f.name && f.uri); 387 401 if (filteredFetches.length > 0) { 388 - payload.fetches = filteredFetches.map((f) => ({ name: f.name, uri: f.uri })); 402 + payload.fetches = filteredFetches.map((f) => ({ 403 + name: f.name, 404 + uri: f.uri, 405 + ...(f.comment ? { comment: f.comment } : {}), 406 + })); 389 407 } 390 408 const filteredConditions = conditions.filter((c) => c.field && c.value); 391 409 if (filteredConditions.length > 0) { ··· 393 411 field: c.field, 394 412 operator: c.operator, 395 413 value: c.value, 414 + ...(c.comment ? { comment: c.comment } : {}), 396 415 })); 397 416 } 398 417 payload.actions = actions.map((a) => { 399 - if (a.type === "webhook") return { type: "webhook", callbackUrl: a.callbackUrl }; 418 + const comment = a.comment ? { comment: a.comment } : {}; 419 + if (a.type === "webhook") return { type: "webhook", callbackUrl: a.callbackUrl, ...comment }; 400 420 return { 401 421 type: "record", 402 422 targetCollection: a.targetCollection, 403 423 recordTemplate: a.recordTemplate, 424 + ...comment, 404 425 }; 405 426 }); 406 427 return JSON.stringify(payload, null, 2); 407 - }, [lexicon, fetches, conditions, actions]); 428 + }, [name, description, lexicon, fetches, conditions, actions]); 408 429 409 430 const handleSubmit = useCallback( 410 431 async (e: Event) => { ··· 447 468 <fieldset disabled={submitting} class={s.resetFieldset}> 448 469 <div class={s.form}> 449 470 <div class={s.fieldGroup}> 471 + <label class={s.label} for="sub-name"> 472 + Name 473 + </label> 474 + <input 475 + id="sub-name" 476 + class={s.input} 477 + type="text" 478 + placeholder="e.g. Forward likes to my server" 479 + value={name} 480 + onInput={(e: Event) => setName((e.target as HTMLInputElement).value)} 481 + required 482 + /> 483 + </div> 484 + 485 + <div class={s.fieldGroup}> 486 + <label class={s.label} for="sub-description"> 487 + Description <span class={s.hint}>(optional)</span> 488 + </label> 489 + <textarea 490 + id="sub-description" 491 + class={s.textarea} 492 + placeholder="What does this subscription do?" 493 + value={description} 494 + onInput={(e: Event) => setDescription((e.target as HTMLTextAreaElement).value)} 495 + rows={2} 496 + /> 497 + </div> 498 + 499 + <div class={s.fieldGroup}> 450 500 <label class={s.label} for="lexicon"> 451 501 Lexicon NSID 452 502 </label> ··· 541 591 </p> 542 592 </div> 543 593 {conditions.map((cond, i) => ( 544 - <div key={i} class={s.conditionRow}> 545 - <div class={s.conditionField}> 546 - <select 547 - class={s.select} 548 - value={cond.field} 549 - onChange={(e: Event) => 550 - updateCondition(i, "field", (e.target as HTMLSelectElement).value) 551 - } 552 - > 553 - <option value="">Select field...</option> 554 - {conditionFields.map((f) => ( 555 - <option key={f.path} value={f.path}> 556 - {f.path} 557 - </option> 558 - ))} 559 - </select> 594 + <div key={i} class={s.conditionBlock}> 595 + <div class={s.conditionRow}> 596 + <div class={s.conditionField}> 597 + <select 598 + class={s.select} 599 + value={cond.field} 600 + onChange={(e: Event) => 601 + updateCondition(i, "field", (e.target as HTMLSelectElement).value) 602 + } 603 + > 604 + <option value="">Select field...</option> 605 + {conditionFields.map((f) => ( 606 + <option key={f.path} value={f.path}> 607 + {f.path} 608 + </option> 609 + ))} 610 + </select> 611 + {cond.field && 612 + conditionFields.find((f) => f.path === cond.field)?.description && ( 613 + <span class={s.hint}> 614 + {conditionFields.find((f) => f.path === cond.field)!.description} 615 + </span> 616 + )} 617 + </div> 560 618 {cond.field && 561 - conditionFields.find((f) => f.path === cond.field)?.description && ( 562 - <span class={s.hint}> 563 - {conditionFields.find((f) => f.path === cond.field)!.description} 564 - </span> 619 + conditionFields.find((f) => f.path === cond.field)?.type !== "boolean" && ( 620 + <div class={s.conditionOperator}> 621 + <select 622 + class={s.select} 623 + value={cond.operator} 624 + onChange={(e: Event) => 625 + updateCondition(i, "operator", (e.target as HTMLSelectElement).value) 626 + } 627 + > 628 + <option value="eq">equals</option> 629 + <option value="startsWith">starts with</option> 630 + <option value="endsWith">ends with</option> 631 + <option value="contains">contains</option> 632 + </select> 633 + </div> 565 634 )} 566 - </div> 567 - {cond.field && 568 - conditionFields.find((f) => f.path === cond.field)?.type !== "boolean" && ( 569 - <div class={s.conditionOperator}> 635 + <div class={s.conditionValue}> 636 + {conditionFields.find((f) => f.path === cond.field)?.type === "boolean" ? ( 570 637 <select 571 638 class={s.select} 572 - value={cond.operator} 639 + value={cond.value} 573 640 onChange={(e: Event) => 574 - updateCondition(i, "operator", (e.target as HTMLSelectElement).value) 641 + updateCondition(i, "value", (e.target as HTMLSelectElement).value) 575 642 } 576 643 > 577 - <option value="eq">equals</option> 578 - <option value="startsWith">starts with</option> 579 - <option value="endsWith">ends with</option> 580 - <option value="contains">contains</option> 644 + <option value="">Select...</option> 645 + <option value="true">true</option> 646 + <option value="false">false</option> 581 647 </select> 582 - </div> 583 - )} 584 - <div class={s.conditionValue}> 585 - {conditionFields.find((f) => f.path === cond.field)?.type === "boolean" ? ( 586 - <select 587 - class={s.select} 588 - value={cond.value} 589 - onChange={(e: Event) => 590 - updateCondition(i, "value", (e.target as HTMLSelectElement).value) 591 - } 592 - > 593 - <option value="">Select...</option> 594 - <option value="true">true</option> 595 - <option value="false">false</option> 596 - </select> 597 - ) : ( 598 - <input 599 - class={s.input} 600 - type="text" 601 - placeholder="Value" 602 - value={cond.value} 603 - onInput={(e: Event) => 604 - updateCondition(i, "value", (e.target as HTMLInputElement).value) 605 - } 606 - /> 607 - )} 648 + ) : ( 649 + <input 650 + class={s.input} 651 + type="text" 652 + placeholder="Value" 653 + value={cond.value} 654 + onInput={(e: Event) => 655 + updateCondition(i, "value", (e.target as HTMLInputElement).value) 656 + } 657 + /> 658 + )} 659 + </div> 660 + <button type="button" class={s.removeBtn} onClick={() => removeCondition(i)}> 661 + Remove 662 + </button> 608 663 </div> 609 - <button type="button" class={s.removeBtn} onClick={() => removeCondition(i)}> 610 - Remove 611 - </button> 664 + <input 665 + class={s.input} 666 + type="text" 667 + placeholder="Note (optional)" 668 + value={cond.comment} 669 + onInput={(e: Event) => 670 + updateCondition(i, "comment", (e.target as HTMLInputElement).value) 671 + } 672 + /> 612 673 </div> 613 674 ))} 614 675 <button type="button" class={s.addBtn} onClick={addCondition}> ··· 627 688 </p> 628 689 </div> 629 690 {fetches.map((f, i) => ( 630 - <div key={i} class={s.fetchRow}> 631 - <div class={s.fetchName}> 632 - <input 633 - class={s.input} 634 - type="text" 635 - placeholder="Variable name" 636 - value={f.name} 637 - onInput={(e: Event) => 638 - updateFetch(i, "name", (e.target as HTMLInputElement).value) 639 - } 640 - /> 691 + <div key={i} class={s.conditionBlock}> 692 + <div class={s.fetchRow}> 693 + <div class={s.fetchName}> 694 + <input 695 + class={s.input} 696 + type="text" 697 + placeholder="Variable name" 698 + value={f.name} 699 + onInput={(e: Event) => 700 + updateFetch(i, "name", (e.target as HTMLInputElement).value) 701 + } 702 + /> 703 + </div> 704 + <div class={s.fetchUri}> 705 + <input 706 + class={s.input} 707 + type="text" 708 + placeholder="AT URI template, e.g. {{event.commit.record.subject}}" 709 + value={f.uri} 710 + onInput={(e: Event) => 711 + updateFetch(i, "uri", (e.target as HTMLInputElement).value) 712 + } 713 + /> 714 + </div> 715 + <button type="button" class={s.removeBtn} onClick={() => removeFetch(i)}> 716 + Remove 717 + </button> 641 718 </div> 642 - <div class={s.fetchUri}> 643 - <input 644 - class={s.input} 645 - type="text" 646 - placeholder="AT URI template, e.g. {{event.commit.record.subject}}" 647 - value={f.uri} 648 - onInput={(e: Event) => 649 - updateFetch(i, "uri", (e.target as HTMLInputElement).value) 650 - } 651 - /> 652 - </div> 653 - <button type="button" class={s.removeBtn} onClick={() => removeFetch(i)}> 654 - Remove 655 - </button> 719 + <input 720 + class={s.input} 721 + type="text" 722 + placeholder="Note (optional)" 723 + value={f.comment} 724 + onInput={(e: Event) => 725 + updateFetch(i, "comment", (e.target as HTMLInputElement).value) 726 + } 727 + /> 656 728 </div> 657 729 ))} 658 730 <button type="button" class={s.addBtn} onClick={addFetch}> ··· 687 759 placeholders={allPlaceholders} 688 760 /> 689 761 )} 762 + <input 763 + class={s.input} 764 + type="text" 765 + placeholder="Note (optional)" 766 + value={action.comment} 767 + onInput={(e: Event) => 768 + updateAction(i, { 769 + ...action, 770 + comment: (e.target as HTMLInputElement).value, 771 + }) 772 + } 773 + /> 690 774 </div> 691 775 ))} 692 776 ··· 709 793 710 794 {error && <div class={s.alertError}>{error}</div>} 711 795 712 - <button type="submit" class={s.submitBtn} disabled={actions.length === 0}> 796 + <button type="submit" class={s.submitBtn} disabled={!name.trim() || actions.length === 0}> 713 797 {submitting ? "Creating..." : "Create subscription"} 714 798 </button> 715 799
+44 -6
app/routes/api/subscriptions/[rkey].ts
··· 24 24 import { notifySubscriptionChange } from "@/jetstream/consumer.js"; 25 25 26 26 type ActionInput = 27 - | { type: "webhook"; callbackUrl: string } 28 - | { type: "record"; targetCollection: string; recordTemplate: string }; 27 + | { type: "webhook"; callbackUrl: string; comment?: string } 28 + | { type: "record"; targetCollection: string; recordTemplate: string; comment?: string }; 29 29 30 30 const VALID_OPERATORS = new Set(["eq", "startsWith", "endsWith", "contains"]); 31 31 ··· 51 51 return c.json({ 52 52 uri: sub.uri, 53 53 rkey: sub.rkey, 54 + name: sub.name, 55 + description: sub.description, 54 56 lexicon: sub.lexicon, 55 57 actions: sub.actions, 56 58 fetches: sub.fetches, ··· 77 79 if (!sub) return c.json({ error: "Subscription not found" }, 404); 78 80 79 81 const body = await c.req.json<{ 82 + name?: string; 83 + description?: string | null; 80 84 actions?: ActionInput[]; 81 - fetches?: Array<{ name: string; uri: string }>; 82 - conditions?: Array<{ field: string; operator?: string; value: string }>; 85 + fetches?: Array<{ name: string; uri: string; comment?: string }>; 86 + conditions?: Array<{ field: string; operator?: string; value: string; comment?: string }>; 83 87 active?: boolean; 84 88 }>(); 89 + 90 + // Validate name/description if provided 91 + const name = body.name !== undefined ? body.name : sub.name; 92 + if (!name || typeof name !== "string" || !name.trim()) { 93 + return c.json({ error: "Name is required" }, 400); 94 + } 95 + if (name.length > 128) { 96 + return c.json({ error: "Name must be 128 characters or less" }, 400); 97 + } 98 + const description = body.description !== undefined ? body.description : sub.description; 99 + if (description && description.length > 1024) { 100 + return c.json({ error: "Description must be 1024 characters or less" }, 400); 101 + } 85 102 86 103 const conditions = body.conditions 87 104 ? body.conditions ··· 90 107 field: cond.field, 91 108 operator: cond.operator ?? "eq", 92 109 value: cond.value, 110 + ...(cond.comment ? { comment: cond.comment } : {}), 93 111 })) 94 112 : sub.conditions; 95 113 if (conditions.length > 20) { ··· 119 137 return c.json({ error: stepValidation.error }, 400); 120 138 } 121 139 seenNames.add(f.name); 122 - newLocalFetches.push({ name: f.name, uri: f.uri }); 123 - newPdsFetches.push({ $type: "app.rglw.subscription#fetchStep", name: f.name, uri: f.uri }); 140 + newLocalFetches.push({ 141 + name: f.name, 142 + uri: f.uri, 143 + ...(f.comment ? { comment: f.comment } : {}), 144 + }); 145 + newPdsFetches.push({ 146 + $type: "app.rglw.subscription#fetchStep", 147 + name: f.name, 148 + uri: f.uri, 149 + ...(f.comment ? { comment: f.comment } : {}), 150 + }); 124 151 } 125 152 localFetches = newLocalFetches; 126 153 pdsFetches = newPdsFetches; ··· 169 196 $type: "webhook", 170 197 callbackUrl: input.callbackUrl, 171 198 secret, 199 + ...(input.comment ? { comment: input.comment } : {}), 172 200 } satisfies WebhookAction); 173 201 newPdsActions.push({ 174 202 $type: "app.rglw.subscription#webhookAction", 175 203 callbackUrl: input.callbackUrl, 204 + ...(input.comment ? { comment: input.comment } : {}), 176 205 }); 177 206 } else if (input.type === "record") { 178 207 if (!input.targetCollection) { ··· 196 225 $type: "record", 197 226 targetCollection: input.targetCollection, 198 227 recordTemplate: input.recordTemplate, 228 + ...(input.comment ? { comment: input.comment } : {}), 199 229 } satisfies RecordAction); 200 230 newPdsActions.push({ 201 231 $type: "app.rglw.subscription#recordAction", 202 232 targetCollection: input.targetCollection, 203 233 recordTemplate: input.recordTemplate, 234 + ...(input.comment ? { comment: input.comment } : {}), 204 235 }); 205 236 } else { 206 237 return c.json({ error: "Invalid action type" }, 400); ··· 234 265 return { 235 266 $type: "app.rglw.subscription#webhookAction", 236 267 callbackUrl: a.callbackUrl, 268 + ...(a.comment ? { comment: a.comment } : {}), 237 269 }; 238 270 } 239 271 return { 240 272 $type: "app.rglw.subscription#recordAction", 241 273 targetCollection: a.targetCollection, 242 274 recordTemplate: a.recordTemplate, 275 + ...(a.comment ? { comment: a.comment } : {}), 243 276 }; 244 277 }); 245 278 } ··· 247 280 // Update on PDS 248 281 try { 249 282 await putRecord(user.did, rkey, { 283 + name: name.trim(), 284 + description: description?.trim() || undefined, 250 285 lexicon: sub.lexicon, 251 286 actions: pdsActions, 252 287 fetches: ··· 255 290 $type: "app.rglw.subscription#fetchStep" as const, 256 291 name: f.name, 257 292 uri: f.uri, 293 + ...(f.comment ? { comment: f.comment } : {}), 258 294 })), 259 295 conditions, 260 296 active, ··· 270 306 await db 271 307 .update(subscriptions) 272 308 .set({ 309 + name: name.trim(), 310 + description: description?.trim() || null, 273 311 actions: localActions, 274 312 fetches: localFetches, 275 313 conditions,
+35 -6
app/routes/api/subscriptions/index.ts
··· 22 22 import { notifySubscriptionChange } from "@/jetstream/consumer.js"; 23 23 24 24 type ActionInput = 25 - | { type: "webhook"; callbackUrl: string } 26 - | { type: "record"; targetCollection: string; recordTemplate: string }; 25 + | { type: "webhook"; callbackUrl: string; comment?: string } 26 + | { type: "record"; targetCollection: string; recordTemplate: string; comment?: string }; 27 27 28 28 const VALID_OPERATORS = new Set(["eq", "startsWith", "endsWith", "contains"]); 29 29 ··· 36 36 rows.map((r) => ({ 37 37 uri: r.uri, 38 38 rkey: r.rkey, 39 + name: r.name, 40 + description: r.description, 39 41 lexicon: r.lexicon, 40 42 actions: r.actions, 41 43 fetches: r.fetches, ··· 49 51 export const POST = createRoute(async (c) => { 50 52 const user = c.get("user"); 51 53 const body = await c.req.json<{ 54 + name: string; 55 + description?: string; 52 56 lexicon: string; 53 57 actions: ActionInput[]; 54 - fetches?: Array<{ name: string; uri: string }>; 55 - conditions?: Array<{ field: string; operator?: string; value: string }>; 58 + fetches?: Array<{ name: string; uri: string; comment?: string }>; 59 + conditions?: Array<{ field: string; operator?: string; value: string; comment?: string }>; 56 60 active?: boolean; 57 61 }>(); 58 62 63 + // Validate name 64 + if (!body.name || typeof body.name !== "string" || !body.name.trim()) { 65 + return c.json({ error: "Name is required" }, 400); 66 + } 67 + if (body.name.length > 128) { 68 + return c.json({ error: "Name must be 128 characters or less" }, 400); 69 + } 70 + if (body.description && body.description.length > 1024) { 71 + return c.json({ error: "Description must be 1024 characters or less" }, 400); 72 + } 73 + 59 74 // Validate lexicon NSID 60 75 if (!body.lexicon || !isValidNsid(body.lexicon)) { 61 76 return c.json({ error: "Invalid lexicon NSID" }, 400); ··· 80 95 field: cond.field, 81 96 operator: cond.operator ?? "eq", 82 97 value: cond.value, 98 + ...(cond.comment ? { comment: cond.comment } : {}), 83 99 })); 84 100 if (conditions.length > 20) { 85 101 return c.json({ error: "Maximum 20 conditions allowed" }, 400); ··· 105 121 return c.json({ error: stepValidation.error }, 400); 106 122 } 107 123 seenNames.add(f.name); 108 - localFetches.push({ name: f.name, uri: f.uri }); 109 - pdsFetches.push({ $type: "app.rglw.subscription#fetchStep", name: f.name, uri: f.uri }); 124 + localFetches.push({ name: f.name, uri: f.uri, ...(f.comment ? { comment: f.comment } : {}) }); 125 + pdsFetches.push({ 126 + $type: "app.rglw.subscription#fetchStep", 127 + name: f.name, 128 + uri: f.uri, 129 + ...(f.comment ? { comment: f.comment } : {}), 130 + }); 110 131 } 111 132 } 112 133 const fetchNames = localFetches.map((f) => f.name); ··· 136 157 $type: "webhook", 137 158 callbackUrl: input.callbackUrl, 138 159 secret, 160 + ...(input.comment ? { comment: input.comment } : {}), 139 161 } satisfies WebhookAction); 140 162 pdsActions.push({ 141 163 $type: "app.rglw.subscription#webhookAction", 142 164 callbackUrl: input.callbackUrl, 165 + ...(input.comment ? { comment: input.comment } : {}), 143 166 }); 144 167 } else if (input.type === "record") { 145 168 if (!input.targetCollection) { ··· 163 186 $type: "record", 164 187 targetCollection: input.targetCollection, 165 188 recordTemplate: input.recordTemplate, 189 + ...(input.comment ? { comment: input.comment } : {}), 166 190 } satisfies RecordAction); 167 191 pdsActions.push({ 168 192 $type: "app.rglw.subscription#recordAction", 169 193 targetCollection: input.targetCollection, 170 194 recordTemplate: input.recordTemplate, 195 + ...(input.comment ? { comment: input.comment } : {}), 171 196 }); 172 197 } else { 173 198 return c.json({ error: "Invalid action type" }, 400); ··· 182 207 183 208 try { 184 209 const result = await createRecord(user.did, { 210 + name: body.name.trim(), 211 + description: body.description?.trim() || undefined, 185 212 lexicon: body.lexicon, 186 213 actions: pdsActions, 187 214 fetches: pdsFetches.length > 0 ? pdsFetches : undefined, ··· 202 229 uri, 203 230 did: user.did, 204 231 rkey, 232 + name: body.name.trim(), 233 + description: body.description?.trim() || null, 205 234 lexicon: body.lexicon, 206 235 actions: localActions, 207 236 fetches: localFetches,
+5 -5
app/routes/dashboard/index.tsx
··· 48 48 <Table> 49 49 <thead> 50 50 <tr> 51 + <th>Name</th> 51 52 <th>Lexicon</th> 52 53 <th>Actions</th> 53 - <th>Conditions</th> 54 54 <th>Status</th> 55 55 <th></th> 56 56 </tr> ··· 59 59 {subs.map((sub) => ( 60 60 <tr key={sub.uri}> 61 61 <td> 62 - <a href={`/dashboard/subscriptions/${sub.rkey}`}> 63 - <InlineCode>{sub.lexicon}</InlineCode> 64 - </a> 62 + <a href={`/dashboard/subscriptions/${sub.rkey}`}>{sub.name}</a> 63 + </td> 64 + <td> 65 + <InlineCode>{sub.lexicon}</InlineCode> 65 66 </td> 66 67 <td> 67 68 {sub.actions.length} action{sub.actions.length !== 1 ? "s" : ""} 68 69 </td> 69 - <td>{sub.conditions.length}</td> 70 70 <td> 71 71 <Badge variant={sub.active ? "success" : "neutral"}> 72 72 {sub.active ? "Active" : "Inactive"}
+7 -3
app/routes/dashboard/subscriptions/[rkey].tsx
··· 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"; 17 - import { inlineCluster, plainList } from "../../../styles/utilities.css.js"; 17 + import { inlineCluster, plainList, textMuted } from "../../../styles/utilities.css.js"; 18 18 19 19 export default createRoute(async (c) => { 20 20 const user = c.get("user"); ··· 53 53 <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 54 54 <Container> 55 55 <PageHeader 56 - title={sub.lexicon} 56 + title={sub.name} 57 + description={sub.description ?? undefined} 57 58 actions={ 58 59 <div class={inlineCluster}> 59 60 <Badge variant={sub.active ? "success" : "neutral"}> ··· 103 104 <InlineCode>{cond.field}</InlineCode>{" "} 104 105 {opLabels[cond.operator] ?? cond.operator}{" "} 105 106 <InlineCode>{cond.value}</InlineCode> 107 + {cond.comment && <span class={textMuted}> — {cond.comment}</span>} 106 108 </li> 107 109 ); 108 110 })} ··· 119 121 {sub.fetches.map((f, i) => ( 120 122 <li key={i}> 121 123 <InlineCode>{f.name}</InlineCode> &larr; <InlineCode>{f.uri}</InlineCode> 124 + {f.comment && <span class={textMuted}> — {f.comment}</span>} 122 125 </li> 123 126 ))} 124 127 </ul> ··· 133 136 <Stack gap={2}> 134 137 <h4> 135 138 {action.$type === "webhook" ? "Webhook" : "Record"} {i + 1} 139 + {action.comment && <span class={textMuted}> — {action.comment}</span>} 136 140 </h4> 137 141 <DescriptionList> 138 142 {action.$type === "webhook" ? ( ··· 180 184 </Stack> 181 185 </Container> 182 186 </AppShell>, 183 - { title: `${sub.lexicon} — Airglow` }, 187 + { title: `${sub.name} — Airglow` }, 184 188 ); 185 189 });
+7
app/styles/utilities.css.ts
··· 1 1 import { style } from "@vanilla-extract/css"; 2 + import { vars } from "./theme.css.ts"; 2 3 import { space } from "./tokens/spacing.ts"; 4 + import { fontSize } from "./tokens/typography.ts"; 3 5 4 6 export const centerText = style({ 5 7 textAlign: "center", ··· 29 31 flexDirection: "column", 30 32 gap: space[1], 31 33 }); 34 + 35 + export const textMuted = style({ 36 + fontSize: fontSize.sm, 37 + color: vars.color.textMuted, 38 + });
+31 -1
lexicons/app/rglw/subscription.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["lexicon", "actions", "createdAt"], 11 + "required": ["name", "lexicon", "actions", "createdAt"], 12 12 "properties": { 13 + "name": { 14 + "type": "string", 15 + "description": "User-defined name for this subscription.", 16 + "maxLength": 128 17 + }, 18 + "description": { 19 + "type": "string", 20 + "description": "Optional description of what this subscription does.", 21 + "maxLength": 1024 22 + }, 13 23 "lexicon": { 14 24 "type": "string", 15 25 "description": "NSID of the collection to subscribe to.", ··· 65 75 "format": "uri", 66 76 "description": "URL to receive webhook POST requests.", 67 77 "maxLength": 2048 78 + }, 79 + "comment": { 80 + "type": "string", 81 + "description": "Optional user note about this action.", 82 + "maxLength": 512 68 83 } 69 84 } 70 85 }, ··· 82 97 "type": "string", 83 98 "description": "JSON template with {{placeholder}} expressions resolved from event data.", 84 99 "maxLength": 10240 100 + }, 101 + "comment": { 102 + "type": "string", 103 + "description": "Optional user note about this action.", 104 + "maxLength": 512 85 105 } 86 106 } 87 107 }, ··· 99 119 "type": "string", 100 120 "description": "AT URI template, e.g. '{{event.commit.record.subject}}'.", 101 121 "maxLength": 2048 122 + }, 123 + "comment": { 124 + "type": "string", 125 + "description": "Optional user note about this fetch step.", 126 + "maxLength": 512 102 127 } 103 128 } 104 129 }, ··· 123 148 "type": "string", 124 149 "description": "Value to compare against.", 125 150 "maxLength": 1024 151 + }, 152 + "comment": { 153 + "type": "string", 154 + "description": "Optional user note about this condition.", 155 + "maxLength": 512 126 156 } 127 157 } 128 158 }
+3
lib/db/migrations/0000_tearful_tarot.sql lib/db/migrations/0000_yellow_silvermane.sql
··· 33 33 `uri` text PRIMARY KEY NOT NULL, 34 34 `did` text NOT NULL, 35 35 `rkey` text NOT NULL, 36 + `name` text NOT NULL, 37 + `description` text, 36 38 `lexicon` text NOT NULL, 37 39 `actions` text DEFAULT '[]' NOT NULL, 40 + `fetches` text DEFAULT '[]' NOT NULL, 38 41 `conditions` text DEFAULT '[]' NOT NULL, 39 42 `active` integer DEFAULT false NOT NULL, 40 43 `indexed_at` integer NOT NULL
-1
lib/db/migrations/0001_wide_solo.sql
··· 1 - ALTER TABLE `subscriptions` ADD `fetches` text DEFAULT '[]' NOT NULL;
+33 -5
lib/db/migrations/meta/0000_snapshot.json
··· 1 1 { 2 2 "version": "6", 3 3 "dialect": "sqlite", 4 - "id": "b7fc849c-bab7-48f6-8ff2-aff7575d0a7d", 4 + "id": "9f071f8b-3361-461b-940c-b8577b94e01d", 5 5 "prevId": "00000000-0000-0000-0000-000000000000", 6 6 "tables": { 7 7 "delivery_logs": { ··· 79 79 "name": "delivery_logs_subscription_uri_subscriptions_uri_fk", 80 80 "tableFrom": "delivery_logs", 81 81 "tableTo": "subscriptions", 82 - "columnsFrom": ["subscription_uri"], 83 - "columnsTo": ["uri"], 82 + "columnsFrom": [ 83 + "subscription_uri" 84 + ], 85 + "columnsTo": [ 86 + "uri" 87 + ], 84 88 "onDelete": "cascade", 85 89 "onUpdate": "no action" 86 90 } ··· 206 210 "notNull": true, 207 211 "autoincrement": false 208 212 }, 213 + "name": { 214 + "name": "name", 215 + "type": "text", 216 + "primaryKey": false, 217 + "notNull": true, 218 + "autoincrement": false 219 + }, 220 + "description": { 221 + "name": "description", 222 + "type": "text", 223 + "primaryKey": false, 224 + "notNull": false, 225 + "autoincrement": false 226 + }, 209 227 "lexicon": { 210 228 "name": "lexicon", 211 229 "type": "text", ··· 215 233 }, 216 234 "actions": { 217 235 "name": "actions", 236 + "type": "text", 237 + "primaryKey": false, 238 + "notNull": true, 239 + "autoincrement": false, 240 + "default": "'[]'" 241 + }, 242 + "fetches": { 243 + "name": "fetches", 218 244 "type": "text", 219 245 "primaryKey": false, 220 246 "notNull": true, ··· 286 312 "indexes": { 287 313 "users_did_unique": { 288 314 "name": "users_did_unique", 289 - "columns": ["did"], 315 + "columns": [ 316 + "did" 317 + ], 290 318 "isUnique": true 291 319 } 292 320 }, ··· 306 334 "internal": { 307 335 "indexes": {} 308 336 } 309 - } 337 + }
-317
lib/db/migrations/meta/0001_snapshot.json
··· 1 - { 2 - "version": "6", 3 - "dialect": "sqlite", 4 - "id": "0bf27488-9d9d-4dba-a902-a94840770439", 5 - "prevId": "b7fc849c-bab7-48f6-8ff2-aff7575d0a7d", 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 - "action_index": { 25 - "name": "action_index", 26 - "type": "integer", 27 - "primaryKey": false, 28 - "notNull": true, 29 - "autoincrement": false, 30 - "default": 0 31 - }, 32 - "event_time_us": { 33 - "name": "event_time_us", 34 - "type": "integer", 35 - "primaryKey": false, 36 - "notNull": true, 37 - "autoincrement": false 38 - }, 39 - "payload": { 40 - "name": "payload", 41 - "type": "text", 42 - "primaryKey": false, 43 - "notNull": false, 44 - "autoincrement": false 45 - }, 46 - "status_code": { 47 - "name": "status_code", 48 - "type": "integer", 49 - "primaryKey": false, 50 - "notNull": false, 51 - "autoincrement": false 52 - }, 53 - "error": { 54 - "name": "error", 55 - "type": "text", 56 - "primaryKey": false, 57 - "notNull": false, 58 - "autoincrement": false 59 - }, 60 - "attempt": { 61 - "name": "attempt", 62 - "type": "integer", 63 - "primaryKey": false, 64 - "notNull": true, 65 - "autoincrement": false, 66 - "default": 1 67 - }, 68 - "created_at": { 69 - "name": "created_at", 70 - "type": "integer", 71 - "primaryKey": false, 72 - "notNull": true, 73 - "autoincrement": false 74 - } 75 - }, 76 - "indexes": {}, 77 - "foreignKeys": { 78 - "delivery_logs_subscription_uri_subscriptions_uri_fk": { 79 - "name": "delivery_logs_subscription_uri_subscriptions_uri_fk", 80 - "tableFrom": "delivery_logs", 81 - "tableTo": "subscriptions", 82 - "columnsFrom": ["subscription_uri"], 83 - "columnsTo": ["uri"], 84 - "onDelete": "cascade", 85 - "onUpdate": "no action" 86 - } 87 - }, 88 - "compositePrimaryKeys": {}, 89 - "uniqueConstraints": {}, 90 - "checkConstraints": {} 91 - }, 92 - "lexicon_cache": { 93 - "name": "lexicon_cache", 94 - "columns": { 95 - "nsid": { 96 - "name": "nsid", 97 - "type": "text", 98 - "primaryKey": true, 99 - "notNull": true, 100 - "autoincrement": false 101 - }, 102 - "schema": { 103 - "name": "schema", 104 - "type": "text", 105 - "primaryKey": false, 106 - "notNull": true, 107 - "autoincrement": false 108 - }, 109 - "fetched_at": { 110 - "name": "fetched_at", 111 - "type": "integer", 112 - "primaryKey": false, 113 - "notNull": true, 114 - "autoincrement": false 115 - } 116 - }, 117 - "indexes": {}, 118 - "foreignKeys": {}, 119 - "compositePrimaryKeys": {}, 120 - "uniqueConstraints": {}, 121 - "checkConstraints": {} 122 - }, 123 - "oauth_sessions": { 124 - "name": "oauth_sessions", 125 - "columns": { 126 - "key": { 127 - "name": "key", 128 - "type": "text", 129 - "primaryKey": true, 130 - "notNull": true, 131 - "autoincrement": false 132 - }, 133 - "value": { 134 - "name": "value", 135 - "type": "text", 136 - "primaryKey": false, 137 - "notNull": true, 138 - "autoincrement": false 139 - }, 140 - "expires_at": { 141 - "name": "expires_at", 142 - "type": "integer", 143 - "primaryKey": false, 144 - "notNull": false, 145 - "autoincrement": false 146 - } 147 - }, 148 - "indexes": {}, 149 - "foreignKeys": {}, 150 - "compositePrimaryKeys": {}, 151 - "uniqueConstraints": {}, 152 - "checkConstraints": {} 153 - }, 154 - "oauth_states": { 155 - "name": "oauth_states", 156 - "columns": { 157 - "key": { 158 - "name": "key", 159 - "type": "text", 160 - "primaryKey": true, 161 - "notNull": true, 162 - "autoincrement": false 163 - }, 164 - "value": { 165 - "name": "value", 166 - "type": "text", 167 - "primaryKey": false, 168 - "notNull": true, 169 - "autoincrement": false 170 - }, 171 - "expires_at": { 172 - "name": "expires_at", 173 - "type": "integer", 174 - "primaryKey": false, 175 - "notNull": false, 176 - "autoincrement": false 177 - } 178 - }, 179 - "indexes": {}, 180 - "foreignKeys": {}, 181 - "compositePrimaryKeys": {}, 182 - "uniqueConstraints": {}, 183 - "checkConstraints": {} 184 - }, 185 - "subscriptions": { 186 - "name": "subscriptions", 187 - "columns": { 188 - "uri": { 189 - "name": "uri", 190 - "type": "text", 191 - "primaryKey": true, 192 - "notNull": true, 193 - "autoincrement": false 194 - }, 195 - "did": { 196 - "name": "did", 197 - "type": "text", 198 - "primaryKey": false, 199 - "notNull": true, 200 - "autoincrement": false 201 - }, 202 - "rkey": { 203 - "name": "rkey", 204 - "type": "text", 205 - "primaryKey": false, 206 - "notNull": true, 207 - "autoincrement": false 208 - }, 209 - "lexicon": { 210 - "name": "lexicon", 211 - "type": "text", 212 - "primaryKey": false, 213 - "notNull": true, 214 - "autoincrement": false 215 - }, 216 - "actions": { 217 - "name": "actions", 218 - "type": "text", 219 - "primaryKey": false, 220 - "notNull": true, 221 - "autoincrement": false, 222 - "default": "'[]'" 223 - }, 224 - "fetches": { 225 - "name": "fetches", 226 - "type": "text", 227 - "primaryKey": false, 228 - "notNull": true, 229 - "autoincrement": false, 230 - "default": "'[]'" 231 - }, 232 - "conditions": { 233 - "name": "conditions", 234 - "type": "text", 235 - "primaryKey": false, 236 - "notNull": true, 237 - "autoincrement": false, 238 - "default": "'[]'" 239 - }, 240 - "active": { 241 - "name": "active", 242 - "type": "integer", 243 - "primaryKey": false, 244 - "notNull": true, 245 - "autoincrement": false, 246 - "default": false 247 - }, 248 - "indexed_at": { 249 - "name": "indexed_at", 250 - "type": "integer", 251 - "primaryKey": false, 252 - "notNull": true, 253 - "autoincrement": false 254 - } 255 - }, 256 - "indexes": {}, 257 - "foreignKeys": {}, 258 - "compositePrimaryKeys": {}, 259 - "uniqueConstraints": {}, 260 - "checkConstraints": {} 261 - }, 262 - "users": { 263 - "name": "users", 264 - "columns": { 265 - "id": { 266 - "name": "id", 267 - "type": "integer", 268 - "primaryKey": true, 269 - "notNull": true, 270 - "autoincrement": true 271 - }, 272 - "did": { 273 - "name": "did", 274 - "type": "text", 275 - "primaryKey": false, 276 - "notNull": true, 277 - "autoincrement": false 278 - }, 279 - "handle": { 280 - "name": "handle", 281 - "type": "text", 282 - "primaryKey": false, 283 - "notNull": true, 284 - "autoincrement": false 285 - }, 286 - "created_at": { 287 - "name": "created_at", 288 - "type": "integer", 289 - "primaryKey": false, 290 - "notNull": true, 291 - "autoincrement": false 292 - } 293 - }, 294 - "indexes": { 295 - "users_did_unique": { 296 - "name": "users_did_unique", 297 - "columns": ["did"], 298 - "isUnique": true 299 - } 300 - }, 301 - "foreignKeys": {}, 302 - "compositePrimaryKeys": {}, 303 - "uniqueConstraints": {}, 304 - "checkConstraints": {} 305 - } 306 - }, 307 - "views": {}, 308 - "enums": {}, 309 - "_meta": { 310 - "schemas": {}, 311 - "tables": {}, 312 - "columns": {} 313 - }, 314 - "internal": { 315 - "indexes": {} 316 - } 317 - }
+3 -10
lib/db/migrations/meta/_journal.json
··· 5 5 { 6 6 "idx": 0, 7 7 "version": "6", 8 - "when": 1775492878874, 9 - "tag": "0000_tearful_tarot", 10 - "breakpoints": true 11 - }, 12 - { 13 - "idx": 1, 14 - "version": "6", 15 - "when": 1775505023185, 16 - "tag": "0001_wide_solo", 8 + "when": 1775647145739, 9 + "tag": "0000_yellow_silvermane", 17 10 "breakpoints": true 18 11 } 19 12 ] 20 - } 13 + }
+6 -1
lib/db/schema.ts
··· 12 12 $type: "webhook"; 13 13 callbackUrl: string; 14 14 secret: string; // instance-local HMAC secret, not stored on PDS 15 + comment?: string; 15 16 }; 16 17 17 18 export type RecordAction = { 18 19 $type: "record"; 19 20 targetCollection: string; 20 21 recordTemplate: string; 22 + comment?: string; 21 23 }; 22 24 23 25 export type Action = WebhookAction | RecordAction; ··· 25 27 export type FetchStep = { 26 28 name: string; 27 29 uri: string; // AT URI template 30 + comment?: string; 28 31 }; 29 32 30 33 // Local index of app.rglw.subscription records living on user PDS. ··· 33 36 uri: text("uri").primaryKey(), // at://did/app.rglw.subscription/rkey 34 37 did: text("did").notNull(), 35 38 rkey: text("rkey").notNull(), 39 + name: text("name").notNull(), 40 + description: text("description"), 36 41 lexicon: text("lexicon").notNull(), // NSID being watched 37 42 actions: text("actions", { mode: "json" }).notNull().$type<Action[]>().default([]), 38 43 fetches: text("fetches", { mode: "json" }).notNull().$type<FetchStep[]>().default([]), 39 44 conditions: text("conditions", { mode: "json" }) 40 45 .notNull() 41 - .$type<Array<{ field: string; operator: string; value: string }>>() 46 + .$type<Array<{ field: string; operator: string; value: string; comment?: string }>>() 42 47 .default([]), 43 48 active: integer("active", { mode: "boolean" }).notNull().default(false), 44 49 indexedAt: integer("indexed_at", { mode: "timestamp_ms" }).notNull(),
+18 -1
lib/subscriptions/pds.ts
··· 1 1 import { getOAuthClient } from "../auth/client.js"; 2 + import { config } from "../config.js"; 2 3 3 4 const COLLECTION = "app.rglw.subscription"; 4 5 6 + const isLocalDev = 7 + Boolean(config.pdsUrl) || 8 + config.publicUrl.startsWith("http://localhost") || 9 + config.publicUrl.startsWith("http://127.0.0.1"); 10 + 5 11 // AT Protocol TID: base32-sort encoded (timestamp_us << 10 | clock_id) 6 12 const S32 = "234567abcdefghijklmnopqrstuvwxyz"; 7 13 export function generateTid(): string { ··· 19 25 type PdsWebhookAction = { 20 26 $type: "app.rglw.subscription#webhookAction"; 21 27 callbackUrl: string; 28 + comment?: string; 22 29 }; 23 30 24 31 type PdsRecordAction = { 25 32 $type: "app.rglw.subscription#recordAction"; 26 33 targetCollection: string; 27 34 recordTemplate: string; 35 + comment?: string; 28 36 }; 29 37 30 38 export type PdsAction = PdsWebhookAction | PdsRecordAction; ··· 33 41 $type: "app.rglw.subscription#fetchStep"; 34 42 name: string; 35 43 uri: string; 44 + comment?: string; 36 45 }; 37 46 38 47 type SubscriptionRecord = { 48 + name: string; 49 + description?: string; 39 50 lexicon: string; 40 51 actions: PdsAction[]; 41 52 fetches?: PdsFetchStep[]; 42 - conditions: Array<{ field: string; operator: string; value: string }>; 53 + conditions: Array<{ field: string; operator: string; value: string; comment?: string }>; 43 54 active: boolean; 44 55 createdAt: string; 45 56 }; ··· 68 79 record: SubscriptionRecord, 69 80 ): Promise<{ uri: string; rkey: string }> { 70 81 const rkey = generateTid(); 82 + if (isLocalDev) { 83 + return { uri: `at://${did}/${COLLECTION}/${rkey}`, rkey }; 84 + } 71 85 const data = await pdsCall(did, "com.atproto.repo.createRecord", { 72 86 repo: did, 73 87 collection: COLLECTION, ··· 81 95 did: string, 82 96 rkey: string, 83 97 ): Promise<Record<string, unknown> | null> { 98 + if (isLocalDev) return null; 84 99 const client = await getOAuthClient(); 85 100 const session = await client.restore(did); 86 101 const params = new URLSearchParams({ ··· 101 116 rkey: string, 102 117 record: SubscriptionRecord, 103 118 ): Promise<void> { 119 + if (isLocalDev) return; 104 120 await pdsCall(did, "com.atproto.repo.putRecord", { 105 121 repo: did, 106 122 collection: COLLECTION, ··· 110 126 } 111 127 112 128 export async function deleteRecord(did: string, rkey: string): Promise<void> { 129 + if (isLocalDev) return; 113 130 await pdsCall(did, "com.atproto.repo.deleteRecord", { 114 131 repo: did, 115 132 collection: COLLECTION,