diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..7ac2fdb --- /dev/null +++ b/TODO.md @@ -0,0 +1,83 @@ +# TODO + +## Lexicon Publisher + +Remaining issues from code review (2025-12-23). + +### Important + +#### CSP `unsafe-inline` vulnerability +**lexicon-publisher.html:7-9** + +The CSP includes `'unsafe-inline'` for both scripts and styles, which defeats much of the purpose of having a CSP. Requires major refactor to move inline scripts/styles to external files or use nonces. + +#### Pin quickslice to specific version +**lexicon-publisher.html:777** + +Currently using `@main` branch which can change at any time. Pin to a specific commit or tag and add SRI integrity attribute. + +#### Validate DNS lookup inputs +**lexicon-publisher.html:1802-1812** + +The `resolveDNS()` function directly uses user-controlled domain names without validation. Should validate domain names against RFC standards before DNS lookup. + +#### Full re-render performance +**lexicon-publisher.html** + +`renderAllSteps()` rebuilds the entire UI on every state change, losing focus/scroll position. Consider targeted updates for specific UI sections. + +#### Accessibility improvements +**lexicon-publisher.html** + +- Add `role="progressbar"` or `role="tablist"` for wizard +- Add `aria-label` to buttons with only icons +- Add `aria-live` regions for status messages +- Add `aria-expanded` for collapsible sections +- Make step headers keyboard navigable +- Add escape key handler for dialogs + +### Medium + +#### `escapeAttr()` missing single quote escape +**lexicon-publisher.html:940** + +Add `.replace(/'/g, "'")` to prevent attribute injection with single quotes. + +#### Domain extraction edge cases +**lexicon-publisher.html:1672-1698** + +`getUserDomain()` assumes all domains are TLD + SLD (e.g., `example.com`). Fails for country-code TLDs like `.co.uk`. Consider using a public suffix list. + +#### No file count limit in ZIP +**lexicon-publisher.html:2044-2051** + +While there's a check for zero files, there's no upper limit. A ZIP with thousands of files could freeze the browser. Add reasonable limit (e.g., 100 files). + +#### Clipboard API fallback +**lexicon-publisher.html:1836-1840** + +No user feedback on success/failure and no fallback for older browsers. Add toast notification and `document.execCommand('copy')` fallback. + +#### OAuth callback race condition +**lexicon-publisher.html:2130-2146** + +If `handleRedirectCallback()` fails, the URL still has `?code=...` but history is only replaced on success. Move `window.history.replaceState()` to a `finally` block. + +### Low Priority + +#### Split into multiple files +**lexicon-publisher.html (2200+ lines)** + +Consider splitting into separate HTML, CSS, and JS files for maintainability. This would also help solve the CSP `unsafe-inline` issue. + +#### Add lexicon preview +Users can't see what a lexicon defines without reading JSON. Add human-readable schema preview. + +#### Add export functionality +Users can upload ZIPs but can't download their lexicons. Add "Export as ZIP" feature. + +#### Add schema templates +Provide multiple templates: record, query, procedure, subscription. + +#### Real-time validation +Add debounced validation as users type instead of only on "Continue to Publish". diff --git a/docs/plans/2025-12-23-external-dependency-validation.md b/docs/plans/2025-12-23-external-dependency-validation.md new file mode 100644 index 0000000..76e6a95 --- /dev/null +++ b/docs/plans/2025-12-23-external-dependency-validation.md @@ -0,0 +1,265 @@ +# External Dependency Validation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Warn users when their lexicons reference external dependencies that aren't published. + +**Architecture:** After honk validation passes, extract all `ref` properties pointing to external NSIDs, batch-query the lexicons quickslice to check which exist, show yellow warnings for missing ones. + +**Tech Stack:** JavaScript, GraphQL (lexicons quickslice) + +--- + +### Task 1: Add missingDependencies to state + +**Files:** +- Modify: `lexicon-publisher.html:642-644` + +**Step 1: Add the new state property** + +Find this block: +```javascript + // New: DNS instructions + dnsInstructions: [], // [{ domain, record, lexicons }] + }; +``` + +Change to: +```javascript + // New: DNS instructions + dnsInstructions: [], // [{ domain, record, lexicons }] + + // New: External dependency check + missingDependencies: [], // [nsid, ...] + }; +``` + +**Step 2: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "feat(lexicon-publisher): add missingDependencies state" +``` + +--- + +### Task 2: Add extractExternalRefs function + +**Files:** +- Modify: `lexicon-publisher.html` (after `getPublishableLexicons` function, around line 1097) + +**Step 1: Add the function** + +After the `getPublishableLexicons` function closing brace, add: + +```javascript + function extractExternalRefs(lexicons, userDomain) { + const refs = new Set(); + + function walk(obj) { + if (!obj || typeof obj !== 'object') return; + if (obj.ref && typeof obj.ref === 'string') { + const nsid = obj.ref.split('#')[0]; + const domain = getDomainAuthority(nsid); + if (domain !== userDomain && !domain.endsWith('.' + userDomain)) { + refs.add(nsid); + } + } + for (const val of Object.values(obj)) walk(val); + } + + for (const lex of lexicons) walk(lex); + return [...refs]; + } +``` + +**Step 2: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "feat(lexicon-publisher): add extractExternalRefs function" +``` + +--- + +### Task 3: Add checkExternalDependencies function + +**Files:** +- Modify: `lexicon-publisher.html` (after `extractExternalRefs`) + +**Step 1: Add the function** + +After `extractExternalRefs`, add: + +```javascript + async function checkExternalDependencies(externalNsids) { + if (externalNsids.length === 0) return { found: [], missing: [] }; + + try { + const result = await state.client.query(` + query CheckDeps($nsids: [String!]!) { + comAtprotoLexiconSchema( + first: 100, + where: { id: { in: $nsids } } + ) { + edges { + node { id } + } + } + } + `, { nsids: externalNsids }); + + const foundIds = (result?.comAtprotoLexiconSchema?.edges || []) + .map(e => e.node.id); + + return { + found: externalNsids.filter(n => foundIds.includes(n)), + missing: externalNsids.filter(n => !foundIds.includes(n)), + }; + } catch (err) { + console.error("External dependency check failed:", err); + return { found: [], missing: [] }; + } + } +``` + +**Step 2: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "feat(lexicon-publisher): add checkExternalDependencies function" +``` + +--- + +### Task 4: Integrate into validateLexicons + +**Files:** +- Modify: `lexicon-publisher.html:1396-1440` (validateLexicons function) + +**Step 1: Reset missingDependencies at start** + +Find: +```javascript + state.result = null; + state.validationPassed = false; + state.conflicts = []; +``` + +Change to: +```javascript + state.result = null; + state.validationPassed = false; + state.conflicts = []; + state.missingDependencies = []; +``` + +**Step 2: Add external dependency check after honk passes** + +Find: +```javascript + // Check for conflicts if authenticated + if (state.viewer) { + await checkConflicts(); + } +``` + +Change to: +```javascript + // Check for external dependencies and conflicts if authenticated + if (state.viewer) { + const userDomain = getUserDomain(state.viewer.handle); + const externalRefs = extractExternalRefs(parseResult.lexicons, userDomain); + const { missing } = await checkExternalDependencies(externalRefs); + state.missingDependencies = missing; + + await checkConflicts(); + } +``` + +**Step 3: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "feat(lexicon-publisher): check external deps during validation" +``` + +--- + +### Task 5: Update renderResult to show warnings + +**Files:** +- Modify: `lexicon-publisher.html:1251-1266` (renderResult function) + +**Step 1: Add warning display after success message** + +Find: +```javascript + section.innerHTML = ` +
${icon} ${esc(state.result.message)}
+ `; + } +``` + +Change to: +```javascript + let html = `
${icon} ${esc(state.result.message)}
`; + + if (state.missingDependencies.length > 0) { + html += ` +
+ Missing external dependencies: + +
+ `; + } + + section.innerHTML = html; + } +``` + +**Step 2: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "feat(lexicon-publisher): display missing dependency warnings" +``` + +--- + +### Task 6: Manual test + +**Step 1: Test with external refs** + +1. Open lexicon-publisher in browser +2. Sign in +3. Upload or paste a lexicon that references an external type, e.g.: + ```json + { + "lexicon": 1, + "id": "slices.network.test", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "properties": { + "actor": { "type": "ref", "ref": "app.bsky.actor.defs#profileViewBasic" }, + "fake": { "type": "ref", "ref": "com.fake.nonexistent#thing" } + } + } + } + } + } + ``` +4. Click Validate +5. Verify: Success message appears, plus yellow warning box showing `com.fake.nonexistent` as missing (but not `app.bsky.actor.defs` if it's published) + +**Step 2: Final commit if needed** + +```bash +git add lexicon-publisher.html +git commit -m "feat(lexicon-publisher): complete external dependency validation" +``` diff --git a/docs/plans/2025-12-23-lexicon-publisher-wizard.md b/docs/plans/2025-12-23-lexicon-publisher-wizard.md new file mode 100644 index 0000000..d132a1a --- /dev/null +++ b/docs/plans/2025-12-23-lexicon-publisher-wizard.md @@ -0,0 +1,1193 @@ +# Lexicon Publisher Wizard Flow Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Transform lexicon-publisher into a 5-step wizard that guides first-time users through sign-in, lexicon definition, publishing, DNS verification, and viewing published lexicons. + +**Architecture:** Single-file HTML app refactored from section-based layout to step-based wizard. Each step renders as a collapsible container with visual states (current/completed/future). Step state machine controls progression and reset-on-edit behavior. + +**Tech Stack:** HTML/CSS/JS (inline), QuicksliceClient (OAuth/GraphQL), honk (validation), JSZip + +--- + +## Task 1: Add Step State Management + +**Files:** +- Modify: `lexicon-publisher.html:624-650` (state object) + +**Step 1: Add step state properties to state object** + +Find the state object and add these properties after `publishResult`: + +```javascript +// Step wizard state +currentStep: 1, // 1=SignIn, 2=Define, 3=Publish, 4=DNS, 5=Published +stepCompleted: { + 1: false, // Sign In + 2: false, // Define Lexicons + 3: false, // Publish + 4: false, // Verify DNS (non-blocking) +}, +``` + +**Step 2: Verify change** + +Open `lexicon-publisher.html` in browser, open console, type `state.currentStep` - should return `1`. + +**Step 3: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +feat(lexicon-publisher): add step wizard state management + +Add currentStep and stepCompleted to track wizard progression. +EOF +)" +``` + +--- + +## Task 2: Add Step Navigation Helper Functions + +**Files:** +- Modify: `lexicon-publisher.html` (after state object, ~line 651) + +**Step 1: Add step navigation functions** + +Add these functions after the state object: + +```javascript +// ============================================================================= +// STEP NAVIGATION +// ============================================================================= + +function goToStep(stepNumber) { + if (stepNumber < 1 || stepNumber > 5) return; + + // Can only go to completed steps or the next available step + const maxAllowedStep = getMaxAllowedStep(); + if (stepNumber > maxAllowedStep) return; + + state.currentStep = stepNumber; + renderAllSteps(); +} + +function getMaxAllowedStep() { + // Can go to step N+1 if step N is completed + for (let i = 1; i <= 4; i++) { + if (!state.stepCompleted[i]) return i; + } + return 5; +} + +function completeStep(stepNumber) { + state.stepCompleted[stepNumber] = true; + state.currentStep = Math.min(stepNumber + 1, 5); + renderAllSteps(); +} + +function resetStepsFrom(stepNumber) { + // Reset this step and all downstream steps + for (let i = stepNumber; i <= 4; i++) { + state.stepCompleted[i] = false; + } + // Clear downstream state + if (stepNumber <= 2) { + state.validationPassed = false; + state.conflicts = []; + state.missingDependencies = []; + state.result = null; + } + if (stepNumber <= 3) { + state.publishResult = null; + state.publishResults = []; + } +} + +function getStepSummary(stepNumber) { + switch (stepNumber) { + case 1: + return state.viewer ? `@${state.viewer.handle}` : ''; + case 2: + const validCount = state.lexicons.filter(l => l.value.trim()).length; + return `${validCount} lexicon${validCount !== 1 ? 's' : ''} ready`; + case 3: + const publishedCount = state.publishResults.filter(r => r.success).length; + return `Published ${publishedCount} lexicon${publishedCount !== 1 ? 's' : ''}`; + case 4: + const verifiedCount = state.dnsInstructions.filter(d => d.verified === true).length; + const totalDns = state.dnsInstructions.length; + if (totalDns === 0) return 'No DNS needed'; + if (verifiedCount === totalDns) return 'DNS verified'; + return `${verifiedCount}/${totalDns} domains verified`; + default: + return ''; + } +} +``` + +**Step 2: Verify change** + +Open console, type `getMaxAllowedStep()` - should return `1`. + +**Step 3: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +feat(lexicon-publisher): add step navigation helper functions + +Add goToStep, completeStep, resetStepsFrom, getStepSummary for wizard flow control. +EOF +)" +``` + +--- + +## Task 3: Add Step Container CSS + +**Files:** +- Modify: `lexicon-publisher.html:11-585` (style section) + +**Step 1: Add step container styles** + +Add before the closing `` tag (~line 585): + +```css +/* Step wizard styles */ +.step { + background: var(--bg-card); + border-radius: 0.5rem; + margin-bottom: 0.75rem; + border: 1px solid var(--border); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); + overflow: hidden; +} + +.step-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + cursor: pointer; + user-select: none; +} + +.step-header:hover { + background: var(--bg-input); +} + +.step-header-left { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.step-number { + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + font-weight: 600; + background: var(--border); + color: var(--text-secondary); +} + +.step-title { + font-weight: 600; + font-size: 0.9375rem; +} + +.step-summary { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.step-content { + padding: 0 1rem 1rem 1rem; + border-top: 1px solid var(--border); +} + +/* Step states */ +.step.step-current .step-number { + background: var(--accent); + color: #ffffff; +} + +.step.step-current .step-header { + cursor: default; +} + +.step.step-current .step-header:hover { + background: transparent; +} + +.step.step-completed .step-number { + background: var(--success-text); + color: #ffffff; +} + +.step.step-completed .step-number::after { + content: '✓'; +} + +.step.step-completed .step-number span { + display: none; +} + +.step.step-future { + opacity: 0.5; +} + +.step.step-future .step-header { + cursor: not-allowed; +} + +.step.step-future .step-header:hover { + background: transparent; +} + +.step.step-collapsed .step-content { + display: none; +} + +.step.step-collapsed .step-header { + border-bottom: none; +} +``` + +**Step 2: Verify change** + +Reload page, check that styles are loaded (no CSS errors in console). + +**Step 3: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +feat(lexicon-publisher): add step container CSS styles + +Add visual states for current, completed, future, and collapsed steps. +EOF +)" +``` + +--- + +## Task 4: Create Step Container Render Function + +**Files:** +- Modify: `lexicon-publisher.html` (in rendering section, ~line 816) + +**Step 1: Add renderStep helper function** + +Add before `renderAuthStatus()`: + +```javascript +function renderStep(stepNumber, title, contentHtml) { + const isCompleted = state.stepCompleted[stepNumber]; + const isCurrent = state.currentStep === stepNumber; + const isFuture = stepNumber > getMaxAllowedStep(); + const isCollapsed = !isCurrent; + + const classes = ['step']; + if (isCompleted) classes.push('step-completed'); + if (isCurrent) classes.push('step-current'); + if (isFuture) classes.push('step-future'); + if (isCollapsed) classes.push('step-collapsed'); + + const summary = isCompleted ? getStepSummary(stepNumber) : ''; + const clickHandler = !isCurrent && !isFuture ? `onclick="goToStep(${stepNumber})"` : ''; + + return ` +
+
+
+
${stepNumber}
+ ${esc(title)} +
+ ${summary ? `${esc(summary)}` : ''} +
+
+ ${contentHtml} +
+
+ `; +} +``` + +**Step 2: Verify change** + +Console: `renderStep(1, 'Test', '

Content

')` should return HTML string. + +**Step 3: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +feat(lexicon-publisher): add renderStep helper function + +Creates step container HTML with proper state classes and click handlers. +EOF +)" +``` + +--- + +## Task 5: Refactor to Step-Based Main Render + +**Files:** +- Modify: `lexicon-publisher.html:601-606` (main element) +- Modify: `lexicon-publisher.html` (add renderAllSteps function) + +**Step 1: Update main element HTML** + +Replace the main element content with a single container: + +```html +
+
+``` + +**Step 2: Add renderAllSteps function** + +Add after `renderStep` function: + +```javascript +function renderAllSteps() { + const container = document.getElementById('steps-container'); + if (!container) return; + + container.innerHTML = [ + renderStep(1, 'Sign In', renderSignInContent()), + renderStep(2, 'Define Lexicons', renderDefineLexiconsContent()), + renderStep(3, 'Publish', renderPublishContent()), + renderStep(4, 'Verify DNS', renderDnsContent()), + renderStep(5, 'Your Published Lexicons', renderPublishedLexiconsContent()), + ].join(''); +} +``` + +**Step 3: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +feat(lexicon-publisher): add step-based main render structure + +Replace section-based layout with single steps container and renderAllSteps function. +EOF +)" +``` + +--- + +## Task 6: Implement Step 1 - Sign In + +**Files:** +- Modify: `lexicon-publisher.html` (add renderSignInContent function) + +**Step 1: Add renderSignInContent function** + +Add after `renderAllSteps`: + +```javascript +function renderSignInContent() { + if (state.viewer) { + // Already signed in - show minimal confirmation + return ` +

