Browse Source

repo management

main
Silberengel 4 weeks ago
parent
commit
56fe0b573a
  1. 29
      src/lib/components/CodeEditor.svelte
  2. 661
      src/lib/components/PRDetail.svelte
  3. 110
      src/lib/services/git/repo-manager.ts
  4. 384
      src/lib/services/nostr/highlights-service.ts
  5. 96
      src/lib/services/nostr/maintainer-service.ts
  6. 207
      src/lib/services/nostr/nip98-auth.ts
  7. 312
      src/lib/services/nostr/ownership-transfer-service.ts
  8. 90
      src/lib/services/nostr/relay-write-proof.ts
  9. 90
      src/lib/services/nostr/repo-polling.ts
  10. 4
      src/lib/types/nostr.ts
  11. 49
      src/lib/utils/repo-privacy.ts
  12. 13
      src/routes/+page.svelte
  13. 144
      src/routes/api/git/[...path]/+server.ts
  14. 10
      src/routes/api/repos/[npub]/[repo]/commits/+server.ts
  15. 10
      src/routes/api/repos/[npub]/[repo]/diff/+server.ts
  16. 21
      src/routes/api/repos/[npub]/[repo]/file/+server.ts
  17. 129
      src/routes/api/repos/[npub]/[repo]/highlights/+server.ts
  18. 10
      src/routes/api/repos/[npub]/[repo]/issues/+server.ts
  19. 10
      src/routes/api/repos/[npub]/[repo]/prs/+server.ts
  20. 10
      src/routes/api/repos/[npub]/[repo]/tags/+server.ts
  21. 172
      src/routes/api/repos/[npub]/[repo]/transfer/+server.ts
  22. 25
      src/routes/api/repos/[npub]/[repo]/tree/+server.ts
  23. 86
      src/routes/api/repos/[npub]/[repo]/verify/+server.ts
  24. 18
      src/routes/repos/[npub]/[repo]/+page.svelte
  25. 14
      src/routes/signup/+page.svelte

29
src/lib/components/CodeEditor.svelte

@ -11,12 +11,18 @@ @@ -11,12 +11,18 @@
content?: string;
language?: 'markdown' | 'asciidoc' | 'text';
onChange?: (value: string) => void;
onSelection?: (selectedText: string, startLine: number, endLine: number, startPos: number, endPos: number) => void;
readOnly?: boolean;
highlights?: Array<{ id: string; startLine: number; endLine: number; content: string }>;
}
let {
content = $bindable(''),
language = $bindable('text'),
onChange = () => {}
onChange = () => {},
onSelection = () => {},
readOnly = false,
highlights = []
}: Props = $props();
let editorView: EditorView | null = null;
@ -44,7 +50,26 @@ @@ -44,7 +50,26 @@
const newContent = update.state.doc.toString();
onChange(newContent);
}
})
// Handle text selection
if (update.selectionSet && !readOnly) {
const selection = update.state.selection.main;
if (!selection.empty) {
const selectedText = update.state.doc.sliceString(selection.from, selection.to);
const startLine = update.state.doc.lineAt(selection.from);
const endLine = update.state.doc.lineAt(selection.to);
onSelection(
selectedText,
startLine.number,
endLine.number,
selection.from,
selection.to
);
}
}
}),
EditorView.editable.of(!readOnly)
]
});

661
src/lib/components/PRDetail.svelte

@ -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>

110
src/lib/services/git/repo-manager.ts

