this repo has no description
0
fork

Configure Feed

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

refactor(registry): split license into own lexicon, replace website/repoUrl with links[], add developerTool

Profile lexicon now stores outbound links as a `links[]` array of
`{kind, url, label?}` entries so the surface stays minimal and any new
link kind (donate, mastodon, matrix, discord, contact, …) is additive
without a new field. The previous `website`, `repoUrl`, and `openSource`
fields are removed.

License moves to its own record (`com.atmosphereaccount.registry.license`,
key=self) so the profile lexicon stays small and we can grow license
metadata independently. The form still presents both as a single Save
action; the API writes/deletes both records and the index joins them by
DID so badges keep working.

`developerTool` is now a recognised category. Categories use lexicon
`knownValues`, so adding more later remains a non-breaking change.

Other changes:
- New lib/link-kinds.ts: kind→icon/label resolver. `repo` defers to
detectRepoHost() so GitHub/Tangled branding still surfaces.
- pds.ts gains a generic deleteRecord() for the license sibling record.
- registry.ts: profile rows now carry a `links: LinkEntry[]` and
optional joined `license`. New upsertLicense / deleteLicense /
getLicenseByDid helpers; deleteProfile cascades to license.
- worker/indexer.ts subscribes to the license collection too.
- Form: dynamic links editor (add/remove/inline kind picker) and a
collapsed license section with type/SPDX/url/notes.
- Wipe script drops the new license table.

The Turso index has been wiped to drop legacy columns; lib/db.ts will
recreate the schema on first request.

Made-with: Cursor