+ Signed in as @${esc(state.viewer.handle)} +

+
+ +
+ `; + } + + return ` +

+ Sign in with your Bluesky handle to publish lexicons to your PDS. +

+
+
+ +
+ +
+ `; +} + +async function handleStepLogin(event) { + event.preventDefault(); + const handle = document.getElementById('step-login-handle').value.trim(); + if (!handle) return; + + try { + await initClient(); + await state.client.loginWithRedirect({ handle }); + } catch (err) { + console.error('Login error:', err); + state.result = { success: false, message: 'Login failed: ' + err.message }; + renderAllSteps(); + } +} +``` + +**Step 2: Update init() to complete step 1 if signed in** + +In the init function, after setting `state.viewer`, add: + +```javascript +// Complete step 1 if already signed in +if (state.viewer) { + state.stepCompleted[1] = true; + state.currentStep = 2; +} +``` + +**Step 3: Update OAuth callback handling** + +After successful OAuth callback in init(), add same step completion logic. + +**Step 4: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +feat(lexicon-publisher): implement Step 1 - Sign In + +Add inline sign-in form in step wizard, complete step on successful auth. +EOF +)" +``` + +--- + +## Task 7: Implement Step 2 - Define Lexicons + +**Files:** +- Modify: `lexicon-publisher.html` (add renderDefineLexiconsContent function) + +**Step 1: Add renderDefineLexiconsContent function** + +```javascript +function renderDefineLexiconsContent() { + const getLexiconLabel = (lex, idx) => { + try { + const obj = JSON.parse(lex.value); + if (obj.id) return obj.id; + } catch (e) {} + return `Lexicon ${idx + 1}`; + }; + + const editorsHtml = state.lexicons + .map( + (lex, idx) => ` +
+
+
+ + ${esc(getLexiconLabel(lex, idx))} +
+ +
+ +
+`, + ) + .join(""); + + const hasMultiple = state.lexicons.length > 1; + const allCollapsed = state.lexicons.every((l) => l.collapsed); + const collapseAllRow = hasMultiple + ? `
+ +
` + : ""; + + const resultHtml = state.result && state.currentStep === 2 ? ` +
+ ${state.result.success ? '✓' : '✗'} ${esc(state.result.message)} +
+ ${state.missingDependencies.length > 0 ? ` +
+ Missing external dependencies: + +
+ ` : ''} + ` : ''; + + return ` +
+
📦
+
+ Drop a .zip file or click to browse +
+
Supports nested folders with .json lexicon files
+
+ + ${collapseAllRow} + ${editorsHtml} +
+ + +
+ ${resultHtml} + `; +} + +function handleLexiconChange(id, value) { + const lex = state.lexicons.find((l) => l.id === id); + if (lex) { + lex.value = value; + // Reset downstream steps when editing + if (state.stepCompleted[2]) { + resetStepsFrom(2); + } + } +} + +async function continueToPublish() { + // Validate lexicons first + state.result = null; + state.validationPassed = false; + state.conflicts = []; + state.missingDependencies = []; + + const parseResult = parseLexicons(); + if (parseResult.error) { + state.result = { success: false, message: parseResult.error }; + renderAllSteps(); + return; + } + + try { + const lexiconList = honk.toList(parseResult.lexicons); + const result = honk.validate(lexiconList); + const unwrapped = unwrapHonkResult(result); + + if (!unwrapped.ok) { + state.result = { + success: false, + message: `Validation failed: ${formatHonkError(unwrapped.value)}`, + }; + renderAllSteps(); + return; + } + + const count = parseResult.lexicons.length; + state.result = { success: true, message: `${count} lexicon${count !== 1 ? 's' : ''} valid` }; + state.validationPassed = true; + + // Check external dependencies and conflicts + if (state.viewer) { + const userDomain = getUserDomain(state.viewer.handle); + const externalRefs = extractExternalRefs(parseResult.lexicons, userDomain); + const { missing } = await checkExternalDependencies(externalRefs); + state.missingDependencies = missing; + await checkConflicts(); + } + + // Complete step 2 and move to step 3 + completeStep(2); + } catch (e) { + state.result = { + success: false, + message: `Validation error: ${e.message}`, + }; + renderAllSteps(); + } +} +``` + +**Step 2: Update addLexicon and removeLexicon to re-render** + +Modify `addLexicon()` and `removeLexicon()` to call `renderAllSteps()` instead of `renderLexiconsSection()`. + +**Step 3: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +feat(lexicon-publisher): implement Step 2 - Define Lexicons + +Add lexicon editors with Continue to Publish button that validates and advances. +EOF +)" +``` + +--- + +## Task 8: Implement Step 3 - Publish + +**Files:** +- Modify: `lexicon-publisher.html` (add renderPublishContent function) + +**Step 1: Add renderPublishContent function** + +```javascript +function renderPublishContent() { + const { publishable, excluded } = getPublishableLexicons(); + const userDomain = state.viewer ? getUserDomain(state.viewer.handle) : ''; + + const excludedWarning = excluded.length > 0 ? ` +
+ Excluded (not on ${esc(userDomain)}): + +
+ ` : ''; + + const conflictWarnings = state.conflicts.length > 0 ? ` +
+ Conflicts detected: + +
+ ` : ''; + + const publishCount = publishable.length; + const canPublish = publishCount > 0; + + const resultHtml = state.publishResult ? ` +
+ ${state.publishResult.success ? '✓' : '✗'} ${esc(state.publishResult.message)} +
+ ` : ''; + + // Show list of lexicons to be published + const lexiconList = publishable.length > 0 ? ` +
+ Ready to publish: + +
+ ` : ''; + + return ` +

