25 changed files with 2582 additions and 124 deletions
@ -0,0 +1,661 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import CodeEditor from './CodeEditor.svelte'; |
||||||
|
import { HighlightsService } from '../services/nostr/highlights-service.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '../config.js'; |
||||||
|
import { getUserRelays } from '../services/nostr/user-relays.js'; |
||||||
|
import { NostrClient } from '../services/nostr/nostr-client.js'; |
||||||
|
import { signEventWithNIP07 } from '../services/nostr/nip07-signer.js'; |
||||||
|
import { getPublicKeyWithNIP07 } from '../services/nostr/nip07-signer.js'; |
||||||
|
import { KIND } from '../types/nostr.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
pr: { |
||||||
|
id: string; |
||||||
|
subject: string; |
||||||
|
content: string; |
||||||
|
status: string; |
||||||
|
author: string; |
||||||
|
created_at: number; |
||||||
|
commitId?: string; |
||||||
|
}; |
||||||
|
npub: string; |
||||||
|
repo: string; |
||||||
|
repoOwnerPubkey: string; |
||||||
|
} |
||||||
|
|
||||||
|
let { pr, npub, repo, repoOwnerPubkey }: Props = $props(); |
||||||
|
|
||||||
|
let highlights = $state<Array<any>>([]); |
||||||
|
let comments = $state<Array<any>>([]); |
||||||
|
let loading = $state(false); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
let userPubkey = $state<string | null>(null); |
||||||
|
|
||||||
|
// Highlight creation |
||||||
|
let selectedText = $state(''); |
||||||
|
let selectedStartLine = $state(0); |
||||||
|
let selectedEndLine = $state(0); |
||||||
|
let selectedStartPos = $state(0); |
||||||
|
let selectedEndPos = $state(0); |
||||||
|
let showHighlightDialog = $state(false); |
||||||
|
let highlightComment = $state(''); |
||||||
|
let creatingHighlight = $state(false); |
||||||
|
|
||||||
|
// Comment creation |
||||||
|
let showCommentDialog = $state(false); |
||||||
|
let commentContent = $state(''); |
||||||
|
let replyingTo = $state<string | null>(null); |
||||||
|
let creatingComment = $state(false); |
||||||
|
|
||||||
|
// PR diff/file content |
||||||
|
let prDiff = $state(''); |
||||||
|
let prFileContent = $state(''); |
||||||
|
let currentFilePath = $state<string | null>(null); |
||||||
|
let loadingDiff = $state(false); |
||||||
|
|
||||||
|
const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS); |
||||||
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
await checkAuth(); |
||||||
|
await loadHighlights(); |
||||||
|
await loadPRDiff(); |
||||||
|
}); |
||||||
|
|
||||||
|
async function checkAuth() { |
||||||
|
try { |
||||||
|
if (typeof window !== 'undefined' && window.nostr) { |
||||||
|
userPubkey = await getPublicKeyWithNIP07(); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('Auth check failed:', err); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function loadHighlights() { |
||||||
|
loading = true; |
||||||
|
error = null; |
||||||
|
try { |
||||||
|
const response = await fetch( |
||||||
|
`/api/repos/${npub}/${repo}/highlights?prId=${pr.id}&prAuthor=${pr.author}` |
||||||
|
); |
||||||
|
if (response.ok) { |
||||||
|
const data = await response.json(); |
||||||
|
highlights = data.highlights || []; |
||||||
|
comments = data.comments || []; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
error = err instanceof Error ? err.message : 'Failed to load highlights'; |
||||||
|
} finally { |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function loadPRDiff() { |
||||||
|
if (!pr.commitId) return; |
||||||
|
|
||||||
|
loadingDiff = true; |
||||||
|
try { |
||||||
|
// Load diff for the commit |
||||||
|
const response = await fetch( |
||||||
|
`/api/repos/${npub}/${repo}/diff?from=${pr.commitId}^&to=${pr.commitId}` |
||||||
|
); |
||||||
|
if (response.ok) { |
||||||
|
const data = await response.json(); |
||||||
|
// Combine all file diffs |
||||||
|
prDiff = data.map((d: any) => |
||||||
|
`--- ${d.file}\n+++ ${d.file}\n${d.diff}` |
||||||
|
).join('\n\n'); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('Failed to load PR diff:', err); |
||||||
|
} finally { |
||||||
|
loadingDiff = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleCodeSelection( |
||||||
|
text: string, |
||||||
|
startLine: number, |
||||||
|
endLine: number, |
||||||
|
startPos: number, |
||||||
|
endPos: number |
||||||
|
) { |
||||||
|
if (!text.trim() || !userPubkey) return; |
||||||
|
|
||||||
|
selectedText = text; |
||||||
|
selectedStartLine = startLine; |
||||||
|
selectedEndLine = endLine; |
||||||
|
selectedStartPos = startPos; |
||||||
|
selectedEndPos = endPos; |
||||||
|
showHighlightDialog = true; |
||||||
|
} |
||||||
|
|
||||||
|
async function createHighlight() { |
||||||
|
if (!userPubkey || !selectedText.trim()) return; |
||||||
|
|
||||||
|
creatingHighlight = true; |
||||||
|
error = null; |
||||||
|
|
||||||
|
try { |
||||||
|
const eventTemplate = highlightsService.createHighlightEvent( |
||||||
|
selectedText, |
||||||
|
pr.id, |
||||||
|
pr.author, |
||||||
|
repoOwnerPubkey, |
||||||
|
repo, |
||||||
|
currentFilePath || undefined, |
||||||
|
selectedStartLine, |
||||||
|
selectedEndLine, |
||||||
|
undefined, // context |
||||||
|
highlightComment.trim() || undefined |
||||||
|
); |
||||||
|
|
||||||
|
const signedEvent = await signEventWithNIP07(eventTemplate); |
||||||
|
|
||||||
|
const { outbox } = await getUserRelays(userPubkey, nostrClient); |
||||||
|
const combinedRelays = combineRelays(outbox); |
||||||
|
|
||||||
|
const response = await fetch(`/api/repos/${npub}/${repo}/highlights`, { |
||||||
|
method: 'POST', |
||||||
|
headers: { 'Content-Type': 'application/json' }, |
||||||
|
body: JSON.stringify({ |
||||||
|
type: 'highlight', |
||||||
|
event: signedEvent, |
||||||
|
userPubkey |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
if (response.ok) { |
||||||
|
showHighlightDialog = false; |
||||||
|
selectedText = ''; |
||||||
|
highlightComment = ''; |
||||||
|
await loadHighlights(); |
||||||
|
} else { |
||||||
|
const data = await response.json(); |
||||||
|
error = data.error || 'Failed to create highlight'; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
error = err instanceof Error ? err.message : 'Failed to create highlight'; |
||||||
|
} finally { |
||||||
|
creatingHighlight = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function startComment(parentId?: string) { |
||||||
|
if (!userPubkey) { |
||||||
|
alert('Please connect your NIP-07 extension'); |
||||||
|
return; |
||||||
|
} |
||||||
|
replyingTo = parentId || null; |
||||||
|
showCommentDialog = true; |
||||||
|
} |
||||||
|
|
||||||
|
async function createComment() { |
||||||
|
if (!userPubkey || !commentContent.trim()) return; |
||||||
|
|
||||||
|
creatingComment = true; |
||||||
|
error = null; |
||||||
|
|
||||||
|
try { |
||||||
|
const rootEventId = replyingTo || pr.id; |
||||||
|
const rootEventKind = replyingTo ? KIND.COMMENT : KIND.PULL_REQUEST; |
||||||
|
const rootPubkey = replyingTo ? |
||||||
|
(comments.find(c => c.id === replyingTo)?.pubkey || pr.author) : |
||||||
|
pr.author; |
||||||
|
|
||||||
|
let parentEventId: string | undefined; |
||||||
|
let parentEventKind: number | undefined; |
||||||
|
let parentPubkey: string | undefined; |
||||||
|
|
||||||
|
if (replyingTo) { |
||||||
|
// Reply to a comment |
||||||
|
const parentComment = comments.find(c => c.id === replyingTo) || |
||||||
|
highlights.flatMap(h => h.comments || []).find(c => c.id === replyingTo); |
||||||
|
if (parentComment) { |
||||||
|
parentEventId = replyingTo; |
||||||
|
parentEventKind = KIND.COMMENT; |
||||||
|
parentPubkey = parentComment.pubkey; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const eventTemplate = highlightsService.createCommentEvent( |
||||||
|
commentContent.trim(), |
||||||
|
rootEventId, |
||||||
|
rootEventKind, |
||||||
|
rootPubkey, |
||||||
|
parentEventId, |
||||||
|
parentEventKind, |
||||||
|
parentPubkey |
||||||
|
); |
||||||
|
|
||||||
|
const signedEvent = await signEventWithNIP07(eventTemplate); |
||||||
|
|
||||||
|
const { outbox } = await getUserRelays(userPubkey, nostrClient); |
||||||
|
const combinedRelays = combineRelays(outbox); |
||||||
|
|
||||||
|
const response = await fetch(`/api/repos/${npub}/${repo}/highlights`, { |
||||||
|
method: 'POST', |
||||||
|
headers: { 'Content-Type': 'application/json' }, |
||||||
|
body: JSON.stringify({ |
||||||
|
type: 'comment', |
||||||
|
event: signedEvent, |
||||||
|
userPubkey |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
if (response.ok) { |
||||||
|
showCommentDialog = false; |
||||||
|
commentContent = ''; |
||||||
|
replyingTo = null; |
||||||
|
await loadHighlights(); |
||||||
|
} else { |
||||||
|
const data = await response.json(); |
||||||
|
error = data.error || 'Failed to create comment'; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
error = err instanceof Error ? err.message : 'Failed to create comment'; |
||||||
|
} finally { |
||||||
|
creatingComment = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function formatPubkey(pubkey: string): string { |
||||||
|
try { |
||||||
|
return nip19.npubEncode(pubkey); |
||||||
|
} catch { |
||||||
|
return pubkey.slice(0, 8) + '...'; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="pr-detail-view"> |
||||||
|
<div class="pr-header"> |
||||||
|
<h2>{pr.subject}</h2> |
||||||
|
<div class="pr-meta"> |
||||||
|
<span class="pr-status" class:open={pr.status === 'open'} class:closed={pr.status === 'closed'} class:merged={pr.status === 'merged'}> |
||||||
|
{pr.status} |
||||||
|
</span> |
||||||
|
{#if pr.commitId} |
||||||
|
<span>Commit: {pr.commitId.slice(0, 7)}</span> |
||||||
|
{/if} |
||||||
|
<span>Created {new Date(pr.created_at * 1000).toLocaleString()}</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="pr-body"> |
||||||
|
<div class="pr-description"> |
||||||
|
{@html pr.content.replace(/\n/g, '<br>')} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if error} |
||||||
|
<div class="error">{error}</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="pr-content"> |
||||||
|
<div class="code-section"> |
||||||
|
<h3>Changes</h3> |
||||||
|
{#if loadingDiff} |
||||||
|
<div class="loading">Loading diff...</div> |
||||||
|
{:else if prDiff} |
||||||
|
<div class="diff-viewer"> |
||||||
|
<CodeEditor |
||||||
|
content={prDiff} |
||||||
|
language="text" |
||||||
|
readOnly={true} |
||||||
|
onSelection={handleCodeSelection} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="empty">No diff available</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="highlights-section"> |
||||||
|
<div class="section-header"> |
||||||
|
<h3>Highlights & Comments</h3> |
||||||
|
{#if userPubkey} |
||||||
|
<button onclick={() => startComment()} class="add-comment-btn">Add Comment</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if loading} |
||||||
|
<div class="loading">Loading highlights...</div> |
||||||
|
{:else} |
||||||
|
<!-- Top-level comments on PR --> |
||||||
|
{#each comments as comment} |
||||||
|
<div class="comment-item"> |
||||||
|
<div class="comment-header"> |
||||||
|
<span class="comment-author">{formatPubkey(comment.pubkey)}</span> |
||||||
|
<span class="comment-date">{new Date(comment.created_at * 1000).toLocaleString()}</span> |
||||||
|
</div> |
||||||
|
<div class="comment-content">{comment.content}</div> |
||||||
|
{#if userPubkey} |
||||||
|
<button onclick={() => startComment(comment.id)} class="reply-btn">Reply</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
|
||||||
|
<!-- Highlights with comments --> |
||||||
|
{#each highlights as highlight} |
||||||
|
<div class="highlight-item"> |
||||||
|
<div class="highlight-header"> |
||||||
|
<span class="highlight-author">{formatPubkey(highlight.pubkey)}</span> |
||||||
|
<span class="highlight-date">{new Date(highlight.created_at * 1000).toLocaleString()}</span> |
||||||
|
{#if highlight.file} |
||||||
|
<span class="highlight-file">{highlight.file}</span> |
||||||
|
{/if} |
||||||
|
{#if highlight.lineStart} |
||||||
|
<span class="highlight-lines">Lines {highlight.lineStart}-{highlight.lineEnd}</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
<div class="highlighted-code"> |
||||||
|
<pre><code>{highlight.highlightedContent}</code></pre> |
||||||
|
</div> |
||||||
|
{#if highlight.comment} |
||||||
|
<div class="highlight-comment">{highlight.comment}</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Comments on this highlight --> |
||||||
|
{#if highlight.comments && highlight.comments.length > 0} |
||||||
|
<div class="highlight-comments"> |
||||||
|
{#each highlight.comments as comment} |
||||||
|
<div class="comment-item nested"> |
||||||
|
<div class="comment-header"> |
||||||
|
<span class="comment-author">{formatPubkey(comment.pubkey)}</span> |
||||||
|
<span class="comment-date">{new Date(comment.created_at * 1000).toLocaleString()}</span> |
||||||
|
</div> |
||||||
|
<div class="comment-content">{comment.content}</div> |
||||||
|
{#if userPubkey} |
||||||
|
<button onclick={() => startComment(comment.id)} class="reply-btn">Reply</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if userPubkey} |
||||||
|
<button onclick={() => startComment(highlight.id)} class="add-comment-btn">Add Comment</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
|
||||||
|
{#if highlights.length === 0 && comments.length === 0} |
||||||
|
<div class="empty">No highlights or comments yet</div> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Highlight Dialog --> |
||||||
|
{#if showHighlightDialog} |
||||||
|
<div class="modal-overlay" onclick={() => showHighlightDialog = false}> |
||||||
|
<div class="modal" onclick={(e) => e.stopPropagation()}> |
||||||
|
<h3>Create Highlight</h3> |
||||||
|
<div class="selected-code"> |
||||||
|
<pre><code>{selectedText}</code></pre> |
||||||
|
</div> |
||||||
|
<label> |
||||||
|
Comment (optional): |
||||||
|
<textarea bind:value={highlightComment} rows="4" placeholder="Add a comment about this code..."></textarea> |
||||||
|
</label> |
||||||
|
<div class="modal-actions"> |
||||||
|
<button onclick={() => showHighlightDialog = false} class="cancel-btn">Cancel</button> |
||||||
|
<button onclick={createHighlight} disabled={creatingHighlight} class="save-btn"> |
||||||
|
{creatingHighlight ? 'Creating...' : 'Create Highlight'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Comment Dialog --> |
||||||
|
{#if showCommentDialog} |
||||||
|
<div class="modal-overlay" onclick={() => { showCommentDialog = false; replyingTo = null; }}> |
||||||
|
<div class="modal" onclick={(e) => e.stopPropagation()}> |
||||||
|
<h3>{replyingTo ? 'Reply to Comment' : 'Add Comment'}</h3> |
||||||
|
<label> |
||||||
|
Comment: |
||||||
|
<textarea bind:value={commentContent} rows="6" placeholder="Write your comment..."></textarea> |
||||||
|
</label> |
||||||
|
<div class="modal-actions"> |
||||||
|
<button onclick={() => { showCommentDialog = false; replyingTo = null; }} class="cancel-btn">Cancel</button> |
||||||
|
<button onclick={createComment} disabled={creatingComment || !commentContent.trim()} class="save-btn"> |
||||||
|
{creatingComment ? 'Posting...' : 'Post Comment'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
.pr-detail-view { |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-header { |
||||||
|
margin-bottom: 1rem; |
||||||
|
padding-bottom: 1rem; |
||||||
|
border-bottom: 1px solid #e0e0e0; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-header h2 { |
||||||
|
margin: 0 0 0.5rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-meta { |
||||||
|
display: flex; |
||||||
|
gap: 1rem; |
||||||
|
font-size: 0.9rem; |
||||||
|
color: #666; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-status { |
||||||
|
padding: 0.2rem 0.5rem; |
||||||
|
border-radius: 3px; |
||||||
|
font-weight: bold; |
||||||
|
font-size: 0.8rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-status.open { |
||||||
|
background: #d4edda; |
||||||
|
color: #155724; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-status.closed { |
||||||
|
background: #f8d7da; |
||||||
|
color: #721c24; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-status.merged { |
||||||
|
background: #d1ecf1; |
||||||
|
color: #0c5460; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-body { |
||||||
|
margin-bottom: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-description { |
||||||
|
padding: 1rem; |
||||||
|
background: #f5f5f5; |
||||||
|
border-radius: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
.pr-content { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: 1fr 1fr; |
||||||
|
gap: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.code-section, .highlights-section { |
||||||
|
border: 1px solid #e0e0e0; |
||||||
|
border-radius: 4px; |
||||||
|
padding: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.code-section h3, .highlights-section h3 { |
||||||
|
margin-top: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.diff-viewer { |
||||||
|
height: 500px; |
||||||
|
border: 1px solid #ddd; |
||||||
|
border-radius: 4px; |
||||||
|
overflow: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.section-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.add-comment-btn, .reply-btn { |
||||||
|
padding: 0.4rem 0.8rem; |
||||||
|
background: #007bff; |
||||||
|
color: white; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
font-size: 0.9rem; |
||||||
|
} |
||||||
|
|
||||||
|
.add-comment-btn:hover, .reply-btn:hover { |
||||||
|
background: #0056b3; |
||||||
|
} |
||||||
|
|
||||||
|
.highlight-item, .comment-item { |
||||||
|
margin-bottom: 1.5rem; |
||||||
|
padding: 1rem; |
||||||
|
background: #f9f9f9; |
||||||
|
border-radius: 4px; |
||||||
|
border-left: 3px solid #007bff; |
||||||
|
} |
||||||
|
|
||||||
|
.comment-item.nested { |
||||||
|
margin-left: 2rem; |
||||||
|
margin-top: 0.5rem; |
||||||
|
border-left-color: #28a745; |
||||||
|
} |
||||||
|
|
||||||
|
.highlight-header, .comment-header { |
||||||
|
display: flex; |
||||||
|
gap: 1rem; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
font-size: 0.9rem; |
||||||
|
color: #666; |
||||||
|
} |
||||||
|
|
||||||
|
.highlight-author, .comment-author { |
||||||
|
font-weight: bold; |
||||||
|
color: #333; |
||||||
|
} |
||||||
|
|
||||||
|
.highlighted-code { |
||||||
|
background: #fff; |
||||||
|
padding: 0.5rem; |
||||||
|
border-radius: 3px; |
||||||
|
margin: 0.5rem 0; |
||||||
|
} |
||||||
|
|
||||||
|
.highlighted-code pre { |
||||||
|
margin: 0; |
||||||
|
font-size: 0.9rem; |
||||||
|
} |
||||||
|
|
||||||
|
.highlight-comment, .comment-content { |
||||||
|
margin: 0.5rem 0; |
||||||
|
padding: 0.5rem; |
||||||
|
background: white; |
||||||
|
border-radius: 3px; |
||||||
|
} |
||||||
|
|
||||||
|
.highlight-comments { |
||||||
|
margin-top: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-overlay { |
||||||
|
position: fixed; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
background: rgba(0, 0, 0, 0.5); |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
z-index: 1000; |
||||||
|
} |
||||||
|
|
||||||
|
.modal { |
||||||
|
background: white; |
||||||
|
padding: 2rem; |
||||||
|
border-radius: 8px; |
||||||
|
max-width: 600px; |
||||||
|
width: 90%; |
||||||
|
max-height: 80vh; |
||||||
|
overflow-y: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.selected-code { |
||||||
|
background: #f5f5f5; |
||||||
|
padding: 1rem; |
||||||
|
border-radius: 4px; |
||||||
|
margin-bottom: 1rem; |
||||||
|
max-height: 200px; |
||||||
|
overflow: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.selected-code pre { |
||||||
|
margin: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.modal-actions { |
||||||
|
display: flex; |
||||||
|
gap: 1rem; |
||||||
|
justify-content: flex-end; |
||||||
|
margin-top: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.cancel-btn, .save-btn { |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
border: none; |
||||||
|
border-radius: 4px; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.cancel-btn { |
||||||
|
background: #6c757d; |
||||||
|
color: white; |
||||||
|
} |
||||||
|
|
||||||
|
.save-btn { |
||||||
|
background: #007bff; |
||||||
|
color: white; |
||||||
|
} |
||||||
|
|
||||||
|
.save-btn:disabled { |
||||||
|
background: #ccc; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
|
||||||
|
.loading, .empty { |
||||||
|
padding: 2rem; |
||||||
|
text-align: center; |
||||||
|
color: #666; |
||||||
|
} |
||||||
|
|
||||||
|
.error { |
||||||
|
padding: 1rem; |
||||||
|
background: #f8d7da; |
||||||
|
color: #721c24; |
||||||
|
border-radius: 4px; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,384 @@ |
|||||||
|
/** |
||||||
|
* Service for managing NIP-84 Highlights (kind 9802) |
||||||
|
* Used for code selections and comments in pull requests |
||||||
|
*/ |
||||||
|
|
||||||
|
import { NostrClient } from './nostr-client.js'; |
||||||
|
import { KIND } from '../../types/nostr.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { verifyEvent } from 'nostr-tools'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
export interface Highlight extends NostrEvent { |
||||||
|
kind: typeof KIND.HIGHLIGHT; |
||||||
|
highlightedContent: string; |
||||||
|
sourceUrl?: string; |
||||||
|
sourceEventId?: string; |
||||||
|
sourceEventAddress?: string; |
||||||
|
context?: string; |
||||||
|
authors?: Array<{ pubkey: string; role?: string }>; |
||||||
|
comment?: string; // If present, this is a quote highlight
|
||||||
|
file?: string; |
||||||
|
lineStart?: number; |
||||||
|
lineEnd?: number; |
||||||
|
} |
||||||
|
|
||||||
|
export interface HighlightWithComments extends Highlight { |
||||||
|
comments: Comment[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Comment extends NostrEvent { |
||||||
|
kind: typeof KIND.COMMENT; |
||||||
|
rootKind: number; |
||||||
|
parentKind: number; |
||||||
|
rootPubkey?: string; |
||||||
|
parentPubkey?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Service for managing highlights and comments |
||||||
|
*/ |
||||||
|
export class HighlightsService { |
||||||
|
private nostrClient: NostrClient; |
||||||
|
private relays: string[]; |
||||||
|
|
||||||
|
constructor(relays: string[] = []) { |
||||||
|
this.relays = relays; |
||||||
|
this.nostrClient = new NostrClient(relays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get repository announcement address (a tag format) |
||||||
|
*/ |
||||||
|
private getRepoAddress(repoOwnerPubkey: string, repoId: string): string { |
||||||
|
return `30617:${repoOwnerPubkey}:${repoId}`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get PR address (a tag format for PR) |
||||||
|
*/ |
||||||
|
private getPRAddress(prId: string, prAuthor: string, repoOwnerPubkey: string, repoId: string): string { |
||||||
|
return `1618:${prAuthor}:${repoId}`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch highlights for a pull request |
||||||
|
*/ |
||||||
|
async getHighlightsForPR( |
||||||
|
prId: string, |
||||||
|
prAuthor: string, |
||||||
|
repoOwnerPubkey: string, |
||||||
|
repoId: string |
||||||
|
): Promise<HighlightWithComments[]> { |
||||||
|
const prAddress = this.getPRAddress(prId, prAuthor, repoOwnerPubkey, repoId); |
||||||
|
|
||||||
|
// Fetch highlights that reference this PR
|
||||||
|
const highlights = await this.nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.HIGHLIGHT], |
||||||
|
'#a': [prAddress], |
||||||
|
limit: 100 |
||||||
|
} |
||||||
|
]) as Highlight[]; |
||||||
|
|
||||||
|
// Also fetch highlights that reference the PR by event ID
|
||||||
|
const highlightsByEvent = await this.nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.HIGHLIGHT], |
||||||
|
'#e': [prId], |
||||||
|
limit: 100 |
||||||
|
} |
||||||
|
]) as Highlight[]; |
||||||
|
|
||||||
|
// Combine and deduplicate
|
||||||
|
const allHighlights = [...highlights, ...highlightsByEvent]; |
||||||
|
const uniqueHighlights = new Map<string, Highlight>(); |
||||||
|
for (const highlight of allHighlights) { |
||||||
|
if (!uniqueHighlights.has(highlight.id) || highlight.created_at > uniqueHighlights.get(highlight.id)!.created_at) { |
||||||
|
uniqueHighlights.set(highlight.id, highlight); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Parse highlights
|
||||||
|
const parsedHighlights: Highlight[] = []; |
||||||
|
for (const event of Array.from(uniqueHighlights.values())) { |
||||||
|
const highlight = this.parseHighlight(event); |
||||||
|
if (highlight) { |
||||||
|
parsedHighlights.push(highlight); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch comments for each highlight
|
||||||
|
const highlightsWithComments: HighlightWithComments[] = []; |
||||||
|
for (const highlight of parsedHighlights) { |
||||||
|
const comments = await this.getCommentsForHighlight(highlight.id); |
||||||
|
highlightsWithComments.push({ |
||||||
|
...highlight, |
||||||
|
comments |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Sort by created_at descending (newest first)
|
||||||
|
highlightsWithComments.sort((a, b) => b.created_at - a.created_at); |
||||||
|
|
||||||
|
return highlightsWithComments; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse a highlight event |
||||||
|
*/ |
||||||
|
private parseHighlight(event: NostrEvent): Highlight | null { |
||||||
|
if (event.kind !== KIND.HIGHLIGHT) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
if (!verifyEvent(event)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Extract source references
|
||||||
|
const aTag = event.tags.find(t => t[0] === 'a'); |
||||||
|
const eTag = event.tags.find(t => t[0] === 'e'); |
||||||
|
const rTag = event.tags.find(t => t[0] === 'r' && !t[2]?.includes('mention')); |
||||||
|
const contextTag = event.tags.find(t => t[0] === 'context'); |
||||||
|
const commentTag = event.tags.find(t => t[0] === 'comment'); |
||||||
|
|
||||||
|
// Extract authors
|
||||||
|
const authors: Array<{ pubkey: string; role?: string }> = []; |
||||||
|
for (const tag of event.tags) { |
||||||
|
if (tag[0] === 'p' && !tag[2]?.includes('mention')) { |
||||||
|
let pubkey = tag[1]; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(pubkey); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
pubkey = decoded.data as string; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Assume it's already hex
|
||||||
|
} |
||||||
|
authors.push({ |
||||||
|
pubkey, |
||||||
|
role: tag[3] // role is in 4th position
|
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Extract file path and line numbers
|
||||||
|
const fileTag = event.tags.find(t => t[0] === 'file'); |
||||||
|
const lineStartTag = event.tags.find(t => t[0] === 'line-start'); |
||||||
|
const lineEndTag = event.tags.find(t => t[0] === 'line-end'); |
||||||
|
|
||||||
|
return { |
||||||
|
...event, |
||||||
|
kind: KIND.HIGHLIGHT, |
||||||
|
highlightedContent: event.content, |
||||||
|
sourceEventAddress: aTag?.[1], |
||||||
|
sourceEventId: eTag?.[1], |
||||||
|
sourceUrl: rTag?.[1], |
||||||
|
context: contextTag?.[1], |
||||||
|
authors: authors.length > 0 ? authors : undefined, |
||||||
|
comment: commentTag?.[1], |
||||||
|
file: fileTag?.[1], |
||||||
|
lineStart: lineStartTag ? parseInt(lineStartTag[1]) : undefined, |
||||||
|
lineEnd: lineEndTag ? parseInt(lineEndTag[1]) : undefined |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get comments for a highlight or PR |
||||||
|
*/ |
||||||
|
async getCommentsForHighlight(highlightId: string): Promise<Comment[]> { |
||||||
|
const comments = await this.nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.COMMENT], |
||||||
|
'#e': [highlightId], |
||||||
|
limit: 100 |
||||||
|
} |
||||||
|
]) as NostrEvent[]; |
||||||
|
|
||||||
|
const parsedComments: Comment[] = []; |
||||||
|
for (const event of comments) { |
||||||
|
if (!verifyEvent(event)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Parse NIP-22 comment structure
|
||||||
|
const kTag = event.tags.find(t => t[0] === 'k'); // Parent kind
|
||||||
|
const KTag = event.tags.find(t => t[0] === 'K'); // Root kind
|
||||||
|
const pTag = event.tags.find(t => t[0] === 'p'); // Parent author
|
||||||
|
const PTag = event.tags.find(t => t[0] === 'P'); // Root author
|
||||||
|
|
||||||
|
parsedComments.push({ |
||||||
|
...event, |
||||||
|
kind: KIND.COMMENT, |
||||||
|
rootKind: KTag ? parseInt(KTag[1]) : 0, |
||||||
|
parentKind: kTag ? parseInt(kTag[1]) : 0, |
||||||
|
rootPubkey: PTag?.[1], |
||||||
|
parentPubkey: pTag?.[1] |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Sort by created_at ascending (oldest first)
|
||||||
|
parsedComments.sort((a, b) => a.created_at - b.created_at); |
||||||
|
|
||||||
|
return parsedComments; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get comments for a pull request |
||||||
|
*/ |
||||||
|
async getCommentsForPR(prId: string): Promise<Comment[]> { |
||||||
|
const comments = await this.nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.COMMENT], |
||||||
|
'#E': [prId], // Root event (uppercase E)
|
||||||
|
limit: 100 |
||||||
|
} |
||||||
|
]) as NostrEvent[]; |
||||||
|
|
||||||
|
const parsedComments: Comment[] = []; |
||||||
|
for (const event of comments) { |
||||||
|
if (!verifyEvent(event)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
const kTag = event.tags.find(t => t[0] === 'k'); |
||||||
|
const KTag = event.tags.find(t => t[0] === 'K'); |
||||||
|
const pTag = event.tags.find(t => t[0] === 'p'); |
||||||
|
const PTag = event.tags.find(t => t[0] === 'P'); |
||||||
|
|
||||||
|
parsedComments.push({ |
||||||
|
...event, |
||||||
|
kind: KIND.COMMENT, |
||||||
|
rootKind: KTag ? parseInt(KTag[1]) : 0, |
||||||
|
parentKind: kTag ? parseInt(kTag[1]) : 0, |
||||||
|
rootPubkey: PTag?.[1], |
||||||
|
parentPubkey: pTag?.[1] |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
parsedComments.sort((a, b) => a.created_at - b.created_at); |
||||||
|
return parsedComments; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a highlight event template |
||||||
|
*
|
||||||
|
* @param highlightedContent - The selected code/text content |
||||||
|
* @param prId - Pull request event ID |
||||||
|
* @param prAuthor - PR author pubkey |
||||||
|
* @param repoOwnerPubkey - Repository owner pubkey |
||||||
|
* @param repoId - Repository identifier |
||||||
|
* @param filePath - Path to the file being highlighted |
||||||
|
* @param lineStart - Starting line number (optional) |
||||||
|
* @param lineEnd - Ending line number (optional) |
||||||
|
* @param context - Surrounding context (optional) |
||||||
|
* @param comment - Comment text (optional, creates quote highlight) |
||||||
|
*/ |
||||||
|
createHighlightEvent( |
||||||
|
highlightedContent: string, |
||||||
|
prId: string, |
||||||
|
prAuthor: string, |
||||||
|
repoOwnerPubkey: string, |
||||||
|
repoId: string, |
||||||
|
filePath?: string, |
||||||
|
lineStart?: number, |
||||||
|
lineEnd?: number, |
||||||
|
context?: string, |
||||||
|
comment?: string |
||||||
|
): Omit<NostrEvent, 'sig' | 'id'> { |
||||||
|
const prAddress = `1618:${prAuthor}:${repoId}`; |
||||||
|
|
||||||
|
const tags: string[][] = [ |
||||||
|
['a', prAddress], // Reference to PR
|
||||||
|
['e', prId], // PR event ID
|
||||||
|
['P', prAuthor], // PR author
|
||||||
|
['K', KIND.PULL_REQUEST.toString()], // Root kind
|
||||||
|
]; |
||||||
|
|
||||||
|
// Add file path and line numbers if provided
|
||||||
|
if (filePath) { |
||||||
|
tags.push(['file', filePath]); |
||||||
|
} |
||||||
|
if (lineStart !== undefined) { |
||||||
|
tags.push(['line-start', lineStart.toString()]); |
||||||
|
} |
||||||
|
if (lineEnd !== undefined) { |
||||||
|
tags.push(['line-end', lineEnd.toString()]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add context if provided
|
||||||
|
if (context) { |
||||||
|
tags.push(['context', context]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add comment if provided (creates quote highlight)
|
||||||
|
if (comment) { |
||||||
|
tags.push(['comment', comment]); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
kind: KIND.HIGHLIGHT, |
||||||
|
pubkey: '', // Will be filled by signer
|
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
content: highlightedContent, |
||||||
|
tags |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a comment event template (NIP-22) |
||||||
|
*
|
||||||
|
* @param content - Comment text |
||||||
|
* @param rootEventId - Root event ID (PR or highlight) |
||||||
|
* @param rootEventKind - Root event kind |
||||||
|
* @param rootPubkey - Root event author pubkey |
||||||
|
* @param parentEventId - Parent event ID (for replies) |
||||||
|
* @param parentEventKind - Parent event kind |
||||||
|
* @param parentPubkey - Parent event author pubkey |
||||||
|
* @param rootEventAddress - Root event address (optional, for replaceable events) |
||||||
|
*/ |
||||||
|
createCommentEvent( |
||||||
|
content: string, |
||||||
|
rootEventId: string, |
||||||
|
rootEventKind: number, |
||||||
|
rootPubkey: string, |
||||||
|
parentEventId?: string, |
||||||
|
parentEventKind?: number, |
||||||
|
parentPubkey?: string, |
||||||
|
rootEventAddress?: string |
||||||
|
): Omit<NostrEvent, 'sig' | 'id'> { |
||||||
|
const tags: string[][] = [ |
||||||
|
['E', rootEventId, '', rootPubkey], // Root event
|
||||||
|
['K', rootEventKind.toString()], // Root kind
|
||||||
|
['P', rootPubkey], // Root author
|
||||||
|
]; |
||||||
|
|
||||||
|
// Add root event address if provided (for replaceable events)
|
||||||
|
if (rootEventAddress) { |
||||||
|
tags.push(['A', rootEventAddress]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add parent references (for replies)
|
||||||
|
if (parentEventId) { |
||||||
|
tags.push(['e', parentEventId, '', parentPubkey || rootPubkey]); |
||||||
|
tags.push(['k', (parentEventKind || rootEventKind).toString()]); |
||||||
|
if (parentPubkey) { |
||||||
|
tags.push(['p', parentPubkey]); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Top-level comment - parent is same as root
|
||||||
|
tags.push(['e', rootEventId, '', rootPubkey]); |
||||||
|
tags.push(['k', rootEventKind.toString()]); |
||||||
|
tags.push(['p', rootPubkey]); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
kind: KIND.COMMENT, |
||||||
|
pubkey: '', // Will be filled by signer
|
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
content: content, |
||||||
|
tags |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,207 @@ |
|||||||
|
/** |
||||||
|
* NIP-98 HTTP Authentication service |
||||||
|
* Implements NIP-98 for authenticating HTTP requests using Nostr events |
||||||
|
*/ |
||||||
|
|
||||||
|
import { verifyEvent } from 'nostr-tools'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { createHash } from 'crypto'; |
||||||
|
|
||||||
|
export interface NIP98AuthResult { |
||||||
|
valid: boolean; |
||||||
|
error?: string; |
||||||
|
event?: NostrEvent; |
||||||
|
pubkey?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Verify NIP-98 authentication from Authorization header |
||||||
|
*
|
||||||
|
* @param authHeader - The Authorization header value (should start with "Nostr ") |
||||||
|
* @param requestUrl - The absolute request URL (including query parameters) |
||||||
|
* @param requestMethod - The HTTP method (GET, POST, etc.) |
||||||
|
* @param requestBody - Optional request body for payload verification |
||||||
|
* @returns Authentication result with validation status |
||||||
|
*/ |
||||||
|
export function verifyNIP98Auth( |
||||||
|
authHeader: string | null, |
||||||
|
requestUrl: string, |
||||||
|
requestMethod: string, |
||||||
|
requestBody?: ArrayBuffer | Buffer | string |
||||||
|
): NIP98AuthResult { |
||||||
|
// Check Authorization header format
|
||||||
|
if (!authHeader || !authHeader.startsWith('Nostr ')) { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
error: 'Missing or invalid Authorization header. Expected format: "Nostr <base64-encoded-event>"' |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Decode base64 event
|
||||||
|
const base64Event = authHeader.slice(7); // Remove "Nostr " prefix
|
||||||
|
const eventJson = Buffer.from(base64Event, 'base64').toString('utf-8'); |
||||||
|
const nostrEvent: NostrEvent = JSON.parse(eventJson); |
||||||
|
|
||||||
|
// Validate kind (must be 27235)
|
||||||
|
if (nostrEvent.kind !== 27235) { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
error: `Invalid event kind. Expected 27235, got ${nostrEvent.kind}` |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Validate content is empty (SHOULD be empty per spec)
|
||||||
|
if (nostrEvent.content && nostrEvent.content.trim() !== '') { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
error: 'Event content should be empty for NIP-98 authentication' |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Verify event signature
|
||||||
|
if (!verifyEvent(nostrEvent)) { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
error: 'Invalid event signature' |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Check created_at timestamp (within 60 seconds per spec)
|
||||||
|
const now = Math.floor(Date.now() / 1000); |
||||||
|
const eventAge = now - nostrEvent.created_at; |
||||||
|
if (eventAge > 60) { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
error: 'Authentication event is too old (must be within 60 seconds)' |
||||||
|
}; |
||||||
|
} |
||||||
|
if (eventAge < 0) { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
error: 'Authentication event has future timestamp' |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Validate 'u' tag (must match exact request URL)
|
||||||
|
const uTag = nostrEvent.tags.find(t => t[0] === 'u'); |
||||||
|
if (!uTag || !uTag[1]) { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
error: "Missing 'u' tag in authentication event" |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Normalize URLs for comparison (remove trailing slashes, handle encoding)
|
||||||
|
const normalizeUrl = (url: string): string => { |
||||||
|
try { |
||||||
|
const parsed = new URL(url); |
||||||
|
// Remove trailing slash from pathname
|
||||||
|
parsed.pathname = parsed.pathname.replace(/\/$/, ''); |
||||||
|
return parsed.toString(); |
||||||
|
} catch { |
||||||
|
return url; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const eventUrl = normalizeUrl(uTag[1]); |
||||||
|
const requestUrlNormalized = normalizeUrl(requestUrl); |
||||||
|
|
||||||
|
if (eventUrl !== requestUrlNormalized) { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
error: `URL mismatch. Event URL: ${eventUrl}, Request URL: ${requestUrlNormalized}` |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Validate 'method' tag
|
||||||
|
const methodTag = nostrEvent.tags.find(t => t[0] === 'method'); |
||||||
|
if (!methodTag || !methodTag[1]) { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
error: "Missing 'method' tag in authentication event" |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
if (methodTag[1].toUpperCase() !== requestMethod.toUpperCase()) { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
error: `HTTP method mismatch. Event method: ${methodTag[1]}, Request method: ${requestMethod}` |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Validate 'payload' tag if present (for POST/PUT/PATCH with body)
|
||||||
|
if (requestBody && ['POST', 'PUT', 'PATCH'].includes(requestMethod.toUpperCase())) { |
||||||
|
const payloadTag = nostrEvent.tags.find(t => t[0] === 'payload'); |
||||||
|
if (payloadTag && payloadTag[1]) { |
||||||
|
// Calculate SHA256 of request body
|
||||||
|
const bodyBuffer = typeof requestBody === 'string'
|
||||||
|
? Buffer.from(requestBody, 'utf-8') |
||||||
|
: requestBody instanceof ArrayBuffer |
||||||
|
? Buffer.from(requestBody) |
||||||
|
: requestBody; |
||||||
|
|
||||||
|
const bodyHash = createHash('sha256').update(bodyBuffer).digest('hex'); |
||||||
|
|
||||||
|
if (payloadTag[1].toLowerCase() !== bodyHash.toLowerCase()) { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
error: `Payload hash mismatch. Expected: ${payloadTag[1]}, Calculated: ${bodyHash}` |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
valid: true, |
||||||
|
event: nostrEvent, |
||||||
|
pubkey: nostrEvent.pubkey |
||||||
|
}; |
||||||
|
} catch (err) { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
error: `Failed to parse or verify authentication: ${err instanceof Error ? err.message : String(err)}` |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a NIP-98 authentication event |
||||||
|
* This is a helper for clients to create properly formatted auth events |
||||||
|
*/ |
||||||
|
export function createNIP98AuthEvent( |
||||||
|
pubkey: string, |
||||||
|
url: string, |
||||||
|
method: string, |
||||||
|
bodyHash?: string |
||||||
|
): Omit<NostrEvent, 'sig' | 'id'> { |
||||||
|
const tags: string[][] = [ |
||||||
|
['u', url], |
||||||
|
['method', method.toUpperCase()] |
||||||
|
]; |
||||||
|
|
||||||
|
if (bodyHash) { |
||||||
|
tags.push(['payload', bodyHash]); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
kind: 27235, |
||||||
|
pubkey, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
content: '', |
||||||
|
tags |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Calculate SHA256 hash of request body for payload tag |
||||||
|
*/ |
||||||
|
export function calculateBodyHash(body: ArrayBuffer | Buffer | string): string { |
||||||
|
const bodyBuffer = typeof body === 'string'
|
||||||
|
? Buffer.from(body, 'utf-8') |
||||||
|
: body instanceof ArrayBuffer |
||||||
|
? Buffer.from(body) |
||||||
|
: body; |
||||||
|
|
||||||
|
return createHash('sha256').update(bodyBuffer).digest('hex'); |
||||||
|
} |
||||||
@ -0,0 +1,312 @@ |
|||||||
|
/** |
||||||
|
* Service for handling repository ownership transfers |
||||||
|
* Allows current owners to transfer ownership to another pubkey via Nostr events |
||||||
|
*/ |
||||||
|
|
||||||
|
import { NostrClient } from './nostr-client.js'; |
||||||
|
import { KIND } from '../../types/nostr.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { verifyEvent } from 'nostr-tools'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
export interface OwnershipTransfer { |
||||||
|
event: NostrEvent; |
||||||
|
fromPubkey: string; |
||||||
|
toPubkey: string; |
||||||
|
repoId: string; |
||||||
|
timestamp: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Service for managing repository ownership transfers |
||||||
|
*/ |
||||||
|
export class OwnershipTransferService { |
||||||
|
private nostrClient: NostrClient; |
||||||
|
private cache: Map<string, { owner: string; timestamp: number }> = new Map(); |
||||||
|
private cacheTTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
constructor(relays: string[]) { |
||||||
|
this.nostrClient = new NostrClient(relays); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get the current owner of a repository, checking for ownership transfers |
||||||
|
* The initial ownership is proven by a self-transfer event (from owner to themselves) |
||||||
|
*
|
||||||
|
* @param originalOwnerPubkey - The original owner from the repo announcement |
||||||
|
* @param repoId - The repository identifier (d-tag) |
||||||
|
* @returns The current owner pubkey (may be different from original if transferred) |
||||||
|
*/ |
||||||
|
async getCurrentOwner(originalOwnerPubkey: string, repoId: string): Promise<string> { |
||||||
|
const cacheKey = `${originalOwnerPubkey}:${repoId}`; |
||||||
|
const cached = this.cache.get(cacheKey); |
||||||
|
|
||||||
|
// Return cached if still valid
|
||||||
|
if (cached && Date.now() - cached.timestamp < this.cacheTTL) { |
||||||
|
return cached.owner; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Fetch all ownership transfer events for this repo
|
||||||
|
// We use the 'a' tag to reference the repo announcement
|
||||||
|
const repoTag = `30617:${originalOwnerPubkey}:${repoId}`; |
||||||
|
|
||||||
|
const transferEvents = await this.nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.OWNERSHIP_TRANSFER], |
||||||
|
'#a': [repoTag], |
||||||
|
limit: 100 // Get all transfers to find the most recent valid one
|
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
if (transferEvents.length === 0) { |
||||||
|
// No transfer events found - check if there's a self-transfer from the original owner
|
||||||
|
// This would be the initial ownership proof
|
||||||
|
// For now, if no transfers exist, we fall back to original owner
|
||||||
|
// In the future, we might require a self-transfer event for initial ownership
|
||||||
|
const result = originalOwnerPubkey; |
||||||
|
this.cache.set(cacheKey, { owner: result, timestamp: Date.now() }); |
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
// Sort by created_at ascending to process in chronological order
|
||||||
|
transferEvents.sort((a, b) => a.created_at - b.created_at); |
||||||
|
|
||||||
|
// Start with original owner, then apply transfers in chronological order
|
||||||
|
let currentOwner = originalOwnerPubkey; |
||||||
|
const validTransfers: OwnershipTransfer[] = []; |
||||||
|
|
||||||
|
// Collect all valid transfers (including self-transfers for initial ownership proof)
|
||||||
|
for (const event of transferEvents) { |
||||||
|
const transfer = this.parseTransferEvent(event, originalOwnerPubkey, repoId); |
||||||
|
if (transfer && this.isValidTransfer(transfer, originalOwnerPubkey, validTransfers)) { |
||||||
|
validTransfers.push(transfer); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Apply transfers in chronological order
|
||||||
|
for (const transfer of validTransfers) { |
||||||
|
// Verify the transfer is from the current owner
|
||||||
|
// Self-transfers (from == to) don't change ownership but establish initial proof
|
||||||
|
if (transfer.fromPubkey === currentOwner) { |
||||||
|
// Only change owner if it's not a self-transfer
|
||||||
|
if (transfer.fromPubkey !== transfer.toPubkey) { |
||||||
|
currentOwner = transfer.toPubkey; |
||||||
|
} |
||||||
|
// Self-transfers are valid but don't change ownership
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
this.cache.set(cacheKey, { owner: currentOwner, timestamp: Date.now() }); |
||||||
|
return currentOwner; |
||||||
|
} catch (error) { |
||||||
|
console.error('Error fetching ownership transfers:', error); |
||||||
|
// Fallback to original owner
|
||||||
|
return originalOwnerPubkey; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse an ownership transfer event |
||||||
|
*/ |
||||||
|
private parseTransferEvent( |
||||||
|
event: NostrEvent, |
||||||
|
originalOwnerPubkey: string, |
||||||
|
repoId: string |
||||||
|
): OwnershipTransfer | null { |
||||||
|
// Verify event signature
|
||||||
|
if (!verifyEvent(event)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Check that it's an ownership transfer event
|
||||||
|
if (event.kind !== KIND.OWNERSHIP_TRANSFER) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Extract 'a' tag (repo reference)
|
||||||
|
const aTag = event.tags.find(t => t[0] === 'a'); |
||||||
|
if (!aTag || !aTag[1]) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Verify 'a' tag matches this repo
|
||||||
|
const expectedRepoTag = `30617:${originalOwnerPubkey}:${repoId}`; |
||||||
|
if (aTag[1] !== expectedRepoTag) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Extract 'p' tag (new owner)
|
||||||
|
const pTag = event.tags.find(t => t[0] === 'p'); |
||||||
|
if (!pTag || !pTag[1]) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Decode npub if needed
|
||||||
|
let toPubkey = pTag[1]; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(toPubkey); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
toPubkey = decoded.data as string; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Assume it's already a hex pubkey
|
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
event, |
||||||
|
fromPubkey: event.pubkey, // Transfer is signed by current owner
|
||||||
|
toPubkey, |
||||||
|
repoId, |
||||||
|
timestamp: event.created_at |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate that a transfer is valid |
||||||
|
* A transfer is valid if: |
||||||
|
* 1. It's signed by the current owner (at the time of transfer) |
||||||
|
* 2. The event is properly formatted |
||||||
|
* 3. Self-transfers (from owner to themselves) are valid for initial ownership proof |
||||||
|
*
|
||||||
|
* @param transfer - The transfer to validate |
||||||
|
* @param originalOwnerPubkey - The original owner from repo announcement |
||||||
|
* @param previousTransfers - Previously validated transfers (for chain verification) |
||||||
|
*/ |
||||||
|
private isValidTransfer( |
||||||
|
transfer: OwnershipTransfer, |
||||||
|
originalOwnerPubkey: string, |
||||||
|
previousTransfers: OwnershipTransfer[] = [] |
||||||
|
): boolean { |
||||||
|
// Self-transfers are valid (from owner to themselves) - used for initial ownership proof
|
||||||
|
if (transfer.fromPubkey === transfer.toPubkey) { |
||||||
|
// Self-transfer must be from the original owner (initial ownership proof)
|
||||||
|
// or from a current owner (re-asserting ownership)
|
||||||
|
return transfer.fromPubkey === originalOwnerPubkey ||
|
||||||
|
previousTransfers.some(t => t.toPubkey === transfer.fromPubkey); |
||||||
|
} |
||||||
|
|
||||||
|
// Regular transfers must be from a valid owner
|
||||||
|
// Check if the fromPubkey is the original owner or a previous transfer recipient
|
||||||
|
const isValidFrom = transfer.fromPubkey === originalOwnerPubkey || |
||||||
|
previousTransfers.some(t => t.toPubkey === transfer.fromPubkey); |
||||||
|
|
||||||
|
// Also check basic format
|
||||||
|
const validFormat = transfer.fromPubkey.length === 64 &&
|
||||||
|
transfer.toPubkey.length === 64; |
||||||
|
|
||||||
|
return isValidFrom && validFormat; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create an ownership transfer event template |
||||||
|
*
|
||||||
|
* @param fromPubkey - Current owner's pubkey |
||||||
|
* @param toPubkey - New owner's pubkey (hex or npub). If same as fromPubkey, creates a self-transfer (initial ownership proof) |
||||||
|
* @param originalOwnerPubkey - Original owner from repo announcement |
||||||
|
* @param repoId - Repository identifier (d-tag) |
||||||
|
* @returns Event template ready to be signed |
||||||
|
*/ |
||||||
|
createTransferEvent( |
||||||
|
fromPubkey: string, |
||||||
|
toPubkey: string, |
||||||
|
originalOwnerPubkey: string, |
||||||
|
repoId: string |
||||||
|
): Omit<NostrEvent, 'sig' | 'id'> { |
||||||
|
// Decode npub if needed
|
||||||
|
let toPubkeyHex = toPubkey; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(toPubkey); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
toPubkeyHex = decoded.data as string; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Assume it's already a hex pubkey
|
||||||
|
} |
||||||
|
|
||||||
|
const repoTag = `30617:${originalOwnerPubkey}:${repoId}`; |
||||||
|
const isSelfTransfer = fromPubkey === toPubkeyHex; |
||||||
|
const content = isSelfTransfer |
||||||
|
? `Initial ownership proof for repository ${repoId}` |
||||||
|
: `Transferring ownership of repository ${repoId} to ${toPubkeyHex}`; |
||||||
|
|
||||||
|
return { |
||||||
|
kind: KIND.OWNERSHIP_TRANSFER, |
||||||
|
pubkey: fromPubkey, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
content: content, |
||||||
|
tags: [ |
||||||
|
['a', repoTag], // Reference to repo announcement
|
||||||
|
['p', toPubkeyHex], // New owner (or same owner for self-transfer)
|
||||||
|
['d', repoId], // Repository identifier
|
||||||
|
...(isSelfTransfer ? [['t', 'self-transfer']] : []), // Tag to indicate self-transfer
|
||||||
|
] |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create an initial ownership proof event (self-transfer) |
||||||
|
* This should be created when a repository is first announced |
||||||
|
*
|
||||||
|
* @param ownerPubkey - Owner's pubkey |
||||||
|
* @param repoId - Repository identifier (d-tag) |
||||||
|
* @returns Event template ready to be signed |
||||||
|
*/ |
||||||
|
createInitialOwnershipEvent( |
||||||
|
ownerPubkey: string, |
||||||
|
repoId: string |
||||||
|
): Omit<NostrEvent, 'sig' | 'id'> { |
||||||
|
return this.createTransferEvent(ownerPubkey, ownerPubkey, ownerPubkey, repoId); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Verify that a user can initiate a transfer (must be current owner) |
||||||
|
*/ |
||||||
|
async canTransfer(userPubkey: string, originalOwnerPubkey: string, repoId: string): Promise<boolean> { |
||||||
|
const currentOwner = await this.getCurrentOwner(originalOwnerPubkey, repoId); |
||||||
|
return currentOwner === userPubkey; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear cache for a repository (useful after ownership changes) |
||||||
|
*/ |
||||||
|
clearCache(originalOwnerPubkey: string, repoId: string): void { |
||||||
|
const cacheKey = `${originalOwnerPubkey}:${repoId}`; |
||||||
|
this.cache.delete(cacheKey); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get all valid ownership transfers for a repository (for history/display) |
||||||
|
*/ |
||||||
|
async getTransferHistory(originalOwnerPubkey: string, repoId: string): Promise<OwnershipTransfer[]> { |
||||||
|
try { |
||||||
|
const repoTag = `30617:${originalOwnerPubkey}:${repoId}`; |
||||||
|
|
||||||
|
const transferEvents = await this.nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.OWNERSHIP_TRANSFER], |
||||||
|
'#a': [repoTag], |
||||||
|
limit: 100 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
const transfers: OwnershipTransfer[] = []; |
||||||
|
// Sort by timestamp to validate in order
|
||||||
|
const sortedEvents = [...transferEvents].sort((a, b) => a.created_at - b.created_at); |
||||||
|
|
||||||
|
for (const event of sortedEvents) { |
||||||
|
const transfer = this.parseTransferEvent(event, originalOwnerPubkey, repoId); |
||||||
|
if (transfer && this.isValidTransfer(transfer, originalOwnerPubkey, transfers)) { |
||||||
|
transfers.push(transfer); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Sort by timestamp descending (most recent first)
|
||||||
|
transfers.sort((a, b) => b.timestamp - a.timestamp); |
||||||
|
return transfers; |
||||||
|
} catch (error) { |
||||||
|
console.error('Error fetching transfer history:', error); |
||||||
|
return []; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
/** |
||||||
|
* Helper utilities for checking repository privacy |
||||||
|
*/ |
||||||
|
|
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { MaintainerService } from '../services/nostr/maintainer-service.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '../config.js'; |
||||||
|
|
||||||
|
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a user can view a repository |
||||||
|
* Returns the repo owner pubkey and whether access is allowed |
||||||
|
*/ |
||||||
|
export async function checkRepoAccess( |
||||||
|
npub: string, |
||||||
|
repo: string, |
||||||
|
userPubkey: string | null |
||||||
|
): Promise<{ allowed: boolean; repoOwnerPubkey: string; error?: string }> { |
||||||
|
try { |
||||||
|
// Decode npub to get pubkey
|
||||||
|
let repoOwnerPubkey: string; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
repoOwnerPubkey = decoded.data as string; |
||||||
|
} else { |
||||||
|
return { allowed: false, repoOwnerPubkey: '', error: 'Invalid npub format' }; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return { allowed: false, repoOwnerPubkey: '', error: 'Invalid npub format' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if user can view
|
||||||
|
const canView = await maintainerService.canView(userPubkey, repoOwnerPubkey, repo); |
||||||
|
|
||||||
|
return { |
||||||
|
allowed: canView, |
||||||
|
repoOwnerPubkey, |
||||||
|
...(canView ? {} : { error: 'This repository is private. Only owners and maintainers can view it.' }) |
||||||
|
}; |
||||||
|
} catch (error) { |
||||||
|
return { |
||||||
|
allowed: false, |
||||||
|
repoOwnerPubkey: '', |
||||||
|
error: error instanceof Error ? error.message : 'Failed to check repository access' |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,129 @@ |
|||||||
|
/** |
||||||
|
* API endpoint for Highlights (NIP-84 kind 9802) and Comments (NIP-22 kind 1111) |
||||||
|
*/ |
||||||
|
|
||||||
|
import { json, error } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { HighlightsService } from '$lib/services/nostr/highlights-service.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { verifyEvent } from 'nostr-tools'; |
||||||
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||||
|
import { combineRelays } from '$lib/config.js'; |
||||||
|
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
||||||
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||||
|
|
||||||
|
const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS); |
||||||
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
|
||||||
|
/** |
||||||
|
* GET - Get highlights for a pull request |
||||||
|
* Query params: prId, prAuthor |
||||||
|
*/ |
||||||
|
export const GET: RequestHandler = async ({ params, url }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
const prId = url.searchParams.get('prId'); |
||||||
|
const prAuthor = url.searchParams.get('prAuthor'); |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
return error(400, 'Missing npub or repo parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
if (!prId || !prAuthor) { |
||||||
|
return error(400, 'Missing prId or prAuthor parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Decode npub to get pubkey
|
||||||
|
let repoOwnerPubkey: string; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
repoOwnerPubkey = decoded.data as string; |
||||||
|
} else { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
|
||||||
|
// Decode prAuthor if it's an npub
|
||||||
|
let prAuthorPubkey = prAuthor; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(prAuthor); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
prAuthorPubkey = decoded.data as string; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Assume it's already hex
|
||||||
|
} |
||||||
|
|
||||||
|
// Get highlights for the PR
|
||||||
|
const highlights = await highlightsService.getHighlightsForPR( |
||||||
|
prId, |
||||||
|
prAuthorPubkey, |
||||||
|
repoOwnerPubkey, |
||||||
|
repo |
||||||
|
); |
||||||
|
|
||||||
|
// Also get top-level comments on the PR
|
||||||
|
const prComments = await highlightsService.getCommentsForPR(prId); |
||||||
|
|
||||||
|
return json({ |
||||||
|
highlights, |
||||||
|
comments: prComments |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
console.error('Error fetching highlights:', err); |
||||||
|
return error(500, err instanceof Error ? err.message : 'Failed to fetch highlights'); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* POST - Create a highlight or comment |
||||||
|
* Body: { type: 'highlight' | 'comment', event, userPubkey } |
||||||
|
*/ |
||||||
|
export const POST: RequestHandler = async ({ params, request }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
return error(400, 'Missing npub or repo parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const body = await request.json(); |
||||||
|
const { type, event, userPubkey } = body; |
||||||
|
|
||||||
|
if (!type || !event || !userPubkey) { |
||||||
|
return error(400, 'Missing type, event, or userPubkey in request body'); |
||||||
|
} |
||||||
|
|
||||||
|
if (type !== 'highlight' && type !== 'comment') { |
||||||
|
return error(400, 'Type must be "highlight" or "comment"'); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify the event is properly signed
|
||||||
|
if (!event.sig || !event.id) { |
||||||
|
return error(400, 'Invalid event: missing signature or ID'); |
||||||
|
} |
||||||
|
|
||||||
|
if (!verifyEvent(event)) { |
||||||
|
return error(400, 'Invalid event signature'); |
||||||
|
} |
||||||
|
|
||||||
|
// Get user's relays and publish
|
||||||
|
const { outbox } = await getUserRelays(userPubkey, nostrClient); |
||||||
|
const combinedRelays = combineRelays(outbox); |
||||||
|
|
||||||
|
const result = await highlightsService['nostrClient'].publishEvent(event as NostrEvent, combinedRelays); |
||||||
|
|
||||||
|
if (result.failed.length > 0 && result.success.length === 0) { |
||||||
|
return error(500, 'Failed to publish to all relays'); |
||||||
|
} |
||||||
|
|
||||||
|
return json({ success: true, event, published: result }); |
||||||
|
} catch (err) { |
||||||
|
console.error('Error creating highlight/comment:', err); |
||||||
|
return error(500, err instanceof Error ? err.message : 'Failed to create highlight/comment'); |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,172 @@ |
|||||||
|
/** |
||||||
|
* API endpoint for transferring repository ownership |
||||||
|
*/ |
||||||
|
|
||||||
|
import { json, error } from '@sveltejs/kit'; |
||||||
|
import type { RequestHandler } from './$types'; |
||||||
|
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; |
||||||
|
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||||
|
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; |
||||||
|
import { KIND } from '$lib/types/nostr.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { verifyEvent } from 'nostr-tools'; |
||||||
|
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||||
|
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; |
||||||
|
|
||||||
|
const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); |
||||||
|
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||||
|
|
||||||
|
/** |
||||||
|
* GET - Get current owner and transfer history |
||||||
|
*/ |
||||||
|
export const GET: RequestHandler = async ({ params }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
return error(400, 'Missing npub or repo parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Decode npub to get pubkey
|
||||||
|
let originalOwnerPubkey: string; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
originalOwnerPubkey = decoded.data as string; |
||||||
|
} else { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
|
||||||
|
// Get current owner (may be different if transferred)
|
||||||
|
const currentOwner = await ownershipTransferService.getCurrentOwner(originalOwnerPubkey, repo); |
||||||
|
|
||||||
|
// Fetch transfer events for history
|
||||||
|
const repoTag = `30617:${originalOwnerPubkey}:${repo}`; |
||||||
|
const transferEvents = await nostrClient.fetchEvents([ |
||||||
|
{ |
||||||
|
kinds: [KIND.OWNERSHIP_TRANSFER], |
||||||
|
'#a': [repoTag], |
||||||
|
limit: 100 |
||||||
|
} |
||||||
|
]); |
||||||
|
|
||||||
|
// Sort by created_at descending
|
||||||
|
transferEvents.sort((a, b) => b.created_at - a.created_at); |
||||||
|
|
||||||
|
return json({ |
||||||
|
originalOwner: originalOwnerPubkey, |
||||||
|
currentOwner, |
||||||
|
transferred: currentOwner !== originalOwnerPubkey, |
||||||
|
transfers: transferEvents.map(event => { |
||||||
|
const pTag = event.tags.find(t => t[0] === 'p'); |
||||||
|
return { |
||||||
|
eventId: event.id, |
||||||
|
from: event.pubkey, |
||||||
|
to: pTag?.[1] || 'unknown', |
||||||
|
timestamp: event.created_at, |
||||||
|
createdAt: new Date(event.created_at * 1000).toISOString() |
||||||
|
}; |
||||||
|
}) |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
console.error('Error fetching ownership info:', err); |
||||||
|
return error(500, err instanceof Error ? err.message : 'Failed to fetch ownership info'); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* POST - Initiate ownership transfer |
||||||
|
* Requires a pre-signed NIP-98 authenticated event from the current owner |
||||||
|
*/ |
||||||
|
export const POST: RequestHandler = async ({ params, request }) => { |
||||||
|
const { npub, repo } = params; |
||||||
|
|
||||||
|
if (!npub || !repo) { |
||||||
|
return error(400, 'Missing npub or repo parameter'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const body = await request.json(); |
||||||
|
const { transferEvent, userPubkey } = body; |
||||||
|
|
||||||
|
if (!transferEvent || !userPubkey) { |
||||||
|
return error(400, 'Missing transferEvent or userPubkey in request body'); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify the event is properly signed
|
||||||
|
if (!transferEvent.sig || !transferEvent.id) { |
||||||
|
return error(400, 'Invalid event: missing signature or ID'); |
||||||
|
} |
||||||
|
|
||||||
|
if (!verifyEvent(transferEvent)) { |
||||||
|
return error(400, 'Invalid event signature'); |
||||||
|
} |
||||||
|
|
||||||
|
// Decode npub to get original owner pubkey
|
||||||
|
let originalOwnerPubkey: string; |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
originalOwnerPubkey = decoded.data as string; |
||||||
|
} else { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
} catch { |
||||||
|
return error(400, 'Invalid npub format'); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify user is the current owner
|
||||||
|
const canTransfer = await ownershipTransferService.canTransfer( |
||||||
|
userPubkey, |
||||||
|
originalOwnerPubkey, |
||||||
|
repo |
||||||
|
); |
||||||
|
|
||||||
|
if (!canTransfer) { |
||||||
|
return error(403, 'Only the current repository owner can transfer ownership'); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify the transfer event is from the current owner
|
||||||
|
if (transferEvent.pubkey !== userPubkey) { |
||||||
|
return error(403, 'Transfer event must be signed by the current owner'); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify it's an ownership transfer event
|
||||||
|
if (transferEvent.kind !== KIND.OWNERSHIP_TRANSFER) { |
||||||
|
return error(400, 'Event must be kind 30619 (ownership transfer)'); |
||||||
|
} |
||||||
|
|
||||||
|
// Verify the 'a' tag references this repo
|
||||||
|
const aTag = transferEvent.tags.find(t => t[0] === 'a'); |
||||||
|
const expectedRepoTag = `30617:${originalOwnerPubkey}:${repo}`; |
||||||
|
if (!aTag || aTag[1] !== expectedRepoTag) { |
||||||
|
return error(400, "Transfer event 'a' tag does not match this repository"); |
||||||
|
} |
||||||
|
|
||||||
|
// Get user's relays and publish
|
||||||
|
const { outbox } = await getUserRelays(userPubkey, nostrClient); |
||||||
|
const combinedRelays = combineRelays(outbox); |
||||||
|
|
||||||
|
const result = await nostrClient.publishEvent(transferEvent as NostrEvent, combinedRelays); |
||||||
|
|
||||||
|
if (result.success.length === 0) { |
||||||
|
return error(500, 'Failed to publish transfer event to any relays'); |
||||||
|
} |
||||||
|
|
||||||
|
// Clear cache so new owner is recognized immediately
|
||||||
|
ownershipTransferService.clearCache(originalOwnerPubkey, repo); |
||||||
|
|
||||||
|
return json({ |
||||||
|
success: true, |
||||||
|
event: transferEvent, |
||||||
|
published: result, |
||||||
|
message: 'Ownership transfer initiated successfully' |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
console.error('Error transferring ownership:', err); |
||||||
|
return error(500, err instanceof Error ? err.message : 'Failed to transfer ownership'); |
||||||
|
} |
||||||
|
}; |
||||||
Loading…
Reference in new issue