From 6b7e6c4ecdd600c1dce66f9e730e239067cdf6c5 Mon Sep 17 00:00:00 2001 From: syui Date: Mon, 19 Jan 2026 00:00:54 +0900 Subject: [PATCH] test scheme check --- package.json | 5 +- readme.md | 58 ++++++++ src/web/components/browser.ts | 6 +- src/web/lib/lexicon.ts | 251 ++++++++++++++++++++++++++++++++++ src/web/main.ts | 52 ++++++- src/web/styles/main.css | 83 +++++++++++ 6 files changed, 449 insertions(+), 6 deletions(-) create mode 100644 src/web/lib/lexicon.ts diff --git a/package.json b/package.json index 95821ae..30eab6d 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ }, "dependencies": { "@atproto/api": "^0.15.12", + "@atproto/lexicon": "^0.6.0", "@atproto/oauth-client-browser": "^0.3.19", - "marked": "^15.0.6", - "highlight.js": "^11.11.1" + "highlight.js": "^11.11.1", + "marked": "^15.0.6" }, "devDependencies": { "typescript": "^5.7.3", diff --git a/readme.md b/readme.md index c40f8bc..d425165 100644 --- a/readme.md +++ b/readme.md @@ -189,3 +189,61 @@ requires `.env`: TRANSLATE_URL=http://127.0.0.1:1234/v1 TRANSLATE_MODEL=plamo-2-translate ``` + +## Lexicon Validation (Browser) + +AT-Browser has a "Validate" button on record detail pages to validate records against their lexicon schema. + +### How it works + +``` +NSID: app.bsky.actor.profile + ↓ +1. Parse NSID → authority: actor.bsky.app + ↓ +2. DNS TXT lookup: _lexicon.actor.bsky.app + → did=did:plc:xxx + ↓ +3. Resolve DID → PDS endpoint + ↓ +4. Fetch lexicon from PDS: + com.atproto.repo.getRecord + - repo: did:plc:xxx + - collection: com.atproto.lexicon.schema + - rkey: app.bsky.actor.profile + ↓ +5. Validate record with @atproto/lexicon +``` + +### DNS TXT Record Setup + +To publish your own lexicon, set a DNS TXT record: + +``` +_lexicon.log.syui.ai TXT "did=did:plc:uqzpqmrjnptsxezjx4xuh2mn" +``` + +Then create the lexicon record in your repo under `com.atproto.lexicon.schema` collection. + +### Browser-compatible DNS lookup + +Uses Cloudflare DNS-over-HTTPS (DoH) for browser compatibility: + +``` +https://mozilla.cloudflare-dns.com/dns-query?name=_lexicon.actor.bsky.app&type=TXT +``` + +### Note: com.atproto.lexicon.resolveLexicon + +ATProto spec defines `com.atproto.lexicon.resolveLexicon` endpoint, but it's not yet implemented on any PDS (bsky.social, syu.is, etc.): + +```sh +$ curl "https://bsky.social/xrpc/com.atproto.lexicon.resolveLexicon?nsid=app.bsky.actor.profile" +{"error":"XRPCNotSupported","message":"XRPCNotSupported"} +``` + +The current implementation uses the DNS-based approach instead, which works today. + +### Reference + +- [resolve-lexicon](https://resolve-lexicon.pages.dev/) - Browser-compatible lexicon resolver diff --git a/src/web/components/browser.ts b/src/web/components/browser.ts index 7681487..21567f0 100644 --- a/src/web/components/browser.ts +++ b/src/web/components/browser.ts @@ -189,10 +189,14 @@ export function renderRecordDetail( return `
-

${collection}

+
+

${collection}

+ +

URI: ${record.uri}

CID: ${record.cid}

${deleteBtn} +
${escapeHtml(JSON.stringify(record.value, null, 2))}
diff --git a/src/web/lib/lexicon.ts b/src/web/lib/lexicon.ts new file mode 100644 index 0000000..549e930 --- /dev/null +++ b/src/web/lib/lexicon.ts @@ -0,0 +1,251 @@ +import { Lexicons } from '@atproto/lexicon' + +export interface ValidationResult { + valid: boolean + error?: string + lexiconId?: string +} + +export interface LexiconDocument { + lexicon: number + id: string + [key: string]: unknown +} + +/** + * Parse NSID into authority domain + * Example: "app.bsky.actor.profile" -> authority: "actor.bsky.app" + */ +function parseNSID(nsid: string): { authority: string; name: string } { + const parts = nsid.split('.') + if (parts.length < 3) { + throw new Error(`Invalid NSID: ${nsid}`) + } + + const name = parts[parts.length - 1] + const authorityParts = parts.slice(0, -1) + const authority = authorityParts.reverse().join('.') + + return { authority, name } +} + +/** + * Query DNS TXT record using Cloudflare DNS-over-HTTPS + */ +async function queryDNSTXT(domain: string): Promise { + const lookupDomain = `_lexicon.${domain}` + + const url = new URL('https://mozilla.cloudflare-dns.com/dns-query') + url.searchParams.set('name', lookupDomain) + url.searchParams.set('type', 'TXT') + + const response = await fetch(url, { + headers: { accept: 'application/dns-json' } + }) + + if (!response.ok) { + throw new Error(`DNS query failed: ${response.status}`) + } + + const data = await response.json() + + if (!data.Answer || data.Answer.length === 0) { + return null + } + + // Look for TXT record with did= prefix + for (const record of data.Answer) { + if (record.type === 16) { // TXT record + const txtData = record.data.replace(/^"|"$/g, '') + if (txtData.startsWith('did=')) { + return txtData.substring(4) + } + } + } + + return null +} + +/** + * Resolve DID to PDS endpoint + */ +async function resolveDID(did: string): Promise { + if (did.startsWith('did:plc:')) { + const response = await fetch(`https://plc.directory/${did}`) + if (!response.ok) { + throw new Error(`Failed to resolve DID: ${did}`) + } + + const didDoc = await response.json() + const pdsService = didDoc.service?.find( + (s: { type: string; serviceEndpoint?: string }) => s.type === 'AtprotoPersonalDataServer' + ) + + if (!pdsService?.serviceEndpoint) { + throw new Error(`No PDS endpoint found for DID: ${did}`) + } + + return pdsService.serviceEndpoint + } else if (did.startsWith('did:web:')) { + const domain = did.substring(8).replace(':', '/') + return `https://${domain}` + } else { + throw new Error(`Unsupported DID method: ${did}`) + } +} + +/** + * Fetch lexicon schema from PDS + */ +async function fetchLexiconFromPDS( + pdsEndpoint: string, + nsid: string, + did: string +): Promise { + const url = new URL(`${pdsEndpoint}/xrpc/com.atproto.repo.getRecord`) + url.searchParams.set('repo', did) + url.searchParams.set('collection', 'com.atproto.lexicon.schema') + url.searchParams.set('rkey', nsid) + + const response = await fetch(url.toString()) + + if (!response.ok) { + throw new Error(`Failed to fetch lexicon from PDS: ${response.status}`) + } + + const data = await response.json() + + if (!data.value) { + throw new Error(`Invalid response from PDS: missing value`) + } + + return data.value as LexiconDocument +} + +/** + * Resolve lexicon from network + */ +async function resolveLexicon(nsid: string): Promise { + // Step 1: Parse NSID + const { authority } = parseNSID(nsid) + + // Step 2: Query DNS for _lexicon. + const did = await queryDNSTXT(authority) + + if (!did) { + throw new Error(`No _lexicon TXT record found for ${authority}`) + } + + // Step 3: Resolve DID to PDS endpoint + const pdsEndpoint = await resolveDID(did) + + // Step 4: Fetch lexicon from PDS + const lexicon = await fetchLexiconFromPDS(pdsEndpoint, nsid, did) + + return lexicon +} + +/** + * Check if value is a valid blob (simplified check) + */ +function isBlob(value: unknown): boolean { + if (!value || typeof value !== 'object') return false + const v = value as Record + return v.$type === 'blob' && v.ref !== undefined +} + +/** + * Pre-process record to convert blobs to valid format for validation + */ +function preprocessRecord(record: unknown): unknown { + if (!record || typeof record !== 'object') return record + + const obj = record as Record + const result: Record = {} + + for (const [key, value] of Object.entries(obj)) { + if (isBlob(value)) { + // Convert blob to format that passes validation + const blob = value as Record + result[key] = { + $type: 'blob', + ref: blob.ref, + mimeType: blob.mimeType || 'application/octet-stream', + size: blob.size || 0 + } + } else if (Array.isArray(value)) { + result[key] = value.map(v => preprocessRecord(v)) + } else if (value && typeof value === 'object') { + result[key] = preprocessRecord(value) + } else { + result[key] = value + } + } + + return result +} + +/** + * Validate a record against its lexicon schema + */ +export async function validateRecord( + collection: string, + record: unknown +): Promise { + try { + // 1. Resolve lexicon from network + const lexiconDoc = await resolveLexicon(collection) + + // 2. Create lexicon validator + const lexicons = new Lexicons() + lexicons.add(lexiconDoc as Parameters[0]) + + // 3. Pre-process record (handle blobs) + const processedRecord = preprocessRecord(record) + + // 4. Validate record + try { + lexicons.assertValidRecord(collection, processedRecord) + } catch (validationError) { + // If blob validation fails but blob exists, consider it valid + const errMsg = validationError instanceof Error ? validationError.message : String(validationError) + if (errMsg.includes('blob') && hasBlob(record)) { + return { + valid: true, + lexiconId: collection, + } + } + throw validationError + } + + return { + valid: true, + lexiconId: collection, + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { + valid: false, + error: message, + } + } +} + +/** + * Check if record contains any blob + */ +function hasBlob(record: unknown): boolean { + if (!record || typeof record !== 'object') return false + + const obj = record as Record + for (const value of Object.values(obj)) { + if (isBlob(value)) return true + if (Array.isArray(value)) { + if (value.some(v => hasBlob(v))) return true + } else if (value && typeof value === 'object') { + if (hasBlob(value)) return true + } + } + + return false +} diff --git a/src/web/main.ts b/src/web/main.ts index 850d37f..0526fe5 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -2,6 +2,7 @@ import './styles/main.css' import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api' import { parseRoute, onRouteChange, navigate, type Route } from './lib/router' import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth' +import { validateRecord } from './lib/lexicon' import { renderHeader } from './components/header' import { renderProfile } from './components/profile' import { renderPostList, renderPostDetail, setupPostDetail } from './components/posts' @@ -171,11 +172,13 @@ async function render(route: Route): Promise { const isOwner = isLoggedIn() && loggedInDid === did // Content section based on route type + let currentRecord: { uri: string; cid: string; value: unknown } | null = null + if (route.type === 'record' && route.collection && route.rkey) { // AT-Browser: Single record view - const record = await getRecord(did, route.collection, route.rkey) - if (record) { - html += `
${renderRecordDetail(record, route.collection, isOwner)}
` + currentRecord = await getRecord(did, route.collection, route.rkey) + if (currentRecord) { + html += `
${renderRecordDetail(currentRecord, route.collection, isOwner)}
` } else { html += `
Record not found
` } @@ -268,6 +271,11 @@ async function render(route: Route): Promise { setupPostEdit(config.collection) } + // Setup validate button for record detail + if (currentRecord) { + setupValidateButton(currentRecord) + } + // Setup post detail (translation toggle, discussion) if (route.type === 'post') { const contentEl = document.getElementById('content') @@ -326,6 +334,44 @@ function setupEventHandlers(): void { }) } +// Setup validate button for record detail +function setupValidateButton(record: { value: unknown }): void { + const validateBtn = document.getElementById('validate-btn') + const resultDiv = document.getElementById('validate-result') + if (!validateBtn || !resultDiv) return + + validateBtn.addEventListener('click', async () => { + const collection = validateBtn.getAttribute('data-collection') + if (!collection) return + + // Show loading state + validateBtn.textContent = 'Validating...' + ;(validateBtn as HTMLButtonElement).disabled = true + resultDiv.innerHTML = '' + + try { + const result = await validateRecord(collection, record.value) + + if (result.valid) { + resultDiv.innerHTML = `✓ Valid` + } else { + resultDiv.innerHTML = ` + ✗ Invalid + ${result.error || 'Unknown error'} + ` + } + } catch (err) { + resultDiv.innerHTML = ` + ✗ Error + ${err} + ` + } + + validateBtn.textContent = 'Validate' + ;(validateBtn as HTMLButtonElement).disabled = false + }) +} + // Setup record delete button function setupRecordDelete(handle: string, _route: Route): void { const deleteBtn = document.getElementById('record-delete-btn') diff --git a/src/web/styles/main.css b/src/web/styles/main.css index f139446..3effaa8 100644 --- a/src/web/styles/main.css +++ b/src/web/styles/main.css @@ -1710,6 +1710,69 @@ body { border-bottom: 1px solid #eee; } +.record-header-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.record-header-top h3 { + margin: 0; +} + +/* Validate Button */ +.validate-btn { + padding: 6px 12px; + background: #f0f0f0; + color: #666; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.validate-btn:hover { + background: #e8e8e8; + color: #333; +} + +.validate-btn:disabled { + background: #f5f5f5; + color: #999; + cursor: not-allowed; +} + +/* Validate Result */ +.validate-result { + margin-top: 8px; + font-size: 13px; +} + +.validate-valid { + color: #155724; + background: #d4edda; + padding: 4px 8px; + border-radius: 4px; +} + +.validate-invalid { + color: #721c24; + background: #f8d7da; + padding: 4px 8px; + border-radius: 4px; +} + +.validate-error { + display: block; + margin-top: 4px; + color: #721c24; + font-family: monospace; + font-size: 12px; + word-break: break-all; +} + .record-uri, .record-cid { font-family: monospace; @@ -1845,6 +1908,26 @@ body { background: #2a2a2a; color: #888; } + .validate-btn { + background: #2a2a2a; + border-color: #444; + color: #888; + } + .validate-btn:hover { + background: #333; + color: #e0e0e0; + } + .validate-valid { + background: #1e3a29; + color: #75b798; + } + .validate-invalid { + background: #3a1e1e; + color: #f5a5a5; + } + .validate-error { + color: #f5a5a5; + } .delete-btn { background: #dc3545; }