+ ${canPublish + ? `Publish ${publishCount} lexicon${publishCount !== 1 ? 's' : ''} to @${esc(state.viewer?.handle || '')}'s PDS.` + : `No lexicons to publish for ${esc(userDomain)}.`} +

+ ${lexiconList} + ${excludedWarning} + ${conflictWarnings} +
+ +
+ ${resultHtml} + `; +} + +async function publishAndContinue() { + if (!state.viewer || !state.client) return; + + state.publishResults = []; + state.publishResult = null; + + const { publishable: lexiconsToPublish } = getPublishableLexicons(); + + if (lexiconsToPublish.length === 0) { + state.publishResult = { success: false, message: "No valid lexicons to publish" }; + renderAllSteps(); + return; + } + + // Publish each lexicon + for (const { nsid, schema } of lexiconsToPublish) { + try { + const ownedConflict = state.conflicts.find(c => c.nsid === nsid && c.isSameOwner); + const input = { + id: nsid, + defs: schema.defs, + lexicon: schema.lexicon || 1, + }; + + if (ownedConflict) { + await state.client.mutate(` + mutation UpdateSchema($input: ComAtprotoLexiconSchemaInput!, $rkey: String!) { + updateComAtprotoLexiconSchema(input: $input, rkey: $rkey) { + uri + } + } + `, { rkey: nsid, input }); + state.publishResults.push({ nsid, success: true, updated: true }); + } else { + await state.client.mutate(` + mutation CreateSchema($input: ComAtprotoLexiconSchemaInput!, $rkey: String!) { + createComAtprotoLexiconSchema(input: $input, rkey: $rkey) { + uri + } + } + `, { rkey: nsid, input }); + state.publishResults.push({ nsid, success: true }); + } + } catch (err) { + state.publishResults.push({ nsid, success: false, error: err.message }); + } + } + + // Generate DNS instructions + generateDnsInstructions(lexiconsToPublish.map(l => l.nsid)); + + // Refresh published lexicons list + await fetchPublishedLexicons(); + + // Show results + const successCount = state.publishResults.filter(r => r.success).length; + const failCount = state.publishResults.filter(r => !r.success).length; + + if (failCount === 0) { + state.publishResult = { success: true, message: `Published ${successCount} lexicon${successCount !== 1 ? 's' : ''}` }; + // Complete step 3 and move forward + completeStep(3); + } else { + const errors = state.publishResults.filter(r => !r.success).map(r => `${r.nsid}: ${r.error}`).join('\n'); + state.publishResult = { success: false, message: `Published ${successCount}, failed ${failCount}:\n${errors}` }; + renderAllSteps(); + } +} +``` + +**Step 2: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +feat(lexicon-publisher): implement Step 3 - Publish + +Show publishable lexicons, warnings, and publish button that advances to DNS step. +EOF +)" +``` + +--- + +## Task 9: Implement Step 4 - Verify DNS + +**Files:** +- Modify: `lexicon-publisher.html` (add renderDnsContent function) + +**Step 1: Add renderDnsContent function** + +```javascript +function renderDnsContent() { + if (state.dnsInstructions.length === 0) { + return ` +

