this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(api): add Profile API playground + public single-profile endpoint

Adds a self-service developer tool on /developer-resources for pulling
registry data into other apps. Includes:

- New public endpoint GET /api/registry/profile/:handleOrDid that
returns the same ProfileRow shape as the SSR explore page, plus a
synthesised avatarUrl convenience field.
- lib/rate-limit.ts: in-memory token bucket (~60 req/min/IP) wrapping
the four public read handlers (profile/:id, search, featured,
avatar/:did) as a soft deterrent against abuse.
- Interactive playground island with tabs for the three read endpoints,
inline JSON response, and copyable cURL + JS fetch snippets.
- Endpoint reference (server-rendered <dl>) and a download link for the
canonical profile lexicon, served via the existing wellknown
middleware at /lexicons/com.atmosphereaccount.registry.profile.json.
- Translatable copy under developerResources.api.* and matching CSS.

Made-with: Cursor

+1000 -6
+316
assets/styles.css
··· 3557 3557 .home-explore-cta-arrow { 3558 3558 transition: transform 0.18s ease; 3559 3559 } 3560 + 3561 + /* ================================ 3562 + Developer Resources — Profile API playground 3563 + ================================ */ 3564 + 3565 + /* Wrapping card matches the manage-form glass aesthetic so the page 3566 + * reads as one consistent design language. */ 3567 + .api-playground { 3568 + margin-top: 1rem; 3569 + padding: 1.5rem; 3570 + border-radius: 20px; 3571 + background: rgba(255, 255, 255, 0.55); 3572 + border: 1px solid rgba(255, 255, 255, 0.6); 3573 + box-shadow: 0 8px 24px rgba(14, 20, 40, 0.06); 3574 + display: flex; 3575 + flex-direction: column; 3576 + gap: 1rem; 3577 + text-align: left; 3578 + } 3579 + 3580 + .api-playground-tabs { 3581 + display: flex; 3582 + gap: 0.4rem; 3583 + flex-wrap: wrap; 3584 + border-bottom: 1px solid rgba(14, 20, 40, 0.08); 3585 + padding-bottom: 0.6rem; 3586 + } 3587 + 3588 + .api-playground-tab { 3589 + padding: 0.45rem 1rem; 3590 + border-radius: 999px; 3591 + border: 1px solid transparent; 3592 + background: transparent; 3593 + font: inherit; 3594 + font-size: 0.85rem; 3595 + color: rgba(14, 20, 40, 0.7); 3596 + cursor: pointer; 3597 + } 3598 + 3599 + .api-playground-tab.is-active { 3600 + background: rgba(42, 90, 168, 0.12); 3601 + border-color: rgba(42, 90, 168, 0.28); 3602 + color: #1f4f96; 3603 + font-weight: 500; 3604 + } 3605 + 3606 + .api-playground-form { 3607 + display: flex; 3608 + flex-direction: column; 3609 + gap: 0.85rem; 3610 + } 3611 + 3612 + .api-playground-grid { 3613 + display: grid; 3614 + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); 3615 + gap: 0.75rem; 3616 + } 3617 + 3618 + .api-playground-field { 3619 + display: flex; 3620 + flex-direction: column; 3621 + gap: 0.3rem; 3622 + } 3623 + 3624 + .api-playground-label { 3625 + font-size: 0.75rem; 3626 + font-weight: 500; 3627 + letter-spacing: 0.02em; 3628 + color: rgba(14, 20, 40, 0.65); 3629 + text-transform: uppercase; 3630 + } 3631 + 3632 + .api-playground-input { 3633 + padding: 0.55rem 0.8rem; 3634 + border-radius: 10px; 3635 + border: 1px solid rgba(14, 20, 40, 0.14); 3636 + background: rgba(255, 255, 255, 0.85); 3637 + font: inherit; 3638 + font-size: 0.9rem; 3639 + color: #0e1428; 3640 + } 3641 + 3642 + .api-playground-input:focus { 3643 + outline: none; 3644 + border-color: rgba(42, 90, 168, 0.55); 3645 + box-shadow: 0 0 0 3px rgba(42, 90, 168, 0.15); 3646 + } 3647 + 3648 + .api-playground-actions { 3649 + display: flex; 3650 + align-items: center; 3651 + gap: 0.8rem; 3652 + flex-wrap: wrap; 3653 + } 3654 + 3655 + .api-playground-fetch { 3656 + padding: 0.6rem 1.4rem; 3657 + border-radius: 999px; 3658 + border: 0; 3659 + background: #0e1428; 3660 + color: #fff; 3661 + font: inherit; 3662 + font-weight: 500; 3663 + cursor: pointer; 3664 + } 3665 + 3666 + .api-playground-fetch:disabled { 3667 + opacity: 0.55; 3668 + cursor: not-allowed; 3669 + } 3670 + 3671 + /* Inline preview of the resolved request URL — monospace so devs can 3672 + * eyeball the query string without copying. */ 3673 + .api-playground-url { 3674 + font-family: "IBM Plex Mono", monospace; 3675 + font-size: 0.8rem; 3676 + color: rgba(14, 20, 40, 0.7); 3677 + background: rgba(14, 20, 40, 0.05); 3678 + padding: 0.3rem 0.6rem; 3679 + border-radius: 8px; 3680 + overflow: hidden; 3681 + text-overflow: ellipsis; 3682 + white-space: nowrap; 3683 + max-width: 100%; 3684 + } 3685 + 3686 + .api-playground-response { 3687 + display: flex; 3688 + flex-direction: column; 3689 + gap: 0.5rem; 3690 + padding-top: 0.5rem; 3691 + border-top: 1px solid rgba(14, 20, 40, 0.08); 3692 + } 3693 + 3694 + .api-playground-response-header { 3695 + display: flex; 3696 + align-items: center; 3697 + gap: 0.6rem; 3698 + } 3699 + 3700 + .api-playground-status { 3701 + font-family: "IBM Plex Mono", monospace; 3702 + font-size: 0.8rem; 3703 + padding: 0.15rem 0.55rem; 3704 + border-radius: 999px; 3705 + font-weight: 500; 3706 + } 3707 + 3708 + .api-playground-status.is-ok { 3709 + background: rgba(44, 136, 84, 0.12); 3710 + color: #2c8854; 3711 + } 3712 + 3713 + .api-playground-status.is-err { 3714 + background: rgba(194, 80, 72, 0.12); 3715 + color: #c25048; 3716 + } 3717 + 3718 + .api-playground-error { 3719 + font-size: 0.85rem; 3720 + color: #c25048; 3721 + margin: 0; 3722 + } 3723 + 3724 + .api-playground-pre { 3725 + margin: 0; 3726 + padding: 0.85rem 1rem; 3727 + border-radius: 12px; 3728 + background: #0e1428; 3729 + color: rgba(245, 247, 250, 0.95); 3730 + font-family: "IBM Plex Mono", monospace; 3731 + font-size: 0.78rem; 3732 + line-height: 1.55; 3733 + max-height: 360px; 3734 + overflow: auto; 3735 + white-space: pre; 3736 + } 3737 + 3738 + .api-playground-snippets { 3739 + display: grid; 3740 + gap: 0.85rem; 3741 + grid-template-columns: 1fr; 3742 + } 3743 + 3744 + @media (min-width: 720px) { 3745 + .api-playground-snippets { 3746 + grid-template-columns: 1fr 1fr; 3747 + } 3748 + } 3749 + 3750 + .api-playground-snippet { 3751 + display: flex; 3752 + flex-direction: column; 3753 + gap: 0.4rem; 3754 + } 3755 + 3756 + .api-playground-snippet-header { 3757 + display: flex; 3758 + align-items: center; 3759 + justify-content: space-between; 3760 + gap: 0.5rem; 3761 + } 3762 + 3763 + .api-playground-copy { 3764 + padding: 0.3rem 0.7rem; 3765 + border-radius: 999px; 3766 + border: 1px solid rgba(42, 90, 168, 0.28); 3767 + background: rgba(42, 90, 168, 0.08); 3768 + color: #1f4f96; 3769 + font: inherit; 3770 + font-size: 0.75rem; 3771 + cursor: pointer; 3772 + } 3773 + 3774 + .api-playground-copy:hover { 3775 + background: rgba(42, 90, 168, 0.16); 3776 + } 3777 + 3778 + /* ---- Endpoint reference ---- */ 3779 + 3780 + .api-endpoints { 3781 + margin: 1.25rem 0 0; 3782 + display: flex; 3783 + flex-direction: column; 3784 + gap: 1rem; 3785 + } 3786 + 3787 + .api-endpoint { 3788 + padding: 1rem 1.1rem; 3789 + border-radius: 14px; 3790 + border: 1px solid rgba(14, 20, 40, 0.08); 3791 + background: rgba(255, 255, 255, 0.5); 3792 + display: flex; 3793 + flex-direction: column; 3794 + gap: 0.4rem; 3795 + } 3796 + 3797 + .api-endpoint-path { 3798 + display: flex; 3799 + flex-wrap: wrap; 3800 + align-items: center; 3801 + gap: 0.5rem; 3802 + margin: 0; 3803 + font-family: "IBM Plex Mono", monospace; 3804 + font-size: 0.85rem; 3805 + color: #0e1428; 3806 + } 3807 + 3808 + .api-endpoint-method { 3809 + display: inline-block; 3810 + padding: 0.1rem 0.55rem; 3811 + border-radius: 6px; 3812 + background: rgba(42, 90, 168, 0.14); 3813 + color: #1f4f96; 3814 + font-weight: 600; 3815 + font-size: 0.75rem; 3816 + } 3817 + 3818 + .api-endpoint-params { 3819 + font-size: 0.78rem; 3820 + color: rgba(14, 20, 40, 0.6); 3821 + } 3822 + 3823 + .api-endpoint-summary { 3824 + margin: 0; 3825 + font-size: 0.9rem; 3826 + color: rgba(14, 20, 40, 0.78); 3827 + line-height: 1.5; 3828 + } 3829 + 3830 + .api-endpoint-cache { 3831 + margin: 0; 3832 + font-size: 0.75rem; 3833 + color: rgba(14, 20, 40, 0.55); 3834 + } 3835 + 3836 + .api-endpoint-cache code { 3837 + font-family: "IBM Plex Mono", monospace; 3838 + } 3839 + 3840 + /* Dark phase — keep the playground readable when the sky is in its 3841 + * darker pass. The /developer-resources page forces sky-static today 3842 + * (effectsOff allowlist), but mobile and reduced-motion users may 3843 + * still hit the dark phase, so we cover the cases. */ 3844 + .dark-phase .api-playground { 3845 + background: rgba(20, 26, 48, 0.55); 3846 + border-color: rgba(255, 255, 255, 0.12); 3847 + } 3848 + .dark-phase .api-playground-tab { 3849 + color: rgba(245, 247, 250, 0.7); 3850 + } 3851 + .dark-phase .api-playground-tab.is-active { 3852 + background: rgba(150, 180, 240, 0.18); 3853 + color: #cfdfff; 3854 + border-color: rgba(150, 180, 240, 0.32); 3855 + } 3856 + .dark-phase .api-playground-input { 3857 + background: rgba(20, 26, 48, 0.6); 3858 + border-color: rgba(255, 255, 255, 0.18); 3859 + color: rgba(245, 247, 250, 0.95); 3860 + } 3861 + .dark-phase .api-playground-fetch { 3862 + background: rgba(245, 247, 250, 0.92); 3863 + color: #0e1428; 3864 + } 3865 + .dark-phase .api-endpoint { 3866 + background: rgba(20, 26, 48, 0.45); 3867 + border-color: rgba(255, 255, 255, 0.12); 3868 + } 3869 + .dark-phase .api-endpoint-summary, 3870 + .dark-phase .api-endpoint-cache { 3871 + color: rgba(245, 247, 250, 0.78); 3872 + } 3873 + .dark-phase .api-endpoint-path { 3874 + color: rgba(245, 247, 250, 0.95); 3875 + }
+56
components/DeveloperResources.tsx
··· 1 1 import { useT } from "../i18n/mod.ts"; 2 + import RegistryApiPlayground from "../islands/RegistryApiPlayground.tsx"; 2 3 3 4 export default function DeveloperResources() { 4 5 const t = useT(); 6 + const tApi = t.developerResources.api; 5 7 return ( 6 8 <> 7 9 <section class="section-sm reveal"> ··· 54 56 class="badge-download-btn font-mono" 55 57 > 56 58 {t.developerResources.downloadIcons} 59 + </a> 60 + </div> 61 + </div> 62 + </section> 63 + 64 + {/* Profile API: interactive playground + endpoint reference. The 65 + endpoint reference is server-rendered so it's discoverable 66 + without JS; the playground itself is a small island that 67 + handles fetch + copy interactions client-side. */} 68 + <section class="section-sm reveal"> 69 + <div class="container-narrow"> 70 + <div class="text-center"> 71 + <h2 class="text-subsection">{tApi.heading}</h2> 72 + <div class="divider" /> 73 + <p class="text-body mt-2 mb-4">{tApi.intro}</p> 74 + </div> 75 + 76 + <RegistryApiPlayground /> 77 + 78 + <h3 class="text-subsection mt-5">{tApi.endpointsHeading}</h3> 79 + <div class="divider" /> 80 + <dl class="api-endpoints"> 81 + {(["profile", "search", "featured", "avatar"] as const).map( 82 + (key) => { 83 + const e = tApi.endpoints[key]; 84 + return ( 85 + <div class="api-endpoint" key={key}> 86 + <dt class="api-endpoint-path"> 87 + <span class="api-endpoint-method">{e.method}</span> 88 + <code>{e.path}</code> 89 + {"params" in e && e.params && ( 90 + <code class="api-endpoint-params">{e.params}</code> 91 + )} 92 + </dt> 93 + <dd class="api-endpoint-summary">{e.summary}</dd> 94 + <dd class="api-endpoint-cache"> 95 + <code>cache-control: {e.cache}</code> 96 + </dd> 97 + </div> 98 + ); 99 + }, 100 + )} 101 + </dl> 102 + 103 + <h3 class="text-subsection mt-5">{tApi.schemaHeading}</h3> 104 + <div class="divider" /> 105 + <p class="text-body mt-2 mb-3">{tApi.schemaBody}</p> 106 + <div class="badge-downloads"> 107 + <a 108 + href="/lexicons/com.atmosphereaccount.registry.profile.json" 109 + download="com.atmosphereaccount.registry.profile.json" 110 + class="badge-download-btn font-mono" 111 + > 112 + {tApi.downloadLexicon} 57 113 </a> 58 114 </div> 59 115 </div>
+70
i18n/messages/en.tsx
··· 286 286 "The Lottie animation and the image assets embedded inside it (logos and artwork used in the sequence).", 287 287 downloadLottie: "Download Lottie (JSON)", 288 288 downloadIcons: "Download icons (ZIP)", 289 + api: { 290 + heading: "Profile API", 291 + intro: 292 + "Pull registry profiles into your app — sign-in flows, discovery, info pages. All endpoints are public, JSON, and cached at the edge. Soft-rate-limited at ~60 requests per minute per IP.", 293 + tabs: { 294 + profile: "By handle / DID", 295 + search: "Search", 296 + featured: "Featured", 297 + }, 298 + fields: { 299 + profileId: "Handle or DID", 300 + searchQuery: "Search query", 301 + category: "Category", 302 + anyCategory: "Any category", 303 + subcategory: "Subcategory", 304 + anySubcategory: "Any subcategory", 305 + page: "Page", 306 + pageSize: "Page size", 307 + limit: "Limit", 308 + }, 309 + placeholders: { 310 + profileId: "alice.bsky.social or did:plc:…", 311 + searchQuery: "e.g. photo", 312 + }, 313 + fetch: "Send request", 314 + fetching: "Sending…", 315 + response: "Response", 316 + copy: "Copy", 317 + copied: "Copied", 318 + errors: { 319 + missingId: "Enter a handle or DID first.", 320 + }, 321 + endpointsHeading: "Endpoints", 322 + endpoints: { 323 + profile: { 324 + method: "GET", 325 + path: "/api/registry/profile/:handleOrDid", 326 + summary: 327 + "Single profile by handle or DID. Returns the same shape as /explore/<handle>, plus a fully-qualified avatarUrl convenience field.", 328 + cache: "public, max-age=30, s-maxage=120", 329 + }, 330 + search: { 331 + method: "GET", 332 + path: "/api/registry/search", 333 + summary: 334 + "Paginated profile search. Filter by free-text query, category, and subcategory. Returns { profiles, total, page, pageSize }.", 335 + params: "?q=&category=&subcategory=&page=1&pageSize=24", 336 + cache: "public, max-age=10, s-maxage=30", 337 + }, 338 + featured: { 339 + method: "GET", 340 + path: "/api/registry/featured", 341 + summary: 342 + "Curated featured list, ordered by position. Returns { profiles }.", 343 + params: "?limit=12", 344 + cache: "public, max-age=30, s-maxage=120", 345 + }, 346 + avatar: { 347 + method: "GET", 348 + path: "/api/registry/avatar/:did", 349 + summary: 350 + "Avatar bytes for the given DID — proxied + cached from the user's PDS. Long cache headers; safe to use directly in <img src>.", 351 + cache: "public, max-age=3600, s-maxage=86400", 352 + }, 353 + }, 354 + schemaHeading: "Schema", 355 + schemaBody: 356 + "Profiles are AT Protocol records. The canonical schema is the lexicon below — use it to validate records, generate types, or browse the full field set.", 357 + downloadLexicon: "Download lexicon (JSON)", 358 + }, 289 359 }, 290 360 291 361 lottie: {
+360
islands/RegistryApiPlayground.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { useT } from "../i18n/mod.ts"; 3 + import { APP_SUBCATEGORIES, CATEGORIES } from "../lib/lexicons.ts"; 4 + 5 + /** The three read endpoints exposed in the playground. */ 6 + type EndpointKind = "profile" | "search" | "featured"; 7 + 8 + /** A built request: the URL to fetch + the params we used to build it 9 + * (kept separately so the snippet generators don't have to re-parse). */ 10 + interface BuiltRequest { 11 + url: string; 12 + /** Just the path + query (e.g. `/api/registry/profile/alice.bsky.social`). */ 13 + pathAndQuery: string; 14 + } 15 + 16 + /** 17 + * Interactive Profile API playground rendered inside the developer 18 + * resources page. Lets devs try each public read endpoint without 19 + * leaving the page, and copy the matching cURL / fetch snippet 20 + * straight into their own code. 21 + * 22 + * Intentionally dependency-free: no syntax highlighter, no tabs lib — 23 + * everything is a tiny amount of inline JSX so the island bundle stays 24 + * small (this is a reference page, not a hot path). 25 + */ 26 + export default function RegistryApiPlayground() { 27 + const t = useT(); 28 + const tApi = t.developerResources.api; 29 + const tCat = t.categories; 30 + const tSub = t.subcategories; 31 + 32 + const kind = useSignal<EndpointKind>("profile"); 33 + 34 + // Per-tab form state. Kept as separate signals so switching tabs 35 + // doesn't stomp the values you typed in the other tab. 36 + const profileId = useSignal<string>(""); 37 + const searchQuery = useSignal<string>(""); 38 + const searchCategory = useSignal<string>(""); 39 + const searchSubcategory = useSignal<string>(""); 40 + const searchPage = useSignal<string>("1"); 41 + const searchPageSize = useSignal<string>("24"); 42 + const featuredLimit = useSignal<string>("12"); 43 + 44 + // Response state. 45 + const loading = useSignal<boolean>(false); 46 + const responseStatus = useSignal<number | null>(null); 47 + const responseBody = useSignal<string>(""); 48 + const errorMessage = useSignal<string | null>(null); 49 + 50 + /** Build the request URL for whatever tab is currently active. */ 51 + function buildRequest(): BuiltRequest | null { 52 + const origin = globalThis.location?.origin ?? ""; 53 + if (kind.value === "profile") { 54 + const id = profileId.value.trim(); 55 + if (!id) return null; 56 + const path = `/api/registry/profile/${encodeURIComponent(id)}`; 57 + return { url: `${origin}${path}`, pathAndQuery: path }; 58 + } 59 + if (kind.value === "search") { 60 + const params = new URLSearchParams(); 61 + const q = searchQuery.value.trim(); 62 + if (q) params.set("q", q); 63 + if (searchCategory.value) params.set("category", searchCategory.value); 64 + const sub = searchSubcategory.value.trim(); 65 + if (sub) params.set("subcategory", sub); 66 + const page = Number(searchPage.value) || 1; 67 + if (page !== 1) params.set("page", String(page)); 68 + const pageSize = Number(searchPageSize.value) || 24; 69 + if (pageSize !== 24) params.set("pageSize", String(pageSize)); 70 + const qs = params.toString(); 71 + const path = `/api/registry/search${qs ? `?${qs}` : ""}`; 72 + return { url: `${origin}${path}`, pathAndQuery: path }; 73 + } 74 + // featured 75 + const params = new URLSearchParams(); 76 + const limit = Number(featuredLimit.value) || 12; 77 + if (limit !== 12) params.set("limit", String(limit)); 78 + const qs = params.toString(); 79 + const path = `/api/registry/featured${qs ? `?${qs}` : ""}`; 80 + return { url: `${origin}${path}`, pathAndQuery: path }; 81 + } 82 + 83 + async function onFetch() { 84 + const built = buildRequest(); 85 + if (!built) { 86 + errorMessage.value = tApi.errors.missingId; 87 + return; 88 + } 89 + loading.value = true; 90 + errorMessage.value = null; 91 + responseStatus.value = null; 92 + responseBody.value = ""; 93 + try { 94 + const res = await fetch(built.url, { 95 + headers: { accept: "application/json" }, 96 + }); 97 + responseStatus.value = res.status; 98 + const text = await res.text(); 99 + // Try to pretty-print JSON; fall back to raw text on parse error. 100 + try { 101 + responseBody.value = JSON.stringify(JSON.parse(text), null, 2); 102 + } catch { 103 + responseBody.value = text; 104 + } 105 + } catch (err) { 106 + errorMessage.value = err instanceof Error ? err.message : String(err); 107 + } finally { 108 + loading.value = false; 109 + } 110 + } 111 + 112 + async function copy(text: string, key: string) { 113 + try { 114 + await navigator.clipboard.writeText(text); 115 + copiedKey.value = key; 116 + setTimeout(() => { 117 + if (copiedKey.value === key) copiedKey.value = null; 118 + }, 1500); 119 + } catch { 120 + // Best effort — older browsers / insecure contexts get nothing. 121 + } 122 + } 123 + const copiedKey = useSignal<string | null>(null); 124 + 125 + const built = buildRequest(); 126 + const curlSnippet = built ? `curl -sSL '${built.url}'` : ""; 127 + const fetchSnippet = built 128 + ? [ 129 + `const res = await fetch('${built.url}', {`, 130 + ` headers: { accept: 'application/json' },`, 131 + `});`, 132 + `const data = await res.json();`, 133 + ].join("\n") 134 + : ""; 135 + 136 + return ( 137 + <div class="api-playground"> 138 + <div class="api-playground-tabs" role="tablist"> 139 + {(["profile", "search", "featured"] as const).map((k) => ( 140 + <button 141 + key={k} 142 + type="button" 143 + role="tab" 144 + aria-selected={kind.value === k} 145 + class={`api-playground-tab ${ 146 + kind.value === k ? "is-active" : "" 147 + }`} 148 + onClick={() => (kind.value = k)} 149 + > 150 + {tApi.tabs[k]} 151 + </button> 152 + ))} 153 + </div> 154 + 155 + <div class="api-playground-form"> 156 + {kind.value === "profile" && ( 157 + <label class="api-playground-field"> 158 + <span class="api-playground-label">{tApi.fields.profileId}</span> 159 + <input 160 + type="text" 161 + class="api-playground-input" 162 + placeholder={tApi.placeholders.profileId} 163 + value={profileId.value} 164 + onInput={(e) => 165 + (profileId.value = (e.currentTarget as HTMLInputElement).value)} 166 + onKeyDown={(e) => { 167 + if (e.key === "Enter") { 168 + e.preventDefault(); 169 + onFetch(); 170 + } 171 + }} 172 + /> 173 + </label> 174 + )} 175 + 176 + {kind.value === "search" && ( 177 + <div class="api-playground-grid"> 178 + <label class="api-playground-field"> 179 + <span class="api-playground-label">{tApi.fields.searchQuery}</span> 180 + <input 181 + type="text" 182 + class="api-playground-input" 183 + placeholder={tApi.placeholders.searchQuery} 184 + value={searchQuery.value} 185 + onInput={(e) => 186 + (searchQuery.value = 187 + (e.currentTarget as HTMLInputElement).value)} 188 + /> 189 + </label> 190 + <label class="api-playground-field"> 191 + <span class="api-playground-label">{tApi.fields.category}</span> 192 + <select 193 + class="api-playground-input" 194 + value={searchCategory.value} 195 + onChange={(e) => 196 + (searchCategory.value = 197 + (e.currentTarget as HTMLSelectElement).value)} 198 + > 199 + <option value="">{tApi.fields.anyCategory}</option> 200 + {CATEGORIES.map((c) => ( 201 + <option key={c} value={c}>{tCat[c]}</option> 202 + ))} 203 + </select> 204 + </label> 205 + <label class="api-playground-field"> 206 + <span class="api-playground-label"> 207 + {tApi.fields.subcategory} 208 + </span> 209 + <select 210 + class="api-playground-input" 211 + value={searchSubcategory.value} 212 + onChange={(e) => 213 + (searchSubcategory.value = 214 + (e.currentTarget as HTMLSelectElement).value)} 215 + > 216 + <option value="">{tApi.fields.anySubcategory}</option> 217 + {APP_SUBCATEGORIES.map((s) => ( 218 + <option key={s} value={s}>{tSub[s]}</option> 219 + ))} 220 + </select> 221 + </label> 222 + <label class="api-playground-field"> 223 + <span class="api-playground-label">{tApi.fields.page}</span> 224 + <input 225 + type="number" 226 + min={1} 227 + class="api-playground-input" 228 + value={searchPage.value} 229 + onInput={(e) => 230 + (searchPage.value = 231 + (e.currentTarget as HTMLInputElement).value)} 232 + /> 233 + </label> 234 + <label class="api-playground-field"> 235 + <span class="api-playground-label">{tApi.fields.pageSize}</span> 236 + <input 237 + type="number" 238 + min={1} 239 + max={48} 240 + class="api-playground-input" 241 + value={searchPageSize.value} 242 + onInput={(e) => 243 + (searchPageSize.value = 244 + (e.currentTarget as HTMLInputElement).value)} 245 + /> 246 + </label> 247 + </div> 248 + )} 249 + 250 + {kind.value === "featured" && ( 251 + <label class="api-playground-field"> 252 + <span class="api-playground-label">{tApi.fields.limit}</span> 253 + <input 254 + type="number" 255 + min={1} 256 + max={48} 257 + class="api-playground-input" 258 + value={featuredLimit.value} 259 + onInput={(e) => 260 + (featuredLimit.value = 261 + (e.currentTarget as HTMLInputElement).value)} 262 + /> 263 + </label> 264 + )} 265 + 266 + <div class="api-playground-actions"> 267 + <button 268 + type="button" 269 + class="api-playground-fetch" 270 + onClick={onFetch} 271 + disabled={loading.value || !built} 272 + > 273 + {loading.value ? tApi.fetching : tApi.fetch} 274 + </button> 275 + {built && ( 276 + <code class="api-playground-url" title={built.pathAndQuery}> 277 + GET {built.pathAndQuery} 278 + </code> 279 + )} 280 + </div> 281 + </div> 282 + 283 + {(responseStatus.value !== null || errorMessage.value) && ( 284 + <div class="api-playground-response"> 285 + <div class="api-playground-response-header"> 286 + <span class="api-playground-label">{tApi.response}</span> 287 + {responseStatus.value !== null && ( 288 + <span 289 + class={`api-playground-status ${ 290 + responseStatus.value >= 200 && responseStatus.value < 300 291 + ? "is-ok" 292 + : "is-err" 293 + }`} 294 + > 295 + {responseStatus.value} 296 + </span> 297 + )} 298 + </div> 299 + {errorMessage.value && ( 300 + <p class="api-playground-error">{errorMessage.value}</p> 301 + )} 302 + {responseBody.value && ( 303 + <pre class="api-playground-pre"><code>{responseBody.value}</code></pre> 304 + )} 305 + </div> 306 + )} 307 + 308 + {built && ( 309 + <div class="api-playground-snippets"> 310 + <Snippet 311 + label="cURL" 312 + text={curlSnippet} 313 + copied={copiedKey.value === "curl"} 314 + onCopy={() => copy(curlSnippet, "curl")} 315 + copyLabel={tApi.copy} 316 + copiedLabel={tApi.copied} 317 + /> 318 + <Snippet 319 + label="JavaScript" 320 + text={fetchSnippet} 321 + copied={copiedKey.value === "fetch"} 322 + onCopy={() => copy(fetchSnippet, "fetch")} 323 + copyLabel={tApi.copy} 324 + copiedLabel={tApi.copied} 325 + /> 326 + </div> 327 + )} 328 + </div> 329 + ); 330 + } 331 + 332 + interface SnippetProps { 333 + label: string; 334 + text: string; 335 + copied: boolean; 336 + onCopy: () => void; 337 + copyLabel: string; 338 + copiedLabel: string; 339 + } 340 + 341 + function Snippet( 342 + { label, text, copied, onCopy, copyLabel, copiedLabel }: SnippetProps, 343 + ) { 344 + return ( 345 + <div class="api-playground-snippet"> 346 + <div class="api-playground-snippet-header"> 347 + <span class="api-playground-label">{label}</span> 348 + <button 349 + type="button" 350 + class="api-playground-copy" 351 + onClick={onCopy} 352 + aria-live="polite" 353 + > 354 + {copied ? copiedLabel : copyLabel} 355 + </button> 356 + </div> 357 + <pre class="api-playground-pre"><code>{text}</code></pre> 358 + </div> 359 + ); 360 + }
+118
lib/rate-limit.ts
··· 1 + /** 2 + * Soft per-IP rate limit for the public read API. 3 + * 4 + * Implementation note: this is a tiny in-memory token bucket keyed by 5 + * the caller's IP. On Deno Deploy, isolates are per-region and 6 + * relatively short-lived, so this is a deterrent — not a hard fence. 7 + * It cleanly catches scripted abuse without standing up a Turso table 8 + * or a Redis dependency. 9 + * 10 + * If we ever need cross-region enforcement (or hard limits), swap the 11 + * `Map` for a Turso-backed counter or per-key Edge Config row — the 12 + * `withRateLimit` wrapper signature stays the same. 13 + */ 14 + import { define } from "../utils.ts"; 15 + 16 + /** Bucket capacity (max burst). */ 17 + const CAPACITY = 60; 18 + /** 19 + * Refill window in milliseconds. The bucket refills linearly so a 20 + * caller making 1 req/sec sustains forever; bursts above CAPACITY 21 + * within REFILL_MS get a 429. 22 + */ 23 + const REFILL_MS = 60_000; 24 + 25 + interface Bucket { 26 + /** Tokens currently available (float; refills linearly). */ 27 + tokens: number; 28 + /** Last time we refilled, ms since epoch. */ 29 + last: number; 30 + } 31 + 32 + const buckets = new Map<string, Bucket>(); 33 + 34 + /** 35 + * Lightweight LRU-ish trim — when the map gets large we drop entries 36 + * we haven't seen recently. Keeps memory bounded under a sustained 37 + * scan from a botnet without a full LRU impl. 38 + */ 39 + function maybeTrim(now: number): void { 40 + if (buckets.size < 2_000) return; 41 + for (const [ip, b] of buckets) { 42 + if (now - b.last > REFILL_MS * 5) buckets.delete(ip); 43 + } 44 + } 45 + 46 + /** Returns true if the request is allowed; false if it should be rejected. */ 47 + function take(ip: string, now: number): boolean { 48 + let b = buckets.get(ip); 49 + if (!b) { 50 + b = { tokens: CAPACITY, last: now }; 51 + buckets.set(ip, b); 52 + maybeTrim(now); 53 + } else { 54 + const elapsed = now - b.last; 55 + if (elapsed > 0) { 56 + b.tokens = Math.min(CAPACITY, b.tokens + (elapsed / REFILL_MS) * CAPACITY); 57 + b.last = now; 58 + } 59 + } 60 + if (b.tokens < 1) return false; 61 + b.tokens -= 1; 62 + return true; 63 + } 64 + 65 + /** 66 + * Best-effort caller IP. Behind Deno Deploy / Fly we have to trust 67 + * `x-forwarded-for`; we take the first hop (original client) and fall 68 + * back to a synthetic key so we never crash on an empty header. The 69 + * synthetic fallback lumps anonymous callers into one bucket, which 70 + * is fine for "soft" limiting. 71 + */ 72 + function callerIp(req: Request): string { 73 + const xff = req.headers.get("x-forwarded-for"); 74 + if (xff) { 75 + const first = xff.split(",")[0]?.trim(); 76 + if (first) return first; 77 + } 78 + const real = req.headers.get("x-real-ip"); 79 + if (real) return real.trim(); 80 + return "anonymous"; 81 + } 82 + 83 + // deno-lint-ignore no-explicit-any 84 + type FreshHandler = (ctx: any) => Response | Promise<Response>; 85 + 86 + /** 87 + * Wrap a Fresh handler with the soft rate limit. Use as: 88 + * 89 + * GET: withRateLimit(async (ctx) => { ... }) 90 + * 91 + * On 429 we return a small JSON body and a `Retry-After` header (in 92 + * seconds) so well-behaved clients can back off. 93 + */ 94 + export function withRateLimit<H extends FreshHandler>(handler: H): H { 95 + return ((ctx) => { 96 + const ip = callerIp(ctx.req); 97 + const now = Date.now(); 98 + if (!take(ip, now)) { 99 + return new Response( 100 + JSON.stringify({ error: "rate_limited" }), 101 + { 102 + status: 429, 103 + headers: { 104 + "content-type": "application/json; charset=utf-8", 105 + // Suggest waiting a full window for capacity to refill; 106 + // callers can retry sooner since the bucket refills linearly. 107 + "retry-after": String(Math.ceil(REFILL_MS / 1000)), 108 + }, 109 + }, 110 + ); 111 + } 112 + return handler(ctx); 113 + }) as H; 114 + } 115 + 116 + // Re-export the Fresh `define` so callers don't need a second import 117 + // when adding a new rate-limited endpoint. 118 + export { define };
+3 -2
routes/api/registry/avatar/[did].ts
··· 6 6 import { define } from "../../../../utils.ts"; 7 7 import { getProfileByDid } from "../../../../lib/registry.ts"; 8 8 import { fetchBlobPublic } from "../../../../lib/pds.ts"; 9 + import { withRateLimit } from "../../../../lib/rate-limit.ts"; 9 10 10 11 export const handler = define.handlers({ 11 - async GET(ctx) { 12 + GET: withRateLimit(async (ctx) => { 12 13 const did = decodeURIComponent(ctx.params.did); 13 14 const profile = await getProfileByDid(did).catch(() => null); 14 15 if (!profile || !profile.avatarCid) { ··· 37 38 console.warn("avatar proxy error:", err); 38 39 return new Response("upstream error", { status: 502 }); 39 40 } 40 - }, 41 + }), 41 42 });
+3 -2
routes/api/registry/featured.ts
··· 1 1 import { define } from "../../../utils.ts"; 2 2 import { listFeaturedProfiles } from "../../../lib/registry.ts"; 3 + import { withRateLimit } from "../../../lib/rate-limit.ts"; 3 4 4 5 export const handler = define.handlers({ 5 - async GET(ctx) { 6 + GET: withRateLimit(async (ctx) => { 6 7 const limit = Number(ctx.url.searchParams.get("limit") ?? "12") || 12; 7 8 const profiles = await listFeaturedProfiles( 8 9 Math.min(48, Math.max(1, limit)), ··· 13 14 "cache-control": "public, max-age=30, s-maxage=120", 14 15 }, 15 16 }); 16 - }, 17 + }), 17 18 });
+71
routes/api/registry/profile/[id].ts
··· 1 + /** 2 + * Public read endpoint: fetch a single registry profile by handle or DID. 3 + * 4 + * GET /api/registry/profile/alice.bsky.social 5 + * GET /api/registry/profile/did:plc:abc123... 6 + * 7 + * Returns the same `ProfileRow` shape that powers the SSR 8 + * `/explore/[handle]` page so the public response stays in sync with 9 + * the rendered profile view. 10 + * 11 + * Adds one synthesised convenience field — `avatarUrl` — derived from 12 + * the request origin so callers don't have to know about the 13 + * `/api/registry/avatar/<did>` proxy route. `null` when the profile 14 + * has no avatar set. 15 + */ 16 + import { define } from "../../../../utils.ts"; 17 + import { 18 + getProfileByDid, 19 + getProfileByHandle, 20 + type ProfileRow, 21 + } from "../../../../lib/registry.ts"; 22 + import { withRateLimit } from "../../../../lib/rate-limit.ts"; 23 + 24 + interface PublicProfileResponse extends ProfileRow { 25 + /** Fully-qualified URL for the profile's avatar, or null if unset. */ 26 + avatarUrl: string | null; 27 + } 28 + 29 + export const handler = define.handlers({ 30 + GET: withRateLimit(async (ctx) => { 31 + const raw = decodeURIComponent(ctx.params.id ?? "").trim(); 32 + if (!raw) { 33 + return jsonError(400, "missing_id"); 34 + } 35 + 36 + // DIDs always start with `did:`; everything else is treated as a 37 + // handle. We don't normalise handles to lowercase here because the 38 + // DB stores them lowercase already and Fresh routes are 39 + // case-sensitive — callers should match the registry's canonical 40 + // lowercase form. 41 + const profile = raw.startsWith("did:") 42 + ? await getProfileByDid(raw).catch(() => null) 43 + : await getProfileByHandle(raw.toLowerCase()).catch(() => null); 44 + 45 + if (!profile) { 46 + return jsonError(404, "not_found"); 47 + } 48 + 49 + const origin = new URL(ctx.req.url).origin; 50 + const body: PublicProfileResponse = { 51 + ...profile, 52 + avatarUrl: profile.avatarCid 53 + ? `${origin}/api/registry/avatar/${encodeURIComponent(profile.did)}` 54 + : null, 55 + }; 56 + 57 + return new Response(JSON.stringify(body), { 58 + headers: { 59 + "content-type": "application/json; charset=utf-8", 60 + "cache-control": "public, max-age=30, s-maxage=120", 61 + }, 62 + }); 63 + }), 64 + }); 65 + 66 + function jsonError(status: number, code: string): Response { 67 + return new Response(JSON.stringify({ error: code }), { 68 + status, 69 + headers: { "content-type": "application/json; charset=utf-8" }, 70 + }); 71 + }
+3 -2
routes/api/registry/search.ts
··· 1 1 import { define } from "../../../utils.ts"; 2 2 import { CATEGORIES } from "../../../lib/lexicons.ts"; 3 3 import { searchProfiles } from "../../../lib/registry.ts"; 4 + import { withRateLimit } from "../../../lib/rate-limit.ts"; 4 5 5 6 export const handler = define.handlers({ 6 - async GET(ctx) { 7 + GET: withRateLimit(async (ctx) => { 7 8 const url = ctx.url; 8 9 const q = url.searchParams.get("q") ?? undefined; 9 10 const categoryRaw = url.searchParams.get("category") ?? undefined; ··· 28 29 "cache-control": "public, max-age=10, s-maxage=30", 29 30 }, 30 31 }); 31 - }, 32 + }), 32 33 });