25 changed files with 2582 additions and 124 deletions
@ -0,0 +1,661 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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