simple list of pds servers with open registration

Add multi-PDS software support with per-software version tracking

Servers running alternative PDS implementations (millipds, cirrus, cocoon,
etc.) are now detected by matching their version against known GitHub tags
and compared against their own software's latest release for the trust
score. Link-only software without version tracking gets benefit of the
doubt. Software name shows as a clickable link in the directory. JSON API
includes per-software latest versions alongside the existing field.

+509 -72
+9
backend/database/migrations.ts
··· 41 41 value TEXT NOT NULL 42 42 ) 43 43 `); 44 + 45 + // Add pds_software column (idempotent — ignore if it already exists) 46 + try { 47 + await sqlite.execute( 48 + `ALTER TABLE ${SERVERS_TABLE} ADD COLUMN pds_software TEXT`, 49 + ); 50 + } catch { 51 + // Column already exists 52 + } 44 53 }
+48 -5
backend/database/queries.ts
··· 1 1 import { sqlite } from "https://esm.town/v/stevekrouse/sqlite?v=13"; 2 2 import { METADATA_TABLE, SERVERS_TABLE } from "../../shared/constants.ts"; 3 - import type { PdsServer, ServerToEnrich } from "../../shared/types.ts"; 3 + import type { 4 + LatestVersionMap, 5 + PdsServer, 6 + ServerToEnrich, 7 + } from "../../shared/types.ts"; 4 8 5 9 // --- Server operations --- 6 10 ··· 56 60 countryCode?: string | null; 57 61 countryName?: string | null; 58 62 ipAddress?: string | null; 63 + pdsSoftware?: string | null; 59 64 }, 60 65 ): Promise<void> { 61 66 const now = new Date().toISOString(); ··· 73 78 user_count = COALESCE(?, user_count), 74 79 country_code = COALESCE(?, country_code), 75 80 country_name = COALESCE(?, country_name), 76 - ip_address = COALESCE(?, ip_address) 81 + ip_address = COALESCE(?, ip_address), 82 + pds_software = COALESCE(?, pds_software) 77 83 WHERE url = ? 78 84 `, 79 85 args: [ ··· 89 95 data.countryCode ?? null, 90 96 data.countryName ?? null, 91 97 data.ipAddress ?? null, 98 + data.pdsSoftware ?? null, 92 99 url, 93 100 ], 94 101 }); ··· 123 130 } 124 131 125 132 export async function getOpenServers( 126 - latestVersion: string | null = null, 133 + latestVersions: LatestVersionMap = {}, 127 134 ): Promise<PdsServer[]> { 135 + // Build per-software version CASE expression 136 + // Servers with known tracked software: compare against that software's latest 137 + // Servers with link-only software (not in latestVersions): pass (benefit of the doubt) 138 + // Servers with NULL pds_software: default to bluesky-pds comparison 139 + const trackedIds = Object.keys(latestVersions); 140 + const args: (string | number)[] = []; 141 + 142 + let versionCaseExpr: string; 143 + if (trackedIds.length === 0) { 144 + versionCaseExpr = "0"; 145 + } else { 146 + const whenClauses: string[] = []; 147 + for (const id of trackedIds) { 148 + // Match servers assigned to this software 149 + whenClauses.push("WHEN pds_software = ? AND version = ? THEN 20"); 150 + args.push(id, latestVersions[id]); 151 + } 152 + // NULL pds_software defaults to bluesky-pds comparison 153 + if (latestVersions["bluesky-pds"]) { 154 + whenClauses.push( 155 + "WHEN pds_software IS NULL AND version = ? THEN 20", 156 + ); 157 + args.push(latestVersions["bluesky-pds"]); 158 + } 159 + // Software with no entry in latestVersions (link-only): pass 160 + whenClauses.push( 161 + `WHEN pds_software IS NOT NULL AND pds_software NOT IN (${ 162 + trackedIds.map(() => "?").join(",") 163 + }) THEN 20`, 164 + ); 165 + args.push(...trackedIds); 166 + 167 + versionCaseExpr = `CASE ${whenClauses.join(" ")} ELSE 0 END`; 168 + } 169 + 128 170 const trustScoreExpr = `( 129 171 (CASE WHEN contact_email IS NOT NULL THEN 20 ELSE 0 END) + 130 172 (CASE WHEN terms_of_service IS NOT NULL THEN 20 ELSE 0 END) + 131 173 (CASE WHEN privacy_policy IS NOT NULL THEN 20 ELSE 0 END) + 132 174 (CASE WHEN user_count > 5 THEN 20 ELSE 0 END) + 133 - (CASE WHEN version = ? THEN 20 ELSE 0 END) 175 + ${versionCaseExpr} 134 176 )`; 135 177 136 178 const result = await sqlite.execute({ ··· 139 181 WHERE is_open = 1 AND last_enriched IS NOT NULL AND version IS NOT NULL 140 182 ORDER BY ${trustScoreExpr} DESC, user_count DESC NULLS LAST 141 183 `, 142 - args: [latestVersion ?? ""], 184 + args, 143 185 }); 144 186 145 187 return result.rows.map(rowToServer); ··· 194 236 last_seen: row.last_seen as string, 195 237 last_enriched: (row.last_enriched as string) || null, 196 238 version: (row.version as string) || null, 239 + pds_software: (row.pds_software as string) || null, 197 240 did: (row.did as string) || null, 198 241 invite_code_required: Boolean(row.invite_code_required), 199 242 phone_verification: Boolean(row.phone_verification),
+14 -3
backend/routes/api.ts
··· 4 4 getMetadata, 5 5 getServerCount, 6 6 } from "../database/queries.ts"; 7 + import { getTrackedSoftware } from "../../shared/constants.ts"; 8 + import type { LatestVersionMap } from "../../shared/types.ts"; 7 9 8 10 const api = new Hono(); 9 11 10 12 api.get("/servers", async (c) => { 11 - const [servers, latestVersion, counts] = await Promise.all([ 13 + // Fetch per-software latest versions 14 + const tracked = getTrackedSoftware(); 15 + const latestVersions: LatestVersionMap = {}; 16 + const versionPromises = tracked.map(async (sw) => { 17 + const v = await getMetadata(`latest_version:${sw.id}`); 18 + if (v) latestVersions[sw.id] = v; 19 + }); 20 + 21 + const [servers, , counts] = await Promise.all([ 12 22 getAllServers(), 13 - getMetadata("latest_pds_version"), 23 + Promise.all(versionPromises), 14 24 getServerCount(), 15 25 ]); 16 26 ··· 20 30 ); 21 31 return c.json({ 22 32 meta: { 23 - latest_pds_version: latestVersion, 33 + latest_pds_version: latestVersions["bluesky-pds"] ?? null, 34 + latest_versions: latestVersions, 24 35 total_known: counts.total, 25 36 total_open: counts.open, 26 37 updated_at: new Date().toISOString(),
+70 -17
backend/routes/pages.ts
··· 4 4 getOpenServers, 5 5 getServerCount, 6 6 } from "../database/queries.ts"; 7 - import { countryFlag } from "../../shared/constants.ts"; 8 - import type { PdsServer } from "../../shared/types.ts"; 7 + import { 8 + countryFlag, 9 + getSoftwareConfig, 10 + getTrackedSoftware, 11 + } from "../../shared/constants.ts"; 12 + import type { LatestVersionMap, PdsServer } from "../../shared/types.ts"; 9 13 import { isSafeHref } from "../../shared/url-validation.ts"; 10 14 11 15 const pages = new Hono(); 12 16 13 17 pages.get("/", async (c) => { 14 - const [latestVersion, counts] = await Promise.all([ 15 - getMetadata("latest_pds_version"), 18 + // Fetch per-software latest versions 19 + const tracked = getTrackedSoftware(); 20 + const latestVersions: LatestVersionMap = {}; 21 + const versionPromises = tracked.map(async (sw) => { 22 + const v = await getMetadata(`latest_version:${sw.id}`); 23 + if (v) latestVersions[sw.id] = v; 24 + }); 25 + const [, counts] = await Promise.all([ 26 + Promise.all(versionPromises), 16 27 getServerCount(), 17 28 ]); 18 29 19 - const servers = await getOpenServers(latestVersion); 30 + const servers = await getOpenServers(latestVersions); 20 31 21 32 c.header( 22 33 "Cache-Control", 23 34 "public, max-age=3600, stale-while-revalidate=3600", 24 35 ); 25 36 return c.html(renderPage(servers, { 26 - latestVersion, 37 + latestVersions, 27 38 openCount: counts.open, 28 39 totalCount: counts.total, 29 40 })); 30 41 }); 31 42 32 43 type PageOpts = { 33 - latestVersion: string | null; 44 + latestVersions: LatestVersionMap; 34 45 openCount: number; 35 46 totalCount: number; 36 47 }; 37 48 38 49 type TrustResult = { score: number; breakdown: string }; 39 50 40 - function trustScore(s: PdsServer, latestVersion: string | null): TrustResult { 51 + function trustScore( 52 + s: PdsServer, 53 + latestVersions: LatestVersionMap, 54 + ): TrustResult { 41 55 const signals: string[] = []; 42 56 let score = 0; 43 57 ··· 50 64 check(!!s.terms_of_service, "Terms of Service"); 51 65 check(!!s.privacy_policy, "Privacy Policy"); 52 66 check(s.user_count != null && s.user_count > 5, "Active users (>5)"); 53 - check(s.version === latestVersion, "Latest version"); 67 + 68 + // Version check: compare against the server's own software latest version 69 + const softwareId = s.pds_software ?? "bluesky-pds"; 70 + const expectedVersion = latestVersions[softwareId]; 71 + if (!expectedVersion) { 72 + // Link-only software with no tracked version: benefit of the doubt 73 + check(true, "Latest version"); 74 + } else { 75 + check(s.version === expectedVersion, "Latest version"); 76 + } 54 77 55 78 return { score, breakdown: signals.join("\n") }; 56 79 } 57 80 58 81 function renderPage(servers: PdsServer[], opts: PageOpts): string { 59 - const { latestVersion, openCount, totalCount } = opts; 82 + const { latestVersions, openCount, totalCount } = opts; 60 83 61 84 const rows = servers 62 - .map((s) => renderRow(s, latestVersion, trustScore(s, latestVersion))) 85 + .map((s) => renderRow(s, latestVersions, trustScore(s, latestVersions))) 63 86 .join("\n"); 64 87 88 + // Build stats for tracked software versions 89 + const versionStats = getTrackedSoftware() 90 + .filter((sw) => latestVersions[sw.id]) 91 + .map((sw) => 92 + `<span>${esc(sw.displayName)}: ${esc(latestVersions[sw.id])}</span>` 93 + ) 94 + .join("\n "); 95 + 65 96 return `<!DOCTYPE html> 66 97 <html lang="en"> 67 98 <head> ··· 107 138 margin-bottom: 1rem; 108 139 font-size: 0.85rem; 109 140 color: var(--muted); 141 + flex-wrap: wrap; 110 142 } 111 143 table { 112 144 width: 100%; ··· 159 191 .trust-mid { background: #451a03; } 160 192 .trust-low { background: #1f2937; } 161 193 } 194 + .software-link { 195 + font-size: 0.7rem; 196 + color: var(--muted); 197 + text-decoration: none; 198 + margin-left: 0.3rem; 199 + } 200 + .software-link:hover { text-decoration: underline; } 162 201 .move-btn { 163 202 display: inline-block; 164 203 padding: 0.2rem 0.5rem; ··· 207 246 <div class="stats"> 208 247 <span>${servers.length} servers listed</span> 209 248 <span>${openCount} open of ${totalCount} known</span> 210 - ${latestVersion ? `<span>Latest PDS: ${esc(latestVersion)}</span>` : ""} 249 + ${versionStats} 211 250 </div> 212 251 ${ 213 252 servers.length > 0 ··· 238 277 <li><strong>Terms of Service</strong> &mdash; published terms for using the server</li> 239 278 <li><strong>Privacy Policy</strong> &mdash; published data handling policy</li> 240 279 <li><strong>Active users</strong> &mdash; more than 5 accounts on the server</li> 241 - <li><strong>Latest version</strong> &mdash; running the most recent PDS software</li> 280 + <li><strong>Latest version</strong> &mdash; running the latest release of its PDS software (servers are compared against their own software's latest version)</li> 242 281 </ul> 243 282 <p>This is not an endorsement. Always review a server's policies before creating an account.</p> 244 283 </div> ··· 253 292 254 293 function renderRow( 255 294 s: PdsServer, 256 - latestVersion: string | null, 295 + latestVersions: LatestVersionMap, 257 296 trust: TrustResult, 258 297 ): string { 259 298 const { score, breakdown } = trust; ··· 262 301 const flag = s.country_code ? esc(countryFlag(s.country_code)) + " " : ""; 263 302 const country = s.country_name || ""; 264 303 304 + // Determine version badge class based on per-software comparison 305 + const softwareId = s.pds_software ?? "bluesky-pds"; 306 + const expectedVersion = latestVersions[softwareId]; 307 + const versionText = s.version || "unknown"; 265 308 let versionClass = "version-outdated"; 266 - const versionText = s.version || "unknown"; 267 - if (s.version === latestVersion) versionClass = "version-current"; 309 + if (!expectedVersion || s.version === expectedVersion) { 310 + versionClass = "version-current"; 311 + } 312 + 313 + // Software link (show name as clickable link if recognized) 314 + let softwareHtml = ""; 315 + const config = s.pds_software ? getSoftwareConfig(s.pds_software) : null; 316 + if (config && config.id !== "bluesky-pds") { 317 + softwareHtml = `<a class="software-link" href="${ 318 + esc(config.projectUrl) 319 + }" target="_blank" rel="noopener">${esc(config.displayName)}</a>`; 320 + } 268 321 269 322 const users = s.user_count != null 270 323 ? (s.user_count >= 1000 ? "1000+" : String(s.user_count)) ··· 294 347 <td class="hide-mobile">${flag}${esc(country)}</td> 295 348 <td><span class="version-badge ${versionClass}">${ 296 349 esc(versionText) 297 - }</span></td> 350 + }</span>${softwareHtml}</td> 298 351 <td>${esc(users)}</td> 299 352 <td class="hide-mobile">${esc(firstSeen)}</td> 300 353 <td><a class="move-btn" href="https://pdsmoover.com/moover/${
+2
backend/services/pds-enricher.ts
··· 18 18 export type EnrichmentResult = { 19 19 url: string; 20 20 version: string | null; 21 + pdsSoftware: string | null; 21 22 did: string | null; 22 23 phoneVerification: boolean; 23 24 userDomains: string[]; ··· 230 231 return { 231 232 url, 232 233 version: null, 234 + pdsSoftware: null, 233 235 did: null, 234 236 phoneVerification: false, 235 237 userDomains: [],
+54 -17
backend/services/version-checker.ts
··· 1 - import { PDS_REPO_NAME, PDS_REPO_OWNER } from "../../shared/constants.ts"; 1 + import type { PdsSoftwareConfig } from "../../shared/constants.ts"; 2 2 3 3 export type FetchVersionResult = { 4 4 version: string | null; ··· 7 7 error: boolean; 8 8 }; 9 9 10 - /** Fetch latest PDS version from GitHub with ETag caching. */ 10 + export type FetchRecentVersionsResult = { 11 + latest: string | null; 12 + versions: string[]; 13 + etag: string | null; 14 + notModified: boolean; 15 + error: boolean; 16 + }; 17 + 18 + /** Fetch latest version for a PDS software from its GitHub tags with ETag caching. */ 11 19 export async function fetchLatestPdsVersion( 20 + software: PdsSoftwareConfig, 12 21 previousEtag?: string | null, 13 22 ): Promise<FetchVersionResult> { 23 + const result = await fetchRecentVersions(software, previousEtag); 24 + return { 25 + version: result.latest, 26 + etag: result.etag, 27 + notModified: result.notModified, 28 + error: result.error, 29 + }; 30 + } 31 + 32 + /** Fetch up to 30 recent parsed versions for a PDS software from GitHub tags. */ 33 + export async function fetchRecentVersions( 34 + software: PdsSoftwareConfig, 35 + previousEtag?: string | null, 36 + ): Promise<FetchRecentVersionsResult> { 37 + if (!software.githubOwner || !software.githubRepo || !software.parseVersion) { 38 + return { 39 + latest: null, 40 + versions: [], 41 + etag: null, 42 + notModified: false, 43 + error: true, 44 + }; 45 + } 46 + 14 47 const url = 15 - `https://api.github.com/repos/${PDS_REPO_OWNER}/${PDS_REPO_NAME}/tags?per_page=5`; 48 + `https://api.github.com/repos/${software.githubOwner}/${software.githubRepo}/tags?per_page=30`; 16 49 17 50 const headers: Record<string, string> = { 18 51 "Accept": "application/vnd.github.v3+json", ··· 27 60 28 61 if (resp.status === 304) { 29 62 return { 30 - version: null, 63 + latest: null, 64 + versions: [], 31 65 etag: etag ?? previousEtag ?? null, 32 66 notModified: true, 33 67 error: false, ··· 35 69 } 36 70 37 71 if (!resp.ok) { 38 - console.error(`GitHub API error: ${resp.status}`); 39 - // Preserve previous etag on error so we can retry conditional request next time 72 + console.error( 73 + `GitHub API error for ${software.id}: ${resp.status}`, 74 + ); 40 75 return { 41 - version: null, 76 + latest: null, 77 + versions: [], 42 78 etag: previousEtag ?? null, 43 79 notModified: false, 44 80 error: true, ··· 46 82 } 47 83 48 84 const tags: Array<{ name: string }> = await resp.json(); 49 - if (tags.length === 0) { 50 - return { 51 - version: null, 52 - etag: etag ?? null, 53 - notModified: false, 54 - error: false, 55 - }; 85 + const versions: string[] = []; 86 + for (const tag of tags) { 87 + const v = software.parseVersion(tag.name); 88 + if (v) versions.push(v); 56 89 } 57 90 58 - // Tags are like "v0.4.74" — strip the "v" prefix to match _health output 59 - const version = tags[0].name.replace(/^v/, ""); 60 - return { version, etag: etag ?? null, notModified: false, error: false }; 91 + return { 92 + latest: versions.length > 0 ? versions[0] : null, 93 + versions, 94 + etag: etag ?? null, 95 + notModified: false, 96 + error: false, 97 + }; 61 98 }
+99 -26
cron/refresh.cron.ts
··· 1 - import { ENRICHMENT_BATCH_SIZE } from "../shared/constants.ts"; 1 + import { 2 + ENRICHMENT_BATCH_SIZE, 3 + getTrackedSoftware, 4 + } from "../shared/constants.ts"; 5 + import type { LatestVersionMap, VersionSoftwareMap } from "../shared/types.ts"; 2 6 import { runMigrations } from "../backend/database/migrations.ts"; 3 7 import { 4 8 getMetadata, ··· 8 12 upsertServer, 9 13 } from "../backend/database/queries.ts"; 10 14 import { fetchPdsList } from "../backend/services/pds-fetcher.ts"; 11 - import { fetchLatestPdsVersion } from "../backend/services/version-checker.ts"; 15 + import { fetchRecentVersions } from "../backend/services/version-checker.ts"; 12 16 import { enrichBatch } from "../backend/services/pds-enricher.ts"; 13 17 14 18 export default async function () { ··· 46 50 ); 47 51 } 48 52 49 - // 2. Fetch latest PDS version (with ETag caching) 50 - const previousGithubEtag = await getMetadata("github_tags_etag"); 51 - const { 52 - version: latestVersion, 53 - etag: githubEtag, 54 - notModified, 55 - error: githubError, 56 - } = await fetchLatestPdsVersion(previousGithubEtag); 53 + // 2. Fetch latest versions for all tracked PDS software 54 + const latestVersions: LatestVersionMap = {}; 55 + const versionToSoftware: VersionSoftwareMap = {}; 56 + const trackedSoftware = getTrackedSoftware(); 57 57 58 - if (githubEtag) { 59 - await setMetadata("github_tags_etag", githubEtag); 58 + for (const software of trackedSoftware) { 59 + const etagKey = `github_tags_etag:${software.id}`; 60 + const previousEtag = await getMetadata(etagKey); 61 + const result = await fetchRecentVersions(software, previousEtag); 62 + 63 + if (result.etag) { 64 + await setMetadata(etagKey, result.etag); 65 + } 66 + 67 + if (result.notModified) { 68 + console.log( 69 + `GitHub tags [${software.id}]: 304 not modified`, 70 + ); 71 + // Load cached versions from metadata 72 + const cachedLatest = await getMetadata( 73 + `latest_version:${software.id}`, 74 + ); 75 + if (cachedLatest) { 76 + latestVersions[software.id] = cachedLatest; 77 + } 78 + const cachedVersions = await getMetadata( 79 + `version_map:${software.id}`, 80 + ); 81 + if (cachedVersions) { 82 + for (const v of JSON.parse(cachedVersions)) { 83 + versionToSoftware[v] = software.id; 84 + } 85 + } 86 + } else if (result.latest) { 87 + latestVersions[software.id] = result.latest; 88 + await setMetadata(`latest_version:${software.id}`, result.latest); 89 + await setMetadata( 90 + `version_map:${software.id}`, 91 + JSON.stringify(result.versions), 92 + ); 93 + for (const v of result.versions) { 94 + versionToSoftware[v] = software.id; 95 + } 96 + console.log( 97 + `GitHub tags [${software.id}]: latest ${result.latest} (${result.versions.length} versions)`, 98 + ); 99 + } else if (result.error) { 100 + console.log( 101 + `GitHub tags [${software.id}]: API error (using cached)`, 102 + ); 103 + const cachedLatest = await getMetadata( 104 + `latest_version:${software.id}`, 105 + ); 106 + if (cachedLatest) { 107 + latestVersions[software.id] = cachedLatest; 108 + } 109 + const cachedVersions = await getMetadata( 110 + `version_map:${software.id}`, 111 + ); 112 + if (cachedVersions) { 113 + for (const v of JSON.parse(cachedVersions)) { 114 + versionToSoftware[v] = software.id; 115 + } 116 + } 117 + } else { 118 + console.log(`GitHub tags [${software.id}]: no versions found`); 119 + } 60 120 } 61 121 62 - if (notModified) { 63 - console.log( 64 - `GitHub tags: 304 not modified (etag=${githubEtag})`, 65 - ); 66 - } else if (latestVersion) { 67 - await setMetadata("latest_pds_version", latestVersion); 68 - console.log( 69 - `GitHub tags: new version ${latestVersion} (etag=${githubEtag})`, 70 - ); 71 - } else if (githubError) { 72 - console.log("GitHub tags: API error (using cached version)"); 73 - } else { 74 - console.log("GitHub tags: no version found"); 122 + // Backward compat: keep latest_pds_version for bluesky-pds 123 + if (latestVersions["bluesky-pds"]) { 124 + await setMetadata("latest_pds_version", latestVersions["bluesky-pds"]); 125 + } 126 + // Also maintain legacy github_tags_etag for backward compat 127 + const bskyEtag = await getMetadata("github_tags_etag:bluesky-pds"); 128 + if (bskyEtag) { 129 + await setMetadata("github_tags_etag", bskyEtag); 75 130 } 76 131 132 + console.log( 133 + `Version map: ${ 134 + Object.keys(versionToSoftware).length 135 + } known versions across ${trackedSoftware.length} software`, 136 + ); 137 + 77 138 // 3. Enrich a batch of servers 78 139 const toEnrich = await getServersToEnrich(ENRICHMENT_BATCH_SIZE); 79 140 if (toEnrich.length > 0) { ··· 86 147 const { results: enriched, stats } = await enrichBatch(toEnrich); 87 148 88 149 for (const data of enriched) { 150 + // Detect software by matching version against known version map 151 + if (data.version && versionToSoftware[data.version]) { 152 + data.pdsSoftware = versionToSoftware[data.version]; 153 + } 154 + 89 155 await updateEnrichment(data.url, { 90 156 version: data.version, 91 157 did: data.did, ··· 98 164 countryCode: data.countryCode, 99 165 countryName: data.countryName, 100 166 ipAddress: data.ipAddress, 167 + pdsSoftware: data.pdsSoftware, 101 168 }); 102 169 } 103 170 104 171 const noUserCount = enriched.filter((s) => s.userCount === null).length; 172 + const softwareCounts: Record<string, number> = {}; 173 + for (const s of enriched) { 174 + const sw = s.pdsSoftware ?? "unknown"; 175 + softwareCounts[sw] = (softwareCounts[sw] ?? 0) + 1; 176 + } 105 177 console.log( 106 178 `Enriched ${enriched.length} servers: ` + 107 179 `DNS ${stats.cachedDns} cached/${stats.freshDns} fresh, ` + 108 180 `geo ${stats.cachedGeo} cached/${stats.freshGeo} fresh` + 109 - (noUserCount > 0 ? `, ${noUserCount} without user count` : ""), 181 + (noUserCount > 0 ? `, ${noUserCount} without user count` : "") + 182 + `, software: ${JSON.stringify(softwareCounts)}`, 110 183 ); 111 184 } 112 185
+100 -3
shared/constants.ts
··· 4 4 export const STATE_JSON_URL = 5 5 "https://raw.githubusercontent.com/mary-ext/atproto-scraping/trunk/state.json"; 6 6 7 - export const PDS_REPO_OWNER = "bluesky-social"; 8 - export const PDS_REPO_NAME = "pds"; 9 - 10 7 export const ENRICHMENT_BATCH_SIZE = 75; 11 8 export const PDS_REQUEST_TIMEOUT_MS = 5000; 12 9 export const DNS_CACHE_TTL_DAYS = 7; 13 10 export const GEO_CACHE_TTL_DAYS = 30; 11 + 12 + /** Configuration for a known PDS software implementation */ 13 + export type PdsSoftwareConfig = { 14 + id: string; 15 + displayName: string; 16 + projectUrl: string; 17 + /** If set, enables version tracking via GitHub tags */ 18 + githubOwner?: string; 19 + githubRepo?: string; 20 + /** Extract a version string from a GitHub tag name, or null if not a valid tag */ 21 + parseVersion?: (tag: string) => string | null; 22 + }; 23 + 24 + /** Registry of known PDS software implementations */ 25 + export const PDS_SOFTWARE_REGISTRY: PdsSoftwareConfig[] = [ 26 + { 27 + id: "bluesky-pds", 28 + displayName: "Bluesky PDS", 29 + projectUrl: "https://github.com/bluesky-social/pds", 30 + githubOwner: "bluesky-social", 31 + githubRepo: "pds", 32 + parseVersion: (tag) => { 33 + const m = tag.match(/^v(\d+\.\d+\.\d+)$/); 34 + return m ? m[1] : null; 35 + }, 36 + }, 37 + { 38 + id: "millipds", 39 + displayName: "millipds", 40 + projectUrl: "https://github.com/DavidBuchanan314/millipds", 41 + githubOwner: "DavidBuchanan314", 42 + githubRepo: "millipds", 43 + parseVersion: (tag) => { 44 + const m = tag.match(/^v(\d+\.\d+\.\d+)$/); 45 + return m ? m[1] : null; 46 + }, 47 + }, 48 + { 49 + id: "cirrus", 50 + displayName: "Cirrus", 51 + projectUrl: "https://github.com/ascorbic/cirrus", 52 + githubOwner: "ascorbic", 53 + githubRepo: "cirrus", 54 + parseVersion: (tag) => { 55 + const m = tag.match(/^@getcirrus\/pds@(\d+\.\d+\.\d+)$/); 56 + return m ? m[1] : null; 57 + }, 58 + }, 59 + { 60 + id: "cocoon", 61 + displayName: "Cocoon", 62 + projectUrl: "https://github.com/haileyok/cocoon", 63 + githubOwner: "haileyok", 64 + githubRepo: "cocoon", 65 + parseVersion: (tag) => { 66 + const m = tag.match(/^v(\d+\.\d+\.\d+)$/); 67 + return m ? m[1] : null; 68 + }, 69 + }, 70 + // Link-only software (no version tracking) 71 + { 72 + id: "tranquil", 73 + displayName: "Tranquil", 74 + projectUrl: "https://tangled.org/tranquil.farm/tranquil-pds", 75 + }, 76 + { 77 + id: "hexpds", 78 + displayName: "hexpds", 79 + projectUrl: "https://github.com/ovnanova/hexpds", 80 + }, 81 + { 82 + id: "bluepds", 83 + displayName: "bluepds", 84 + projectUrl: "https://github.com/DrChat/bluepds", 85 + }, 86 + { 87 + id: "rsky", 88 + displayName: "rsky", 89 + projectUrl: "https://github.com/blacksky-algorithms/rsky", 90 + }, 91 + { 92 + id: "picopds", 93 + displayName: "picopds", 94 + projectUrl: "https://github.com/DavidBuchanan314/picopds", 95 + }, 96 + ]; 97 + 98 + /** Look up a software config by its ID */ 99 + export function getSoftwareConfig( 100 + id: string, 101 + ): PdsSoftwareConfig | undefined { 102 + return PDS_SOFTWARE_REGISTRY.find((s) => s.id === id); 103 + } 104 + 105 + /** Return only software entries that have GitHub version tracking */ 106 + export function getTrackedSoftware(): PdsSoftwareConfig[] { 107 + return PDS_SOFTWARE_REGISTRY.filter( 108 + (s) => s.githubOwner && s.githubRepo && s.parseVersion, 109 + ); 110 + } 14 111 15 112 /** Country code to flag emoji */ 16 113 export function countryFlag(code: string): string {
+106 -1
shared/constants_test.ts
··· 1 1 import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; 2 - import { countryFlag } from "./constants.ts"; 2 + import { 3 + countryFlag, 4 + getSoftwareConfig, 5 + getTrackedSoftware, 6 + PDS_SOFTWARE_REGISTRY, 7 + } from "./constants.ts"; 3 8 4 9 Deno.test("countryFlag returns correct emoji for country codes", () => { 5 10 assertEquals(countryFlag("US"), "\u{1F1FA}\u{1F1F8}"); ··· 10 15 Deno.test("countryFlag handles lowercase input", () => { 11 16 assertEquals(countryFlag("us"), countryFlag("US")); 12 17 }); 18 + 19 + // --- PDS Software Registry tests --- 20 + 21 + Deno.test("getSoftwareConfig returns config for known IDs", () => { 22 + const bsky = getSoftwareConfig("bluesky-pds"); 23 + assertEquals(bsky?.displayName, "Bluesky PDS"); 24 + assertEquals(bsky?.githubOwner, "bluesky-social"); 25 + 26 + const milli = getSoftwareConfig("millipds"); 27 + assertEquals(milli?.displayName, "millipds"); 28 + 29 + const tranquil = getSoftwareConfig("tranquil"); 30 + assertEquals(tranquil?.displayName, "Tranquil"); 31 + }); 32 + 33 + Deno.test("getSoftwareConfig returns undefined for unknown ID", () => { 34 + assertEquals(getSoftwareConfig("nonexistent"), undefined); 35 + }); 36 + 37 + Deno.test("getTrackedSoftware returns only entries with GitHub info", () => { 38 + const tracked = getTrackedSoftware(); 39 + for (const sw of tracked) { 40 + assertEquals(typeof sw.githubOwner, "string"); 41 + assertEquals(typeof sw.githubRepo, "string"); 42 + assertEquals(typeof sw.parseVersion, "function"); 43 + } 44 + // Link-only entries should not be included 45 + const ids = tracked.map((s) => s.id); 46 + assertEquals(ids.includes("tranquil"), false); 47 + assertEquals(ids.includes("hexpds"), false); 48 + assertEquals(ids.includes("picopds"), false); 49 + // Tracked entries should be included 50 + assertEquals(ids.includes("bluesky-pds"), true); 51 + assertEquals(ids.includes("millipds"), true); 52 + assertEquals(ids.includes("cirrus"), true); 53 + assertEquals(ids.includes("cocoon"), true); 54 + }); 55 + 56 + // --- parseVersion tests --- 57 + 58 + Deno.test("bluesky-pds parseVersion handles valid tags", () => { 59 + const sw = getSoftwareConfig("bluesky-pds")!; 60 + assertEquals(sw.parseVersion!("v0.4.74"), "0.4.74"); 61 + assertEquals(sw.parseVersion!("v1.0.0"), "1.0.0"); 62 + }); 63 + 64 + Deno.test("bluesky-pds parseVersion rejects invalid tags", () => { 65 + const sw = getSoftwareConfig("bluesky-pds")!; 66 + assertEquals(sw.parseVersion!("0.4.74"), null); 67 + assertEquals(sw.parseVersion!("v0.4"), null); 68 + assertEquals(sw.parseVersion!("release-1.0"), null); 69 + assertEquals(sw.parseVersion!("@getcirrus/pds@0.10.4"), null); 70 + }); 71 + 72 + Deno.test("millipds parseVersion handles valid tags", () => { 73 + const sw = getSoftwareConfig("millipds")!; 74 + assertEquals(sw.parseVersion!("v0.0.5"), "0.0.5"); 75 + assertEquals(sw.parseVersion!("v1.2.3"), "1.2.3"); 76 + }); 77 + 78 + Deno.test("millipds parseVersion rejects invalid tags", () => { 79 + const sw = getSoftwareConfig("millipds")!; 80 + assertEquals(sw.parseVersion!("0.0.5"), null); 81 + assertEquals(sw.parseVersion!("release-0.0.5"), null); 82 + }); 83 + 84 + Deno.test("cirrus parseVersion handles scoped tags", () => { 85 + const sw = getSoftwareConfig("cirrus")!; 86 + assertEquals(sw.parseVersion!("@getcirrus/pds@0.10.4"), "0.10.4"); 87 + assertEquals(sw.parseVersion!("@getcirrus/pds@1.0.0"), "1.0.0"); 88 + }); 89 + 90 + Deno.test("cirrus parseVersion rejects non-matching tags", () => { 91 + const sw = getSoftwareConfig("cirrus")!; 92 + assertEquals(sw.parseVersion!("v0.10.4"), null); 93 + assertEquals(sw.parseVersion!("0.10.4"), null); 94 + assertEquals(sw.parseVersion!("@getcirrus/other@0.10.4"), null); 95 + }); 96 + 97 + Deno.test("cocoon parseVersion handles valid tags", () => { 98 + const sw = getSoftwareConfig("cocoon")!; 99 + assertEquals(sw.parseVersion!("v0.8.5"), "0.8.5"); 100 + assertEquals(sw.parseVersion!("v2.0.0"), "2.0.0"); 101 + }); 102 + 103 + Deno.test("cocoon parseVersion rejects invalid tags", () => { 104 + const sw = getSoftwareConfig("cocoon")!; 105 + assertEquals(sw.parseVersion!("0.8.5"), null); 106 + assertEquals(sw.parseVersion!("cocoon-v0.8.5"), null); 107 + }); 108 + 109 + Deno.test("all registry entries have required fields", () => { 110 + for (const sw of PDS_SOFTWARE_REGISTRY) { 111 + assertEquals(typeof sw.id, "string"); 112 + assertEquals(sw.id.length > 0, true); 113 + assertEquals(typeof sw.displayName, "string"); 114 + assertEquals(typeof sw.projectUrl, "string"); 115 + assertEquals(sw.projectUrl.startsWith("http"), true); 116 + } 117 + });
+7
shared/types.ts
··· 5 5 last_seen: string; 6 6 last_enriched: string | null; 7 7 version: string | null; 8 + pds_software: string | null; 8 9 did: string | null; 9 10 invite_code_required: boolean; 10 11 phone_verification: boolean; ··· 19 20 is_open: boolean; 20 21 error_at: number | null; 21 22 }; 23 + 24 + /** Maps software ID to its latest version string */ 25 + export type LatestVersionMap = Record<string, string>; 26 + 27 + /** Maps a version string to the software ID that uses it */ 28 + export type VersionSoftwareMap = Record<string, string>; 22 29 23 30 /** Raw PDS entry from mary-ext/atproto-scraping state.json */ 24 31 export type StatePdsEntry = {