+ No DNS records required. Your lexicons are published! +

+
+ +
+ `; + } + + const cards = state.dnsInstructions.map(instr => { + let statusIcon = ''; + if (instr.verified === true) { + statusIcon = '✓'; + } else if (instr.verified === false) { + statusIcon = '✗'; + } + + return ` +
+
${esc(instr.domain)} ${statusIcon}
+
+
+ Type: + TXT +
+
+ Name: + ${esc(instr.recordName)} + +
+
+ Value: + ${esc(instr.recordValue)} + +
+
+
+ Lexicons: + +
+
+ `; + }).join(''); + + const allVerified = state.dnsInstructions.every(d => d.verified === true); + + return ` + ${cards} +
+ Add these TXT records to your DNS provider. You must control these domains to complete lexicon resolution. +
+
+ + +
+ `; +} + +async function verifyDnsAndRender() { + await verifyDnsRecords(); + renderAllSteps(); +} + +function skipDnsAndContinue() { + // Mark DNS step as complete (non-blocking) and continue + state.stepCompleted[4] = true; + state.currentStep = 5; + renderAllSteps(); +} + +function continueToDone() { + // Mark DNS step as complete and move to final step + state.stepCompleted[4] = true; + state.currentStep = 5; + renderAllSteps(); +} +``` + +**Step 2: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +feat(lexicon-publisher): implement Step 4 - Verify DNS + +Show DNS records with verify button and skip/continue options. +EOF +)" +``` + +--- + +## Task 10: Implement Step 5 - Your Published Lexicons + +**Files:** +- Modify: `lexicon-publisher.html` (add renderPublishedLexiconsContent function) + +**Step 1: Add renderPublishedLexiconsContent function** + +```javascript +function renderPublishedLexiconsContent() { + if (state.publishedLexicons.length === 0) { + return ` +

