diff --git a/apps/web/package.json b/apps/web/package.json index f822a2e..46f7644 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "vitest run", "db:migrate": "wrangler d1 migrations apply mooring --local", "deploy": "npm run build && wrangler deploy" }, @@ -26,6 +27,7 @@ "svelte-check": "^4.6.0", "typescript": "^6.0.3", "vite": "^8.0.16", + "vitest": "^4.1.10", "wrangler": "^4.118.0" } } diff --git a/apps/web/src/lib/server/atmosphere/atmosphere.test.ts b/apps/web/src/lib/server/atmosphere/atmosphere.test.ts new file mode 100644 index 0000000..7f63411 --- /dev/null +++ b/apps/web/src/lib/server/atmosphere/atmosphere.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest'; +import { listAllRecords, blobUrl, type AtmosphereContext } from './xrpc'; +import { pdsEndpoint } from './identity'; +import { fetchBlueskyPosts, fetchBlueskyProfile } from './bluesky'; +import { fetchDocuments } from './standard-site'; +import { fetchSifaProfile, fetchSifaSkills } from './sifa'; +import { detectSources } from './sources'; + +/** + * Fake PDS: routes XRPC calls to canned data. `collections` maps collection + * NSID → record values (in listRecords order); `records` maps + * "collection/rkey" → a single record value for getRecord. + */ +function fakeCtx(data: { + collections?: Record[]>; + records?: Record>; + pageSize?: number; +}): AtmosphereContext { + const did = 'did:plc:test123'; + const fakeFetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.pathname === '/xrpc/com.atproto.repo.listRecords') { + const collection = url.searchParams.get('collection') ?? ''; + const all = data.collections?.[collection] ?? []; + const pageSize = data.pageSize ?? 100; + const start = Number(url.searchParams.get('cursor') ?? '0'); + const page = all.slice(start, start + pageSize); + const next = start + pageSize; + return Response.json({ + records: page.map((value, i) => ({ + uri: `at://${did}/${collection}/rkey${start + i}`, + cid: `cid${start + i}`, + value + })), + ...(next < all.length ? { cursor: String(next) } : {}) + }); + } + if (url.pathname === '/xrpc/com.atproto.repo.getRecord') { + const key = `${url.searchParams.get('collection')}/${url.searchParams.get('rkey')}`; + const value = data.records?.[key]; + if (!value) return new Response('{"error":"RecordNotFound"}', { status: 400 }); + return Response.json({ uri: `at://${did}/${key}`, cid: 'cid0', value }); + } + return new Response('not found', { status: 404 }); + }) as typeof fetch; + return { pds: 'https://pds.example', did, fetch: fakeFetch }; +} + +describe('xrpc', () => { + it('paginates listRecords with cursors', async () => { + const ctx = fakeCtx({ + collections: { 'com.example.thing': Array.from({ length: 5 }, (_, i) => ({ n: i })) }, + pageSize: 2 + }); + const records = await listAllRecords(ctx, 'com.example.thing'); + expect(records).toHaveLength(5); + expect(records[4].value).toEqual({ n: 4 }); + }); + + it('builds PDS blob URLs', () => { + const ctx = fakeCtx({}); + expect(blobUrl(ctx, 'bafyfoo')).toBe( + 'https://pds.example/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Atest123&cid=bafyfoo' + ); + }); +}); + +describe('identity', () => { + it('extracts the PDS endpoint from a DID document', () => { + const doc = { + id: 'did:plc:test123', + service: [ + { id: '#other', type: 'SomethingElse', serviceEndpoint: 'https://nope.example' }, + { + id: 'did:plc:test123#atproto_pds', + type: 'AtprotoPersonalDataServer', + serviceEndpoint: 'https://pds.example' + } + ] + }; + expect(pdsEndpoint(doc)).toBe('https://pds.example'); + expect(pdsEndpoint({ id: 'did:plc:x' })).toBeUndefined(); + }); +}); + +describe('bluesky adapter', () => { + it('skips replies, sorts newest first, and applies the limit', async () => { + const ctx = fakeCtx({ + collections: { + 'app.bsky.feed.post': [ + { text: 'old', createdAt: '2026-01-01T00:00:00Z' }, + { text: 'a reply', createdAt: '2026-03-01T00:00:00Z', reply: { parent: {} } }, + { text: 'new', createdAt: '2026-02-01T00:00:00Z', embed: { $type: 'x' } }, + { notText: true } + ] + } + }); + const posts = await fetchBlueskyPosts(ctx, { limit: 2 }); + expect(posts.map((p) => p.text)).toEqual(['new', 'old']); + expect(posts[0].hasEmbed).toBe(true); + }); + + it('reads the profile and serves the avatar from the PDS', async () => { + const ctx = fakeCtx({ + records: { + 'app.bsky.actor.profile/self': { + displayName: 'Test Pilot', + avatar: { $type: 'blob', ref: { $link: 'bafyavatar' }, mimeType: 'image/png' } + } + } + }); + const profile = await fetchBlueskyProfile(ctx); + expect(profile?.displayName).toBe('Test Pilot'); + expect(profile?.avatarUrl).toContain('cid=bafyavatar'); + }); + + it('returns undefined when there is no profile record', async () => { + expect(await fetchBlueskyProfile(fakeCtx({}))).toBeUndefined(); + }); +}); + +describe('standard.site adapter', () => { + it('prefers site.standard.document and ignores legacy records when present', async () => { + const ctx = fakeCtx({ + collections: { + 'site.standard.document': [ + { title: 'First', publishedAt: '2026-01-01T00:00:00Z', site: 'at://pub/1' }, + { title: 'Second', publishedAt: '2026-02-01T00:00:00Z', site: 'at://pub/2' }, + { noTitle: true } + ], + 'pub.leaflet.document': [{ title: 'Legacy', publishedAt: '2026-03-01T00:00:00Z' }] + } + }); + const docs = await fetchDocuments(ctx); + expect(docs.map((d) => d.title)).toEqual(['Second', 'First']); + expect(docs.every((d) => !d.legacy)).toBe(true); + }); + + it('falls back to pub.leaflet.document when no standard.site docs exist', async () => { + const ctx = fakeCtx({ + collections: { + 'pub.leaflet.document': [{ title: 'Legacy', createdAt: '2026-03-01T00:00:00Z' }] + } + }); + const docs = await fetchDocuments(ctx); + expect(docs).toHaveLength(1); + expect(docs[0]).toMatchObject({ title: 'Legacy', legacy: true, publishedAt: '2026-03-01T00:00:00Z' }); + }); + + it('filters by publication when asked', async () => { + const ctx = fakeCtx({ + collections: { + 'site.standard.document': [ + { title: 'A', site: 'at://pub/1' }, + { title: 'B', site: 'at://pub/2' } + ] + } + }); + const docs = await fetchDocuments(ctx, { publicationUri: 'at://pub/2' }); + expect(docs.map((d) => d.title)).toEqual(['B']); + }); +}); + +describe('sifa adapter', () => { + it('reads the profile singleton and tolerates its absence', async () => { + const ctx = fakeCtx({ + records: { 'id.sifa.profile.self/self': { headline: 'Airship captain' } } + }); + expect((await fetchSifaProfile(ctx))?.headline).toBe('Airship captain'); + expect(await fetchSifaProfile(fakeCtx({}))).toBeUndefined(); + }); + + it('skips malformed skill records instead of failing', async () => { + const ctx = fakeCtx({ + collections: { + 'id.sifa.profile.skill': [{ name: 'Navigation' }, { level: 3 }, { name: 'Rigging' }] + } + }); + expect((await fetchSifaSkills(ctx)).map((s) => s.name)).toEqual(['Navigation', 'Rigging']); + }); +}); + +describe('source detection', () => { + it('detects sources in default section order', () => { + const sources = detectSources([ + 'app.bsky.feed.post', + 'page.mooring.page', + 'pub.leaflet.document', + 'id.sifa.profile.self' + ]); + expect(sources.map((s) => s.id)).toEqual(['career', 'writing', 'bluesky', 'pages']); + }); + + it('returns nothing for an empty repo', () => { + expect(detectSources([])).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/server/atmosphere/bluesky.ts b/apps/web/src/lib/server/atmosphere/bluesky.ts new file mode 100644 index 0000000..8054ca8 --- /dev/null +++ b/apps/web/src/lib/server/atmosphere/bluesky.ts @@ -0,0 +1,68 @@ +/** + * Bluesky adapter: profile + recent microblog posts, read straight from the + * user's repo (app.bsky.* is the stable end of our source spectrum). + */ +import { + type AtmosphereContext, + blobRefCid, + blobUrl, + getRecord, + listAllRecords, + str +} from './xrpc'; + +export interface BlueskyProfile { + displayName?: string; + description?: string; + /** URL serving the avatar blob from the user's PDS. */ + avatarUrl?: string; +} + +export interface BlueskyPost { + uri: string; + text: string; + createdAt: string; + /** Post has an embed (image, external link, quote) we don't render yet. */ + hasEmbed: boolean; +} + +export async function fetchBlueskyProfile( + ctx: AtmosphereContext +): Promise { + const record = await getRecord(ctx, 'app.bsky.actor.profile', 'self'); + if (!record) return undefined; + const avatarCid = blobRefCid(record.value, 'avatar'); + return { + displayName: str(record.value, 'displayName'), + description: str(record.value, 'description'), + avatarUrl: avatarCid ? blobUrl(ctx, avatarCid) : undefined + }; +} + +/** + * The owner's own recent posts, newest first. Replies are skipped — a + * professional-presence page shows what someone said, not mid-thread + * fragments. + */ +export async function fetchBlueskyPosts( + ctx: AtmosphereContext, + options?: { limit?: number } +): Promise { + const limit = options?.limit ?? 10; + const records = await listAllRecords(ctx, 'app.bsky.feed.post'); + const posts: BlueskyPost[] = []; + for (const record of records) { + const text = str(record.value, 'text'); + const createdAt = str(record.value, 'createdAt'); + if (text === undefined || createdAt === undefined) continue; + if (record.value.reply !== undefined) continue; + posts.push({ + uri: record.uri, + text, + createdAt, + hasEmbed: record.value.embed !== undefined + }); + } + posts.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + return posts.slice(0, limit); +} diff --git a/apps/web/src/lib/server/atmosphere/identity.ts b/apps/web/src/lib/server/atmosphere/identity.ts new file mode 100644 index 0000000..0c62169 --- /dev/null +++ b/apps/web/src/lib/server/atmosphere/identity.ts @@ -0,0 +1,50 @@ +/** + * DID → DID document → PDS endpoint. Shared by the OAuth compat layer and the + * source adapters. + */ + +const PLC_DIRECTORY_URL = 'https://plc.directory/'; + +export async function resolveDidDocument( + did: string, + options?: { signal?: AbortSignal; fetch?: typeof fetch } +): Promise> { + let url: URL; + if (did.startsWith('did:plc:')) { + url = new URL(`/${encodeURIComponent(did)}`, PLC_DIRECTORY_URL); + } else if (did.startsWith('did:web:')) { + const host = decodeURIComponent(did.slice('did:web:'.length)); + url = new URL( + host.includes(':') + ? `https://${host.split(':').join('/')}/did.json` + : `https://${host}/.well-known/did.json` + ); + } else { + throw new Error(`Unsupported DID method: ${did}`); + } + const doFetch = options?.fetch ?? fetch; + const res = await doFetch(url, { + headers: { accept: 'application/did+ld+json,application/json' }, + signal: options?.signal + }); + if (!res.ok) throw new Error(`DID resolution failed (HTTP ${res.status}) for ${did}`); + return res.json() as Promise>; +} + +/** Extract the PDS base URL from a DID document; undefined when absent. */ +export function pdsEndpoint(didDocument: Record): string | undefined { + const services = didDocument.service; + if (!Array.isArray(services)) return undefined; + for (const s of services) { + if (s === null || typeof s !== 'object') continue; + const { id, type, serviceEndpoint } = s as Record; + if ( + (id === '#atproto_pds' || (typeof id === 'string' && id.endsWith('#atproto_pds'))) && + type === 'AtprotoPersonalDataServer' && + typeof serviceEndpoint === 'string' + ) { + return serviceEndpoint; + } + } + return undefined; +} diff --git a/apps/web/src/lib/server/atmosphere/index.ts b/apps/web/src/lib/server/atmosphere/index.ts new file mode 100644 index 0000000..b29d552 --- /dev/null +++ b/apps/web/src/lib/server/atmosphere/index.ts @@ -0,0 +1,15 @@ +export { type AtmosphereContext, blobUrl } from './xrpc'; +export { resolveDidDocument, pdsEndpoint } from './identity'; +export { fetchBlueskyProfile, fetchBlueskyPosts } from './bluesky'; +export type { BlueskyProfile, BlueskyPost } from './bluesky'; +export { fetchPublications, fetchDocuments } from './standard-site'; +export type { Publication, WritingDocument } from './standard-site'; +export { + fetchSifaProfile, + fetchSifaPositions, + fetchSifaEducation, + fetchSifaSkills +} from './sifa'; +export type { SifaProfile, SifaPosition, SifaEducation, SifaSkill } from './sifa'; +export { detectSources } from './sources'; +export type { DetectedSource, SourceId } from './sources'; diff --git a/apps/web/src/lib/server/atmosphere/sifa.ts b/apps/web/src/lib/server/atmosphere/sifa.ts new file mode 100644 index 0000000..1f1e5a5 --- /dev/null +++ b/apps/web/src/lib/server/atmosphere/sifa.ts @@ -0,0 +1,81 @@ +/** + * sifa adapter: the professional profile. id.sifa.* is alpha-badged and + * single-vendor — the most churn-prone source we read (ADR 0006 contains that + * churn here). Everything is extracted defensively; sifa's two-tier date + * convention means dates arrive as formatless human strings, so we pass them + * through for display rather than parsing them. + */ +import { type AtmosphereContext, getRecord, listAllRecords, str, type ListedRecord } from './xrpc'; + +export interface SifaProfile { + headline?: string; + summary?: string; +} + +export interface SifaPosition { + uri: string; + title?: string; + organization?: string; + startDate?: string; + endDate?: string; +} + +export interface SifaEducation { + uri: string; + school?: string; + degree?: string; + startDate?: string; + endDate?: string; +} + +export interface SifaSkill { + uri: string; + name: string; +} + +export async function fetchSifaProfile(ctx: AtmosphereContext): Promise { + const record = await getRecord(ctx, 'id.sifa.profile.self', 'self'); + if (!record) return undefined; + return { + headline: str(record.value, 'headline'), + summary: str(record.value, 'summary') ?? str(record.value, 'about') + }; +} + +function firstString(record: ListedRecord, keys: string[]): string | undefined { + for (const key of keys) { + const v = str(record.value, key); + if (v !== undefined) return v; + } + return undefined; +} + +export async function fetchSifaPositions(ctx: AtmosphereContext): Promise { + const records = await listAllRecords(ctx, 'id.sifa.profile.position'); + return records.map((record) => ({ + uri: record.uri, + title: firstString(record, ['title', 'role']), + organization: firstString(record, ['organization', 'company', 'org']), + startDate: str(record.value, 'startDate'), + endDate: str(record.value, 'endDate') + })); +} + +export async function fetchSifaEducation(ctx: AtmosphereContext): Promise { + const records = await listAllRecords(ctx, 'id.sifa.profile.education'); + return records.map((record) => ({ + uri: record.uri, + school: firstString(record, ['school', 'institution', 'organization']), + degree: firstString(record, ['degree', 'fieldOfStudy']), + startDate: str(record.value, 'startDate'), + endDate: str(record.value, 'endDate') + })); +} + +export async function fetchSifaSkills(ctx: AtmosphereContext): Promise { + const records = await listAllRecords(ctx, 'id.sifa.profile.skill'); + return records.flatMap((record) => { + const name = firstString(record, ['name', 'skill']); + return name === undefined ? [] : [{ uri: record.uri, name }]; + }); +} diff --git a/apps/web/src/lib/server/atmosphere/sources.ts b/apps/web/src/lib/server/atmosphere/sources.ts new file mode 100644 index 0000000..db8eb9e --- /dev/null +++ b/apps/web/src/lib/server/atmosphere/sources.ts @@ -0,0 +1,36 @@ +/** + * Which sources have data in a repo, from the collection list that + * com.atproto.repo.describeRepo already returns. Powers the admin overview and + * the renderer's default section order for sites without a sections array + * (ADR 0012: "renderers default to a sensible order based on which sources + * have data"). + */ + +export type SourceId = 'bluesky' | 'writing' | 'career' | 'pages'; + +export interface DetectedSource { + id: SourceId; + label: string; +} + +const SOURCE_DEFS: { id: SourceId; label: string; collections: string[] }[] = [ + { id: 'career', label: 'sifa professional profile', collections: ['id.sifa.profile.self'] }, + { + id: 'writing', + label: 'standard.site writing', + collections: ['site.standard.document', 'pub.leaflet.document'] + }, + { id: 'bluesky', label: 'Bluesky posts', collections: ['app.bsky.feed.post'] }, + { id: 'pages', label: 'Mooring pages', collections: ['page.mooring.page'] } +]; + +/** + * Detected sources in default section order: career first (professional + * presence is the wedge), then writing, then posts, then authored pages. + */ +export function detectSources(collections: string[]): DetectedSource[] { + const present = new Set(collections); + return SOURCE_DEFS.filter((def) => def.collections.some((c) => present.has(c))).map( + ({ id, label }) => ({ id, label }) + ); +} diff --git a/apps/web/src/lib/server/atmosphere/standard-site.ts b/apps/web/src/lib/server/atmosphere/standard-site.ts new file mode 100644 index 0000000..399779d --- /dev/null +++ b/apps/web/src/lib/server/atmosphere/standard-site.ts @@ -0,0 +1,82 @@ +/** + * standard.site adapter: the user's long-form writing. Reads + * site.standard.document/publication, with pub.leaflet.* legacy records as + * fallback for accounts that predate Leaflet's ~Jan 2026 migration (ADR 0008). + */ +import { type AtmosphereContext, listAllRecords, str } from './xrpc'; + +export interface WritingDocument { + uri: string; + title: string; + publishedAt?: string; + /** AT-URI of the site.standard.publication this document belongs to. */ + publicationUri?: string; + /** Best-effort external URL when the record carries one. */ + url?: string; + /** True when this came from a pub.leaflet.* legacy record. */ + legacy: boolean; +} + +export interface Publication { + uri: string; + name?: string; + url?: string; +} + +export async function fetchPublications(ctx: AtmosphereContext): Promise { + const records = await listAllRecords(ctx, 'site.standard.publication'); + return records.map((record) => ({ + uri: record.uri, + name: str(record.value, 'name') ?? str(record.value, 'title'), + url: str(record.value, 'url') + })); +} + +/** + * Documents newest-first. When the account has no site.standard.document + * records at all, falls back to pub.leaflet.document legacy records; the two + * are not merged — post-migration accounts keep legacy records in place, and + * merging would duplicate them. + */ +export async function fetchDocuments( + ctx: AtmosphereContext, + options?: { limit?: number; publicationUri?: string } +): Promise { + const limit = options?.limit ?? 20; + + let docs: WritingDocument[] = (await listAllRecords(ctx, 'site.standard.document')).flatMap((record) => { + const title = str(record.value, 'title'); + if (title === undefined) return []; + return [ + { + uri: record.uri, + title, + publishedAt: str(record.value, 'publishedAt'), + publicationUri: str(record.value, 'site'), + url: str(record.value, 'url'), + legacy: false + } satisfies WritingDocument + ]; + }); + + if (docs.length === 0) { + docs = (await listAllRecords(ctx, 'pub.leaflet.document')).flatMap((record) => { + const title = str(record.value, 'title'); + if (title === undefined) return []; + return [ + { + uri: record.uri, + title, + publishedAt: str(record.value, 'publishedAt') ?? str(record.value, 'createdAt'), + legacy: true + } satisfies WritingDocument + ]; + }); + } + + if (options?.publicationUri) { + docs = docs.filter((d) => d.publicationUri === options.publicationUri); + } + docs.sort((a, b) => (b.publishedAt ?? '').localeCompare(a.publishedAt ?? '')); + return docs.slice(0, limit); +} diff --git a/apps/web/src/lib/server/atmosphere/xrpc.ts b/apps/web/src/lib/server/atmosphere/xrpc.ts new file mode 100644 index 0000000..dc9b65c --- /dev/null +++ b/apps/web/src/lib/server/atmosphere/xrpc.ts @@ -0,0 +1,135 @@ +/** + * Minimal unauthenticated XRPC reads against a PDS. Direct PDS reads are the + * v1 data layer (ADR 0010 §4) — no AppView dependency. Fetch is injectable so + * adapters are unit-testable and Workers-safe. + */ + +export interface AtmosphereContext { + /** PDS base URL, e.g. https://bsky.social */ + pds: string; + /** The repo (account) to read, as a DID. */ + did: string; + fetch?: typeof fetch; +} + +export interface ListedRecord { + uri: string; + cid: string; + value: Record; +} + +interface ListRecordsPage { + records: ListedRecord[]; + cursor?: string; +} + +/** Hard cap on pagination so one render can't walk an unbounded repo. */ +const MAX_PAGES = 10; +const PAGE_LIMIT = 100; + +export async function xrpcGet( + ctx: AtmosphereContext, + nsid: string, + params: Record +): Promise { + const url = new URL(`/xrpc/${nsid}`, ctx.pds); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + const doFetch = ctx.fetch ?? fetch; + const res = await doFetch(url, { headers: { accept: 'application/json' } }); + if (!res.ok) throw new Error(`${nsid} failed (HTTP ${res.status}) against ${ctx.pds}`); + return res.json(); +} + +function asListPage(data: unknown): ListRecordsPage { + const page: ListRecordsPage = { records: [] }; + if (data === null || typeof data !== 'object') return page; + const { records, cursor } = data as { records?: unknown; cursor?: unknown }; + if (typeof cursor === 'string') page.cursor = cursor; + if (Array.isArray(records)) { + for (const r of records) { + if (r === null || typeof r !== 'object') continue; + const { uri, cid, value } = r as Record; + if (typeof uri === 'string' && typeof cid === 'string' && value && typeof value === 'object') { + page.records.push({ uri, cid, value: value as Record }); + } + } + } + return page; +} + +/** + * List every record in a collection (up to MAX_PAGES pages). An empty or + * missing collection returns [] rather than throwing. + */ +export async function listAllRecords( + ctx: AtmosphereContext, + collection: string +): Promise { + const out: ListedRecord[] = []; + let cursor: string | undefined; + for (let i = 0; i < MAX_PAGES; i++) { + const params: Record = { + repo: ctx.did, + collection, + limit: String(PAGE_LIMIT) + }; + if (cursor) params.cursor = cursor; + const page = asListPage(await xrpcGet(ctx, 'com.atproto.repo.listRecords', params)); + out.push(...page.records); + if (!page.cursor || page.records.length === 0) return out; + cursor = page.cursor; + } + console.warn(`listAllRecords truncated at ${MAX_PAGES} pages for ${collection}`); + return out; +} + +/** Fetch a single record; undefined when it doesn't exist. */ +export async function getRecord( + ctx: AtmosphereContext, + collection: string, + rkey: string +): Promise { + try { + const data = await xrpcGet(ctx, 'com.atproto.repo.getRecord', { + repo: ctx.did, + collection, + rkey + }); + if (data === null || typeof data !== 'object') return undefined; + const { uri, cid, value } = data as Record; + if (typeof uri === 'string' && value && typeof value === 'object') { + return { uri, cid: typeof cid === 'string' ? cid : '', value: value as Record }; + } + return undefined; + } catch { + return undefined; + } +} + +/** URL serving a blob (avatar, icon) straight from the user's PDS. */ +export function blobUrl(ctx: AtmosphereContext, blobCid: string): string { + const url = new URL('/xrpc/com.atproto.sync.getBlob', ctx.pds); + url.searchParams.set('did', ctx.did); + url.searchParams.set('cid', blobCid); + return url.href; +} + +/* Defensive field extraction — external schemas range from stable (app.bsky) + * to alpha (id.sifa); never trust shapes (ADR 0006). */ + +export function str(value: Record, key: string): string | undefined { + const v = value[key]; + return typeof v === 'string' ? v : undefined; +} + +export function blobRefCid(value: Record, key: string): string | undefined { + const v = value[key]; + if (v === null || typeof v !== 'object') return undefined; + const ref = (v as Record).ref; + if (typeof ref === 'string') return ref; // some serializations inline the CID + if (ref !== null && typeof ref === 'object') { + const link = (ref as Record).$link; + if (typeof link === 'string') return link; + } + return undefined; +} diff --git a/apps/web/src/lib/server/oauth/workers-compat.ts b/apps/web/src/lib/server/oauth/workers-compat.ts index 6586e79..6228f14 100644 --- a/apps/web/src/lib/server/oauth/workers-compat.ts +++ b/apps/web/src/lib/server/oauth/workers-compat.ts @@ -15,11 +15,10 @@ */ import type { NodeOAuthClientOptions } from '@atproto/oauth-client-node'; +import { resolveDidDocument } from '../atmosphere/identity'; export const HANDLE_RESOLVER_URL = 'https://public.api.bsky.app'; -const PLC_DIRECTORY_URL = 'https://plc.directory/'; - type OAuthDidResolver = NonNullable; // Implemented concretely and cast once: the interface's resolve() is generic @@ -27,25 +26,7 @@ type OAuthDidResolver = NonNullable; export const workersDidResolver = { // eslint-disable-next-line @typescript-eslint/no-explicit-any async resolve(did: string, options?: { signal?: AbortSignal }): Promise { - let url: URL; - if (did.startsWith('did:plc:')) { - url = new URL(`/${encodeURIComponent(did)}`, PLC_DIRECTORY_URL); - } else if (did.startsWith('did:web:')) { - const host = decodeURIComponent(did.slice('did:web:'.length)); - url = new URL( - host.includes(':') - ? `https://${host.split(':').join('/')}/did.json` - : `https://${host}/.well-known/did.json` - ); - } else { - throw new Error(`Unsupported DID method: ${did}`); - } - const res = await fetch(url, { - headers: { accept: 'application/did+ld+json,application/json' }, - signal: options?.signal - }); - if (!res.ok) throw new Error(`DID resolution failed (HTTP ${res.status}) for ${did}`); - return res.json(); + return resolveDidDocument(did, options); } } as OAuthDidResolver; diff --git a/apps/web/src/routes/admin/+page.server.ts b/apps/web/src/routes/admin/+page.server.ts index 097b086..6272ad3 100644 --- a/apps/web/src/routes/admin/+page.server.ts +++ b/apps/web/src/routes/admin/+page.server.ts @@ -1,11 +1,22 @@ import { redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { clearSessionCookie, readSessionCookie } from '$lib/server/session'; +import { + type AtmosphereContext, + detectSources, + fetchBlueskyPosts, + fetchBlueskyProfile, + fetchDocuments, + fetchSifaProfile, + pdsEndpoint, + resolveDidDocument +} from '$lib/server/atmosphere'; export const load: PageServerLoad = async ({ locals, cookies, platform }) => { const did = await readSessionCookie(cookies, platform!.env); if (!did) redirect(302, '/login'); + let repo: { handle: string; collections: string[] }; try { const session = await locals.oauth.restore(did); // Smoke-test call against the user's own PDS through the OAuth session. @@ -14,13 +25,43 @@ export const load: PageServerLoad = async ({ locals, cookies, platform }) => { {} ); if (!res.ok) throw new Error(`describeRepo returned HTTP ${res.status}`); - const repo = (await res.json()) as { handle: string; collections: string[] }; - return { did, handle: repo.handle, collections: repo.collections }; + repo = (await res.json()) as { handle: string; collections: string[] }; } catch (err) { console.error('session restore failed', err); clearSessionCookie(cookies); redirect(302, '/login'); } + + const sources = detectSources(repo.collections); + + // Source previews are public unauthenticated reads; any one failing (an + // alpha schema moved, a PDS hiccup) must not take down the admin page. + let ctx: AtmosphereContext | undefined; + try { + const pds = pdsEndpoint(await resolveDidDocument(did)); + if (pds) ctx = { pds, did }; + } catch (err) { + console.error('PDS resolution failed; skipping source previews', err); + } + const [profile, sifaProfile, posts, documents] = ctx + ? await Promise.all([ + fetchBlueskyProfile(ctx).catch(() => undefined), + fetchSifaProfile(ctx).catch(() => undefined), + fetchBlueskyPosts(ctx, { limit: 3 }).catch(() => []), + fetchDocuments(ctx, { limit: 3 }).catch(() => []) + ]) + : [undefined, undefined, [], []]; + + return { + did, + handle: repo.handle, + collections: repo.collections, + sources, + profile, + sifaProfile, + posts, + documents + }; }; export const actions: Actions = { diff --git a/apps/web/src/routes/admin/+page.svelte b/apps/web/src/routes/admin/+page.svelte index fdd26d2..948bd4f 100644 --- a/apps/web/src/routes/admin/+page.svelte +++ b/apps/web/src/routes/admin/+page.svelte @@ -13,14 +13,42 @@

