static site frontend for mapped.at mapped.at
3
fork

Configure Feed

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

Add little backend which helps service account keep track of profiles and trails

+739 -125
+30
lexicons/at/mapped/member.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.mapped.member", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "A registered mapped.at member. Stored on the service account repo.", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "did", 13 + "joinedAt" 14 + ], 15 + "properties": { 16 + "did": { 17 + "type": "string", 18 + "maxLength": 100, 19 + "description": "The member's DID" 20 + }, 21 + "joinedAt": { 22 + "type": "string", 23 + "format": "datetime", 24 + "description": "When the member registered" 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
-5
lexicons/at/mapped/post.json
··· 42 42 "ref": "com.atproto.repo.strongRef", 43 43 "description": "Reference to a trail record with route geometry" 44 44 }, 45 - "basePost": { 46 - "type": "ref", 47 - "ref": "com.atproto.repo.strongRef", 48 - "description": "Reference to another post that this post is based on (e.g. a travel post based on an activity post)" 49 - }, 50 45 "images": { 51 46 "type": "array", 52 47 "description": "Optional images attached to the post",
+34
lexicons/at/mapped/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.mapped.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "literal:self", 8 + "description": "A user's mapped.at profile. Singleton record — rkey is always 'self'.", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "createdAt" 13 + ], 14 + "properties": { 15 + "displayName": { 16 + "type": "string", 17 + "description": "Optional display name", 18 + "maxLength": 64 19 + }, 20 + "bio": { 21 + "type": "string", 22 + "description": "Optional short bio", 23 + "maxLength": 256 24 + }, 25 + "createdAt": { 26 + "type": "string", 27 + "format": "datetime", 28 + "description": "When the profile was created" 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }
+5
lexicons/at/mapped/trail.json
··· 27 27 "type": "ref", 28 28 "ref": "at.mapped.location" 29 29 } 30 + }, 31 + "contributor": { 32 + "type": "string", 33 + "maxLength": 100, 34 + "description": "DID of the user who contributed this trail" 30 35 } 31 36 }, 32 37 "required": [
+249 -116
src/api.ts
··· 9 9 // ── Constants ───────────────────────────────────────────────────── 10 10 const MAPPED_AT_DID = "did:plc:l5m5nuh5cvdatyn5fjxar2sh"; 11 11 const MAPPED_AT_PDS = "https://leccinum.us-west.host.bsky.network"; 12 - const MAPPED_AT_BASE_POST_URI = 13 - "at://did:plc:l5m5nuh5cvdatyn5fjxar2sh/at.mapped.post/3mitem4c3p727"; 14 - const MAPPED_AT_BASE_POST_CID = 15 - "bafyreie4cz5gwb7ogcabmomr4wjkoici7eem5yfctzxdm342zkcq5r4hnq"; 16 - const CONSTELLATION_URL = "https://constellation.microcosm.blue"; 12 + const MAPPED_AT_BACKEND_URL = "https://mapped-at-backend.val.run"; 17 13 18 14 // ── decodePolyline ───────────────────────────────────────────────── 19 15 // Decodes a Google encoded polyline string into a GeoJSON Feature. ··· 130 126 trailRecs: any[], 131 127 locationRecs: any[], 132 128 activityRecs: any[], 129 + memberRecs: any[], 133 130 ) { 134 131 const activityMap = new Map(); 135 132 for (const { uri, cid, value } of activityRecs) { ··· 172 169 const trails = [...trailMap.values()]; 173 170 const locations = [...locationMap.values()]; 174 171 const activities = [...activityMap.values()]; 172 + const memberDids = new Set<string>( 173 + memberRecs.map((r: any) => r.value?.did).filter(Boolean), 174 + ); 175 175 176 - return { trailMap, locationMap, activityMap, trails, locations, activities }; 176 + return { 177 + trailMap, 178 + locationMap, 179 + activityMap, 180 + trails, 181 + locations, 182 + activities, 183 + memberDids, 184 + }; 177 185 } 178 186 179 187 async function _fetchLiveServiceData() { 180 - const [trailRecs, locationRecs, activityRecs] = await Promise.all([ 181 - listRecords("at.mapped.trail"), 182 - listRecords("at.mapped.location"), 183 - listRecords("at.mapped.activity"), 184 - ]); 185 - const data = _buildFromRecs(trailRecs, locationRecs, activityRecs); 188 + const [trailRecs, locationRecs, activityRecs, memberRecs] = await Promise.all( 189 + [ 190 + listRecords("at.mapped.trail"), 191 + listRecords("at.mapped.location"), 192 + listRecords("at.mapped.activity"), 193 + listRecords("at.mapped.member"), 194 + ], 195 + ); 196 + const data = _buildFromRecs( 197 + trailRecs, 198 + locationRecs, 199 + activityRecs, 200 + memberRecs, 201 + ); 186 202 _cachedServiceData = data; 187 203 return data; 188 204 } 189 205 190 - 191 - // ── collectPostRefs ──────────────────────────────────────────────── 192 - // Discovers all user posts that reference the mapped.at base post via basePost. 193 - // Returns an array of { did, rkey } unique post references. 194 - async function collectPostRefs() { 195 - const url = 196 - `${CONSTELLATION_URL}/xrpc/blue.microcosm.links.getBacklinks` + 197 - `?subject=${encodeURIComponent(MAPPED_AT_BASE_POST_URI)}&source=at.mapped.post:basePost.uri&limit=100`; 198 - const res = await fetch(url); 199 - if (!res.ok) return []; 200 - const links = (await res.json()).records ?? []; 201 - 202 - const seen = new Set(); 203 - const refs = []; 204 - for (const { did, rkey } of links) { 205 - const uri = `at://${did}/at.mapped.post/${rkey}`; 206 - if (seen.has(uri)) continue; 207 - seen.add(uri); 208 - refs.push({ did, rkey }); 209 - } 210 - return refs; 211 - } 212 - 213 206 // ── DID resolver ─────────────────────────────────────────────────── 214 207 const _didResolver = new CompositeDidDocumentResolver({ 215 208 methods: { ··· 248 241 } 249 242 250 243 export async function fetchPds(did: string): Promise<string | null> { 251 - const info = await resolveDidInfo(did as Parameters<typeof _didResolver.resolve>[0]); 244 + const info = await resolveDidInfo( 245 + did as Parameters<typeof _didResolver.resolve>[0], 246 + ); 252 247 return info?.pds ?? null; 253 248 } 254 249 255 - // ── fetchPostRecord ──────────────────────────────────────────────── 256 - // Fetches a single at.mapped.post record from its author's PDS. 257 - // Returns the raw record value, or null on failure. 258 - async function fetchPostRecord(pds: string, did: string, rkey: string) { 250 + // ── createProfile ────────────────────────────────────────────────── 251 + // Creates an at.mapped.profile singleton in the user's repo (if absent), 252 + // then registers the user with the backend so a member record is written 253 + // to the service account. Safe to call on every login — both are idempotent. 254 + async function _profileExists(pds: string, did: string): Promise<boolean> { 259 255 try { 260 - const url = 261 - `${pds}/xrpc/com.atproto.repo.getRecord` + 262 - `?repo=${encodeURIComponent(did)}&collection=at.mapped.post&rkey=${rkey}`; 263 - const res = await fetch(url); 264 - if (!res.ok) return null; 265 - return ((await res.json()).value as Post) ?? null; 266 - } catch (err) { 267 - console.warn( 268 - `fetchPostRecord failed for at://${did}/at.mapped.post/${rkey}:`, 269 - err, 256 + const res = await fetch( 257 + `${pds}/xrpc/com.atproto.repo.getRecord?` + 258 + `repo=${encodeURIComponent(did)}&collection=at.mapped.profile&rkey=self`, 259 + ); 260 + return res.ok; 261 + } catch { 262 + return false; 263 + } 264 + } 265 + 266 + export async function createProfile( 267 + agent: OAuthUserAgent, 268 + { did, pds }: { did: string; pds: string }, 269 + ): Promise<void> { 270 + const exists = await _profileExists(pds, did); 271 + 272 + if (!exists) { 273 + const rpc = new Client< 274 + {}, 275 + { 276 + "com.atproto.repo.createRecord": { 277 + input: { 278 + repo: string; 279 + collection: string; 280 + rkey: string; 281 + record: unknown; 282 + }; 283 + }; 284 + } 285 + >({ handler: agent }); 286 + await ok( 287 + rpc.post("com.atproto.repo.createRecord", { 288 + input: { 289 + repo: did, 290 + rkey: "self", 291 + collection: "at.mapped.profile", 292 + record: { 293 + createdAt: new Date().toISOString(), 294 + }, 295 + }, 296 + as: "json", 297 + }), 298 + ); 299 + } 300 + setTimeout(() => { 301 + // Register with backend — idempotent, non-blocking 302 + fetch(`${MAPPED_AT_BACKEND_URL}/register`, { 303 + method: "POST", 304 + headers: { "Content-Type": "application/json" }, 305 + body: JSON.stringify({ did }), 306 + }).catch((err) => console.warn("Registration backend call failed:", err)); 307 + }, 500); 308 + } 309 + 310 + // ── submitTrail ──────────────────────────────────────────────────── 311 + // Submits a new trail to the service account via the backend. 312 + // Returns the service account's { uri, cid } for use in createPost. 313 + export async function submitTrail( 314 + did: string, 315 + trail: { 316 + name: string; 317 + polyline?: string; 318 + activityType?: { uri: string; cid: string }; 319 + locations?: { uri: string; cid: string }[]; 320 + }, 321 + ): Promise<{ uri: string; cid: string }> { 322 + const res = await fetch(`${MAPPED_AT_BACKEND_URL}/submit-trail`, { 323 + method: "POST", 324 + headers: { "Content-Type": "application/json" }, 325 + body: JSON.stringify({ did, trail }), 326 + }); 327 + if (!res.ok) { 328 + const err = await res.json().catch(() => ({ error: res.statusText })); 329 + throw new Error( 330 + (err as { error?: string }).error ?? "Trail submission failed", 270 331 ); 271 - return null; 272 332 } 333 + return res.json(); 273 334 } 274 335 275 336 export type Post = { ··· 336 397 const POST_CACHE_KEY = "mapped_cache"; 337 398 const POST_CACHE_TTL_MS = 60_000; 338 399 339 - function _readPostsCache() { 400 + function _readPostsCache(did: string) { 340 401 try { 341 - const raw = localStorage.getItem(POST_CACHE_KEY); 402 + const raw = localStorage.getItem(`${POST_CACHE_KEY}_${did}`); 342 403 if (!raw) return null; 343 404 const { posts, cachedAt } = JSON.parse(raw) as { 344 405 posts: Post[]; 345 406 cachedAt: number; 346 407 }; 347 408 if (Date.now() - cachedAt > POST_CACHE_TTL_MS) return null; 348 - // Revive Date objects (JSON serialises them as strings) 349 - return posts; //posts.map((p) => ({ ...p, timestamp: new Date(p.timestamp) })); 409 + return posts; 350 410 } catch (_) { 351 411 return null; 352 412 } 353 413 } 354 414 355 - function _writePostsCache(posts: Post[]) { 415 + function _writePostsCache(did: string, posts: Post[]) { 356 416 try { 357 417 localStorage.setItem( 358 - POST_CACHE_KEY, 418 + `${POST_CACHE_KEY}_${did}`, 359 419 JSON.stringify({ posts, cachedAt: Date.now() }), 360 420 ); 361 421 } catch (_) { ··· 364 424 } 365 425 366 426 // ── fetchAll ─────────────────────────────────────────────────────── 367 - // Main entry point. Returns cached data instantly when available. 368 - // Revalidates posts in background; dispatches 'mapped:posts' on update. 369 - let _cachedResult: { 370 - trails: any; 371 - locations: any; 372 - activities: any; 373 - posts: Post[]; 374 - } | null = null; 375 - let _fetchAllInflight: Promise<{ 376 - trails: any; 377 - locations: any; 378 - activities: any; 379 - posts: Post[]; 380 - }> | null = null; 427 + // Returns posts for the logged-in user based on their follow graph. 428 + // When called without opts (logged-out), returns service data with empty posts. 429 + const _cachedResults = new Map< 430 + string, 431 + { trails: any; locations: any; activities: any; posts: Post[] } 432 + >(); 433 + const _fetchAllInflights = new Map< 434 + string, 435 + Promise<{ trails: any; locations: any; activities: any; posts: Post[] }> 436 + >(); 437 + 438 + export function fetchAll(opts?: { did?: string; pds?: string }) { 439 + const did = opts?.did; 440 + const pds = opts?.pds ?? ""; 381 441 382 - export function fetchAll() { 383 - if (_cachedResult) return Promise.resolve(_cachedResult); 384 - if (!_fetchAllInflight) _fetchAllInflight = _fetchAll(); 385 - return _fetchAllInflight; 442 + if (!did) { 443 + return fetchServiceData().then(({ trails, locations, activities }) => ({ 444 + trails, 445 + locations, 446 + activities, 447 + posts: [] as Post[], 448 + })); 449 + } 450 + 451 + const cached = _cachedResults.get(did); 452 + if (cached) return Promise.resolve(cached); 453 + 454 + const inflight = _fetchAllInflights.get(did); 455 + if (inflight) return inflight; 456 + 457 + const promise = _fetchAll(did, pds); 458 + _fetchAllInflights.set(did, promise); 459 + return promise; 386 460 } 387 461 388 - async function _fetchAll() { 462 + async function _fetchAll(did: string, pds: string) { 389 463 const serviceData = await fetchServiceData(); 390 464 const { trails, locations, activities } = serviceData; 391 465 392 466 // Serve from localStorage cache if fresh 393 - const cached = _readPostsCache(); 467 + const cached = _readPostsCache(did); 394 468 if (cached) { 395 - _cachedResult = { trails, locations, activities, posts: cached }; 396 - _fetchAllInflight = null; 397 - _revalidatePosts(serviceData); 398 - return _cachedResult; 469 + const result = { trails, locations, activities, posts: cached }; 470 + _cachedResults.set(did, result); 471 + _fetchAllInflights.delete(did); 472 + _revalidatePosts(serviceData, did, pds); 473 + return result; 399 474 } 400 475 401 476 // Cold fetch 402 - const posts = await _fetchPosts(serviceData); 403 - _writePostsCache(posts); 404 - _cachedResult = { trails, locations, activities, posts }; 405 - return _cachedResult; 477 + const posts = await _fetchPosts(serviceData, did, pds); 478 + _writePostsCache(did, posts); 479 + const result = { trails, locations, activities, posts }; 480 + _cachedResults.set(did, result); 481 + _fetchAllInflights.delete(did); 482 + return result; 406 483 } 407 484 408 - // Extracted post-fetching logic (constellation + user PDSs). 409 - async function _fetchPosts(serviceData: ReturnType<typeof _buildFromRecs>) { 410 - const postRefs = await collectPostRefs(); 485 + // Fetches the DIDs that `did` follows, from their PDS. 486 + // Only retrieves the first 100 follows (no pagination — sufficient for MVP). 487 + async function getFollows(did: string, pds: string): Promise<string[]> { 488 + try { 489 + const url = 490 + `${pds}/xrpc/com.atproto.repo.listRecords` + 491 + `?repo=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&limit=100`; 492 + const res = await fetch(url); 493 + if (!res.ok) return []; 494 + const { records } = await res.json(); 495 + return (records ?? []) 496 + .map((r: any) => r.value?.subject) 497 + .filter((s: unknown): s is string => typeof s === "string"); 498 + } catch { 499 + return []; 500 + } 501 + } 411 502 412 - const uniqueDids = [...new Set(postRefs.map((r) => r.did))]; 413 - const didInfoMap = new Map(); 503 + // Lists all at.mapped.post records for a user from their PDS. 504 + async function listUserPosts(pds: string, did: string): Promise<any[]> { 505 + try { 506 + const url = 507 + `${pds}/xrpc/com.atproto.repo.listRecords` + 508 + `?repo=${encodeURIComponent(did)}&collection=at.mapped.post&limit=100`; 509 + const res = await fetch(url); 510 + if (!res.ok) return []; 511 + return (await res.json()).records ?? []; 512 + } catch { 513 + return []; 514 + } 515 + } 516 + 517 + async function _fetchPosts( 518 + serviceData: ReturnType<typeof _buildFromRecs>, 519 + loggedInDid: string, 520 + loggedInPds: string, 521 + ) { 522 + // Get who the logged-in user follows 523 + const followedDids = await getFollows(loggedInDid, loggedInPds); 524 + 525 + // Keep only follows who are mapped.at members 526 + const memberFollows = followedDids.filter((did) => 527 + serviceData.memberDids.has(did), 528 + ); 529 + 530 + // Resolve DID info (author + PDS) for each member-follow in parallel 531 + const didInfoMap = new Map<string, { author: any; pds: string }>(); 414 532 await Promise.all( 415 - uniqueDids.map(async (did) => { 416 - const info = await resolveDidInfo(did); 533 + memberFollows.map(async (did) => { 534 + const info = await resolveDidInfo( 535 + did as Parameters<typeof _didResolver.resolve>[0], 536 + ); 417 537 if (info) didInfoMap.set(did, info); 418 538 }), 419 539 ); 420 540 421 - const posts = ( 541 + // Fetch all posts from each member-follow in parallel 542 + const allPosts = ( 422 543 await Promise.all( 423 - postRefs.map(async ({ did, rkey }) => { 544 + memberFollows.map(async (did) => { 424 545 const info = didInfoMap.get(did); 425 - if (!info) return null; 426 - const value = await fetchPostRecord(info.pds, did, rkey); 427 - if (!value) return null; 428 - return _hydratePost(did, rkey, value, info.author, serviceData, info.pds); 546 + if (!info) return []; 547 + const records = await listUserPosts(info.pds, did); 548 + return records.map((r: any) => 549 + _hydratePost( 550 + did, 551 + r.uri.split("/").pop()!, 552 + r.value, 553 + info.author, 554 + serviceData, 555 + info.pds, 556 + ), 557 + ); 429 558 }), 430 559 ) 431 - ).filter((p): p is Post => p !== null); 560 + ).flat(); 432 561 433 - posts.sort((a, b) => b!.timestamp - a!.timestamp); 434 - return posts; 562 + allPosts.sort((a, b) => b.timestamp - a.timestamp); 563 + return allPosts; 435 564 } 436 565 437 566 async function _revalidatePosts( 438 567 serviceData: ReturnType<typeof _buildFromRecs>, 568 + did: string, 569 + pds: string, 439 570 ) { 440 571 try { 441 - const prevPosts = _cachedResult?.posts ?? []; 442 - const posts = await _fetchPosts(serviceData); 443 - _writePostsCache(posts); 572 + const prevPosts = _cachedResults.get(did)?.posts ?? []; 573 + const posts = await _fetchPosts(serviceData, did, pds); 574 + _writePostsCache(did, posts); 444 575 const { trails, locations, activities } = serviceData; 445 - _cachedResult = { trails, locations, activities, posts }; 576 + _cachedResults.set(did, { trails, locations, activities, posts }); 446 577 if (_postsChanged(prevPosts, posts)) { 447 578 document.dispatchEvent(new CustomEvent("mapped:posts")); 448 579 } ··· 475 606 }; 476 607 } 477 608 >({ handler: agent }); 478 - const result = await ok( 609 + const result = (await ok( 479 610 rpc.post("com.atproto.repo.uploadBlob", { 480 611 input: file, 481 612 headers: { "Content-Type": file.type }, 482 613 as: "json", 483 - }) 484 - ) as unknown as { blob: BlobRef }; 614 + }), 615 + )) as unknown as { blob: BlobRef }; 485 616 return result.blob; 486 617 } 487 618 ··· 522 653 // Upload images in parallel before creating the record 523 654 let imageRefs: Array<{ image: BlobRef; alt?: string }> | undefined; 524 655 if (images && images.length > 0) { 525 - const blobRefs = await Promise.all(images.map(({ file }) => uploadBlob(agent, file))); 656 + const blobRefs = await Promise.all( 657 + images.map(({ file }) => uploadBlob(agent, file)), 658 + ); 526 659 imageRefs = blobRefs.map((image, i) => ({ 527 660 image, 528 661 ...(images[i].alt ? { alt: images[i].alt } : {}), ··· 548 681 timestamp: string; 549 682 activity: { uri: string; cid: string }; 550 683 trail: { uri: string; cid: string }; 551 - basePost: { uri: string; cid: string }; 552 684 images?: Array<{ image: BlobRef; alt?: string }>; 553 685 stats?: { distance?: number; duration?: number; elevation?: number }; 554 686 } = { ··· 556 688 timestamp: new Date().toISOString(), 557 689 activity: { uri: activityEntry.uri, cid: activityEntry.cid }, 558 690 trail: { uri: trailEntry.uri, cid: trailEntry.cid }, 559 - basePost: { uri: MAPPED_AT_BASE_POST_URI, cid: MAPPED_AT_BASE_POST_CID }, 560 691 }; 561 692 if (title) record.title = title; 562 693 if (text) record.text = text; 563 694 if (imageRefs) record.images = imageRefs; 564 - if (stats && Object.values(stats).some((v) => v !== undefined)) record.stats = stats; 695 + if (stats && Object.values(stats).some((v) => v !== undefined)) 696 + record.stats = stats; 565 697 566 698 const res = (await ok( 567 699 rpc.post("com.atproto.repo.createRecord", { ··· 574 706 const author = { handle, initials: toInitials(handle) }; 575 707 576 708 // Optimistic update: prepend to in-memory cache 577 - if (_cachedResult) { 578 - const post = _hydratePost(did, rkey, record, author, _cachedResult, pds); 579 - _cachedResult.posts.unshift(post); 580 - _writePostsCache(_cachedResult.posts); 709 + const cached = _cachedResults.get(did); 710 + if (cached) { 711 + const post = _hydratePost(did, rkey, record, author, cached, pds); 712 + cached.posts.unshift(post); 713 + _writePostsCache(did, cached.posts); 581 714 document.dispatchEvent(new CustomEvent("mapped:posts")); 582 715 } 583 716 }
+21 -3
src/components/activity-feed.ts
··· 57 57 private async _load() { 58 58 this._loading = true; 59 59 this._error = false; 60 - this._loggedIn = !!localStorage.getItem("atproto-did"); 60 + const did = localStorage.getItem("atproto-did") ?? undefined; 61 + const pds = localStorage.getItem("atproto-pds") ?? undefined; 62 + this._loggedIn = !!did; 63 + 64 + if (!did) { 65 + this._posts = []; 66 + this._loading = false; 67 + return; 68 + } 69 + 61 70 let data; 62 71 try { 63 - data = await fetchAll(); 72 + data = await fetchAll({ did, pds }); 64 73 } catch { 65 74 this._loading = false; 66 75 this._error = true; ··· 83 92 </div> 84 93 `; 85 94 } 95 + if (!this._loggedIn) { 96 + return html` 97 + <div class="feed"> 98 + <p style="text-align:center;color:#999;padding:32px"> 99 + Sign in to see posts from people you follow. 100 + </p> 101 + </div> 102 + `; 103 + } 86 104 return html` 87 105 <div class="feed"> 88 - ${this._loggedIn ? html`<compose-card></compose-card>` : ""} 106 + <compose-card></compose-card> 89 107 ${this._posts.map( 90 108 (post) => html`<activity-card .post=${post}></activity-card>`, 91 109 )}
+13
src/components/atproto-login.ts
··· 8 8 OAuthUserAgent, 9 9 } from "@atcute/oauth-browser-client"; 10 10 import metadata from "../oauth-client-metadata.json" with { type: "json" }; 11 + import { createProfile, fetchPds } from "../api.ts"; 11 12 12 13 @customElement("atproto-login") 13 14 export class AtprotoLogin extends LitElement { ··· 222 223 } 223 224 localStorage.removeItem("atproto-did"); 224 225 localStorage.removeItem("atproto-handle"); 226 + localStorage.removeItem("atproto-pds"); 225 227 this._handle = null; 226 228 this._dispatchAuthChange(); 227 229 } ··· 239 241 localStorage.setItem("atproto-did", did); 240 242 localStorage.setItem("atproto-handle", handle); 241 243 this._handle = handle; 244 + 245 + // Resolve PDS, store it, then create profile + register (non-blocking) 246 + fetchPds(did).then((pds) => { 247 + if (!pds) return; 248 + localStorage.setItem("atproto-pds", pds); 249 + const agent = new OAuthUserAgent(session); 250 + createProfile(agent, { did, pds }).catch((err) => 251 + console.warn("createProfile failed:", err), 252 + ); 253 + }); 254 + 242 255 this._dispatchAuthChange(); 243 256 } catch (err: any) { 244 257 await this.updateComplete;
+1 -1
src/oauth-client-metadata.json
··· 12 12 "response_types": [ 13 13 "code" 14 14 ], 15 - "scope": "atproto blob:*/* repo:at.mapped.post repo:at.mapped.activity repo:at.mapped.location repo:at.mapped.trail", 15 + "scope": "atproto blob:*/* repo:at.mapped.post repo:at.mapped.activity repo:at.mapped.location repo:at.mapped.trail repo:at.mapped.profile", 16 16 "token_endpoint_auth_method": "none", 17 17 "client_name": "mapped.at", 18 18 "client_uri": "https://mapped.at"
+108
val/backend.test.ts
··· 1 + import { assertEquals } from "jsr:@std/assert"; 2 + import { handler } from "./backend.ts"; 3 + 4 + // ── Routing ─────────────────────────────────────────────────────────────────── 5 + 6 + Deno.test("GET /register returns 405", async () => { 7 + const res = await handler( 8 + new Request("http://localhost/register", { method: "GET" }), 9 + ); 10 + assertEquals(res.status, 405); 11 + const body = await res.json(); 12 + assertEquals(body.error, "Method not allowed"); 13 + }); 14 + 15 + Deno.test("POST /unknown returns 404", async () => { 16 + const res = await handler( 17 + new Request("http://localhost/unknown", { 18 + method: "POST", 19 + headers: { "Content-Type": "application/json" }, 20 + body: JSON.stringify({}), 21 + }), 22 + ); 23 + assertEquals(res.status, 404); 24 + const body = await res.json(); 25 + assertEquals(body.error, "Not found"); 26 + }); 27 + 28 + // ── /register validation ────────────────────────────────────────────────────── 29 + 30 + Deno.test("POST /register rejects invalid JSON", async () => { 31 + const res = await handler( 32 + new Request("http://localhost/register", { 33 + method: "POST", 34 + headers: { "Content-Type": "application/json" }, 35 + body: "not json", 36 + }), 37 + ); 38 + assertEquals(res.status, 400); 39 + const body = await res.json(); 40 + assertEquals(body.error, "Invalid JSON"); 41 + }); 42 + 43 + Deno.test("POST /register rejects missing did", async () => { 44 + const res = await handler( 45 + new Request("http://localhost/register", { 46 + method: "POST", 47 + headers: { "Content-Type": "application/json" }, 48 + body: JSON.stringify({}), 49 + }), 50 + ); 51 + assertEquals(res.status, 400); 52 + const body = await res.json(); 53 + assertEquals(body.error, "Missing required field: did"); 54 + }); 55 + 56 + Deno.test("POST /register rejects non-DID string", async () => { 57 + const res = await handler( 58 + new Request("http://localhost/register", { 59 + method: "POST", 60 + headers: { "Content-Type": "application/json" }, 61 + body: JSON.stringify({ did: "alice.bsky.social" }), 62 + }), 63 + ); 64 + assertEquals(res.status, 400); 65 + const body = await res.json(); 66 + assertEquals(body.error, "Invalid DID format"); 67 + }); 68 + 69 + // ── /submit-trail validation ────────────────────────────────────────────────── 70 + 71 + Deno.test("POST /submit-trail rejects missing did", async () => { 72 + const res = await handler( 73 + new Request("http://localhost/submit-trail", { 74 + method: "POST", 75 + headers: { "Content-Type": "application/json" }, 76 + body: JSON.stringify({ trail: { name: "Test Trail" } }), 77 + }), 78 + ); 79 + assertEquals(res.status, 400); 80 + const body = await res.json(); 81 + assertEquals(body.error, "Missing required field: did"); 82 + }); 83 + 84 + Deno.test("POST /submit-trail rejects missing trail.name", async () => { 85 + const res = await handler( 86 + new Request("http://localhost/submit-trail", { 87 + method: "POST", 88 + headers: { "Content-Type": "application/json" }, 89 + body: JSON.stringify({ did: "did:plc:test", trail: {} }), 90 + }), 91 + ); 92 + assertEquals(res.status, 400); 93 + const body = await res.json(); 94 + assertEquals(body.error, "Missing required field: trail.name"); 95 + }); 96 + 97 + Deno.test("POST /submit-trail rejects non-DID string", async () => { 98 + const res = await handler( 99 + new Request("http://localhost/submit-trail", { 100 + method: "POST", 101 + headers: { "Content-Type": "application/json" }, 102 + body: JSON.stringify({ did: "not-a-did", trail: { name: "Trail" } }), 103 + }), 104 + ); 105 + assertEquals(res.status, 400); 106 + const body = await res.json(); 107 + assertEquals(body.error, "Invalid DID format"); 108 + });
+278
val/backend.ts
··· 1 + // val/backend.ts 2 + // Val.town HTTP val — Deno-compatible TypeScript. 3 + // 4 + // Required val.town environment secrets: 5 + // MAPPED_AT_IDENTIFIER service account handle, e.g. "mapped.at" 6 + // MAPPED_AT_APP_PASSWORD service account app password (not the main password) 7 + // MAPPED_AT_PDS service account PDS URL, e.g. "https://leccinum.us-west.host.bsky.network" 8 + // MAPPED_AT_DID service account DID, e.g. "did:plc:l5m5nuh5cvdatyn5fjxar2sh" 9 + 10 + const SERVICE_ACCOUNT_PDS = Deno.env.get("MAPPED_AT_PDS") ?? ""; 11 + const SERVICE_ACCOUNT_DID = Deno.env.get("MAPPED_AT_DID") ?? ""; 12 + 13 + // ── Routing ─────────────────────────────────────────────────────────────────── 14 + 15 + export function handler(req: Request) { 16 + const url = new URL(req.url); 17 + 18 + if (req.method === "GET") { 19 + return Response.json({ ok: true }); 20 + } 21 + 22 + if (req.method !== "POST") { 23 + return Response.json({ error: "Method not allowed" }, { status: 405 }); 24 + } 25 + 26 + if (url.pathname === "/register") return handleRegister(req); 27 + if (url.pathname === "/submit-trail") return handleSubmitTrail(req); 28 + 29 + return Response.json({ error: "Not found" }, { status: 404 }); 30 + } 31 + 32 + // ── Helpers ─────────────────────────────────────────────────────────────────── 33 + 34 + // Resolves a DID to the PDS serviceEndpoint via the DID document. 35 + // Supports did:plc (via plc.directory) and did:web. 36 + async function resolvePds(did: string): Promise<string | null> { 37 + try { 38 + let docUrl: string; 39 + if (did.startsWith("did:plc:")) { 40 + docUrl = `https://plc.directory/${did}`; 41 + } else if (did.startsWith("did:web:")) { 42 + const domain = did.slice(8).replace(/:/g, "/"); 43 + docUrl = `https://${domain}/.well-known/did.json`; 44 + } else { 45 + return null; 46 + } 47 + const res = await fetch(docUrl); 48 + if (!res.ok) return null; 49 + const doc = await res.json(); 50 + const svc = (doc.service ?? []).find( 51 + (s: { type: string }) => s.type === "AtprotoPersonalDataServer", 52 + ) as { serviceEndpoint: string } | undefined; 53 + return svc?.serviceEndpoint ?? null; 54 + } catch { 55 + return null; 56 + } 57 + } 58 + 59 + // Authenticates as the service account and returns a short-lived access JWT. 60 + // Re-authenticates on every call — acceptable for low-traffic usage. 61 + async function getServiceAccountToken(): Promise<string> { 62 + const identifier = Deno.env.get("MAPPED_AT_IDENTIFIER")!; 63 + const password = Deno.env.get("MAPPED_AT_APP_PASSWORD")!; 64 + const res = await fetch( 65 + `${SERVICE_ACCOUNT_PDS}/xrpc/com.atproto.server.createSession`, 66 + { 67 + method: "POST", 68 + headers: { "Content-Type": "application/json" }, 69 + body: JSON.stringify({ identifier, password }), 70 + }, 71 + ); 72 + if (!res.ok) throw new Error(`Service account auth failed: ${res.status}`); 73 + const { accessJwt } = await res.json(); 74 + return accessJwt; 75 + } 76 + 77 + // ── Handlers ────────────────────────────────────────────────────────────────── 78 + 79 + async function handleRegister(req: Request): Promise<Response> { 80 + // Parse body 81 + let body: { did?: unknown }; 82 + try { 83 + body = await req.json(); 84 + } catch { 85 + return Response.json({ error: "Invalid JSON" }, { status: 400 }); 86 + } 87 + 88 + const { did } = body; 89 + if (!did || typeof did !== "string") { 90 + return Response.json( 91 + { error: "Missing required field: did" }, 92 + { 93 + status: 400, 94 + }, 95 + ); 96 + } 97 + if (!did.startsWith("did:")) { 98 + return Response.json({ error: "Invalid DID format" }, { status: 400 }); 99 + } 100 + 101 + // Verify profile exists on the user's PDS 102 + const pds = await resolvePds(did); 103 + if (!pds) { 104 + return Response.json( 105 + { error: "Could not resolve DID to a PDS" }, 106 + { 107 + status: 400, 108 + }, 109 + ); 110 + } 111 + 112 + const profileRes = await fetch( 113 + `${pds}/xrpc/com.atproto.repo.getRecord?` + 114 + `repo=${encodeURIComponent(did)}&collection=at.mapped.profile&rkey=self`, 115 + ); 116 + if (!profileRes.ok) { 117 + return Response.json( 118 + { error: "at.mapped.profile not found on user PDS" }, 119 + { 120 + status: 400, 121 + }, 122 + ); 123 + } 124 + 125 + // Idempotency: return early if already registered 126 + const membersRes = await fetch( 127 + `${SERVICE_ACCOUNT_PDS}/xrpc/com.atproto.repo.listRecords?` + 128 + `repo=${encodeURIComponent( 129 + SERVICE_ACCOUNT_DID, 130 + )}&collection=at.mapped.member&limit=100`, 131 + ); 132 + if (membersRes.ok) { 133 + const { records } = (await membersRes.json()) as { 134 + records: { value: { did: string } }[]; 135 + }; 136 + if ((records ?? []).some((r) => r.value.did === did)) { 137 + return Response.json({ ok: true }); 138 + } 139 + } 140 + 141 + // Create member record on service account 142 + const token = await getServiceAccountToken(); 143 + const createRes = await fetch( 144 + `${SERVICE_ACCOUNT_PDS}/xrpc/com.atproto.repo.createRecord`, 145 + { 146 + method: "POST", 147 + headers: { 148 + "Content-Type": "application/json", 149 + Authorization: `Bearer ${token}`, 150 + }, 151 + body: JSON.stringify({ 152 + repo: SERVICE_ACCOUNT_DID, 153 + collection: "at.mapped.member", 154 + record: { 155 + $type: "at.mapped.member", 156 + did, 157 + joinedAt: new Date().toISOString(), 158 + }, 159 + }), 160 + }, 161 + ); 162 + 163 + if (!createRes.ok) { 164 + const err = await createRes.text(); 165 + return Response.json( 166 + { error: `Failed to create member record: ${err}` }, 167 + { status: 500 }, 168 + ); 169 + } 170 + 171 + return Response.json({ ok: true }); 172 + } 173 + 174 + async function handleSubmitTrail(req: Request): Promise<Response> { 175 + // Parse body 176 + let body: { 177 + did?: unknown; 178 + trail?: { 179 + name?: unknown; 180 + polyline?: unknown; 181 + activityType?: unknown; 182 + locations?: unknown; 183 + }; 184 + }; 185 + try { 186 + body = await req.json(); 187 + } catch { 188 + return Response.json({ error: "Invalid JSON" }, { status: 400 }); 189 + } 190 + 191 + const { did, trail } = body; 192 + if (!did || typeof did !== "string") { 193 + return Response.json( 194 + { error: "Missing required field: did" }, 195 + { 196 + status: 400, 197 + }, 198 + ); 199 + } 200 + if (!did.startsWith("did:")) { 201 + return Response.json({ error: "Invalid DID format" }, { status: 400 }); 202 + } 203 + if (!trail?.name || typeof trail.name !== "string") { 204 + return Response.json( 205 + { error: "Missing required field: trail.name" }, 206 + { 207 + status: 400, 208 + }, 209 + ); 210 + } 211 + 212 + // Verify membership 213 + const membersRes = await fetch( 214 + `${SERVICE_ACCOUNT_PDS}/xrpc/com.atproto.repo.listRecords?` + 215 + `repo=${encodeURIComponent( 216 + SERVICE_ACCOUNT_DID, 217 + )}&collection=at.mapped.member&limit=100`, 218 + ); 219 + if (!membersRes.ok) { 220 + return Response.json( 221 + { error: "Could not verify membership" }, 222 + { 223 + status: 500, 224 + }, 225 + ); 226 + } 227 + const { records } = (await membersRes.json()) as { 228 + records: { value: { did: string } }[]; 229 + }; 230 + const isMember = (records ?? []).some((r) => r.value.did === did); 231 + if (!isMember) { 232 + return Response.json({ error: "Not a registered member" }, { status: 403 }); 233 + } 234 + 235 + // Build trail record 236 + const trailRecord: Record<string, unknown> = { 237 + $type: "at.mapped.trail", 238 + name: trail.name, 239 + contributor: did, 240 + }; 241 + if (typeof trail.polyline === "string") trailRecord.polyline = trail.polyline; 242 + if (trail.activityType) trailRecord.activityType = trail.activityType; 243 + if (Array.isArray(trail.locations)) trailRecord.locations = trail.locations; 244 + 245 + // Create trail on service account 246 + const token = await getServiceAccountToken(); 247 + const createRes = await fetch( 248 + `${SERVICE_ACCOUNT_PDS}/xrpc/com.atproto.repo.createRecord`, 249 + { 250 + method: "POST", 251 + headers: { 252 + "Content-Type": "application/json", 253 + Authorization: `Bearer ${token}`, 254 + }, 255 + body: JSON.stringify({ 256 + repo: SERVICE_ACCOUNT_DID, 257 + collection: "at.mapped.trail", 258 + record: trailRecord, 259 + }), 260 + }, 261 + ); 262 + 263 + if (!createRes.ok) { 264 + const err = await createRes.text(); 265 + return Response.json( 266 + { error: `Failed to create trail: ${err}` }, 267 + { 268 + status: 500, 269 + }, 270 + ); 271 + } 272 + 273 + const { uri, cid } = (await createRes.json()) as { 274 + uri: string; 275 + cid: string; 276 + }; 277 + return Response.json({ uri, cid }); 278 + }