+ You haven't published any lexicons yet. +

+ `; + } + + const lexiconCards = state.publishedLexicons.map(lex => ` +
+
+ ${esc(lex.rkey)} + +
+
+ Published ${new Date(lex.indexedAt).toLocaleDateString()} +
+
+ `).join(''); + + return ` +

+ You have ${state.publishedLexicons.length} published lexicon${state.publishedLexicons.length !== 1 ? 's' : ''}. +

+ ${lexiconCards} + `; +} + +async function deleteLexiconAndRender(rkey) { + if (!confirm(`Delete lexicon ${rkey}?`)) return; + + try { + await state.client.mutate(` + mutation DeleteSchema($rkey: String!) { + deleteComAtprotoLexiconSchema(rkey: $rkey) { + uri + } + } + `, { rkey }); + + state.publishedLexicons = state.publishedLexicons.filter(l => l.rkey !== rkey); + + // Update DNS instructions if needed + if (state.publishedLexicons.length > 0) { + generateDnsInstructions(state.publishedLexicons.map(l => l.rkey)); + } else { + state.dnsInstructions = []; + } + + renderAllSteps(); + } catch (err) { + alert(`Failed to delete: ${err.message}`); + } +} +``` + +**Step 2: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +feat(lexicon-publisher): implement Step 5 - Your Published Lexicons + +Show all published lexicons with delete functionality. +EOF +)" +``` + +--- + +## Task 11: Update init() and Remove Old Render Functions + +**Files:** +- Modify: `lexicon-publisher.html` (init function and cleanup) + +**Step 1: Update init() to use renderAllSteps** + +Replace the render calls at the end of `init()`: + +```javascript +// Old: +// renderLexiconsSection(); +// renderAuthStatus(); +// renderPublishedLexiconsSection(); +// renderDnsSection(); + +// New: +renderAllSteps(); +``` + +**Step 2: Remove old render functions that are no longer used** + +Remove these functions (they're replaced by step content renderers): +- `renderLexiconsSection()` +- `renderPublishSection()` +- `renderPublishResult()` +- `renderPublishedLexiconsSection()` +- `renderDnsSection()` +- `renderResult()` + +Also remove the `renderAuthStatus()` function since auth is now in Step 1. + +**Step 3: Update header to remove auth-status div** + +Change header to just show title (no auth status): + +```html +
+
+