+1276 -238
+92
assets/styles.css
··· 2576 2576 color: rgba(255, 255, 255, 0.6); 2577 2577 } 2578 2578 2579 + /** 2580 + * Smaller variant of the field label used inside compound fieldsets 2581 + * (links editor row, license sub-fields). Matches the hint typography 2582 + * so nested fields don't compete with the parent legend. 2583 + */ 2584 + .profile-form-label--small { 2585 + font-size: 0.78rem; 2586 + font-weight: 600; 2587 + text-transform: none; 2588 + letter-spacing: 0; 2589 + color: rgba(18, 26, 47, 0.7); 2590 + } 2591 + .dark-phase .profile-form-label--small { 2592 + color: rgba(255, 255, 255, 0.75); 2593 + } 2594 + .profile-form-empty { 2595 + font-size: 0.85rem; 2596 + color: rgba(18, 26, 47, 0.55); 2597 + font-style: italic; 2598 + margin: 0.25rem 0 0.5rem; 2599 + } 2600 + .dark-phase .profile-form-empty { 2601 + color: rgba(255, 255, 255, 0.55); 2602 + } 2603 + 2604 + /** 2605 + * Links editor: each row is a 3-column grid (kind | url | label) with a 2606 + * trailing "Remove" link. On narrow screens the grid collapses to a 2607 + * single column so the inputs stay usable on phones. 2608 + */ 2609 + .link-editor-list { 2610 + display: flex; 2611 + flex-direction: column; 2612 + gap: 0.55rem; 2613 + margin-top: 0.4rem; 2614 + } 2615 + .link-editor-row { 2616 + display: grid; 2617 + grid-template-columns: 9rem minmax(0, 2fr) minmax(0, 1.5fr) auto; 2618 + gap: 0.45rem; 2619 + align-items: center; 2620 + padding: 0.55rem; 2621 + border-radius: 0.7rem; 2622 + border: 1px solid rgba(18, 26, 47, 0.12); 2623 + background: rgba(255, 255, 255, 0.4); 2624 + } 2625 + .dark-phase .link-editor-row { 2626 + background: rgba(255, 255, 255, 0.05); 2627 + border-color: rgba(255, 255, 255, 0.15); 2628 + } 2629 + .link-editor-kind, 2630 + .link-editor-url, 2631 + .link-editor-label { 2632 + min-width: 0; 2633 + } 2634 + .link-editor-remove { 2635 + white-space: nowrap; 2636 + font-size: 0.8rem; 2637 + } 2638 + .link-editor-add { 2639 + margin-top: 0.6rem; 2640 + align-self: flex-start; 2641 + } 2642 + @media (max-width: 640px) { 2643 + .link-editor-row { 2644 + grid-template-columns: 1fr; 2645 + gap: 0.35rem; 2646 + } 2647 + .link-editor-remove { 2648 + justify-self: flex-end; 2649 + } 2650 + } 2651 + 2652 + /** 2653 + * License section sits inside the same fields column as the rest of the 2654 + * form. The faint inset background visually groups its sub-fields without 2655 + * adding a heavy border that would clash with the glass card. 2656 + */ 2657 + .profile-form-license { 2658 + padding: 0.85rem 0.95rem; 2659 + border-radius: 0.7rem; 2660 + background: rgba(255, 255, 255, 0.32); 2661 + border: 1px solid rgba(18, 26, 47, 0.08); 2662 + } 2663 + .dark-phase .profile-form-license { 2664 + background: rgba(255, 255, 255, 0.04); 2665 + border-color: rgba(255, 255, 255, 0.1); 2666 + } 2667 + 2579 2668 .profile-form-chips { 2580 2669 display: flex; 2581 2670 gap: 0.4rem; ··· 2740 2829 .signin-form-preview-wrap { 2741 2830 position: relative; 2742 2831 overflow: visible; 2832 + display: flex; 2833 + flex-direction: column; 2834 + gap: 0.85rem; 2743 2835 } 2744 2836 /* Dropdown floats above page chrome (nav z-index 101). */ 2745 2837 .signin-form-preview {
+1 -1
components/explore/ProfileCard.tsx
··· 55 55 {tCat[c] ?? c} 56 56 </span> 57 57 ))} 58 - {profile.openSource && ( 58 + {profile.license?.type === "openSource" && ( 59 59 <span class="profile-card-sub profile-card-sub--oss"> 60 60 {t.explore.detail.openSourceBadge} 61 61 </span>
+1 -1
components/explore/ProfileHero.tsx
··· 51 51 {tCat[c] ?? c} 52 52 </span> 53 53 ))} 54 - {profile.openSource && ( 54 + {profile.license?.type === "openSource" && ( 55 55 <span class="profile-card-sub profile-card-sub--oss"> 56 56 {t.explore.detail.openSourceBadge} 57 57 </span>
+55 -55
components/explore/ProfileLinks.tsx
··· 1 1 import type { ProfileRow } from "../../lib/registry.ts"; 2 2 import { getBskyClient } from "../../lib/bsky-clients.ts"; 3 - import { 4 - detectRepoHost, 5 - trimRepoUrlForDisplay, 6 - } from "../../lib/repo-hosts.ts"; 3 + import { resolveLink } from "../../lib/link-kinds.ts"; 7 4 import { useT } from "../../i18n/mod.ts"; 8 5 9 6 interface Props { 10 7 profile: ProfileRow; 11 8 } 12 9 13 - function trimUrlForDisplay(url: string): string { 14 - try { 15 - const u = new URL(url); 16 - return `${u.host}${u.pathname.replace(/\/$/, "")}`; 17 - } catch { 18 - return url; 19 - } 20 - } 21 - 10 + /** 11 + * Renders the public profile's action buttons: 12 + * 1. The Bluesky button (always shown — every Atmosphere account has one). 13 + * 2. Each entry from `profile.links`, in author-defined order. 14 + * 3. A "View license" button when the joined license row provides a URL. 15 + * 16 + * Link kind → icon/label mapping lives in `lib/link-kinds.ts` so the 17 + * editor (CreateProfileForm) and this view stay in sync. 18 + */ 22 19 export default function ProfileLinks({ profile }: Props) { 23 - const t = useT().explore.detail; 20 + const t = useT(); 21 + const tDetail = t.explore.detail; 22 + const tLink = t.linkKinds; 24 23 const client = getBskyClient(profile.bskyClient); 25 24 const bskyHref = client.profileUrl(profile.handle); 26 - const repoHost = profile.repoUrl ? detectRepoHost(profile.repoUrl) : null; 27 25 28 26 return ( 29 27 <div class="profile-actions"> ··· 42 40 /> 43 41 <span class="profile-action-label"> 44 42 <span class="profile-action-title"> 45 - {t.openOn} {client.name} 43 + {tDetail.openOn} {client.name} 46 44 </span> 47 45 <span class="profile-action-sub">{client.domain}</span> 48 46 </span> 49 47 </a> 50 - {profile.website && ( 51 - <a 52 - class="profile-action" 53 - href={profile.website} 54 - target="_blank" 55 - rel="noopener noreferrer" 56 - > 57 - <span class="profile-action-icon profile-action-icon--glyph"> 58 - 59 - </span> 60 - <span class="profile-action-label"> 61 - <span class="profile-action-title">{t.website}</span> 62 - <span class="profile-action-sub"> 63 - {trimUrlForDisplay(profile.website)} 48 + 49 + {profile.links.map((entry) => { 50 + const r = resolveLink(entry, tLink); 51 + return ( 52 + <a 53 + class="profile-action" 54 + href={r.url} 55 + target="_blank" 56 + rel="noopener noreferrer" 57 + key={r.url} 58 + > 59 + {r.iconUrl 60 + ? ( 61 + <img 62 + src={r.iconUrl} 63 + alt="" 64 + class="profile-action-icon" 65 + loading="lazy" 66 + decoding="async" 67 + /> 68 + ) 69 + : ( 70 + <span class="profile-action-icon profile-action-icon--glyph"> 71 + {r.glyph} 72 + </span> 73 + )} 74 + <span class="profile-action-label"> 75 + <span class="profile-action-title">{r.title}</span> 76 + <span class="profile-action-sub">{r.subtitle}</span> 64 77 </span> 65 - </span> 66 - </a> 67 - )} 68 - {profile.repoUrl && repoHost && ( 78 + </a> 79 + ); 80 + })} 81 + 82 + {profile.license?.licenseUrl && ( 69 83 <a 70 84 class="profile-action" 71 - href={profile.repoUrl} 85 + href={profile.license.licenseUrl} 72 86 target="_blank" 73 87 rel="noopener noreferrer" 74 88 > 75 - {repoHost.iconUrl 76 - ? ( 77 - <img 78 - src={repoHost.iconUrl} 79 - alt="" 80 - class="profile-action-icon" 81 - loading="lazy" 82 - decoding="async" 83 - /> 84 - ) 85 - : ( 86 - <span class="profile-action-icon profile-action-icon--glyph"> 87 - {/* Generic "code" glyph for self-hosted Forgejo / Gitea / etc. */} 88 - {"</>"} 89 - </span> 90 - )} 89 + <span class="profile-action-icon profile-action-icon--glyph">©</span> 91 90 <span class="profile-action-label"> 92 91 <span class="profile-action-title"> 93 - {repoHost.id === "other" 94 - ? t.sourceCode 95 - : `${t.sourceOn} ${repoHost.name}`} 92 + {tDetail.license.viewLicense} 96 93 </span> 97 94 <span class="profile-action-sub"> 98 - {trimRepoUrlForDisplay(profile.repoUrl)} 95 + {profile.license.spdxId ?? 96 + (t.licenseTypes as Record<string, string>)[ 97 + profile.license.type 98 + ] ?? profile.license.type} 99 99 </span> 100 100 </span> 101 101 </a>
+62 -13
i18n/messages/en.tsx
··· 309 309 accountProvider: "Account provider", 310 310 moderator: "Moderator", 311 311 infrastructure: "Infrastructure", 312 + developerTool: "Developer tool", 312 313 all: "All", 313 314 }, 314 315 ··· 331 332 official: "Official", 332 333 }, 333 334 335 + /** 336 + * Display labels for `LinkEntry.kind`. The "repoOnPrefix" / "repoGeneric" 337 + * pair are used when a link kind is "repo" — the resolver appends the 338 + * detected host name ("Source on GitHub") or falls back to the generic 339 + * label when the host can't be identified. 340 + */ 341 + linkKinds: { 342 + website: "Website", 343 + repo: "Source repository", 344 + repoOnPrefix: "Source on", 345 + repoGeneric: "Source code", 346 + donate: "Donate", 347 + docs: "Documentation", 348 + mastodon: "Mastodon", 349 + matrix: "Matrix", 350 + discord: "Discord", 351 + contact: "Contact", 352 + other: "Link", 353 + }, 354 + 355 + /** Public-facing labels for `LicenseRecord.type`. */ 356 + licenseTypes: { 357 + openSource: "Open source", 358 + sourceAvailable: "Source available", 359 + proprietary: "Proprietary", 360 + }, 361 + 334 362 explore: { 335 363 metaTitle: "Explore — Atmosphere Account", 336 364 metaDescription: ··· 352 380 poweredByYou: 353 381 "Powered by you — every entry is created and signed by the project's own Atmosphere account.", 354 382 detail: { 355 - website: "Website", 356 383 openOn: "Open on", 357 - sourceOn: "Source on", 358 - sourceCode: "Source code", 359 - openSourceBadge: "Open source", 360 384 lastUpdated: "Last updated", 361 385 hostedOn: "Hosted on", 362 386 editProfile: "Edit this profile", ··· 365 389 categoryLabel: "Category", 366 390 notFoundTitle: "404", 367 391 notFoundBody: "We couldn't find a profile for that handle.", 392 + /** Compact badge on cards/hero. Spelled out for the public profile. */ 393 + openSourceBadge: "Open source", 394 + /** License section on the public profile. */ 395 + license: { 396 + viewLicense: "View license", 397 + }, 368 398 }, 369 399 create: { 370 400 eyebrow: "Add to Explore", ··· 413 443 "Pick all that apply. A project can be both an app and an account provider.", 414 444 subcategoriesLabel: "Subcategories (optional)", 415 445 subcategoriesHint: "For apps. Pick up to a few.", 416 - websiteLabel: "Website", 417 - websitePlaceholder: "https://yourproject.com", 418 - repoUrlLabel: "Source repository (optional)", 419 - repoUrlHint: 420 - "Tangled or GitHub URL — we'll show a button on your profile with the matching icon.", 421 - repoUrlPlaceholder: "https://github.com/yourorg/yourproject", 422 - openSourceLabel: "This project is open source", 423 - openSourceHint: 424 - "Adds a small \"Open source\" badge to your profile so visitors know.", 425 446 bskyClientLabel: "Bluesky client", 426 447 bskyClientHint: 427 448 "Pick which client opens when visitors click the Bluesky button on your profile. Your handle works on all of them.", ··· 433 454 avatarTooLarge: "Avatar must be 1MB or smaller.", 434 455 confirmDelete: "Remove your project from Explore?", 435 456 categoryRequired: "Pick at least one category.", 457 + links: { 458 + sectionLabel: "Links", 459 + sectionHint: 460 + "Website, source repo, donate, docs, chat — anything you want on your profile. Drag to reorder later.", 461 + addButton: "Add link", 462 + removeButton: "Remove", 463 + kindLabel: "Type", 464 + urlLabel: "URL", 465 + urlPlaceholder: "https://…", 466 + labelLabel: "Label", 467 + labelPlaceholderOther: "What is this? (e.g. Press kit)", 468 + labelHelp: 'Optional override. Required for "Other".', 469 + emptyHint: 'No links yet. Click "Add link" to add your website, repo, or anything else.', 470 + }, 471 + license: { 472 + sectionLabel: "License", 473 + sectionHint: 474 + "Tell visitors how the project is licensed. Stored as a separate record on your PDS.", 475 + typeLabel: "License type", 476 + typeNone: "Don't say (no license info shown)", 477 + spdxLabel: "SPDX identifier (optional)", 478 + spdxHint: 'For example: "MIT", "Apache-2.0", "AGPL-3.0", "BUSL-1.1".', 479 + spdxPlaceholder: "MIT", 480 + urlLabel: "License URL (optional)", 481 + urlPlaceholder: "https://github.com/yourorg/yourproject/blob/main/LICENSE", 482 + notesLabel: "Notes (optional)", 483 + notesPlaceholder: 'e.g. "Server is AGPL-3.0; mobile clients are MIT"', 484 + }, 436 485 }, 437 486 }, 438 487
+219 -49
islands/CreateProfileForm.tsx
··· 4 4 APP_SUBCATEGORIES, 5 5 CATEGORIES, 6 6 type Category, 7 + LICENSE_TYPES, 8 + type LinkEntry, 7 9 } from "../lib/lexicons.ts"; 10 + import { LINK_KIND_ORDER } from "../lib/link-kinds.ts"; 8 11 import { BSKY_CLIENTS, DEFAULT_BSKY_CLIENT_ID } from "../lib/bsky-clients.ts"; 9 12 import { useT } from "../i18n/mod.ts"; 10 13 14 + interface ExistingLicense { 15 + type: string; 16 + spdxId: string | null; 17 + licenseUrl: string | null; 18 + notes: string | null; 19 + } 20 + 11 21 interface ExistingProfile { 12 22 name: string; 13 23 description: string; ··· 15 25 * first item is the primary, used for sort/grouping in lists. */ 16 26 categories: string[]; 17 27 subcategories: string[]; 18 - website: string | null; 19 - repoUrl: string | null; 20 - openSource: boolean; 28 + links: LinkEntry[]; 21 29 bskyClient: string | null; 22 30 avatar: { ref: string; mime: string } | null; 31 + /** Joined license record, if the user has published one. */ 32 + license: ExistingLicense | null; 23 33 } 24 34 25 35 interface Props { ··· 60 70 ) { 61 71 const t = useT(); 62 72 const tForm = t.forms.profile; 73 + const tLink = t.linkKinds; 74 + const tLicense = t.licenseTypes as Record<string, string>; 63 75 const tManage = t.explore.manage; 64 76 /** Live registry status. Flips on save (-> true) and delete (-> false). 65 77 * Drives the colored pill that tells the user whether their entry is ··· 74 86 initial?.categories?.length ? initial.categories : ["app"], 75 87 ); 76 88 const subcategories = useSignal<string[]>(initial?.subcategories ?? []); 77 - const website = useSignal(initial?.website ?? ""); 78 - const repoUrl = useSignal(initial?.repoUrl ?? ""); 79 - const openSource = useSignal<boolean>(initial?.openSource ?? false); 89 + /** Local-only signal: signals don't deep-track array element mutations, 90 + * so each edit replaces the entire array. */ 91 + const links = useSignal<LinkEntry[]>(initial?.links ?? []); 80 92 const bskyClient = useSignal<string>( 81 93 initial?.bskyClient ?? DEFAULT_BSKY_CLIENT_ID, 82 94 ); 95 + /** 96 + * License state. `licenseType === ""` means "don't publish a license 97 + * record" — the form sends `license: null` in that case so the API 98 + * deletes any existing record. 99 + */ 100 + const licenseType = useSignal<string>(initial?.license?.type ?? ""); 101 + const licenseSpdx = useSignal<string>(initial?.license?.spdxId ?? ""); 102 + const licenseUrl = useSignal<string>(initial?.license?.licenseUrl ?? ""); 103 + const licenseNotes = useSignal<string>(initial?.license?.notes ?? ""); 104 + 83 105 const avatarKeep = useSignal<BlobRefShape | null>(null); 84 106 /** Preview URL precedence: locally-picked file blob > existing registry 85 107 * record (cached proxy) > prefill source (Bluesky PDS getBlob) > none. */ ··· 138 160 */ 139 161 const showSubcategories = categories.value.includes("app"); 140 162 163 + /* ---------- Links editor helpers --------------------------------------- */ 164 + const addLink = (kind: string = "website") => { 165 + if (links.value.length >= 12) return; 166 + links.value = [...links.value, { kind, url: "", label: "" }]; 167 + }; 168 + const removeLink = (index: number) => { 169 + links.value = links.value.filter((_, i) => i !== index); 170 + }; 171 + const updateLink = (index: number, patch: Partial<LinkEntry>) => { 172 + links.value = links.value.map((entry, i) => 173 + i === index ? { ...entry, ...patch } : entry 174 + ); 175 + }; 176 + 141 177 const onAvatarChange = (event: Event) => { 142 178 const input = event.currentTarget as HTMLInputElement; 143 179 const file = input.files?.[0]; ··· 170 206 message.value = null; 171 207 172 208 try { 209 + // Sanitise links: drop empty rows; require URL on every kept row; 210 + // for kind="other", require a label. Mirroring the validator means 211 + // the user gets fast client-side feedback. 212 + const cleanedLinks: LinkEntry[] = []; 213 + for (const l of links.value) { 214 + const url = (l.url ?? "").trim(); 215 + if (!url) continue; 216 + const kind = (l.kind ?? "").trim() || "other"; 217 + const label = (l.label ?? "").trim(); 218 + if (kind === "other" && !label) { 219 + throw new Error(`Add a label for the "${tLink.other}" link or remove it.`); 220 + } 221 + const entry: LinkEntry = { kind, url }; 222 + if (label) entry.label = label; 223 + cleanedLinks.push(entry); 224 + } 225 + 173 226 const payload: Record<string, unknown> = { 174 227 name: name.value.trim(), 175 228 description: description.value.trim(), 176 229 categories: categories.value, 177 230 subcategories: showSubcategories ? subcategories.value : [], 178 - website: website.value.trim() || undefined, 179 - repoUrl: repoUrl.value.trim() || undefined, 180 - openSource: openSource.value, 231 + links: cleanedLinks, 181 232 bskyClient: bskyClient.value || undefined, 182 233 }; 183 234 if (avatarFile.value) { ··· 191 242 payload.avatar = null; 192 243 } 193 244 245 + // License sub-record. Empty type = "don't publish" → null tells the 246 + // API to delete any existing license record so the badge goes away. 247 + if (licenseType.value) { 248 + payload.license = { 249 + type: licenseType.value, 250 + spdxId: licenseSpdx.value.trim() || undefined, 251 + licenseUrl: licenseUrl.value.trim() || undefined, 252 + notes: licenseNotes.value.trim() || undefined, 253 + }; 254 + } else { 255 + payload.license = null; 256 + } 257 + 194 258 const res = await fetch("/api/registry/profile", { 195 259 method: "PUT", 196 260 headers: { "content-type": "application/json" }, ··· 200 264 const text = await res.text(); 201 265 throw new Error(text || `HTTP ${res.status}`); 202 266 } 267 + const json = await res.json().catch(() => ({})) as { 268 + licenseWarning?: string | null; 269 + }; 203 270 published.value = true; 204 - message.value = { kind: "ok", text: tManage.savedToast }; 271 + message.value = json.licenseWarning 272 + ? { kind: "error", text: json.licenseWarning } 273 + : { kind: "ok", text: tManage.savedToast }; 205 274 } catch (err) { 206 275 message.value = { 207 276 kind: "error", ··· 379 448 </fieldset> 380 449 )} 381 450 382 - <label class="profile-form-field"> 383 - <span class="profile-form-label">{tForm.websiteLabel}</span> 384 - <input 385 - type="url" 386 - placeholder={tForm.websitePlaceholder} 387 - value={website.value} 388 - onInput={(e) => 389 - website.value = (e.currentTarget as HTMLInputElement).value} 390 - class="profile-form-input" 391 - /> 392 - </label> 393 - 394 - <label class="profile-form-field"> 395 - <span class="profile-form-label">{tForm.repoUrlLabel}</span> 396 - <input 397 - type="url" 398 - placeholder={tForm.repoUrlPlaceholder} 399 - value={repoUrl.value} 400 - onInput={(e) => 401 - repoUrl.value = (e.currentTarget as HTMLInputElement).value} 402 - class="profile-form-input" 403 - /> 404 - <p class="profile-form-hint">{tForm.repoUrlHint}</p> 405 - </label> 451 + {/* ---------------- Links editor ----------------------------- */} 452 + <fieldset class="profile-form-field"> 453 + <legend class="profile-form-label">{tForm.links.sectionLabel}</legend> 454 + <p class="profile-form-hint">{tForm.links.sectionHint}</p> 455 + {links.value.length === 0 && ( 456 + <p class="profile-form-empty">{tForm.links.emptyHint}</p> 457 + )} 458 + <div class="link-editor-list"> 459 + {links.value.map((entry, i) => ( 460 + <div class="link-editor-row" key={i}> 461 + <select 462 + class="profile-form-input link-editor-kind" 463 + value={entry.kind} 464 + onChange={(e) => 465 + updateLink(i, { 466 + kind: (e.currentTarget as HTMLSelectElement).value, 467 + })} 468 + aria-label={tForm.links.kindLabel} 469 + > 470 + {LINK_KIND_ORDER.map((k) => ( 471 + <option value={k} key={k}> 472 + {tLink[k] ?? k} 473 + </option> 474 + ))} 475 + </select> 476 + <input 477 + type="url" 478 + class="profile-form-input link-editor-url" 479 + placeholder={tForm.links.urlPlaceholder} 480 + value={entry.url} 481 + onInput={(e) => 482 + updateLink(i, { 483 + url: (e.currentTarget as HTMLInputElement).value, 484 + })} 485 + aria-label={tForm.links.urlLabel} 486 + /> 487 + <input 488 + type="text" 489 + class="profile-form-input link-editor-label" 490 + placeholder={entry.kind === "other" 491 + ? tForm.links.labelPlaceholderOther 492 + : tForm.links.labelLabel} 493 + value={entry.label ?? ""} 494 + maxLength={64} 495 + onInput={(e) => 496 + updateLink(i, { 497 + label: (e.currentTarget as HTMLInputElement).value, 498 + })} 499 + aria-label={tForm.links.labelLabel} 500 + /> 501 + <button 502 + type="button" 503 + class="profile-form-button-link link-editor-remove" 504 + onClick={() => removeLink(i)} 505 + > 506 + {tForm.links.removeButton} 507 + </button> 508 + </div> 509 + ))} 510 + </div> 511 + <button 512 + type="button" 513 + class="profile-form-button-secondary link-editor-add" 514 + onClick={() => addLink("website")} 515 + disabled={links.value.length >= 12} 516 + > 517 + + {tForm.links.addButton} 518 + </button> 519 + </fieldset> 406 520 407 - <label class="profile-form-toggle"> 408 - <input 409 - type="checkbox" 410 - checked={openSource.value} 411 - onChange={(e) => 412 - openSource.value = (e.currentTarget as HTMLInputElement).checked} 413 - /> 414 - <span class="profile-form-toggle-body"> 415 - <span class="profile-form-toggle-label"> 416 - {tForm.openSourceLabel} 417 - </span> 418 - <span class="profile-form-toggle-hint"> 419 - {tForm.openSourceHint} 521 + {/* ---------------- License section ------------------------- */} 522 + <fieldset class="profile-form-field profile-form-license"> 523 + <legend class="profile-form-label">{tForm.license.sectionLabel}</legend> 524 + <p class="profile-form-hint">{tForm.license.sectionHint}</p> 525 + <label class="profile-form-field"> 526 + <span class="profile-form-label profile-form-label--small"> 527 + {tForm.license.typeLabel} 420 528 </span> 421 - </span> 422 - </label> 529 + <select 530 + class="profile-form-input" 531 + value={licenseType.value} 532 + onChange={(e) => 533 + licenseType.value = 534 + (e.currentTarget as HTMLSelectElement).value} 535 + > 536 + <option value="">{tForm.license.typeNone}</option> 537 + {LICENSE_TYPES.map((lt) => ( 538 + <option value={lt} key={lt}>{tLicense[lt] ?? lt}</option> 539 + ))} 540 + </select> 541 + </label> 542 + 543 + {licenseType.value && ( 544 + <> 545 + <label class="profile-form-field"> 546 + <span class="profile-form-label profile-form-label--small"> 547 + {tForm.license.spdxLabel} 548 + </span> 549 + <input 550 + type="text" 551 + class="profile-form-input" 552 + placeholder={tForm.license.spdxPlaceholder} 553 + maxLength={64} 554 + value={licenseSpdx.value} 555 + onInput={(e) => 556 + licenseSpdx.value = 557 + (e.currentTarget as HTMLInputElement).value} 558 + /> 559 + <p class="profile-form-hint">{tForm.license.spdxHint}</p> 560 + </label> 561 + <label class="profile-form-field"> 562 + <span class="profile-form-label profile-form-label--small"> 563 + {tForm.license.urlLabel} 564 + </span> 565 + <input 566 + type="url" 567 + class="profile-form-input" 568 + placeholder={tForm.license.urlPlaceholder} 569 + value={licenseUrl.value} 570 + onInput={(e) => 571 + licenseUrl.value = 572 + (e.currentTarget as HTMLInputElement).value} 573 + /> 574 + </label> 575 + <label class="profile-form-field"> 576 + <span class="profile-form-label profile-form-label--small"> 577 + {tForm.license.notesLabel} 578 + </span> 579 + <input 580 + type="text" 581 + class="profile-form-input" 582 + placeholder={tForm.license.notesPlaceholder} 583 + maxLength={280} 584 + value={licenseNotes.value} 585 + onInput={(e) => 586 + licenseNotes.value = 587 + (e.currentTarget as HTMLInputElement).value} 588 + /> 589 + </label> 590 + </> 591 + )} 592 + </fieldset> 423 593 424 594 <fieldset class="profile-form-field"> 425 595 <legend class="profile-form-label">{tForm.bskyClientLabel}</legend>
+46
lexicons/com/atmosphereaccount/registry/license.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atmosphereaccount.registry.license", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "License / source-availability declaration for a project in the Atmosphere registry. Intentionally separate from the profile record so the profile stays focused on identity/branding and so this can be extended (e.g. third-party attestations) without touching the profile lexicon. One record per project, on the project's own PDS.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": ["type", "createdAt"], 12 + "properties": { 13 + "type": { 14 + "type": "string", 15 + "knownValues": [ 16 + "openSource", 17 + "sourceAvailable", 18 + "proprietary" 19 + ], 20 + "description": "Distribution model. \"openSource\" = OSI-style FOSS (free to fork/redistribute). \"sourceAvailable\" = code is public but not freely redistributable (e.g. BSL, SSPL, FUSL). \"proprietary\" = closed source / commercial." 21 + }, 22 + "spdxId": { 23 + "type": "string", 24 + "maxLength": 64, 25 + "description": "SPDX license identifier when applicable (e.g. \"MIT\", \"Apache-2.0\", \"AGPL-3.0\", \"BUSL-1.1\"). Optional, but encouraged for openSource and sourceAvailable." 26 + }, 27 + "licenseUrl": { 28 + "type": "string", 29 + "format": "uri", 30 + "maxLength": 256, 31 + "description": "Direct link to the project's LICENSE file or license page." 32 + }, 33 + "notes": { 34 + "type": "string", 35 + "maxLength": 280, 36 + "description": "Optional free-form note (e.g. \"server is AGPL-3.0; mobile clients are MIT\")." 37 + }, 38 + "createdAt": { 39 + "type": "string", 40 + "format": "datetime" 41 + } 42 + } 43 + } 44 + } 45 + } 46 + }
+43 -17
lexicons/com/atmosphereaccount/registry/profile.json
··· 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", 7 - "description": "A project's profile in the Atmosphere registry. Created by the project's own account on its PDS; one record per account.", 7 + "description": "A project's profile in the Atmosphere registry. Created by the project's own account on its PDS; one record per account. Kept intentionally minimal — anything that could plausibly stand on its own (license, reviews, age ratings, etc.) is published as a sibling record under com.atmosphereaccount.registry.* so this lexicon can stay stable.", 8 8 "key": "literal:self", 9 9 "record": { 10 10 "type": "object", ··· 40 40 "app", 41 41 "accountProvider", 42 42 "moderator", 43 - "infrastructure" 43 + "infrastructure", 44 + "developerTool" 44 45 ] 45 46 }, 46 - "description": "All categories that apply to the project. A project can be both an app and an account provider, etc. The first item is treated as the primary category for sort/grouping." 47 + "description": "All categories that apply to the project. A project can be both an app and an account provider, etc. The first item is treated as the primary category for sort/grouping. New values can be added to knownValues over time without breaking existing records." 47 48 }, 48 49 "subcategories": { 49 50 "type": "array", ··· 54 55 }, 55 56 "description": "Open-ended subcategory tags. For apps: microblog, photo, video, blogging, music, events, clients, tools, social, reading, productivity." 56 57 }, 57 - "website": { 58 - "type": "string", 59 - "format": "uri", 60 - "maxLength": 256 61 - }, 62 - "repoUrl": { 63 - "type": "string", 64 - "format": "uri", 65 - "maxLength": 256, 66 - "description": "Source repository URL. Tangled and GitHub URLs render with the matching icon on the public profile." 67 - }, 68 - "openSource": { 69 - "type": "boolean", 70 - "description": "True if the project is open source. Adds an \"Open source\" badge on the public profile." 58 + "links": { 59 + "type": "array", 60 + "maxLength": 12, 61 + "items": { 62 + "type": "ref", 63 + "ref": "#linkEntry" 64 + }, 65 + "description": "Outbound links shown on the public profile (website, source repo, donate, docs, fediverse, chat, etc.). Order is preserved; the first \"website\" is treated as the primary." 71 66 }, 72 67 "bskyClient": { 73 68 "type": "string", ··· 84 79 "type": "string", 85 80 "format": "datetime" 86 81 } 82 + } 83 + } 84 + }, 85 + "linkEntry": { 86 + "type": "object", 87 + "required": ["kind", "url"], 88 + "properties": { 89 + "kind": { 90 + "type": "string", 91 + "knownValues": [ 92 + "website", 93 + "repo", 94 + "donate", 95 + "docs", 96 + "mastodon", 97 + "matrix", 98 + "discord", 99 + "contact", 100 + "other" 101 + ], 102 + "description": "Link kind. Drives the icon/label shown on the profile. \"other\" falls back to a generic glyph and uses `label` for the title." 103 + }, 104 + "url": { 105 + "type": "string", 106 + "format": "uri", 107 + "maxLength": 512 108 + }, 109 + "label": { 110 + "type": "string", 111 + "maxLength": 64, 112 + "description": "Optional override label. Required when kind=\"other\"; defaults to the kind's display name otherwise." 87 113 } 88 114 } 89 115 }
+24 -14
lib/db.ts
··· 72 72 description TEXT NOT NULL, 73 73 categories TEXT NOT NULL DEFAULT '[]', 74 74 subcategories TEXT NOT NULL DEFAULT '[]', 75 - website TEXT, 76 - repo_url TEXT, 77 - open_source INTEGER NOT NULL DEFAULT 0, 75 + links TEXT NOT NULL DEFAULT '[]', 78 76 bsky_client TEXT, 79 77 avatar_cid TEXT, 80 78 avatar_mime TEXT, ··· 102 100 INSERT INTO profile_fts(rowid, name, description) 103 101 VALUES (new.rowid, new.name, new.description); 104 102 END`, 103 + // License is its own record on the project's PDS (com.atmosphereaccount 104 + // .registry.license/self), joined to profile by DID. Splitting it out 105 + // keeps the profile lexicon small and lets us extend license metadata 106 + // (or add other sibling records like reviews / age ratings) without 107 + // touching the profile schema. 108 + `CREATE TABLE IF NOT EXISTS license ( 109 + did TEXT PRIMARY KEY, 110 + type TEXT NOT NULL, 111 + spdx_id TEXT, 112 + license_url TEXT, 113 + notes TEXT, 114 + pds_url TEXT NOT NULL, 115 + record_cid TEXT NOT NULL, 116 + record_rev TEXT NOT NULL, 117 + created_at INTEGER NOT NULL, 118 + indexed_at INTEGER NOT NULL 119 + )`, 105 120 `CREATE TABLE IF NOT EXISTS featured ( 106 121 did TEXT PRIMARY KEY, 107 122 badges TEXT NOT NULL DEFAULT '[]', ··· 141 156 * Additive migrations applied after the base schema. SQLite has no 142 157 * `ADD COLUMN IF NOT EXISTS`, so we attempt the ALTER and swallow the 143 158 * "duplicate column" error. SQLite makes column drops painful, so legacy 144 - * columns we no longer use (e.g. the old single-value `category`, `tags`) 145 - * are just left around and ignored — running `scripts/wipe-registry.ts` 146 - * recreates the table cleanly when desired. 159 + * columns we no longer use (e.g. the old single-value `category`, `tags`, 160 + * the pre-`links[]` `website`/`repo_url`/`open_source`) are just left 161 + * around and ignored — running `scripts/wipe-registry.ts` recreates the 162 + * table cleanly when desired. 147 163 */ 148 164 async function applyAdditiveMigrations( 149 165 c: { execute: (s: string) => Promise<unknown> }, ··· 163 179 }, 164 180 { 165 181 table: "profile", 166 - column: "repo_url", 167 - ddl: "ALTER TABLE profile ADD COLUMN repo_url TEXT", 168 - }, 169 - { 170 - table: "profile", 171 - column: "open_source", 172 - ddl: 173 - "ALTER TABLE profile ADD COLUMN open_source INTEGER NOT NULL DEFAULT 0", 182 + column: "links", 183 + ddl: "ALTER TABLE profile ADD COLUMN links TEXT NOT NULL DEFAULT '[]'", 174 184 }, 175 185 ]; 176 186 for (const m of additiveColumns) {
+145 -18
lib/lexicons.ts
··· 9 9 10 10 export const PROFILE_NSID = "com.atmosphereaccount.registry.profile"; 11 11 export const FEATURED_NSID = "com.atmosphereaccount.registry.featured"; 12 + export const LICENSE_NSID = "com.atmosphereaccount.registry.license"; 12 13 13 - export const REGISTRY_NSIDS = [PROFILE_NSID, FEATURED_NSID] as const; 14 + export const REGISTRY_NSIDS = [ 15 + PROFILE_NSID, 16 + FEATURED_NSID, 17 + LICENSE_NSID, 18 + ] as const; 14 19 15 20 export const CATEGORIES = [ 16 21 "app", 17 22 "accountProvider", 18 23 "moderator", 19 24 "infrastructure", 25 + "developerTool", 20 26 ] as const; 21 27 export type Category = typeof CATEGORIES[number]; 22 28 ··· 38 44 export const FEATURED_BADGES = ["verified", "official"] as const; 39 45 export type FeaturedBadge = typeof FEATURED_BADGES[number]; 40 46 47 + /** 48 + * Recognised link kinds. The lexicon stores `kind` as an open string with 49 + * `knownValues`, so adding new kinds later is a non-breaking change — old 50 + * records with unknown kinds just render with the "other" fallback icon. 51 + */ 52 + export const LINK_KINDS = [ 53 + "website", 54 + "repo", 55 + "donate", 56 + "docs", 57 + "mastodon", 58 + "matrix", 59 + "discord", 60 + "contact", 61 + "other", 62 + ] as const; 63 + export type LinkKind = typeof LINK_KINDS[number]; 64 + 65 + export interface LinkEntry { 66 + kind: string; 67 + url: string; 68 + /** Optional display override; required for kind="other". */ 69 + label?: string; 70 + } 71 + 72 + export const LICENSE_TYPES = [ 73 + "openSource", 74 + "sourceAvailable", 75 + "proprietary", 76 + ] as const; 77 + export type LicenseType = typeof LICENSE_TYPES[number]; 78 + 41 79 export interface BlobRef { 42 80 $type: "blob"; 43 81 ref: { $link: string }; ··· 54 92 * primary category used for sort/grouping in lists. */ 55 93 categories: string[]; 56 94 subcategories?: string[]; 57 - website?: string; 58 - /** Source repo URL — host (GitHub / Tangled / other) is auto-detected. */ 59 - repoUrl?: string; 60 - /** True if the project is open source (drives the small profile badge). */ 61 - openSource?: boolean; 95 + /** Outbound links shown on the public profile (website, repo, donate, …). */ 96 + links?: LinkEntry[]; 62 97 /** Preferred Bluesky client (bluesky | blacksky | anisota | deer | witchsky). */ 63 98 bskyClient?: string; 99 + createdAt: string; 100 + } 101 + 102 + export interface LicenseRecord { 103 + $type?: typeof LICENSE_NSID; 104 + type: string; 105 + spdxId?: string; 106 + licenseUrl?: string; 107 + notes?: string; 64 108 createdAt: string; 65 109 } 66 110 ··· 112 156 error?: string; 113 157 } 114 158 159 + /** 160 + * Normalise + validate a links[] array. Drops empties, dedupes by URL, 161 + * caps at 12 to match the lexicon, and enforces the "other requires 162 + * label" rule. Unknown kinds are accepted (lexicon `knownValues` is a 163 + * hint, not a constraint). 164 + */ 165 + function normalizeLinks(input: unknown): { 166 + ok: true; 167 + value: LinkEntry[]; 168 + } | { ok: false; error: string } { 169 + if (input === undefined) return { ok: true, value: [] }; 170 + if (!Array.isArray(input)) { 171 + return { ok: false, error: "links: must be an array" }; 172 + } 173 + if (input.length > 12) return { ok: false, error: "links: at most 12" }; 174 + const seen = new Set<string>(); 175 + const out: LinkEntry[] = []; 176 + for (const raw of input) { 177 + if (!raw || typeof raw !== "object") { 178 + return { ok: false, error: "links: items must be objects" }; 179 + } 180 + const e = raw as Record<string, unknown>; 181 + if (!isStr(e.kind, 32)) { 182 + return { ok: false, error: "links[].kind: string required" }; 183 + } 184 + if (!isUrl(e.url)) { 185 + return { ok: false, error: "links[].url: must be http(s) URL" }; 186 + } 187 + const url = (e.url as string).trim(); 188 + if (seen.has(url)) continue; 189 + seen.add(url); 190 + const entry: LinkEntry = { kind: e.kind as string, url }; 191 + if (e.label !== undefined) { 192 + if (!isStr(e.label, 64)) { 193 + return { ok: false, error: "links[].label: string <=64" }; 194 + } 195 + const label = (e.label as string).trim(); 196 + if (label) entry.label = label; 197 + } 198 + if (entry.kind === "other" && !entry.label) { 199 + return { 200 + ok: false, 201 + error: 'links[]: kind="other" requires a label', 202 + }; 203 + } 204 + out.push(entry); 205 + } 206 + return { ok: true, value: out }; 207 + } 208 + 115 209 export function validateProfile( 116 210 input: unknown, 117 211 ): ValidationResult<ProfileRecord> { ··· 164 258 if (v.avatar !== undefined && !isBlob(v.avatar)) { 165 259 return { ok: false, error: "avatar: invalid blob ref" }; 166 260 } 167 - if (v.website !== undefined && !isUrl(v.website)) { 168 - return { ok: false, error: "website: must be http(s) URL" }; 169 - } 170 - if (v.repoUrl !== undefined && !isUrl(v.repoUrl)) { 171 - return { ok: false, error: "repoUrl: must be http(s) URL" }; 172 - } 173 - if (v.openSource !== undefined && typeof v.openSource !== "boolean") { 174 - return { ok: false, error: "openSource: must be boolean" }; 175 - } 261 + const linksRes = normalizeLinks(v.links); 262 + if (!linksRes.ok) return { ok: false, error: linksRes.error }; 176 263 if ( 177 264 v.bskyClient !== undefined && 178 265 (!isStr(v.bskyClient) || ··· 206 293 avatar: v.avatar as BlobRef | undefined, 207 294 categories: normalizedCategories, 208 295 subcategories: v.subcategories as string[] | undefined, 209 - website: v.website as string | undefined, 210 - repoUrl: v.repoUrl as string | undefined, 211 - openSource: v.openSource as boolean | undefined, 296 + links: linksRes.value.length > 0 ? linksRes.value : undefined, 212 297 bskyClient: v.bskyClient as string | undefined, 213 298 createdAt: v.createdAt as string, 214 299 }, 215 300 }; 216 301 } 217 302 303 + export function validateLicense( 304 + input: unknown, 305 + ): ValidationResult<LicenseRecord> { 306 + if (!input || typeof input !== "object") { 307 + return { ok: false, error: "record must be an object" }; 308 + } 309 + const v = input as Record<string, unknown>; 310 + if ( 311 + !isStr(v.type) || 312 + !(LICENSE_TYPES as readonly string[]).includes(v.type as string) 313 + ) { 314 + return { 315 + ok: false, 316 + error: `type: must be one of ${LICENSE_TYPES.join(", ")}`, 317 + }; 318 + } 319 + if (!isStr(v.createdAt)) { 320 + return { ok: false, error: "createdAt required (ISO 8601)" }; 321 + } 322 + if (v.spdxId !== undefined && !isStr(v.spdxId, 64)) { 323 + return { ok: false, error: "spdxId: string <=64" }; 324 + } 325 + if (v.licenseUrl !== undefined && !isUrl(v.licenseUrl)) { 326 + return { ok: false, error: "licenseUrl: must be http(s) URL" }; 327 + } 328 + if (v.notes !== undefined && !isStr(v.notes, 280)) { 329 + return { ok: false, error: "notes: string <=280" }; 330 + } 331 + return { 332 + ok: true, 333 + value: { 334 + $type: LICENSE_NSID, 335 + type: v.type as string, 336 + spdxId: v.spdxId as string | undefined, 337 + licenseUrl: v.licenseUrl as string | undefined, 338 + notes: v.notes as string | undefined, 339 + createdAt: v.createdAt as string, 340 + }, 341 + }; 342 + } 343 + 218 344 export function validateFeatured( 219 345 input: unknown, 220 346 ): ValidationResult<FeaturedRecord> { ··· 274 400 const fileMap: Record<string, string> = { 275 401 [PROFILE_NSID]: "profile.json", 276 402 [FEATURED_NSID]: "featured.json", 403 + [LICENSE_NSID]: "license.json", 277 404 }; 278 405 const filename = fileMap[nsid]; 279 406 const url = new URL(
+17 -5
lib/pds.ts
··· 68 68 did: string, 69 69 pdsUrl: string, 70 70 ): Promise<void> { 71 + await deleteRecord(did, pdsUrl, PROFILE_NSID, "self"); 72 + } 73 + 74 + /** 75 + * Generic deleteRecord helper for any collection in the user's repo. 76 + * Returns silently on 404 so callers can call it unconditionally to 77 + * "make sure this record doesn't exist" (e.g. when toggling off a 78 + * sibling record like the license). Other failures throw. 79 + */ 80 + export async function deleteRecord( 81 + did: string, 82 + pdsUrl: string, 83 + collection: string, 84 + rkey: string, 85 + ): Promise<void> { 71 86 const url = `${pdsUrl.replace(/\/$/, "")}/xrpc/com.atproto.repo.deleteRecord`; 72 87 const res = await authedFetch(did, url, { 73 88 method: "POST", 74 89 headers: { "content-type": "application/json" }, 75 - body: JSON.stringify({ 76 - repo: did, 77 - collection: PROFILE_NSID, 78 - rkey: "self", 79 - }), 90 + body: JSON.stringify({ repo: did, collection, rkey }), 80 91 }); 92 + if (res.status === 404) return; 81 93 if (!res.ok) { 82 94 const text = await res.text(); 83 95 throw new Error(`deleteRecord failed: HTTP ${res.status}: ${text}`);
+177 -25
lib/registry.ts
··· 4 4 */ 5 5 import type { InValue } from "@libsql/client"; 6 6 import { withDb } from "./db.ts"; 7 - import type { FeaturedBadge } from "./lexicons.ts"; 7 + import type { FeaturedBadge, LinkEntry } from "./lexicons.ts"; 8 8 9 9 export interface ProfileRow { 10 10 did: string; ··· 15 15 * primary category used for sort/grouping in lists. */ 16 16 categories: string[]; 17 17 subcategories: string[]; 18 - website: string | null; 19 - repoUrl: string | null; 20 - openSource: boolean; 18 + /** Outbound links (website, repo, donate, …) in author-defined order. */ 19 + links: LinkEntry[]; 21 20 bskyClient: string | null; 22 21 avatarCid: string | null; 23 22 avatarMime: string | null; ··· 31 30 badges: FeaturedBadge[] | string[]; 32 31 position: number; 33 32 }; 33 + /** Populated when joined with the license table. Absent = no license 34 + * record published. */ 35 + license?: { 36 + type: string; 37 + spdxId: string | null; 38 + licenseUrl: string | null; 39 + notes: string | null; 40 + }; 34 41 } 35 42 36 43 interface RawProfileRow { ··· 40 47 description: string; 41 48 categories: string; 42 49 subcategories: string; 43 - website: string | null; 44 - repo_url: string | null; 45 - open_source: number | null; 50 + links: string | null; 46 51 bsky_client: string | null; 47 52 avatar_cid: string | null; 48 53 avatar_mime: string | null; ··· 53 58 indexed_at: number; 54 59 featured_badges?: string | null; 55 60 featured_position?: number | null; 61 + license_type?: string | null; 62 + license_spdx_id?: string | null; 63 + license_url?: string | null; 64 + license_notes?: string | null; 56 65 } 57 66 58 67 function safeJsonArray(text: string | null | undefined): string[] { ··· 65 74 } 66 75 } 67 76 77 + function safeJsonLinks(text: string | null | undefined): LinkEntry[] { 78 + if (!text) return []; 79 + try { 80 + const v = JSON.parse(text); 81 + if (!Array.isArray(v)) return []; 82 + return v 83 + .filter((x): x is { kind: unknown; url: unknown; label?: unknown } => 84 + !!x && typeof x === "object" 85 + ) 86 + .filter((x) => typeof x.kind === "string" && typeof x.url === "string") 87 + .map((x) => { 88 + const e: LinkEntry = { kind: x.kind as string, url: x.url as string }; 89 + if (typeof x.label === "string" && x.label) e.label = x.label; 90 + return e; 91 + }); 92 + } catch { 93 + return []; 94 + } 95 + } 96 + 68 97 function rowToProfile(r: RawProfileRow): ProfileRow { 69 98 const out: ProfileRow = { 70 99 did: r.did, ··· 73 102 description: r.description, 74 103 categories: safeJsonArray(r.categories), 75 104 subcategories: safeJsonArray(r.subcategories), 76 - website: r.website, 77 - repoUrl: r.repo_url, 78 - openSource: Number(r.open_source ?? 0) === 1, 105 + links: safeJsonLinks(r.links), 79 106 bskyClient: r.bsky_client, 80 107 avatarCid: r.avatar_cid, 81 108 avatarMime: r.avatar_mime, ··· 91 118 position: Number(r.featured_position ?? 0), 92 119 }; 93 120 } 121 + if (r.license_type) { 122 + out.license = { 123 + type: r.license_type, 124 + spdxId: r.license_spdx_id ?? null, 125 + licenseUrl: r.license_url ?? null, 126 + notes: r.license_notes ?? null, 127 + }; 128 + } 94 129 return out; 95 130 } 96 131 ··· 102 137 /** Required: 1-4 known category strings. The first is the primary. */ 103 138 categories: string[]; 104 139 subcategories: string[]; 105 - website?: string | null; 106 - repoUrl?: string | null; 107 - openSource?: boolean | null; 140 + links?: LinkEntry[] | null; 108 141 bskyClient?: string | null; 109 142 avatarCid?: string | null; 110 143 avatarMime?: string | null; ··· 138 171 await c.execute({ 139 172 sql: ` 140 173 INSERT INTO profile ( 141 - did, handle, name, description, categories, subcategories, 142 - website, repo_url, open_source, bsky_client, 143 - avatar_cid, avatar_mime, pds_url, record_cid, record_rev, 144 - created_at, indexed_at 145 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 174 + did, handle, name, description, categories, subcategories, links, 175 + bsky_client, avatar_cid, avatar_mime, pds_url, record_cid, 176 + record_rev, created_at, indexed_at 177 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 146 178 ON CONFLICT(did) DO UPDATE SET 147 179 handle=excluded.handle, 148 180 name=excluded.name, 149 181 description=excluded.description, 150 182 categories=excluded.categories, 151 183 subcategories=excluded.subcategories, 152 - website=excluded.website, 153 - repo_url=excluded.repo_url, 154 - open_source=excluded.open_source, 184 + links=excluded.links, 155 185 bsky_client=excluded.bsky_client, 156 186 avatar_cid=excluded.avatar_cid, 157 187 avatar_mime=excluded.avatar_mime, ··· 168 198 input.description, 169 199 JSON.stringify(cats), 170 200 JSON.stringify(input.subcategories ?? []), 171 - input.website ?? null, 172 - input.repoUrl ?? null, 173 - input.openSource ? 1 : 0, 201 + JSON.stringify(input.links ?? []), 174 202 input.bskyClient ?? null, 175 203 input.avatarCid ?? null, 176 204 input.avatarMime ?? null, ··· 186 214 187 215 export async function deleteProfile(did: string): Promise<void> { 188 216 await withDb(async (c) => { 217 + // License is paired with the profile from the user's POV; cleaning it 218 + // up here keeps the index from carrying orphan rows after a removal. 219 + await c.execute({ sql: `DELETE FROM license WHERE did = ?`, args: [did] }); 189 220 await c.execute({ sql: `DELETE FROM profile WHERE did = ?`, args: [did] }); 190 221 }); 191 222 } 192 223 193 224 const SELECT_PROFILE = ` 194 - SELECT p.*, f.badges AS featured_badges, f.position AS featured_position 225 + SELECT p.*, 226 + f.badges AS featured_badges, 227 + f.position AS featured_position, 228 + l.type AS license_type, 229 + l.spdx_id AS license_spdx_id, 230 + l.license_url AS license_url, 231 + l.notes AS license_notes 195 232 FROM profile p 196 233 LEFT JOIN featured f ON f.did = p.did 234 + LEFT JOIN license l ON l.did = p.did 197 235 `; 198 236 199 237 export async function getProfileByDid(did: string): Promise<ProfileRow | null> { ··· 334 372 } 335 373 }); 336 374 } 375 + 376 + /* -------------------------------------------------------------------------- * 377 + * License (com.atmosphereaccount.registry.license/self) * 378 + * -------------------------------------------------------------------------- */ 379 + 380 + export interface LicenseRow { 381 + did: string; 382 + type: string; 383 + spdxId: string | null; 384 + licenseUrl: string | null; 385 + notes: string | null; 386 + pdsUrl: string; 387 + recordCid: string; 388 + recordRev: string; 389 + createdAt: number; 390 + indexedAt: number; 391 + } 392 + 393 + interface RawLicenseRow { 394 + did: string; 395 + type: string; 396 + spdx_id: string | null; 397 + license_url: string | null; 398 + notes: string | null; 399 + pds_url: string; 400 + record_cid: string; 401 + record_rev: string; 402 + created_at: number; 403 + indexed_at: number; 404 + } 405 + 406 + function rowToLicense(r: RawLicenseRow): LicenseRow { 407 + return { 408 + did: r.did, 409 + type: r.type, 410 + spdxId: r.spdx_id, 411 + licenseUrl: r.license_url, 412 + notes: r.notes, 413 + pdsUrl: r.pds_url, 414 + recordCid: r.record_cid, 415 + recordRev: r.record_rev, 416 + createdAt: Number(r.created_at), 417 + indexedAt: Number(r.indexed_at), 418 + }; 419 + } 420 + 421 + export interface UpsertLicenseInput { 422 + did: string; 423 + type: string; 424 + spdxId?: string | null; 425 + licenseUrl?: string | null; 426 + notes?: string | null; 427 + pdsUrl: string; 428 + recordCid: string; 429 + recordRev: string; 430 + createdAt: number; 431 + } 432 + 433 + export async function upsertLicense(input: UpsertLicenseInput): Promise<void> { 434 + const now = Date.now(); 435 + await withDb(async (c) => { 436 + await c.execute({ 437 + sql: ` 438 + INSERT INTO license ( 439 + did, type, spdx_id, license_url, notes, 440 + pds_url, record_cid, record_rev, created_at, indexed_at 441 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 442 + ON CONFLICT(did) DO UPDATE SET 443 + type=excluded.type, 444 + spdx_id=excluded.spdx_id, 445 + license_url=excluded.license_url, 446 + notes=excluded.notes, 447 + pds_url=excluded.pds_url, 448 + record_cid=excluded.record_cid, 449 + record_rev=excluded.record_rev, 450 + created_at=excluded.created_at, 451 + indexed_at=excluded.indexed_at 452 + `, 453 + args: [ 454 + input.did, 455 + input.type, 456 + input.spdxId ?? null, 457 + input.licenseUrl ?? null, 458 + input.notes ?? null, 459 + input.pdsUrl, 460 + input.recordCid, 461 + input.recordRev, 462 + input.createdAt, 463 + now, 464 + ], 465 + }); 466 + }); 467 + } 468 + 469 + export async function deleteLicense(did: string): Promise<void> { 470 + await withDb(async (c) => { 471 + await c.execute({ sql: `DELETE FROM license WHERE did = ?`, args: [did] }); 472 + }); 473 + } 474 + 475 + export async function getLicenseByDid(did: string): Promise<LicenseRow | null> { 476 + return await withDb(async (c) => { 477 + const r = await c.execute({ 478 + sql: `SELECT * FROM license WHERE did = ? LIMIT 1`, 479 + args: [did], 480 + }); 481 + if (r.rows.length === 0) return null; 482 + return rowToLicense(r.rows[0] as unknown as RawLicenseRow); 483 + }); 484 + } 485 + 486 + /* -------------------------------------------------------------------------- * 487 + * Jetstream cursor * 488 + * -------------------------------------------------------------------------- */ 337 489 338 490 export async function getJetstreamCursor(): Promise<number | null> { 339 491 return await withDb(async (c) => {
+6 -6
lib/repo-hosts.ts
··· 1 1 /** 2 - * Auto-detected source-repo hosts. A project's profile carries a single 3 - * `repoUrl`; we pick the right icon + label based on the URL host so the 4 - * UI stays simple (no extra picker the way Bluesky-clients have one). 2 + * Auto-detected source-repo hosts. A project's profile can publish any 3 + * number of `links` entries with kind="repo"; we pick the right icon + 4 + * label based on the URL host so the UI stays simple (no extra picker 5 + * the way Bluesky-clients have one). 5 6 * 6 7 * Currently recognised: 7 8 * - GitHub (github.com) ··· 11 12 * Anything else falls back to a generic "code" host with the website-style 12 13 * arrow glyph so the button still works for self-hosted Forgejo/Gitea/etc. 13 14 * 14 - * Single source of truth used by: 15 - * - the public profile detail page (components/explore/ProfileLinks.tsx) 16 - * - the create / manage form helper text (forms.profile.repoUrlHint) 15 + * Used by `lib/link-kinds.ts` when resolving a `repo`-kind link entry 16 + * into render-ready data for components/explore/ProfileLinks.tsx. 17 17 */ 18 18 19 19 export type RepoHostId = "github" | "tangled" | "other";
+145 -21
routes/api/registry/profile.ts
··· 1 1 /** 2 2 * Authenticated registry profile mutations. The session must hold a 3 - * valid OAuth session for the authoring DID; we then write the record 4 - * directly to the user's PDS via DPoP-bound XRPC. The Jetstream-fed 5 - * indexer picks it up shortly after. 3 + * valid OAuth session for the authoring DID; we then write the record(s) 4 + * directly to the user's PDS via DPoP-bound XRPC and mirror them into 5 + * the local index. The Jetstream-fed indexer picks up the same writes 6 + * shortly after for any other consumers of the registry. 7 + * 8 + * PUT /api/registry/profile (create/update profile + license) 9 + * DELETE /api/registry/profile (delete both records) 6 10 * 7 - * PUT /api/registry/profile (create or update one's own profile) 8 - * DELETE /api/registry/profile (delete one's own profile) 11 + * Note: the request body carries an optional `license` sub-object which 12 + * is published as a sibling `com.atmosphereaccount.registry.license` 13 + * record. Splitting it out keeps the profile lexicon minimal — the form 14 + * still presents both as a single Save action for UX simplicity. 9 15 */ 10 16 import { define } from "../../../utils.ts"; 11 17 import { loadSession } from "../../../lib/oauth.ts"; 12 18 import { 13 19 deleteProfileRecord, 20 + deleteRecord, 14 21 putProfileRecord, 22 + putRecord, 15 23 uploadBlob, 16 24 } from "../../../lib/pds.ts"; 17 - import { type ProfileRecord, validateProfile } from "../../../lib/lexicons.ts"; 18 - import { deleteProfile, upsertProfile } from "../../../lib/registry.ts"; 25 + import { 26 + LICENSE_NSID, 27 + type LicenseRecord, 28 + type LinkEntry, 29 + type ProfileRecord, 30 + validateLicense, 31 + validateProfile, 32 + } from "../../../lib/lexicons.ts"; 33 + import { 34 + deleteLicense, 35 + deleteProfile, 36 + upsertLicense, 37 + upsertProfile, 38 + } from "../../../lib/registry.ts"; 39 + 40 + interface LicensePayload { 41 + type?: string; 42 + spdxId?: string; 43 + licenseUrl?: string; 44 + notes?: string; 45 + } 46 + 47 + interface LinkPayload { 48 + kind?: string; 49 + url?: string; 50 + label?: string; 51 + } 19 52 20 53 interface ProfileFormPayload { 21 54 name?: string; ··· 23 56 /** Required multi-select. The first entry is the primary category. */ 24 57 categories?: string[]; 25 58 subcategories?: string[]; 26 - website?: string; 27 - repoUrl?: string; 28 - openSource?: boolean; 59 + links?: LinkPayload[]; 29 60 bskyClient?: string; 30 61 /** Either keep an existing avatar (passed as the BlobRef) or upload new bytes */ 31 62 avatar?: { ··· 35 66 size: number; 36 67 } | null; 37 68 avatarUpload?: { dataBase64: string; mimeType: string }; 69 + /** 70 + * Optional license sub-record. `null` means "remove any existing license 71 + * record"; `undefined` means "leave it alone". 72 + */ 73 + license?: LicensePayload | null; 38 74 } 39 75 40 76 function trimOrNull(s: unknown): string | undefined { ··· 51 87 .map((x) => x.trim().slice(0, 32)); 52 88 } 53 89 90 + function normalizeLinksPayload(input: unknown): LinkEntry[] { 91 + if (!Array.isArray(input)) return []; 92 + const seen = new Set<string>(); 93 + const out: LinkEntry[] = []; 94 + for (const raw of input) { 95 + if (!raw || typeof raw !== "object") continue; 96 + const e = raw as LinkPayload; 97 + const kind = trimOrNull(e.kind); 98 + const url = trimOrNull(e.url); 99 + if (!kind || !url) continue; 100 + if (seen.has(url)) continue; 101 + seen.add(url); 102 + const entry: LinkEntry = { kind, url }; 103 + const label = trimOrNull(e.label); 104 + if (label) entry.label = label; 105 + out.push(entry); 106 + } 107 + return out; 108 + } 109 + 54 110 function decodeBase64(b64: string): Uint8Array { 55 111 const binary = atob(b64); 56 112 const out = new Uint8Array(binary.length); ··· 113 169 return out; 114 170 })(); 115 171 172 + const links = normalizeLinksPayload(body.links); 173 + 116 174 const draft: ProfileRecord = { 117 175 name: trimOrNull(body.name) ?? "", 118 176 description: trimOrNull(body.description) ?? "", 119 177 categories: normalizedCategories, 120 178 subcategories: asArray(body.subcategories), 121 - website: trimOrNull(body.website), 122 - repoUrl: trimOrNull(body.repoUrl), 123 - openSource: typeof body.openSource === "boolean" 124 - ? body.openSource 125 - : undefined, 179 + links: links.length > 0 ? links : undefined, 126 180 bskyClient: trimOrNull(body.bskyClient), 127 181 avatar: avatar ?? undefined, 128 182 createdAt: new Date().toISOString(), ··· 166 220 description: validation.value.description, 167 221 categories: validation.value.categories, 168 222 subcategories: validation.value.subcategories ?? [], 169 - website: validation.value.website ?? null, 170 - repoUrl: validation.value.repoUrl ?? null, 171 - openSource: validation.value.openSource ?? false, 223 + links: validation.value.links ?? [], 172 224 bskyClient: validation.value.bskyClient ?? null, 173 225 avatarCid: validation.value.avatar?.ref.$link ?? null, 174 226 avatarMime: validation.value.avatar?.mimeType ?? null, ··· 187 239 ); 188 240 } 189 241 242 + /** 243 + * Optional license sub-record handling. The form sends: 244 + * - `license: undefined` → leave any existing record alone 245 + * - `license: null` → delete any existing record 246 + * - `license: { ... }` → upsert 247 + * 248 + * Failures here are treated as soft errors: the profile is already 249 + * saved, so we still return 200 but include a warning the form can 250 + * surface. 251 + */ 252 + let licenseWarning: string | null = null; 253 + if (body.license === null) { 254 + try { 255 + await deleteRecord(user.did, session.pdsUrl, LICENSE_NSID, "self"); 256 + await deleteLicense(user.did); 257 + } catch (err) { 258 + licenseWarning = err instanceof Error ? err.message : String(err); 259 + console.error("[registry] license delete failed:", err); 260 + } 261 + } else if (body.license && typeof body.license === "object") { 262 + const lp = body.license; 263 + const licenseDraft: LicenseRecord = { 264 + type: trimOrNull(lp.type) ?? "", 265 + spdxId: trimOrNull(lp.spdxId), 266 + licenseUrl: trimOrNull(lp.licenseUrl), 267 + notes: trimOrNull(lp.notes), 268 + createdAt: new Date().toISOString(), 269 + }; 270 + const lv = validateLicense(licenseDraft); 271 + if (!lv.ok || !lv.value) { 272 + licenseWarning = `Profile saved, but license rejected: ${lv.error}`; 273 + } else { 274 + try { 275 + const lr = await putRecord( 276 + user.did, 277 + session.pdsUrl, 278 + LICENSE_NSID, 279 + "self", 280 + lv.value as unknown as Record<string, unknown>, 281 + ); 282 + await upsertLicense({ 283 + did: user.did, 284 + type: lv.value.type, 285 + spdxId: lv.value.spdxId ?? null, 286 + licenseUrl: lv.value.licenseUrl ?? null, 287 + notes: lv.value.notes ?? null, 288 + pdsUrl: session.pdsUrl, 289 + recordCid: lr.cid, 290 + recordRev: lr.commit?.rev ?? lr.cid, 291 + createdAt: Date.parse(lv.value.createdAt) || Date.now(), 292 + }); 293 + } catch (err) { 294 + licenseWarning = err instanceof Error ? err.message : String(err); 295 + console.error("[registry] license upsert failed:", err); 296 + } 297 + } 298 + } 299 + 190 300 return new Response( 191 - JSON.stringify({ ok: true, uri: result.uri, cid: result.cid }), 301 + JSON.stringify({ 302 + ok: true, 303 + uri: result.uri, 304 + cid: result.cid, 305 + licenseWarning, 306 + }), 192 307 { status: 200, headers: { "content-type": "application/json" } }, 193 308 ); 194 309 }, ··· 206 321 const m = err instanceof Error ? err.message : String(err); 207 322 return new Response(`deleteRecord failed: ${m}`, { status: 502 }); 208 323 } 324 + // Removing from Explore implies removing the paired license record 325 + // too — there's no orphaned-license UX, and the user can re-add 326 + // both by republishing. 327 + try { 328 + await deleteRecord(user.did, session.pdsUrl, LICENSE_NSID, "self"); 329 + } catch (err) { 330 + console.warn("[registry] license deleteRecord (best effort) failed:", err); 331 + } 209 332 210 - /** Mirror the delete in our local index so /explore stops listing it 333 + /** Mirror the deletes in our local index so /explore stops listing it 211 334 * immediately. As above, the Jetstream worker would eventually do 212 - * this too, but we don't want to wait. */ 335 + * this too, but we don't want to wait. `deleteProfile` cascades to 336 + * the license row. */ 213 337 try { 214 338 await deleteProfile(user.did); 215 339 } catch (err) {
+16 -8
routes/explore/manage.tsx
··· 4 4 import Footer from "../../components/Footer.tsx"; 5 5 import CreateProfileForm from "../../islands/CreateProfileForm.tsx"; 6 6 import { getMessages } from "../../i18n/mod.ts"; 7 - import { getProfileByDid } from "../../lib/registry.ts"; 7 + import { getLicenseByDid, getProfileByDid } from "../../lib/registry.ts"; 8 8 import { loadSession } from "../../lib/oauth.ts"; 9 9 import { getBskyProfile } from "../../lib/pds.ts"; 10 10 ··· 26 26 * registry record exists, the form switches to the cached 27 27 * /api/registry/avatar/:did proxy. */ 28 28 let initialAvatarUrl: string | null = null; 29 - const existing = await getProfileByDid(user.did).catch(() => null); 29 + const [existing, license] = await Promise.all([ 30 + getProfileByDid(user.did).catch(() => null), 31 + getLicenseByDid(user.did).catch(() => null), 32 + ]); 30 33 if (existing) { 31 34 initial = { 32 35 name: existing.name, 33 36 description: existing.description, 34 37 categories: existing.categories, 35 38 subcategories: existing.subcategories, 36 - website: existing.website, 37 - repoUrl: existing.repoUrl, 38 - openSource: existing.openSource, 39 + links: existing.links, 39 40 bskyClient: existing.bskyClient, 40 41 avatar: existing.avatarCid && existing.avatarMime 41 42 ? { ref: existing.avatarCid, mime: existing.avatarMime } 42 43 : null, 44 + license: license 45 + ? { 46 + type: license.type, 47 + spdxId: license.spdxId, 48 + licenseUrl: license.licenseUrl, 49 + notes: license.notes, 50 + } 51 + : null, 43 52 }; 44 53 } else { 45 54 const session = await loadSession(user.did); ··· 53 62 description: bsky.description ?? "", 54 63 categories: ["app"], 55 64 subcategories: [], 56 - website: null, 57 - repoUrl: null, 58 - openSource: false, 65 + links: [], 59 66 bskyClient: null, 60 67 avatar: bsky.avatar 61 68 ? { ··· 63 70 mime: bsky.avatar.mimeType, 64 71 } 65 72 : null, 73 + license: null, 66 74 }; 67 75 if (bsky.avatar) { 68 76 const cid = bsky.avatar.ref.$link;
+1
scripts/wipe-registry.ts
··· 34 34 `DROP INDEX IF EXISTS profile_category`, 35 35 `DROP TABLE IF EXISTS profile`, 36 36 `DROP TABLE IF EXISTS featured`, 37 + `DROP TABLE IF EXISTS license`, 37 38 // Reset the Jetstream cursor too so the indexer replays everything from 38 39 // scratch on next start (which is what you want after wiping the index). 39 40 `DROP TABLE IF EXISTS jetstream_cursor`,
+50 -5
worker/indexer.ts
··· 2 2 * Atmosphere registry indexer. 3 3 * 4 4 * Long-running Deno process that subscribes to Bluesky's Jetstream WebSocket 5 - * filtered to our two registry collections, fetches the authoritative record 5 + * filtered to our registry collections, fetches the authoritative record 6 6 * from each author's PDS, validates it, and upserts (or deletes) the row in 7 7 * the Turso registry DB. Cursor is persisted in the DB so the worker can 8 8 * resume after restarts. ··· 14 14 */ 15 15 import { 16 16 FEATURED_NSID, 17 + LICENSE_NSID, 17 18 PROFILE_NSID, 18 19 validateFeatured, 20 + validateLicense, 19 21 validateProfile, 20 22 } from "../lib/lexicons.ts"; 21 23 import { 24 + deleteLicense, 22 25 deleteProfile, 23 26 getJetstreamCursor, 24 27 replaceFeatured, 25 28 setJetstreamCursor, 29 + upsertLicense, 26 30 upsertProfile, 27 31 } from "../lib/registry.ts"; 28 32 import { findPdsEndpoint, resolveDidDocument } from "../lib/identity.ts"; ··· 45 49 commit?: JetstreamCommit; 46 50 } 47 51 48 - const COLLECTIONS = [PROFILE_NSID, FEATURED_NSID]; 52 + const COLLECTIONS = [PROFILE_NSID, FEATURED_NSID, LICENSE_NSID]; 49 53 const RECONNECT_DELAY_MS = 5_000; 50 54 const CURSOR_PERSIST_INTERVAL_MS = 5_000; 51 55 ··· 112 116 description: r.description, 113 117 categories: r.categories, 114 118 subcategories: r.subcategories ?? [], 115 - website: r.website ?? null, 116 - repoUrl: r.repoUrl ?? null, 117 - openSource: r.openSource ?? false, 119 + links: r.links ?? [], 118 120 bskyClient: r.bskyClient ?? null, 119 121 avatarCid: r.avatar?.ref.$link ?? null, 120 122 avatarMime: r.avatar?.mimeType ?? null, ··· 126 128 console.log(`[indexer] upsert profile ${handle} (${event.did})`); 127 129 } 128 130 131 + async function handleLicenseEvent(event: JetstreamEvent): Promise<void> { 132 + const commit = event.commit; 133 + if (!commit) return; 134 + 135 + if (commit.operation === "delete") { 136 + await deleteLicense(event.did); 137 + return; 138 + } 139 + 140 + const pdsUrl = await resolvePdsForDid(event.did); 141 + const fetched = await getRecordPublic( 142 + pdsUrl, 143 + event.did, 144 + LICENSE_NSID, 145 + "self", 146 + ); 147 + if (!fetched) return; 148 + 149 + const validation = validateLicense(fetched.value); 150 + if (!validation.ok || !validation.value) { 151 + console.warn( 152 + `[indexer] invalid license from ${event.did}: ${validation.error}`, 153 + ); 154 + return; 155 + } 156 + const r = validation.value; 157 + 158 + await upsertLicense({ 159 + did: event.did, 160 + type: r.type, 161 + spdxId: r.spdxId ?? null, 162 + licenseUrl: r.licenseUrl ?? null, 163 + notes: r.notes ?? null, 164 + pdsUrl, 165 + recordCid: fetched.cid, 166 + recordRev: commit.rev, 167 + createdAt: Date.parse(r.createdAt) || Date.now(), 168 + }); 169 + console.log(`[indexer] upsert license ${event.did} (${r.type})`); 170 + } 171 + 129 172 async function handleFeaturedEvent(event: JetstreamEvent): Promise<void> { 130 173 const commit = event.commit; 131 174 if (!commit) return; ··· 179 222 await handleProfileEvent(event); 180 223 } else if (collection === FEATURED_NSID) { 181 224 await handleFeaturedEvent(event); 225 + } else if (collection === LICENSE_NSID) { 226 + await handleLicenseEvent(event); 182 227 } 183 228 } catch (err) { 184 229 console.error(`[indexer] handler error for ${collection}:`, err);