Signed in as @{data.handle} ({data.did})

+ {#if data.profile?.displayName || data.sifaProfile?.headline} +

+ {#if data.profile?.displayName}{data.profile.displayName}{/if} + {#if data.sifaProfile?.headline}— {data.sifaProfile.headline}{/if} +

+ {/if} -

Collections in your repo

- {#if data.collections.length === 0} -

No collections yet.

+

Your sources

+ {#if data.sources.length === 0} +

No site sources detected in your repo yet — your site will start from your profile alone.

{:else}
    - {#each data.collections as nsid (nsid)} -
  • {nsid}
  • + {#each data.sources as source (source.id)} +
  • {source.label}
  • + {/each} +
+ {/if} + + {#if data.documents.length > 0} +

Recent writing

+
    + {#each data.documents as doc (doc.uri)} +
  • + {doc.title} + {#if doc.publishedAt} · {doc.publishedAt.slice(0, 10)}{/if} + {#if doc.legacy} · legacy{/if} +
  • + {/each} +
+ {/if} + + {#if data.posts.length > 0} +

Recent posts

+
    + {#each data.posts as post (post.uri)} +
  • {post.text.length > 120 ? post.text.slice(0, 120) + '…' : post.text}
  • {/each}
{/if} @@ -41,4 +69,7 @@ margin-top: 1rem; padding: 0.5rem 1rem; } + small { + color: #666; + } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..c0dcb28 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +// Standalone unit-test config: server-side modules only, no SvelteKit plugin +// (tests use relative imports, not $lib). +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'] + } +}); diff --git a/docs/NEXT.md b/docs/NEXT.md index 1ea1de7..99d0b73 100644 --- a/docs/NEXT.md +++ b/docs/NEXT.md @@ -12,9 +12,9 @@ Feasibility is done and the verdict was **build it** (see `FEASIBILITY.md`). All ### 1. Continue v1 (per ADR 0008 — the scope is ratified; don't re-scope) -Done so far: OAuth login (loopback dev client; hosted-client path ready pending a real key + deploy) with D1-backed state/session stores; lexicon convention tests. Remaining, roughly in dependency order: +Done so far: OAuth login (loopback dev client; hosted-client path ready pending a real key + deploy) with D1-backed state/session stores; lexicon convention tests; **read-only adapters** for Bluesky (profile + posts, replies filtered), standard.site (documents/publications, `pub.leaflet.document` fallback only when no standard.site docs exist), and sifa (profile/positions/education/skills, defensively parsed) — fetch-injected modules in `apps/web/src/lib/server/atmosphere/` with unit tests, plus `detectSources` (drives the ADR 0012 default section order) and a source-overview admin page. Remaining, roughly in dependency order: -- **Read-only adapters**: Bluesky posts, standard.site documents (+ `pub.leaflet.*` legacy fallback), sifa profile — direct PDS reads behind a thin deletion-honoring cache (ADR 0010 §4). +- **Thin deletion-honoring cache** over the direct PDS reads (ADR 0010 §4) — adapters currently read live per request, fine until traffic exists. - **Site/page authoring**: write `page.mooring.site` / `page.mooring.page` records from the admin (empty site record on first login = working site). - **The one professional-presence theme** (renderer kept separable per ADR 0005). - **Hosting wiring**: real D1 database + first `wrangler deploy`, wildcard subdomains, then Cloudflare for SaaS custom domains with `_mooring` DNS-TXT DID verification (ADR 0011). diff --git a/package-lock.json b/package-lock.json index 1e777e6..f9842a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "svelte-check": "^4.6.0", "typescript": "^6.0.3", "vite": "^8.0.16", + "vitest": "^4.1.10", "wrangler": "^4.118.0" } },