@ -48,8 +48,12 @@ export class RepoManager { @@ -48,8 +48,12 @@ export class RepoManager {
/**
* Create a bare git repository from a NIP-34 repo announcement
*
* @param event - The repo announcement event
* @param selfTransferEvent - Optional self-transfer event to include in initial commit
* @param isExistingRepo - Whether this is an existing repo being added to the server
*/
async provisionRepo(event: NostrEvent): Promise<void> {
async provisionRepo(event: NostrEvent, selfTransferEvent?: NostrEvent, isExistingRepo: boolean = false): Promise<void> {
const cloneUrls = this.extractCloneUrls(event);
const domainUrl = cloneUrls.find(url => url.includes(this.domain));
@ -68,19 +72,34 @@ export class RepoManager { @@ -68,19 +72,34 @@ export class RepoManager {
mkdirSync(repoDir, { recursive: true });
}
// Check if repo already exists
const repoExists = existsSync(repoPath.fullPath);
// If there are other clone URLs, sync from them first (for existing repos)
const otherUrls = cloneUrls.filter(url => !url.includes(this.domain));
if (otherUrls.length > 0 && repoExists) {
// For existing repos, sync first to get the latest state
await this.syncFromRemotes(repoPath.fullPath, otherUrls);
}
// Create bare repository if it doesn't exist
const isNewRepo = !existsSync(repoPath.fullPath);
const isNewRepo = !repoExists;
if (isNewRepo) {
await execAsync(`git init --bare "${repoPath.fullPath}"`);
// Create verification file in the repository
await this.createVerificationFile(repoPath.fullPath, event);
}
// Create verification file and self-transfer event in the repository
await this.createVerificationFile(repoPath.fullPath, event, selfTransferEvent);
// If there are other clone URLs, sync from them
const otherUrls = cloneUrls.filter(url => !url.includes(this.domain));
if (otherUrls.length > 0) {
await this.syncFromRemotes(repoPath.fullPath, otherUrls);
// If there are other clone URLs, sync from them after creating the repo
if (otherUrls.length > 0) {
await this.syncFromRemotes(repoPath.fullPath, otherUrls);
}
} else if (isExistingRepo && selfTransferEvent) {
// For existing repos, we might want to add the self-transfer event
// But we should be careful not to overwrite existing history
// For now, we'll just ensure the verification file exists
// The self-transfer event should already be published to relays
console.log(`Existing repo ${repoPath.fullPath} - self-transfer event should be published to relays`);
}
}
@ -215,10 +234,10 @@ export class RepoManager { @@ -215,10 +234,10 @@ export class RepoManager {
}
/**
* Create verification file in a new repository
* Create verification file and self-transfer event in a new repository
* This proves the repository is owned by the announcement author
*/
private async createVerificationFile(repoPath: string, event: NostrEvent): Promise<void> {
private async createVerificationFile(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent): Promise<void> {
try {
// Create a temporary working directory
const repoName = this.parseRepoPathForName(repoPath)?.repoName || 'temp';
@ -242,13 +261,40 @@ export class RepoManager { @@ -242,13 +261,40 @@ export class RepoManager {
const verificationPath = join(workDir, VERIFICATION_FILE_PATH);
writeFileSync(verificationPath, verificationContent, 'utf-8');
// Commit the verification file
// If self-transfer event is provided, include it in the commit
const filesToAdd = [VERIFICATION_FILE_PATH];
if (selfTransferEvent) {
const selfTransferPath = join(workDir, '.nostr-ownership-transfer');
const isTemplate = !selfTransferEvent.sig || !selfTransferEvent.id;
const selfTransferContent = JSON.stringify({
eventId: selfTransferEvent.id || '(unsigned - needs owner signature)',
pubkey: selfTransferEvent.pubkey,
signature: selfTransferEvent.sig || '(unsigned - needs owner signature)',
timestamp: selfTransferEvent.created_at,
kind: selfTransferEvent.kind,
content: selfTransferEvent.content,
tags: selfTransferEvent.tags,
...(isTemplate ? {
_note: 'This is a template. The owner must sign and publish this event to relays for it to be valid.',
_instructions: 'To publish: 1. Sign this event with your private key, 2. Publish to relays using your Nostr client'
} : {})
}, null, 2) + '\n';
writeFileSync(selfTransferPath, selfTransferContent, 'utf-8');
filesToAdd.push('.nostr-ownership-transfer');
}
// Commit the verification file and self-transfer event
const workGit: SimpleGit = simpleGit(workDir);
await workGit.add(VERIFICATION_FILE_PATH);
await workGit.add(filesToAdd);
// Use the event timestamp for commit date
const commitDate = new Date(event.created_at * 1000).toISOString();
await workGit.commit('Add Nostr repository verification file', [VERIFICATION_FILE_PATH], {
const commitMessage = selfTransferEvent
? 'Add Nostr repository verification and initial ownership proof'
: 'Add Nostr repository verification file';
await workGit.commit(commitMessage, filesToAdd, {
'--author': `Nostr <${event.pubkey}@nostr>`,
'--date': commitDate
});
@ -276,4 +322,40 @@ export class RepoManager { @@ -276,4 +322,40 @@ export class RepoManager {
if (!match) return null;
return { repoName: match[1] };
}
/**
* Check if a repository already has a verification file
* Used to determine if this is a truly new repo or an existing one being added
*/
async hasVerificationFile(repoPath: string): Promise<boolean> {
if (!this.repoExists(repoPath)) {
return false;
}
try {
const git: SimpleGit = simpleGit();
const repoName = this.parseRepoPathForName(repoPath)?.repoName || 'temp';
const workDir = join(repoPath, '..', `${repoName}.check`);
const { rm, mkdir } = await import('fs/promises');
// Clean up if exists
if (existsSync(workDir)) {
await rm(workDir, { recursive: true, force: true });
}
await mkdir(workDir, { recursive: true });
// Try to clone and check for verification file
await git.clone(repoPath, workDir);
const verificationPath = join(workDir, VERIFICATION_FILE_PATH);
const hasFile = existsSync(verificationPath);
// Clean up
await rm(workDir, { recursive: true, force: true });
return hasFile;
} catch {
// If we can't check, assume it doesn't have one
return false;
}
}
}

384
src/lib/services/nostr/highlights-service.ts

@ -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
};
}
}

96
src/lib/services/nostr/maintainer-service.ts

@ -7,26 +7,51 @@ import { NostrClient } from './nostr-client.js'; @@ -7,26 +7,51 @@ import { NostrClient } from './nostr-client.js';
import { KIND } from '../../types/nostr.js';
import type { NostrEvent } from '../../types/nostr.js';
import { nip19 } from 'nostr-tools';
import { OwnershipTransferService } from './ownership-transfer-service.js';
export interface RepoPrivacyInfo {
isPrivate: boolean;
owner: string;
maintainers: string[];
}
export class MaintainerService {
private nostrClient: NostrClient;
private cache: Map<string, { maintainers: string[]; owner: string; timestamp: number }> = new Map();
private ownershipTransferService: OwnershipTransferService;
private cache: Map<string, { maintainers: string[]; owner: string; timestamp: number; isPrivate: boolean }> = new Map();
private cacheTTL = 5 * 60 * 1000; // 5 minutes
constructor(relays: string[]) {
this.nostrClient = new NostrClient(relays);
this.ownershipTransferService = new OwnershipTransferService(relays);
}
/**
* Check if a repository is private
* A repo is private if it has a tag ["private", "true"] or ["t", "private"]
*/
private isPrivateRepo(announcement: NostrEvent): boolean {
// Check for ["private", "true"] tag
const privateTag = announcement.tags.find(t => t[0] === 'private' && t[1] === 'true');
if (privateTag) return true;
// Check for ["t", "private"] tag (topic tag)
const topicTag = announcement.tags.find(t => t[0] === 't' && t[1] === 'private');
if (topicTag) return true;
return false;
}
/**
* Get maintainers for a repository from NIP-34 announcement
* Get maintainers and privacy info for a repository from NIP-34 announcement
*/
async getMaintainers(repoOwnerPubkey: string, repoId: string): Promise<{ owner: string; maintainers: string[] }> {
async getMaintainers(repoOwnerPubkey: string, repoId: string): Promise<{ owner: string; maintainers: string[]; isPrivate: boolean }> {
const cacheKey = `${repoOwnerPubkey}:${repoId}`;
const cached = this.cache.get(cacheKey);
// Return cached if still valid
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
return { owner: cached.owner, maintainers: cached.maintainers };
return { owner: cached.owner, maintainers: cached.maintainers, isPrivate: cached.isPrivate };
}
try {
@ -41,14 +66,24 @@ export class MaintainerService { @@ -41,14 +66,24 @@ export class MaintainerService {
]);
if (events.length === 0) {
// If no announcement found, only the owner is a maintainer
const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey] };
// If no announcement found, only the owner is a maintainer, and repo is public by default
const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey], isPrivate: false };
this.cache.set(cacheKey, { ...result, timestamp: Date.now() });
return result;
}
const announcement = events[0];
const maintainers: string[] = [announcement.pubkey]; // Owner is always a maintainer
// Check if repo is private
const isPrivate = this.isPrivateRepo(announcement);
// Check for ownership transfers - get current owner
const currentOwner = await this.ownershipTransferService.getCurrentOwner(
announcement.pubkey,
repoId
);
const maintainers: string[] = [currentOwner]; // Current owner is always a maintainer
// Extract maintainers from tags
for (const tag of announcement.tags) {
@ -70,13 +105,13 @@ export class MaintainerService { @@ -70,13 +105,13 @@ export class MaintainerService {
}
}
const result = { owner: announcement.pubkey, maintainers };
const result = { owner: currentOwner, maintainers, isPrivate };
this.cache.set(cacheKey, { ...result, timestamp: Date.now() });
return result;
} catch (error) {
console.error('Error fetching maintainers:', error);
// Fallback: only owner is maintainer
const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey] };
// Fallback: only owner is maintainer, repo is public by default
const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey], isPrivate: false };
this.cache.set(cacheKey, { ...result, timestamp: Date.now() });
return result;
}
@ -90,6 +125,47 @@ export class MaintainerService { @@ -90,6 +125,47 @@ export class MaintainerService {
return maintainers.includes(userPubkey);
}
/**
* Check if a user can view a repository
* Public repos: anyone can view
* Private repos: only owners and maintainers can view
*/
async canView(userPubkey: string | null, repoOwnerPubkey: string, repoId: string): Promise<boolean> {
const { isPrivate, maintainers } = await this.getMaintainers(repoOwnerPubkey, repoId);
// Public repos are viewable by anyone
if (!isPrivate) {
return true;
}
// Private repos require authentication
if (!userPubkey) {
return false;
}
// Convert userPubkey to hex if needed
let userPubkeyHex = userPubkey;
try {
const decoded = nip19.decode(userPubkey);
if (decoded.type === 'npub') {
userPubkeyHex = decoded.data as string;
}
} catch {
// Assume it's already a hex pubkey
}
// Check if user is owner or maintainer
return maintainers.includes(userPubkeyHex);
}
/**
* Get privacy info for a repository
*/
async getPrivacyInfo(repoOwnerPubkey: string, repoId: string): Promise<RepoPrivacyInfo> {
const { owner, maintainers, isPrivate } = await this.getMaintainers(repoOwnerPubkey, repoId);
return { isPrivate, owner, maintainers };
}
/**
* Clear cache for a repository (useful after maintainer changes)
*/

207
src/lib/services/nostr/nip98-auth.ts

@ -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');
}

312
src/lib/services/nostr/ownership-transfer-service.ts

@ -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 [];
}
}
}

90
src/lib/services/nostr/relay-write-proof.ts

@ -1,9 +1,12 @@ @@ -1,9 +1,12 @@
/**
* Service for verifying that a user can write to at least one default relay
* This replaces rate limiting by requiring proof of relay write capability
*
* Accepts NIP-98 events (kind 27235) as proof, since publishing a NIP-98 event
* to a relay proves the user can write to that relay.
*/
import { verifyEvent, getEventHash } from 'nostr-tools';
import { verifyEvent } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js';
import { NostrClient } from './nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '../../config.js';
@ -16,7 +19,13 @@ export interface RelayWriteProof { @@ -16,7 +19,13 @@ export interface RelayWriteProof {
/**
* Verify that a user can write to at least one default relay
* The proof should be a recent event (within last 5 minutes) published to a default relay
*
* Accepts:
* - NIP-98 events (kind 27235) - preferred, since they're already used for HTTP auth
* - Kind 1 (text note) events - for backward compatibility
*
* The proof should be a recent event (within 60 seconds for NIP-98, 5 minutes for kind 1)
* published to a default relay.
*/
export async function verifyRelayWriteProof(
proofEvent: NostrEvent,
@ -33,16 +42,43 @@ export async function verifyRelayWriteProof( @@ -33,16 +42,43 @@ export async function verifyRelayWriteProof(
return { valid: false, error: 'Event pubkey does not match user pubkey' };
}
// Verify the event is recent (within last 5 minutes)
// Determine time window based on event kind
// NIP-98 events (27235) should be within 60 seconds per spec
// Other events (like kind 1) can be within 5 minutes
const isNIP98Event = proofEvent.kind === 27235;
const maxAge = isNIP98Event ? 60 : 300; // 60 seconds for NIP-98, 5 minutes for others
// Verify the event is recent
const now = Math.floor(Date.now() / 1000);
const eventAge = now - proofEvent.created_at;
if (eventAge > 300) { // 5 minutes
return { valid: false, error: 'Proof event is too old (must be within 5 minutes)' };
if (eventAge > maxAge) {
return {
valid: false,
error: `Proof event is too old (must be within ${maxAge} seconds${isNIP98Event ? ' for NIP-98 events' : ''})`
};
}
if (eventAge < 0) {
return { valid: false, error: 'Proof event has future timestamp' };
}
// For NIP-98 events, validate they have required tags
if (isNIP98Event) {
const uTag = proofEvent.tags.find(t => t[0] === 'u');
const methodTag = proofEvent.tags.find(t => t[0] === 'method');
if (!uTag || !uTag[1]) {
return { valid: false, error: "NIP-98 event missing 'u' tag" };
}
if (!methodTag || !methodTag[1]) {
return { valid: false, error: "NIP-98 event missing 'method' tag" };
}
// Content should be empty for NIP-98
if (proofEvent.content && proofEvent.content.trim() !== '') {
return { valid: false, error: 'NIP-98 event content should be empty' };
}
}
// Try to verify the event exists on at least one default relay
const nostrClient = new NostrClient(relays);
try {
@ -76,7 +112,11 @@ export async function verifyRelayWriteProof( @@ -76,7 +112,11 @@ export async function verifyRelayWriteProof(
/**
* Create a proof event that can be used to prove relay write capability
* This is a simple kind 1 (text note) event with a specific content
*
* For new implementations, prefer using NIP-98 events (kind 27235) as they
* serve dual purpose: HTTP authentication and relay write proof.
*
* This function creates a simple kind 1 event for backward compatibility.
*/
export function createProofEvent(userPubkey: string, content: string = 'gitrepublic-write-proof'): Omit<NostrEvent, 'sig' | 'id'> {
return {
@ -87,3 +127,41 @@ export function createProofEvent(userPubkey: string, content: string = 'gitrepub @@ -87,3 +127,41 @@ export function createProofEvent(userPubkey: string, content: string = 'gitrepub
tags: [['t', 'gitrepublic-proof']]
};
}
/**
* Verify relay write proof from NIP-98 Authorization header
* This is a convenience function that extracts the NIP-98 event from the
* Authorization header and verifies it as relay write proof.
*
* @param authHeader - The Authorization header value (should start with "Nostr ")
* @param userPubkey - The expected user pubkey
* @param relays - List of relays to check (defaults to DEFAULT_NOSTR_RELAYS)
* @returns Verification result
*/
export async function verifyRelayWriteProofFromAuth(
authHeader: string | null,
userPubkey: string,
relays: string[] = DEFAULT_NOSTR_RELAYS
): Promise<{ valid: boolean; error?: string; relay?: string }> {
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 proofEvent: NostrEvent = JSON.parse(eventJson);
// Verify as relay write proof
return await verifyRelayWriteProof(proofEvent, userPubkey, relays);
} catch (err) {
return {
valid: false,
error: `Failed to parse Authorization header: ${err instanceof Error ? err.message : String(err)}`
};
}
}

90
src/lib/services/nostr/repo-polling.ts

@ -6,6 +6,7 @@ import { NostrClient } from './nostr-client.js'; @@ -6,6 +6,7 @@ import { NostrClient } from './nostr-client.js';
import { KIND } from '../../types/nostr.js';
import type { NostrEvent } from '../../types/nostr.js';
import { RepoManager } from '../git/repo-manager.js';
import { OwnershipTransferService } from './ownership-transfer-service.js';
export class RepoPollingService {
private nostrClient: NostrClient;
@ -13,6 +14,7 @@ export class RepoPollingService { @@ -13,6 +14,7 @@ export class RepoPollingService {
private pollingInterval: number;
private intervalId: NodeJS.Timeout | null = null;
private domain: string;
private relays: string[];
constructor(
relays: string[],
@ -20,6 +22,7 @@ export class RepoPollingService { @@ -20,6 +22,7 @@ export class RepoPollingService {
domain: string,
pollingInterval: number = 60000 // 1 minute
) {
this.relays = relays;
this.nostrClient = new NostrClient(relays);
this.repoManager = new RepoManager(repoRoot, domain);
this.pollingInterval = pollingInterval;
@ -74,8 +77,91 @@ export class RepoPollingService { @@ -74,8 +77,91 @@ export class RepoPollingService {
// Provision each repo
for (const event of relevantEvents) {
try {
await this.repoManager.provisionRepo(event);
console.log(`Provisioned repo from announcement ${event.id}`);
// Extract repo ID from d-tag
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) {
console.warn(`Repo announcement ${event.id} missing d-tag`);
continue;
}
// Check if this is an existing repo or new repo
const cloneUrls = this.extractCloneUrls(event);
const domainUrl = cloneUrls.find(url => url.includes(this.domain));
if (!domainUrl) continue;
const repoPath = this.repoManager.parseRepoUrl(domainUrl);
if (!repoPath) continue;
const repoExists = this.repoManager.repoExists(repoPath.fullPath);
const isExistingRepo = repoExists;
// Fetch self-transfer event for this repo
const ownershipService = new OwnershipTransferService(this.relays);
const repoTag = `30617:${event.pubkey}:${dTag}`;
const selfTransferEvents = await this.nostrClient.fetchEvents([
{
kinds: [KIND.OWNERSHIP_TRANSFER],
'#a': [repoTag],
authors: [event.pubkey],
limit: 10
}
]);
// Find self-transfer event (from owner to themselves)
let selfTransferEvent: NostrEvent | undefined;
for (const transferEvent of selfTransferEvents) {
const pTag = transferEvent.tags.find(t => t[0] === 'p');
if (pTag && pTag[1] === event.pubkey) {
// Decode npub if needed
let toPubkey = pTag[1];
try {
const { nip19 } = await import('nostr-tools');
const decoded = nip19.decode(toPubkey);
if (decoded.type === 'npub') {
toPubkey = decoded.data as string;
}
} catch {
// Assume it's already hex
}
if (transferEvent.pubkey === event.pubkey && toPubkey === event.pubkey) {
selfTransferEvent = transferEvent;
break;
}
}
}
// For existing repos without self-transfer, create one retroactively
if (isExistingRepo && !selfTransferEvent) {
console.log(`Existing repo ${dTag} from ${event.pubkey} has no self-transfer event. Creating template for owner to sign and publish.`);
try {
// Create a self-transfer event template for the existing repo
// The owner will need to sign and publish this to relays
const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(event.pubkey, dTag);
// Create an unsigned event template that can be included in the repo
// This serves as a reference and the owner can use it to create the actual event
const selfTransferTemplate = {
...initialOwnershipEvent,
id: '', // Will be computed when signed
sig: '', // Needs owner signature
_note: 'This is a template. The owner must sign and publish this event to relays for it to be valid.'
} as NostrEvent & { _note?: string };
// Use the template (even though it's unsigned, it will be included in the repo)
selfTransferEvent = selfTransferTemplate;
console.warn(`Self-transfer event template created for ${dTag}. Owner ${event.pubkey} should sign and publish it to relays.`);
} catch (err) {
console.error(`Failed to create self-transfer event template for ${dTag}:`, err);
}
}
// Provision the repo with self-transfer event if available
await this.repoManager.provisionRepo(event, selfTransferEvent, isExistingRepo);
console.log(`Provisioned repo from announcement ${event.id}${isExistingRepo ? ' (existing)' : ' (new)'}`);
} catch (error) {
console.error(`Failed to provision repo from ${event.id}:`, error);
}

4
src/lib/types/nostr.ts

@ -19,6 +19,7 @@ export interface NostrFilter { @@ -19,6 +19,7 @@ export interface NostrFilter {
'#e'?: string[];
'#p'?: string[];
'#d'?: string[];
'#a'?: string[];
since?: number;
until?: number;
limit?: number;
@ -27,6 +28,7 @@ export interface NostrFilter { @@ -27,6 +28,7 @@ export interface NostrFilter {
export const KIND = {
REPO_ANNOUNCEMENT: 30617,
REPO_STATE: 30618,
OWNERSHIP_TRANSFER: 30619, // Repository ownership transfer event
PATCH: 1617,
PULL_REQUEST: 1618,
PULL_REQUEST_UPDATE: 1619,
@ -35,6 +37,8 @@ export const KIND = { @@ -35,6 +37,8 @@ export const KIND = {
STATUS_APPLIED: 1631,
STATUS_CLOSED: 1632,
STATUS_DRAFT: 1633,
HIGHLIGHT: 9802, // NIP-84: Highlight event
COMMENT: 1111, // NIP-22: Comment event
} as const;
export interface Issue extends NostrEvent {

49
src/lib/utils/repo-privacy.ts

@ -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'
};
}
}

13
src/routes/+page.svelte

@ -31,14 +31,23 @@ @@ -31,14 +31,23 @@
// Get git domain from layout data
const gitDomain = $page.data.gitDomain || 'localhost:6543';
// Filter for repos that list our domain in clone tags
// Filter for repos that list our domain in clone tags and are public
repos = events.filter(event => {
const cloneUrls = event.tags
.filter(t => t[0] === 'clone')
.flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string');
return cloneUrls.some(url => url.includes(gitDomain));
const hasDomain = cloneUrls.some(url => url.includes(gitDomain));
if (!hasDomain) return false;
// Filter out private repos from public listing
const isPrivate = event.tags.some(t =>
(t[0] === 'private' && t[1] === 'true') ||
(t[0] === 't' && t[1] === 'private')
);
return !isPrivate; // Only show public repos
});
// Sort by created_at descending

144
src/routes/api/git/[...path]/+server.ts

@ -6,7 +6,6 @@ @@ -6,7 +6,6 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { RepoManager } from '$lib/services/git/repo-manager.js';
import { verifyEvent } from 'nostr-tools';
import { nip19 } from 'nostr-tools';
import { spawn, execSync } from 'child_process';
import { existsSync } from 'fs';
@ -15,10 +14,15 @@ import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; @@ -15,10 +14,15 @@ import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { KIND } from '$lib/types/nostr.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import { verifyNIP98Auth } from '$lib/services/nostr/nip98-auth.js';
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const repoManager = new RepoManager(repoRoot);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
// Path to git-http-backend (common locations)
const GIT_HTTP_BACKEND_PATHS = [
@ -52,59 +56,6 @@ function findGitHttpBackend(): string | null { @@ -52,59 +56,6 @@ function findGitHttpBackend(): string | null {
return null;
}
/**
* Verify NIP-98 authentication for push operations
*/
async function verifyNIP98Auth(
request: Request,
expectedPubkey: string
): Promise<{ valid: boolean; error?: string }> {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Nostr ')) {
return { valid: false, error: 'Missing or invalid Authorization header (expected "Nostr <event>")' };
}
try {
const eventJson = authHeader.slice(7); // Remove "Nostr " prefix
const nostrEvent: NostrEvent = JSON.parse(eventJson);
// Verify event signature
if (!verifyEvent(nostrEvent)) {
return { valid: false, error: 'Invalid event signature' };
}
// Verify pubkey matches repo owner
if (nostrEvent.pubkey !== expectedPubkey) {
return { valid: false, error: 'Event pubkey does not match repository owner' };
}
// Verify event is recent (within last 5 minutes)
const now = Math.floor(Date.now() / 1000);
const eventAge = now - nostrEvent.created_at;
if (eventAge > 300) {
return { valid: false, error: 'Authentication event is too old (must be within 5 minutes)' };
}
if (eventAge < 0) {
return { valid: false, error: 'Authentication event has future timestamp' };
}
// Verify the event method and URL match the request
const methodTag = nostrEvent.tags.find(t => t[0] === 'method');
const urlTag = nostrEvent.tags.find(t => t[0] === 'u');
if (methodTag && methodTag[1] !== request.method) {
return { valid: false, error: 'Event method does not match request method' };
}
return { valid: true };
} catch (err) {
return {
valid: false,
error: `Failed to parse or verify authentication: ${err instanceof Error ? err.message : String(err)}`
};
}
}
/**
* Get repository announcement to extract clone URLs for post-receive sync
@ -162,6 +113,11 @@ export const GET: RequestHandler = async ({ params, url, request }) => { @@ -162,6 +113,11 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
const [, npub, repoName, gitPath = ''] = match;
const service = url.searchParams.get('service');
// Build absolute request URL for NIP-98 validation
const protocol = request.headers.get('x-forwarded-proto') || (url.protocol === 'https:' ? 'https' : 'http');
const host = request.headers.get('host') || url.host;
const requestUrl = `${protocol}://${host}${url.pathname}${url.search}`;
// Validate npub format
try {
const decoded = nip19.decode(npub);
@ -178,6 +134,52 @@ export const GET: RequestHandler = async ({ params, url, request }) => { @@ -178,6 +134,52 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
return error(404, 'Repository not found');
}
// Check repository privacy for clone/fetch operations
let originalOwnerPubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
return error(400, 'Invalid npub format');
}
originalOwnerPubkey = decoded.data as string;
} catch {
return error(400, 'Invalid npub format');
}
// For clone/fetch operations, check if repo is private
// If private, require NIP-98 authentication
const privacyInfo = await maintainerService.getPrivacyInfo(originalOwnerPubkey, repoName);
if (privacyInfo.isPrivate) {
// Private repos require authentication for clone/fetch
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Nostr ')) {
return error(401, 'This repository is private. Authentication required.');
}
// Build absolute request URL for NIP-98 validation
const protocol = request.headers.get('x-forwarded-proto') || (url.protocol === 'https:' ? 'https' : 'http');
const host = request.headers.get('host') || url.host;
const requestUrl = `${protocol}://${host}${url.pathname}${url.search}`;
// Verify NIP-98 authentication
const authResult = verifyNIP98Auth(
authHeader,
requestUrl,
request.method,
undefined // GET requests don't have body
);
if (!authResult.valid) {
return error(401, authResult.error || 'Authentication required');
}
// Verify user can view the repo
const canView = await maintainerService.canView(authResult.pubkey || null, originalOwnerPubkey, repoName);
if (!canView) {
return error(403, 'You do not have permission to access this private repository.');
}
}
// Find git-http-backend
const gitHttpBackend = findGitHttpBackend();
if (!gitHttpBackend) {
@ -265,13 +267,13 @@ export const POST: RequestHandler = async ({ params, url, request }) => { @@ -265,13 +267,13 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
const [, npub, repoName, gitPath = ''] = match;
// Validate npub format and decode to get pubkey
let repoOwnerPubkey: string;
let originalOwnerPubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
return error(400, 'Invalid npub format');
}
repoOwnerPubkey = decoded.data as string;
originalOwnerPubkey = decoded.data as string;
} catch {
return error(400, 'Invalid npub format');
}
@ -282,12 +284,36 @@ export const POST: RequestHandler = async ({ params, url, request }) => { @@ -282,12 +284,36 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
return error(404, 'Repository not found');
}
// Get current owner (may be different if ownership was transferred)
const currentOwnerPubkey = await ownershipTransferService.getCurrentOwner(originalOwnerPubkey, repoName);
// Build absolute request URL for NIP-98 validation
const protocol = request.headers.get('x-forwarded-proto') || (url.protocol === 'https:' ? 'https' : 'http');
const host = request.headers.get('host') || url.host;
const requestUrl = `${protocol}://${host}${url.pathname}${url.search}`;
// Get request body (read once, use for both auth and git-http-backend)
const body = await request.arrayBuffer();
const bodyBuffer = Buffer.from(body);
// For push operations (git-receive-pack), require NIP-98 authentication
if (gitPath === 'git-receive-pack' || path.includes('git-receive-pack')) {
const authResult = await verifyNIP98Auth(request, repoOwnerPubkey);
// Verify NIP-98 authentication
const authResult = verifyNIP98Auth(
request.headers.get('Authorization'),
requestUrl,
request.method,
bodyBuffer.length > 0 ? bodyBuffer : undefined
);
if (!authResult.valid) {
return error(401, authResult.error || 'Authentication required');
}
// Verify pubkey matches current repo owner (may have been transferred)
if (authResult.pubkey !== currentOwnerPubkey) {
return error(403, 'Event pubkey does not match repository owner');
}
}
// Find git-http-backend
@ -299,10 +325,6 @@ export const POST: RequestHandler = async ({ params, url, request }) => { @@ -299,10 +325,6 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
// Build PATH_INFO
const pathInfo = gitPath ? `/${npub}/${repoName}.git/${gitPath}` : `/${npub}/${repoName}.git`;
// Get request body
const body = await request.arrayBuffer();
const bodyBuffer = Buffer.from(body);
// Set up environment variables for git-http-backend
const envVars = {
...process.env,

10
src/routes/api/repos/[npub]/[repo]/commits/+server.ts

@ -9,11 +9,12 @@ import { FileManager } from '$lib/services/git/file-manager.js'; @@ -9,11 +9,12 @@ import { FileManager } from '$lib/services/git/file-manager.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
export const GET: RequestHandler = async ({ params, url }) => {
export const GET: RequestHandler = async ({ params, url, request }) => {
const { npub, repo } = params;
const branch = url.searchParams.get('branch') || 'main';
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const path = url.searchParams.get('path') || undefined;
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
@ -24,6 +25,13 @@ export const GET: RequestHandler = async ({ params, url }) => { @@ -24,6 +25,13 @@ export const GET: RequestHandler = async ({ params, url }) => {
return error(404, 'Repository not found');
}
// Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) {
return error(403, access.error || 'Access denied');
}
const commits = await fileManager.getCommitHistory(npub, repo, branch, limit, path);
return json(commits);
} catch (err) {

10
src/routes/api/repos/[npub]/[repo]/diff/+server.ts

@ -9,11 +9,12 @@ import { FileManager } from '$lib/services/git/file-manager.js'; @@ -9,11 +9,12 @@ import { FileManager } from '$lib/services/git/file-manager.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
export const GET: RequestHandler = async ({ params, url }) => {
export const GET: RequestHandler = async ({ params, url, request }) => {
const { npub, repo } = params;
const fromRef = url.searchParams.get('from');
const toRef = url.searchParams.get('to') || 'HEAD';
const filePath = url.searchParams.get('path') || undefined;
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo || !fromRef) {
return error(400, 'Missing npub, repo, or from parameter');
@ -24,6 +25,13 @@ export const GET: RequestHandler = async ({ params, url }) => { @@ -24,6 +25,13 @@ export const GET: RequestHandler = async ({ params, url }) => {
return error(404, 'Repository not found');
}
// Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) {
return error(403, access.error || 'Access denied');
}
const diffs = await fileManager.getDiff(npub, repo, fromRef, toRef, filePath);
return json(diffs);
} catch (err) {

21
src/routes/api/repos/[npub]/[repo]/file/+server.ts

@ -14,10 +14,11 @@ const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; @@ -14,10 +14,11 @@ const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ params, url }: { params: { npub?: string; repo?: string }; url: URL }) => {
export const GET: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => {
const { npub, repo } = params;
const filePath = url.searchParams.get('path');
const ref = url.searchParams.get('ref') || 'HEAD';
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo || !filePath) {
return error(400, 'Missing npub, repo, or path parameter');
@ -28,6 +29,24 @@ export const GET: RequestHandler = async ({ params, url }: { params: { npub?: st @@ -28,6 +29,24 @@ export const GET: RequestHandler = async ({ params, url }: { params: { npub?: st
return error(404, 'Repository not found');
}
// Check repository privacy
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');
}
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo);
if (!canView) {
return error(403, 'This repository is private. Only owners and maintainers can view it.');
}
const fileContent = await fileManager.getFileContent(npub, repo, filePath, ref);
return json(fileContent);
} catch (err) {

129
src/routes/api/repos/[npub]/[repo]/highlights/+server.ts

@ -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');
}
};

10
src/routes/api/repos/[npub]/[repo]/issues/+server.ts

@ -8,8 +8,9 @@ import { IssuesService } from '$lib/services/nostr/issues-service.js'; @@ -8,8 +8,9 @@ import { IssuesService } from '$lib/services/nostr/issues-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
export const GET: RequestHandler = async ({ params, url }) => {
export const GET: RequestHandler = async ({ params, url, request }) => {
const { npub, repo } = params;
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
@ -23,6 +24,13 @@ export const GET: RequestHandler = async ({ params, url }) => { @@ -23,6 +24,13 @@ export const GET: RequestHandler = async ({ params, url }) => {
}
const repoOwnerPubkey = decoded.data as string;
// Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) {
return error(403, access.error || 'Access denied');
}
const issuesService = new IssuesService(DEFAULT_NOSTR_RELAYS);
const issues = await issuesService.getIssues(repoOwnerPubkey, repo);

10
src/routes/api/repos/[npub]/[repo]/prs/+server.ts

@ -9,8 +9,9 @@ import { PRsService } from '$lib/services/nostr/prs-service.js'; @@ -9,8 +9,9 @@ import { PRsService } from '$lib/services/nostr/prs-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => {
export const GET: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => {
const { npub, repo } = params;
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
@ -30,6 +31,13 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; @@ -30,6 +31,13 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
return error(400, 'Invalid npub format');
}
// Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) {
return error(403, access.error || 'Access denied');
}
const prsService = new PRsService(DEFAULT_NOSTR_RELAYS);
const prs = await prsService.getPullRequests(repoOwnerPubkey, repo);

10
src/routes/api/repos/[npub]/[repo]/tags/+server.ts

@ -14,8 +14,9 @@ const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; @@ -14,8 +14,9 @@ const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => {
export const GET: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => {
const { npub, repo } = params;
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
@ -26,6 +27,13 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; @@ -26,6 +27,13 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
return error(404, 'Repository not found');
}
// Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) {
return error(403, access.error || 'Access denied');
}
const tags = await fileManager.getTags(npub, repo);
return json(tags);
} catch (err) {

172
src/routes/api/repos/[npub]/[repo]/transfer/+server.ts

@ -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');
}
};

25
src/routes/api/repos/[npub]/[repo]/tree/+server.ts

@ -5,14 +5,19 @@ @@ -5,14 +5,19 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ params, url }) => {
export const GET: RequestHandler = async ({ params, url, request }) => {
const { npub, repo } = params;
const ref = url.searchParams.get('ref') || 'HEAD';
const path = url.searchParams.get('path') || '';
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
@ -23,6 +28,24 @@ export const GET: RequestHandler = async ({ params, url }) => { @@ -23,6 +28,24 @@ export const GET: RequestHandler = async ({ params, url }) => {
return error(404, 'Repository not found');
}
// Check repository privacy
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');
}
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo);
if (!canView) {
return error(403, 'This repository is private. Only owners and maintainers can view it.');
}
const files = await fileManager.listFiles(npub, repo, ref, path);
return json(files);
} catch (err) {

86
src/routes/api/repos/[npub]/[repo]/verify/+server.ts

@ -8,6 +8,7 @@ import type { RequestHandler } from './$types'; @@ -8,6 +8,7 @@ import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js';
import { verifyRepositoryOwnership, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
@ -17,6 +18,7 @@ import { join } from 'path'; @@ -17,6 +18,7 @@ import { join } from 'path';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => {
const { npub, repo } = params;
@ -45,19 +47,6 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; @@ -45,19 +47,6 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
return error(404, 'Repository not found');
}
// Try to read verification file
let verificationContent: string;
try {
const verificationFile = await fileManager.getFileContent(npub, repo, VERIFICATION_FILE_PATH, 'HEAD');
verificationContent = verificationFile.content;
} catch (err) {
return json({
verified: false,
error: 'Verification file not found in repository',
message: 'This repository does not have a .nostr-verification file. It may have been created before verification was implemented.'
});
}
// Fetch the repository announcement
const events = await nostrClient.fetchEvents([
{
@ -78,21 +67,84 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; @@ -78,21 +67,84 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
const announcement = events[0];
// Verify ownership
const verification = verifyRepositoryOwnership(announcement, verificationContent);
// Check for ownership transfer events (including self-transfer for initial ownership)
const repoTag = `30617:${ownerPubkey}:${repo}`;
const transferEvents = await nostrClient.fetchEvents([
{
kinds: [KIND.OWNERSHIP_TRANSFER],
'#a': [repoTag],
limit: 100
}
]);
// Look for self-transfer event (initial ownership proof)
// Self-transfer: from owner to themselves, tagged with 'self-transfer'
const selfTransfer = transferEvents.find(event => {
const pTag = event.tags.find(t => t[0] === 'p');
let toPubkey = pTag?.[1];
// Decode npub if needed
if (toPubkey) {
try {
const decoded = nip19.decode(toPubkey);
if (decoded.type === 'npub') {
toPubkey = decoded.data as string;
}
} catch {
// Assume it's already hex
}
}
return event.pubkey === ownerPubkey &&
toPubkey === ownerPubkey;
});
// Verify ownership - prefer self-transfer event, fall back to verification file
let verified = false;
let verificationMethod = '';
let error: string | undefined;
if (selfTransfer) {
// Verify self-transfer event signature
const { verifyEvent } = await import('nostr-tools');
if (verifyEvent(selfTransfer)) {
verified = true;
verificationMethod = 'self-transfer-event';
} else {
verified = false;
error = 'Self-transfer event signature is invalid';
verificationMethod = 'self-transfer-event';
}
} else {
// Fall back to verification file method (for backward compatibility)
try {
const verificationFile = await fileManager.getFileContent(npub, repo, VERIFICATION_FILE_PATH, 'HEAD');
const verification = verifyRepositoryOwnership(announcement, verificationFile.content);
verified = verification.valid;
error = verification.error;
verificationMethod = 'verification-file';
} catch (err) {
verified = false;
error = 'No ownership proof found (neither self-transfer event nor verification file)';
verificationMethod = 'none';
}
}
if (verification.valid) {
if (verified) {
return json({
verified: true,
announcementId: announcement.id,
ownerPubkey: ownerPubkey,
verificationMethod,
selfTransferEventId: selfTransfer?.id,
message: 'Repository ownership verified successfully'
});
} else {
return json({
verified: false,
error: verification.error,
error: error || 'Repository ownership verification failed',
announcementId: announcement.id,
verificationMethod,
message: 'Repository ownership verification failed'
});
}

18
src/routes/repos/[npub]/[repo]/+page.svelte

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import PRDetail from '$lib/components/PRDetail.svelte';
import { getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js';
@ -81,6 +82,7 @@ @@ -81,6 +82,7 @@
let newPRCommitId = $state('');
let newPRBranchName = $state('');
let newPRLabels = $state<string[]>(['']);
let selectedPR = $state<string | null>(null);
onMount(async () => {
await loadBranches();
@ -1046,9 +1048,23 @@ @@ -1046,9 +1048,23 @@
<div class="empty-state">
<p>No pull requests found. Create one to get started!</p>
</div>
{:else if selectedPR}
{#each prs.filter(p => p.id === selectedPR) as pr}
{@const decoded = nip19.decode(npub)}
{#if decoded.type === 'npub'}
{@const repoOwnerPubkey = decoded.data as string}
<PRDetail
{pr}
{npub}
{repo}
{repoOwnerPubkey}
/>
<button onclick={() => selectedPR = null} class="back-btn">← Back to PR List</button>
{/if}
{/each}
{:else}
{#each prs as pr}
<div class="pr-detail">
<div class="pr-detail" onclick={() => selectedPR = pr.id} style="cursor: pointer;">
<h3>{pr.subject}</h3>
<div class="pr-meta-detail">
<span class="pr-status" class:open={pr.status === 'open'} class:closed={pr.status === 'closed'} class:merged={pr.status === 'merged'}>

14
src/routes/signup/+page.svelte

@ -187,10 +187,22 @@ @@ -187,10 +187,22 @@
// Combine user's outbox with default relays
const userRelays = combineRelays(outbox);
// Publish to user's outboxes and standard relays
// Publish repository announcement
const result = await nostrClient.publishEvent(signedEvent, userRelays);
if (result.success.length > 0) {
// Create and publish initial ownership proof (self-transfer event)
const { OwnershipTransferService } = await import('../../lib/services/nostr/ownership-transfer-service.js');
const ownershipService = new OwnershipTransferService(userRelays);
const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(pubkey, dTag);
const signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent);
// Publish initial ownership event (don't fail if this fails, announcement is already published)
await nostrClient.publishEvent(signedOwnershipEvent, userRelays).catch(err => {
console.warn('Failed to publish initial ownership event:', err);
});
success = true;
setTimeout(() => {
goto('/');

Loading…
Cancel
Save