+ { Lexicon Publisher + } +

+

+ Publish lexicons to your PDS +

+
+
+``` + +**Step 4: Remove old section divs from HTML** + +Remove these from the main element since we now use steps-container: +- `
` +- `` +- `` +- `` + +**Step 5: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +refactor(lexicon-publisher): remove old section-based render functions + +Clean up deprecated render functions and update init to use wizard steps. +EOF +)" +``` + +--- + +## Task 12: Fix updateLexicon to Use New Handler + +**Files:** +- Modify: `lexicon-publisher.html` + +**Step 1: Remove old updateLexicon function** + +The old `updateLexicon(id, value)` function should be removed since we now use `handleLexiconChange(id, value)` which also handles resetting downstream steps. + +**Step 2: Update any remaining references** + +Search for any remaining calls to old functions and update them. + +**Step 3: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +refactor(lexicon-publisher): consolidate lexicon update handlers + +Remove duplicate updateLexicon in favor of handleLexiconChange. +EOF +)" +``` + +--- + +## Task 13: Handle Returning Users with Published Lexicons + +**Files:** +- Modify: `lexicon-publisher.html` (init function) + +**Step 1: Update init to show step 5 preview for returning users** + +After fetching published lexicons, if the user has lexicons and comes back with session, generate DNS instructions: + +```javascript +// In init(), after setting publishedLexicons: +if (state.publishedLexicons.length > 0) { + generateDnsInstructions(state.publishedLexicons.map(l => l.rkey)); +} +``` + +This is already there, but ensure the step summary for step 5 shows the count even when collapsed. + +**Step 2: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +feat(lexicon-publisher): show published lexicon count for returning users + +Step 5 preview shows count when user has existing published lexicons. +EOF +)" +``` + +--- + +## Task 14: Add File Drop Handler Updates + +**Files:** +- Modify: `lexicon-publisher.html` + +**Step 1: Update processZipFile to call renderAllSteps** + +Replace calls to `renderLexiconsSection()` and `renderResult()` with `renderAllSteps()`. + +**Step 2: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +fix(lexicon-publisher): update ZIP file handler to use wizard renderer + +Replace old section renders with renderAllSteps. +EOF +)" +``` + +--- + +## Task 15: Final Testing and Polish + +**Files:** +- Modify: `lexicon-publisher.html` + +**Step 1: Manual test the full flow** + +1. Open page - Step 1 (Sign In) should be active, steps 2-5 grayed out +2. Enter handle and sign in - after OAuth, Step 1 collapses, Step 2 expands +3. Add/edit lexicons - content edits don't reset steps (until validated) +4. Click "Continue to Publish" - validates, shows errors OR moves to Step 3 +5. Click collapsed Step 2 - should expand and reset Step 3+ +6. Re-validate and continue to Step 3 +7. Click "Publish to PDS" - publishes, moves to Step 4 +8. Verify DNS or skip - moves to Step 5 +9. View published lexicons, delete works +10. Click earlier steps - can go back and edit + +**Step 2: Fix any issues found during testing** + +**Step 3: Final commit** + +```bash +git add lexicon-publisher.html +git commit -m "$(cat <<'EOF' +feat(lexicon-publisher): complete wizard flow implementation + +Tested full 5-step flow: Sign In → Define → Publish → DNS → Published. +EOF +)" +``` + +--- + +## Summary + +This plan transforms the lexicon-publisher from a section-based layout to a 5-step wizard: + +1. **Sign In** - OAuth flow, must complete first +2. **Define Lexicons** - Upload/edit, "Continue to Publish" validates +3. **Publish** - Review warnings, publish to PDS +4. **Verify DNS** - TXT records, non-blocking +5. **Your Published Lexicons** - View all, delete + +Key behaviors implemented: +- Steps collapse with summary when completed +- Can click completed steps to go back +- Going back resets downstream steps +- Future steps grayed out until unlocked +- No localStorage - refresh starts fresh at Step 2 (if signed in) diff --git a/docs/plans/2025-12-23-lexicon-publisher.md b/docs/plans/2025-12-23-lexicon-publisher.md new file mode 100644 index 0000000..de95714 --- /dev/null +++ b/docs/plans/2025-12-23-lexicon-publisher.md @@ -0,0 +1,1223 @@ +# Lexicon Publisher Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a web tool that validates, publishes, and manages ATProto lexicon schemas with OAuth authentication and DNS setup instructions. + +**Architecture:** Single-file HTML tool (like lexicon-validator.html) using quickslice-client-js for OAuth/GraphQL, honk for validation, and JSZip for file handling. Progressive auth: validate without login, publish requires OAuth. + +**Tech Stack:** HTML/CSS/JS, quickslice-client-js (CDN), honk (CDN), JSZip (CDN), GraphQL mutations via lexicons-quickslice backend. + +--- + +## Task 1: Copy Lexicon Validator as Base + +**Files:** +- Copy: `lexicon-validator.html` → `lexicon-publisher.html` + +**Step 1: Copy the file** + +```bash +cp lexicon-validator.html lexicon-publisher.html +``` + +**Step 2: Update title and header** + +Change: +- `{ Lexicon Validator }` → `{ Lexicon Publisher }` +- Header h1 from "Lexicon Validator" → "Lexicon Publisher" +- Tagline: add "Publish lexicons to your PDS" + +**Step 3: Update CSP to allow lexicons-quickslice** + +Add to Content-Security-Policy connect-src: +``` +https://quickslice-production-4b57.up.railway.app wss://quickslice-production-4b57.up.railway.app https://public.api.bsky.app +``` + +**Step 4: Add quickslice-client script** + +After honk script, add: +```html + +``` + +**Step 5: Verify in browser** + +Open `lexicon-publisher.html` - should show renamed validator with same functionality. + +**Step 6: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "feat: scaffold lexicon-publisher from validator" +``` + +--- + +## Task 2: Add State for Auth and Publishing + +**Files:** +- Modify: `lexicon-publisher.html` + +**Step 1: Extend state object** + +Add these properties to the state object: + +```javascript +const state = { + // Existing lexicon state + lexicons: [{ id: 1, value: "", collapsed: false }], + nextLexiconId: 2, + record: "", + nsid: "", + availableNsids: [], + result: null, + + // New: Auth state + client: null, + viewer: null, // { did, handle } + + // New: Published lexicons from user's PDS + publishedLexicons: [], + + // New: Publish workflow + validationPassed: false, + conflicts: [], // [{ nsid, existingOwner, isSameOwner }] + publishResults: [], // [{ nsid, success, error }] + + // New: DNS instructions + dnsInstructions: [], // [{ domain, record, lexicons }] +}; +``` + +**Step 2: Add constants** + +```javascript +const SERVER_URL = "https://quickslice-production-4b57.up.railway.app"; +const CLIENT_ID = "client_0ygtMYHz-rzUtCMgvhigcA"; +const SCOPE = "atproto repo:com.atproto.lexicon"; +``` + +**Step 3: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "feat: add auth and publishing state" +``` + +--- + +## Task 3: Add OAuth Helpers + +**Files:** +- Modify: `lexicon-publisher.html` + +**Step 1: Add initClient function** + +```javascript +async function initClient() { + if (!state.client) { + state.client = await QuicksliceClient.createQuicksliceClient({ + server: SERVER_URL, + clientId: CLIENT_ID, + scope: SCOPE, + }); + } + return state.client; +} +``` + +**Step 2: Add callback detection** + +```javascript +function isCallbackRoute() { + return window.location.pathname === "/lexicon-publisher/callback"; +} +``` + +**Step 3: Add OAuth callback handler** + +```javascript +async function handleOAuthCallback() { + if (!isCallbackRoute()) return false; + + const params = new URLSearchParams(window.location.search); + + // Start OAuth flow from callback route + if (params.get("start") === "1") { + window.history.replaceState({}, document.title, "/lexicon-publisher/callback"); + await startOAuthFromCallback(); + return true; + } + + // Handle OAuth error + const urlError = params.get("error"); + if (urlError) { + state.result = { success: false, message: params.get("error_description") || urlError }; + window.location.href = "/lexicon-publisher"; + return true; + } + + // No code - redirect + if (!params.has("code")) { + window.location.href = "/lexicon-publisher"; + return true; + } + + // Exchange code for tokens + try { + await initClient(); + await state.client.handleRedirectCallback(); + window.history.replaceState({}, document.title, "/lexicon-publisher/callback"); + const returnUrl = sessionStorage.getItem("oauth_return_url") || "/lexicon-publisher"; + sessionStorage.removeItem("oauth_return_url"); + window.location.href = returnUrl; + return true; + } catch (err) { + console.error("OAuth callback error:", err); + state.result = { success: false, message: err.message || "Authentication failed" }; + window.location.href = "/lexicon-publisher"; + return true; + } +} +``` + +**Step 4: Add login flow** + +```javascript +function showLoginModal() { + const existing = document.getElementById("login-modal"); + if (existing) existing.remove(); + + const modal = document.createElement("div"); + modal.id = "login-modal"; + modal.className = "modal-overlay"; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + document.getElementById("login-handle").focus(); +} + +function closeLoginModal() { + const modal = document.getElementById("login-modal"); + if (modal) modal.remove(); +} + +async function handleLogin(event) { + event.preventDefault(); + const handle = document.getElementById("login-handle").value.trim(); + if (!handle) return; + + sessionStorage.setItem("oauth_return_url", window.location.href); + sessionStorage.setItem("oauth_handle", handle); + window.location.href = "/lexicon-publisher/callback?start=1"; +} + +async function startOAuthFromCallback() { + const handle = sessionStorage.getItem("oauth_handle"); + sessionStorage.removeItem("oauth_handle"); + + if (!handle) { + window.location.href = "/lexicon-publisher"; + return; + } + + try { + await initClient(); + await state.client.loginWithRedirect({ handle }); + } catch (err) { + console.error("Login error:", err); + sessionStorage.setItem("oauth_error", err.message); + window.location.href = "/lexicon-publisher"; + } +} + +function handleLogout() { + if (state.client) { + state.client.logout(); + } + state.viewer = null; + state.publishedLexicons = []; + renderAll(); +} +``` + +**Step 5: Commit** + +```bash +git add lexicon-publisher.html +git commit -m "feat: add OAuth helpers" +``` + +--- + +## Task 4: Add Modal and Auth UI Styles + +**Files:** +- Modify: `lexicon-publisher.html` + +**Step 1: Add modal styles** + +Add to ` + + +
+
+
+

+ { Lexicon Publisher + } +

+

+ Publish your AT Protocol Lexicon schemas +

+
+
+
+ Learn more: + AT Protocol Spec + · + Publishing Guide + · + 🐛 Report a bug +
+
+
+ +
+ + + + + + + +