Browse Source

initial commit

main
Silberengel 4 weeks ago
commit
cf514964d4
  1. 10
      .gitignore
  2. 139
      IMPLEMENTATION.md
  3. 122
      README.md
  4. 3787
      package-lock.json
  5. 31
      package.json
  6. 12
      src/app.d.ts
  7. 12
      src/app.html
  8. 25
      src/hooks.server.ts
  9. 21
      src/lib/config.ts
  10. 145
      src/lib/services/git/repo-manager.ts
  11. 32
      src/lib/services/nostr/nip07-signer.ts
  12. 58
      src/lib/services/nostr/nip19-utils.ts
  13. 122
      src/lib/services/nostr/nostr-client.ts
  14. 107
      src/lib/services/nostr/repo-polling.ts
  15. 77
      src/lib/services/nostr/user-relays.ts
  16. 29
      src/lib/types/nostr.ts
  17. 12
      src/routes/+layout.server.ts
  18. 227
      src/routes/+page.svelte
  19. 40
      src/routes/api/git/[...path]/+server.ts
  20. 475
      src/routes/signup/+page.svelte
  21. 12
      svelte.config.js
  22. 16
      tsconfig.json
  23. 6
      vite.config.ts

10
.gitignore vendored

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
npm-debug.log
yarn-error.log

139
IMPLEMENTATION.md

@ -0,0 +1,139 @@ @@ -0,0 +1,139 @@
# Implementation Guide for git-http-backend Integration
## Overview
The git-http-backend integration needs to be implemented in `/src/routes/api/git/[...path]/+server.ts`. This route will handle all git HTTP operations (clone, push, pull).
## URL Structure
All git requests will follow this pattern:
- `GET /api/git/{npub}/{repo-name}.git/info/refs?service=git-upload-pack` (clone/fetch)
- `GET /api/git/{npub}/{repo-name}.git/info/refs?service=git-receive-pack` (push capability check)
- `POST /api/git/{npub}/{repo-name}.git/git-upload-pack` (fetch)
- `POST /api/git/{npub}/{repo-name}.git/git-receive-pack` (push)
## Implementation Steps
### 1. Parse Request Path
Extract `npub` and `repo-name` from the path parameter:
```typescript
const match = params.path.match(/^([^\/]+)\/([^\/]+)\.git\/(.+)$/);
if (!match) return new Response('Invalid path', { status: 400 });
const [, npub, repoName, gitPath] = match;
```
### 2. Authenticate with NIP-98
For push operations, verify NIP-98 authentication:
```typescript
import { verifyEvent } from 'nostr-tools';
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Nostr ')) {
return new Response('Unauthorized', { status: 401 });
}
const nostrEvent = JSON.parse(authHeader.slice(7));
if (!verifyEvent(nostrEvent)) {
return new Response('Invalid signature', { status: 401 });
}
// Verify pubkey matches repo owner
if (nostrEvent.pubkey !== expectedPubkey) {
return new Response('Unauthorized', { status: 403 });
}
```
### 3. Map to Git Repository Path
Use `RepoManager` to get the full path:
```typescript
import { RepoManager } from '$lib/services/git/repo-manager.js';
const repoManager = new RepoManager(process.env.GIT_REPO_ROOT || '/repos');
const repoPath = join(repoManager.repoRoot, npub, `${repoName}.git`);
if (!repoManager.repoExists(repoPath)) {
return new Response('Repository not found', { status: 404 });
}
```
### 4. Proxy to git-http-backend
Execute git-http-backend as a subprocess:
```typescript
import { spawn } from 'child_process';
import { env } from '$env/dynamic/private';
const gitHttpBackend = '/usr/lib/git-core/git-http-backend'; // or wherever it's installed
const envVars = {
...process.env,
GIT_PROJECT_ROOT: repoManager.repoRoot,
GIT_HTTP_EXPORT_ALL: '1',
REQUEST_METHOD: request.method,
PATH_INFO: `/${npub}/${repoName}.git/${gitPath}`,
QUERY_STRING: url.searchParams.toString(),
CONTENT_TYPE: request.headers.get('Content-Type') || '',
CONTENT_LENGTH: request.headers.get('Content-Length') || '0',
};
const gitProcess = spawn(gitHttpBackend, [], {
env: envVars,
stdio: ['pipe', 'pipe', 'pipe']
});
// Pipe request body to git-http-backend
if (request.body) {
request.body.pipeTo(gitProcess.stdin);
}
// Return git-http-backend response
return new Response(gitProcess.stdout, {
headers: {
'Content-Type': 'application/x-git-upload-pack-result',
// or 'application/x-git-receive-pack-result' for push
}
});
```
### 5. Post-Receive Hook
After successful push, sync to other remotes:
```typescript
// After successful git-receive-pack
if (gitPath === 'git-receive-pack' && request.method === 'POST') {
// Fetch NIP-34 announcement for this repo
const announcement = await fetchRepoAnnouncement(npub, repoName);
if (announcement) {
const cloneUrls = extractCloneUrls(announcement);
const otherUrls = cloneUrls.filter(url => !url.includes('git.imwald.eu'));
await repoManager.syncToRemotes(repoPath, otherUrls);
}
}
```
## Alternative: Use a Git Server Library
Instead of calling git-http-backend directly, you could use a Node.js git server library:
- `isomorphic-git` with `@isomorphic-git/http-server`
- `node-git-server`
- Custom implementation using `dugite` or `simple-git`
## Testing
Test with:
```bash
# Clone
git clone https://git.imwald.eu/{npub}/{repo-name}.git
# Push (requires NIP-98 auth)
git push origin main
```
For NIP-98 authentication, you'll need a git credential helper that:
1. Intercepts git HTTP requests
2. Signs a Nostr event with the user's key
3. Adds `Authorization: Nostr {event}` header

122
README.md

@ -0,0 +1,122 @@ @@ -0,0 +1,122 @@
# gitrepublic-web
A Nostr-based git server with NIP-34 repo announcements. Uses git-http-backend for git operations and provides a web interface for managing repositories.
## Features
- **NIP-34 Repo Announcements**: Display and manage repository announcements
- **NIP-07 Authentication**: Sign up and authenticate using browser extensions
- **Auto-provisioning**: Automatically create git repos from NIP-34 announcements
- **Multi-remote Sync**: Sync repos to multiple remotes listed in announcements
- **URL Structure**: `git.imwald.eu/{npub}/{repo-name}.git`
- **User Relay Discovery**: Automatically fetches user's inbox/outbox relays from kind 10002 or 3
## Development
```bash
npm install
npm run dev
```
## Environment Variables
- `NOSTRGIT_SECRET_KEY`: Server's nsec for signing repo announcements (optional, for server-side signing)
- `GIT_REPO_ROOT`: Path to store git repositories (default: `/repos`)
- `GIT_DOMAIN`: Domain for git repositories (default: `git.imwald.eu`)
- `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com,wss://nostr.land,wss://relay.damus.io`)
## Architecture
- **Frontend**: SvelteKit + TypeScript
- **Git Server**: git-http-backend wrapper (TODO: implement in `/src/routes/api/git/[...path]/+server.ts`)
- **Authentication**: NIP-07 (browser extension) for web UI, NIP-98 (HTTP auth) for git operations
- **Discovery**: NIP-34 repo announcements with automatic polling and provisioning
## Project Structure
```
src/
├── lib/
│ ├── services/
│ │ ├── nostr/
│ │ │ ├── nostr-client.ts # WebSocket client for Nostr relays
│ │ │ ├── nip07-signer.ts # NIP-07 browser extension integration
│ │ │ ├── nip19-utils.ts # Decode hex/nevent/naddr addresses
│ │ │ ├── repo-polling.ts # Auto-provision repos from announcements
│ │ │ └── user-relays.ts # Fetch user's preferred relays
│ │ └── git/
│ │ └── repo-manager.ts # Server-side repo provisioning & syncing
│ └── types/
│ └── nostr.ts # TypeScript types for Nostr
├── routes/
│ ├── +page.svelte # Main page: list repos on server
│ ├── signup/
│ │ └── +page.svelte # Sign-up: create/update repo announcements
│ └── api/
│ └── git/
│ └── [...path]/
│ └── +server.ts # Git HTTP backend API (TODO)
└── hooks.server.ts # Server initialization (starts polling)
```
## Implementation Status
✅ **Completed:**
- NIP-07 authentication for sign-up page
- Repo announcement display page
- Repo announcement creation/update with hex/nevent/naddr support
- User relay discovery (kind 10002 and 3)
- NIP-34 polling and auto-provisioning service
- Server-side repo manager for provisioning and syncing
🚧 **In Progress:**
- Git HTTP backend wrapper with Nostr authentication (NIP-98)
## Next Steps
1. **Implement git-http-backend integration** in `/src/routes/api/git/[...path]/+server.ts`:
- Parse URL path to extract `{npub}/{repo-name}`
- Authenticate using NIP-98 (HTTP Authorization header with Nostr event)
- Proxy requests to `git-http-backend` CGI script
- Handle git smart HTTP protocol (info/refs, git-upload-pack, git-receive-pack)
- Trigger post-receive hooks to sync to other remotes
2. **Set up git-http-backend**:
- Install `git-http-backend` (usually comes with git)
- Configure as CGI script or FastCGI
- Set up proper permissions for repo directory
3. **Implement NIP-98 authentication**:
- Verify Nostr event signature in Authorization header
- Check that pubkey matches repo owner (from URL)
- Validate event timestamp (not too old)
4. **Add post-receive hook**:
- After successful push, extract other clone URLs from NIP-34 announcement
- Sync to all other remotes using `git push --all`
## Usage
1. **Create a repository announcement:**
- Go to `/signup`
- Connect your NIP-07 extension
- Enter repository name and description
- Optionally load an existing announcement by providing hex ID, nevent, or naddr
- Add clone URLs (git.imwald.eu will be added automatically)
- Publish the announcement
2. **View repositories:**
- Go to `/` to see all repositories on git.imwald.eu
- Repositories are automatically provisioned when announcements are published
3. **Clone a repository:**
```bash
git clone https://git.imwald.eu/{npub}/{repo-name}.git
```
4. **Push to repository:**
```bash
git remote add origin https://git.imwald.eu/{npub}/{repo-name}.git
git push origin main
```
(Requires NIP-98 authentication - TODO)

3787
package-lock.json generated

File diff suppressed because it is too large Load Diff

31
package.json

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
{
"name": "gitrepublic-web",
"version": "0.1.0",
"type": "module",
"description": "Nostr-based git server with NIP-34 repo announcements",
"scripts": {
"dev": "vite dev",
"build": "tsc && vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"nostr-tools": "^2.22.1",
"svelte": "^5.0.0"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.0.0",
"@types/node": "^20.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.0.0",
"prettier": "^3.0.0",
"svelte-check": "^3.0.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
}

12
src/app.d.ts vendored

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these types
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};

12
src/app.html

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

25
src/hooks.server.ts

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
/**
* Server-side hooks for gitrepublic-web
* Initializes repo polling service
*/
import type { Handle } from '@sveltejs/kit';
import { RepoPollingService } from './lib/services/nostr/repo-polling.js';
import { GIT_DOMAIN } from './lib/config.js';
// Initialize polling service
const relays = (process.env.NOSTR_RELAYS || 'wss://theforest.nostr1.com,wss://nostr.land,wss://relay.damus.io').split(',');
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const domain = GIT_DOMAIN;
let pollingService: RepoPollingService | null = null;
if (typeof process !== 'undefined') {
pollingService = new RepoPollingService(relays, repoRoot, domain);
pollingService.start();
console.log('Started repo polling service');
}
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
};

21
src/lib/config.ts

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
/**
* Application configuration
* Centralized config that can be overridden by environment variables
*/
/**
* Git domain for repository URLs
* Defaults to localhost:6543, can be overridden by GIT_DOMAIN env var
*/
export const GIT_DOMAIN =
typeof process !== 'undefined' && process.env?.GIT_DOMAIN
? process.env.GIT_DOMAIN
: 'localhost:6543';
/**
* Get the full git URL for a repository
*/
export function getGitUrl(npub: string, repoName: string): string {
const protocol = GIT_DOMAIN.startsWith('localhost') ? 'http' : 'https';
return `${protocol}://${GIT_DOMAIN}/${npub}/${repoName}.git`;
}

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

@ -0,0 +1,145 @@ @@ -0,0 +1,145 @@
/**
* Repository manager for git repositories
* Handles repo provisioning, syncing, and NIP-34 integration
*/
import { exec } from 'child_process';
import { promisify } from 'util';
import { existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import type { NostrEvent } from '../../types/nostr.js';
import { GIT_DOMAIN } from '../../config.js';
const execAsync = promisify(exec);
export interface RepoPath {
npub: string;
repoName: string;
fullPath: string;
}
export class RepoManager {
private repoRoot: string;
private domain: string;
constructor(repoRoot: string = '/repos', domain: string = GIT_DOMAIN) {
this.repoRoot = repoRoot;
this.domain = domain;
}
/**
* Parse git domain URL to extract npub and repo name
*/
parseRepoUrl(url: string): RepoPath | null {
// Match: https://{domain}/{npub}/{repo-name}.git or http://{domain}/{npub}/{repo-name}.git
// Escape domain for regex (replace dots with \.)
const escapedDomain = this.domain.replace(/\./g, '\\.');
const match = url.match(new RegExp(`${escapedDomain}\\/(npub[a-z0-9]+)\\/([^\\/]+)\\.git`));
if (!match) return null;
const [, npub, repoName] = match;
const fullPath = join(this.repoRoot, npub, `${repoName}.git`);
return { npub, repoName, fullPath };
}
/**
* Create a bare git repository from a NIP-34 repo announcement
*/
async provisionRepo(event: NostrEvent): Promise<void> {
const cloneUrls = this.extractCloneUrls(event);
const domainUrl = cloneUrls.find(url => url.includes(this.domain));
if (!domainUrl) {
throw new Error(`No ${this.domain} URL found in repo announcement`);
}
const repoPath = this.parseRepoUrl(domainUrl);
if (!repoPath) {
throw new Error(`Invalid ${this.domain} URL format`);
}
// Create directory structure
const repoDir = join(this.repoRoot, repoPath.npub);
if (!existsSync(repoDir)) {
mkdirSync(repoDir, { recursive: true });
}
// Create bare repository if it doesn't exist
if (!existsSync(repoPath.fullPath)) {
await execAsync(`git init --bare "${repoPath.fullPath}"`);
}
// 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);
}
}
/**
* Sync repository from multiple remote URLs
*/
async syncFromRemotes(repoPath: string, remoteUrls: string[]): Promise<void> {
for (const url of remoteUrls) {
try {
// Add remote if not exists
const remoteName = `remote-${remoteUrls.indexOf(url)}`;
await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`);
// Fetch from remote
await execAsync(`cd "${repoPath}" && git fetch ${remoteName} --all`);
// Update all branches
await execAsync(`cd "${repoPath}" && git remote set-head ${remoteName} -a`);
} catch (error) {
console.error(`Failed to sync from ${url}:`, error);
// Continue with other remotes
}
}
}
/**
* Sync repository to multiple remote URLs after a push
*/
async syncToRemotes(repoPath: string, remoteUrls: string[]): Promise<void> {
for (const url of remoteUrls) {
try {
const remoteName = `remote-${remoteUrls.indexOf(url)}`;
await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`);
await execAsync(`cd "${repoPath}" && git push ${remoteName} --all --force`);
await execAsync(`cd "${repoPath}" && git push ${remoteName} --tags --force`);
} catch (error) {
console.error(`Failed to sync to ${url}:`, error);
// Continue with other remotes
}
}
}
/**
* Extract clone URLs from a NIP-34 repo announcement
*/
private extractCloneUrls(event: NostrEvent): string[] {
const urls: string[] = [];
for (const tag of event.tags) {
if (tag[0] === 'clone') {
for (let i = 1; i < tag.length; i++) {
const url = tag[i];
if (url && typeof url === 'string') {
urls.push(url);
}
}
}
}
return urls;
}
/**
* Check if a repository exists
*/
repoExists(repoPath: string): boolean {
return existsSync(repoPath);
}
}

32
src/lib/services/nostr/nip07-signer.ts

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
/**
* NIP-07 browser extension signer
*/
import type { NostrEvent } from '../../types/nostr.js';
declare global {
interface Window {
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: Omit<NostrEvent, 'sig' | 'id'>): Promise<NostrEvent>;
};
}
}
export function isNIP07Available(): boolean {
return typeof window !== 'undefined' && typeof window.nostr !== 'undefined';
}
export async function getPublicKeyWithNIP07(): Promise<string> {
if (!isNIP07Available()) {
throw new Error('NIP-07 extension not available');
}
return await window.nostr!.getPublicKey();
}
export async function signEventWithNIP07(event: Omit<NostrEvent, 'sig' | 'id'>): Promise<NostrEvent> {
if (!isNIP07Available()) {
throw new Error('NIP-07 extension not available');
}
return await window.nostr!.signEvent(event);
}

58
src/lib/services/nostr/nip19-utils.ts

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
/**
* NIP-19 utilities for decoding bech32 addresses
*/
import { nip19 } from 'nostr-tools';
export interface DecodedEvent {
type: 'nevent' | 'naddr' | 'note';
id?: string;
pubkey?: string;
kind?: number;
identifier?: string;
relays?: string[];
}
export function decodeNostrAddress(input: string): DecodedEvent | null {
if (!input || input.trim() === '') return null;
const trimmed = input.trim();
// If it's already hex (64 chars), treat as event ID
if (/^[a-f0-9]{64}$/i.test(trimmed)) {
return { type: 'note', id: trimmed.toLowerCase() };
}
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'nevent') {
const data = decoded.data as { id: string; pubkey?: string; relays?: string[] };
return {
type: 'nevent',
id: data.id,
pubkey: data.pubkey,
relays: data.relays
};
} else if (decoded.type === 'naddr') {
const data = decoded.data as { pubkey: string; kind: number; identifier: string; relays?: string[] };
return {
type: 'naddr',
pubkey: data.pubkey,
kind: data.kind,
identifier: data.identifier,
relays: data.relays
};
} else if (decoded.type === 'note') {
return {
type: 'note',
id: decoded.data as string
};
}
} catch (e) {
// Not a valid bech32
return null;
}
return null;
}

122
src/lib/services/nostr/nostr-client.ts

@ -0,0 +1,122 @@ @@ -0,0 +1,122 @@
/**
* Nostr client for fetching and publishing events
*/
import type { NostrEvent, NostrFilter } from '../../types/nostr.js';
export class NostrClient {
private relays: string[] = [];
constructor(relays: string[]) {
this.relays = relays;
}
async fetchEvents(filters: NostrFilter[]): Promise<NostrEvent[]> {
const events: NostrEvent[] = [];
// Fetch from all relays in parallel
const promises = this.relays.map(relay => this.fetchFromRelay(relay, filters));
const results = await Promise.allSettled(promises);
for (const result of results) {
if (result.status === 'fulfilled') {
events.push(...result.value);
}
}
// Deduplicate by event ID
const uniqueEvents = new Map<string, NostrEvent>();
for (const event of events) {
if (!uniqueEvents.has(event.id) || event.created_at > uniqueEvents.get(event.id)!.created_at) {
uniqueEvents.set(event.id, event);
}
}
return Array.from(uniqueEvents.values());
}
private async fetchFromRelay(relay: string, filters: NostrFilter[]): Promise<NostrEvent[]> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relay);
const events: NostrEvent[] = [];
ws.onopen = () => {
ws.send(JSON.stringify(['REQ', 'sub', ...filters]));
};
ws.onmessage = (event: MessageEvent) => {
const message = JSON.parse(event.data);
if (message[0] === 'EVENT') {
events.push(message[2]);
} else if (message[0] === 'EOSE') {
ws.close();
resolve(events);
}
};
ws.onerror = (error) => {
ws.close();
reject(error);
};
setTimeout(() => {
ws.close();
resolve(events);
}, 5000);
});
}
async publishEvent(event: NostrEvent, relays?: string[]): Promise<{ success: string[]; failed: Array<{ relay: string; error: string }> }> {
const targetRelays = relays || this.relays;
const success: string[] = [];
const failed: Array<{ relay: string; error: string }> = [];
const promises = targetRelays.map(async (relay) => {
try {
await this.publishToRelay(relay, event);
success.push(relay);
} catch (error) {
failed.push({ relay, error: String(error) });
}
});
await Promise.allSettled(promises);
return { success, failed };
}
private async publishToRelay(relay: string, nostrEvent: NostrEvent): Promise<void> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relay);
ws.onopen = () => {
ws.send(JSON.stringify(['EVENT', nostrEvent]));
};
ws.onmessage = (event: MessageEvent) => {
const message = JSON.parse(event.data);
if (message[0] === 'OK' && message[1] === nostrEvent.id) {
if (message[2] === true) {
ws.close();
resolve();
} else {
ws.close();
reject(new Error(message[3] || 'Publish rejected'));
}
}
};
ws.onerror = (error) => {
ws.close();
reject(error);
};
setTimeout(() => {
ws.close();
reject(new Error('Timeout'));
}, 5000);
});
}
}

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

@ -0,0 +1,107 @@ @@ -0,0 +1,107 @@
/**
* Service for polling NIP-34 repo announcements and auto-provisioning repos
*/
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';
export class RepoPollingService {
private nostrClient: NostrClient;
private repoManager: RepoManager;
private pollingInterval: number;
private intervalId: NodeJS.Timeout | null = null;
private domain: string;
constructor(
relays: string[],
repoRoot: string,
domain: string,
pollingInterval: number = 60000 // 1 minute
) {
this.nostrClient = new NostrClient(relays);
this.repoManager = new RepoManager(repoRoot, domain);
this.pollingInterval = pollingInterval;
this.domain = domain;
}
/**
* Start polling for repo announcements
*/
start(): void {
if (this.intervalId) {
this.stop();
}
// Poll immediately
this.poll();
// Then poll at intervals
this.intervalId = setInterval(() => {
this.poll();
}, this.pollingInterval);
}
/**
* Stop polling
*/
stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
/**
* Poll for new repo announcements and provision repos
*/
private async poll(): Promise<void> {
try {
const events = await this.nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
limit: 100
}
]);
// Filter for repos that list our domain
const relevantEvents = events.filter(event => {
const cloneUrls = this.extractCloneUrls(event);
return cloneUrls.some(url => url.includes(this.domain));
});
// Provision each repo
for (const event of relevantEvents) {
try {
await this.repoManager.provisionRepo(event);
console.log(`Provisioned repo from announcement ${event.id}`);
} catch (error) {
console.error(`Failed to provision repo from ${event.id}:`, error);
}
}
} catch (error) {
console.error('Error polling for repo announcements:', error);
}
}
/**
* Extract clone URLs from a NIP-34 repo announcement
*/
private extractCloneUrls(event: NostrEvent): string[] {
const urls: string[] = [];
for (const tag of event.tags) {
if (tag[0] === 'clone') {
for (let i = 1; i < tag.length; i++) {
const url = tag[i];
if (url && typeof url === 'string') {
urls.push(url);
}
}
}
}
return urls;
}
}

77
src/lib/services/nostr/user-relays.ts

@ -0,0 +1,77 @@ @@ -0,0 +1,77 @@
/**
* Service for fetching user's preferred relays from their inbox/outbox
*/
import { NostrClient } from './nostr-client.js';
import type { NostrEvent } from '../../types/nostr.js';
const KIND_RELAY_LIST = 10002;
const KIND_CONTACTS = 3;
export async function getUserRelays(
pubkey: string,
nostrClient: NostrClient
): Promise<{ inbox: string[]; outbox: string[] }> {
const inbox: string[] = [];
const outbox: string[] = [];
try {
// Fetch kind 10002 (relay list)
const relayListEvents = await nostrClient.fetchEvents([
{
kinds: [KIND_RELAY_LIST],
authors: [pubkey],
limit: 1
}
]);
if (relayListEvents.length > 0) {
const event = relayListEvents[0];
for (const tag of event.tags) {
if (tag[0] === 'relay' && tag[1]) {
const relay = tag[1];
const read = tag[2] !== 'write';
const write = tag[2] !== 'read';
if (read) inbox.push(relay);
if (write) outbox.push(relay);
}
}
}
// Fallback to kind 3 (contacts) for older clients
if (inbox.length === 0 && outbox.length === 0) {
const contactEvents = await nostrClient.fetchEvents([
{
kinds: [KIND_CONTACTS],
authors: [pubkey],
limit: 1
}
]);
if (contactEvents.length > 0) {
const event = contactEvents[0];
// Extract relays from content (JSON) or tags
try {
const content = JSON.parse(event.content);
if (content.relays && Array.isArray(content.relays)) {
inbox.push(...content.relays);
outbox.push(...content.relays);
}
} catch {
// Not JSON, check tags
for (const tag of event.tags) {
if (tag[0] === 'relay' && tag[1]) {
inbox.push(tag[1]);
outbox.push(tag[1]);
}
}
}
}
}
} catch (error) {
console.error('Failed to fetch user relays:', error);
}
return { inbox, outbox };
}

29
src/lib/types/nostr.ts

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
/**
* Nostr type definitions
*/
export interface NostrEvent {
id: string;
pubkey: string;
created_at: number;
kind: number;
tags: string[][];
content: string;
sig: string;
}
export interface NostrFilter {
ids?: string[];
authors?: string[];
kinds?: number[];
'#e'?: string[];
'#p'?: string[];
'#d'?: string[];
since?: number;
until?: number;
limit?: number;
}
export const KIND = {
REPO_ANNOUNCEMENT: 30617,
} as const;

12
src/routes/+layout.server.ts

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
/**
* Root layout server load function
* Provides config to all pages
*/
import { GIT_DOMAIN } from '$lib/config.js';
export async function load() {
return {
gitDomain: GIT_DOMAIN
};
}

227
src/routes/+page.svelte

@ -0,0 +1,227 @@ @@ -0,0 +1,227 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { NostrClient } from '../lib/services/nostr/nostr-client.js';
import { KIND } from '../lib/types/nostr.js';
import type { NostrEvent } from '../lib/types/nostr.js';
let repos = $state<NostrEvent[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
const relays = [
'wss://theforest.nostr1.com',
'wss://nostr.land',
'wss://relay.damus.io'
];
const nostrClient = new NostrClient(relays);
onMount(async () => {
await loadRepos();
});
async function loadRepos() {
loading = true;
error = null;
try {
const events = await nostrClient.fetchEvents([
{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 100 }
]);
// Get git domain from layout data
const gitDomain = $page.data.gitDomain || 'localhost:6543';
// Filter for repos that list our domain in clone tags
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));
});
// Sort by created_at descending
repos.sort((a, b) => b.created_at - a.created_at);
} catch (e) {
error = String(e);
console.error('Failed to load repos:', e);
} finally {
loading = false;
}
}
function getRepoName(event: NostrEvent): string {
const nameTag = event.tags.find(t => t[0] === 'name' && t[1]);
if (nameTag?.[1]) return nameTag[1];
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (dTag) return dTag;
return `Repository ${event.id.slice(0, 8)}`;
}
function getRepoDescription(event: NostrEvent): string {
const descTag = event.tags.find(t => t[0] === 'description' && t[1]);
return descTag?.[1] || '';
}
function getCloneUrls(event: NostrEvent): string[] {
const urls: string[] = [];
for (const tag of event.tags) {
if (tag[0] === 'clone') {
for (let i = 1; i < tag.length; i++) {
const url = tag[i];
if (url && typeof url === 'string') {
urls.push(url);
}
}
}
}
return urls;
}
</script>
<div class="container">
<header>
<h1>gitrepublic</h1>
<nav>
<a href="/">Repositories</a>
<a href="/signup">Sign Up</a>
</nav>
</header>
<main>
<div class="repos-header">
<h2>Repositories on {$page.data.gitDomain || 'localhost:6543'}</h2>
<button onclick={loadRepos} disabled={loading}>
{loading ? 'Loading...' : 'Refresh'}
</button>
</div>
{#if error}
<div class="error">
Error loading repositories: {error}
</div>
{:else if loading}
<div class="loading">Loading repositories...</div>
{:else if repos.length === 0}
<div class="empty">No repositories found.</div>
{:else}
<div class="repos-list">
{#each repos as repo}
<div class="repo-card">
<h3>{getRepoName(repo)}</h3>
{#if getRepoDescription(repo)}
<p class="description">{getRepoDescription(repo)}</p>
{/if}
<div class="clone-urls">
<strong>Clone URLs:</strong>
{#each getCloneUrls(repo) as url}
<code>{url}</code>
{/each}
</div>
<div class="repo-meta">
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span>
</div>
</div>
{/each}
</div>
{/if}
</main>
</div>
<style>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
}
nav {
display: flex;
gap: 1rem;
}
nav a {
text-decoration: none;
color: #3b82f6;
}
.repos-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.repos-list {
display: grid;
gap: 1.5rem;
}
.repo-card {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1.5rem;
background: white;
}
.repo-card h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
}
.description {
color: #6b7280;
margin: 0.5rem 0;
}
.clone-urls {
margin: 1rem 0;
}
.clone-urls code {
display: block;
background: #f3f4f6;
padding: 0.5rem;
border-radius: 0.25rem;
margin: 0.25rem 0;
font-family: monospace;
font-size: 0.875rem;
}
.repo-meta {
font-size: 0.875rem;
color: #6b7280;
margin-top: 1rem;
}
.error {
background: #fee2e2;
color: #991b1b;
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.loading, .empty {
text-align: center;
padding: 2rem;
color: #6b7280;
}
</style>

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

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
/**
* Git HTTP backend API route
* Handles git clone, push, pull operations via git-http-backend
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// This will be implemented to proxy requests to git-http-backend
// For now, return a placeholder
export const GET: RequestHandler = async ({ params, url }) => {
const path = params.path || '';
const service = url.searchParams.get('service');
// TODO: Implement git-http-backend integration
// This should:
// 1. Authenticate using NIP-98 (HTTP auth with Nostr)
// 2. Map URL path to git repo ({domain}/{npub}/{repo-name}.git)
// 3. Proxy request to git-http-backend
// 4. Handle git smart HTTP protocol
return json({
message: 'Git HTTP backend not yet implemented',
path,
service
});
};
export const POST: RequestHandler = async ({ params, url, request }) => {
const path = params.path || '';
const service = url.searchParams.get('service');
// TODO: Implement git-http-backend integration for push operations
return json({
message: 'Git HTTP backend not yet implemented',
path,
service
});
};

475
src/routes/signup/+page.svelte

@ -0,0 +1,475 @@ @@ -0,0 +1,475 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from '../../lib/services/nostr/nip07-signer.js';
import { decodeNostrAddress } from '../../lib/services/nostr/nip19-utils.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 { nip19 } from 'nostr-tools';
let nip07Available = $state(false);
let loading = $state(false);
let error = $state<string | null>(null);
let success = $state(false);
// Form fields
let repoName = $state('');
let description = $state('');
let cloneUrls = $state<string[]>(['']);
let existingRepoRef = $state(''); // hex, nevent, or naddr
let loadingExisting = $state(false);
const relays = [
'wss://theforest.nostr1.com',
'wss://nostr.land',
'wss://relay.damus.io'
];
const nostrClient = new NostrClient(relays);
onMount(() => {
nip07Available = isNIP07Available();
});
function addCloneUrl() {
cloneUrls = [...cloneUrls, ''];
}
function removeCloneUrl(index: number) {
cloneUrls = cloneUrls.filter((_, i) => i !== index);
}
function updateCloneUrl(index: number, value: string) {
const newUrls = [...cloneUrls];
newUrls[index] = value;
cloneUrls = newUrls;
}
async function loadExistingRepo() {
if (!existingRepoRef.trim()) return;
loadingExisting = true;
error = null;
try {
const decoded = decodeNostrAddress(existingRepoRef.trim());
if (!decoded) {
error = 'Invalid format. Please provide a hex event ID, nevent, or naddr.';
loadingExisting = false;
return;
}
let event: NostrEvent | null = null;
if (decoded.type === 'note' && decoded.id) {
// Fetch by event ID
const events = await nostrClient.fetchEvents([{ ids: [decoded.id], limit: 1 }]);
event = events[0] || null;
} else if (decoded.type === 'nevent' && decoded.id) {
// Fetch by event ID
const events = await nostrClient.fetchEvents([{ ids: [decoded.id], limit: 1 }]);
event = events[0] || null;
} else if (decoded.type === 'naddr' && decoded.pubkey && decoded.kind && decoded.identifier) {
// Fetch parameterized replaceable event
const events = await nostrClient.fetchEvents([
{
kinds: [decoded.kind],
authors: [decoded.pubkey],
'#d': [decoded.identifier],
limit: 1
}
]);
event = events[0] || null;
}
if (!event) {
error = 'Repository announcement not found. Make sure it exists on the relays.';
loadingExisting = false;
return;
}
if (event.kind !== KIND.REPO_ANNOUNCEMENT) {
error = 'The provided event is not a repository announcement (kind 30617).';
loadingExisting = false;
return;
}
// Populate form with existing data
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
const nameTag = event.tags.find(t => t[0] === 'name')?.[1] || '';
const descTag = event.tags.find(t => t[0] === 'description')?.[1] || '';
repoName = nameTag || dTag;
description = descTag;
// Extract clone URLs
const urls: string[] = [];
for (const tag of event.tags) {
if (tag[0] === 'clone') {
for (let i = 1; i < tag.length; i++) {
const url = tag[i];
if (url && typeof url === 'string') {
urls.push(url);
}
}
}
}
cloneUrls = urls.length > 0 ? urls : [''];
} catch (e) {
error = `Failed to load repository: ${String(e)}`;
} finally {
loadingExisting = false;
}
}
async function submit() {
if (!nip07Available) {
error = 'NIP-07 extension is required. Please install a Nostr browser extension.';
return;
}
if (!repoName.trim()) {
error = 'Repository name is required.';
return;
}
loading = true;
error = null;
try {
const pubkey = await getPublicKeyWithNIP07();
const npub = nip19.npubEncode(pubkey);
// Normalize repo name to d-tag format
const dTag = repoName
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
// Get git domain from layout data
const gitDomain = $page.data.gitDomain || 'localhost:6543';
const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https';
const gitUrl = `${protocol}://${gitDomain}/${npub}/${dTag}.git`;
// Build clone URLs - always include our domain
const allCloneUrls = [
gitUrl,
...cloneUrls.filter(url => url.trim() && !url.includes(gitDomain))
];
// Build tags
const tags: string[][] = [
['d', dTag],
['name', repoName],
...(description ? [['description', description]] : []),
...allCloneUrls.map(url => ['clone', url]),
['relays', ...relays]
];
// Build event
const eventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.REPO_ANNOUNCEMENT,
pubkey,
created_at: Math.floor(Date.now() / 1000),
content: '', // Empty per NIP-34
tags
};
// Sign with NIP-07
const signedEvent = await signEventWithNIP07(eventTemplate);
// Get user's inbox/outbox relays (from kind 3 or 10002)
const { getUserRelays } = await import('../../lib/services/nostr/user-relays.js');
const { inbox, outbox } = await getUserRelays(pubkey, nostrClient);
// Combine user's outbox with default relays
const userRelays = [...new Set([...outbox, ...relays])];
// Publish to user's outboxes and standard relays
const result = await nostrClient.publishEvent(signedEvent, userRelays);
if (result.success.length > 0) {
success = true;
setTimeout(() => {
goto('/');
}, 2000);
} else {
error = 'Failed to publish to any relays.';
}
} catch (e) {
error = `Failed to create repository announcement: ${String(e)}`;
} finally {
loading = false;
}
}
</script>
<div class="container">
<header>
<h1>gitrepublic</h1>
<nav>
<a href="/">Repositories</a>
<a href="/signup">Sign Up</a>
</nav>
</header>
<main>
<h2>Create or Update Repository Announcement</h2>
{#if !nip07Available}
<div class="warning">
<p>NIP-07 browser extension is required to sign repository announcements.</p>
<p>Please install a Nostr browser extension (like Alby, nos2x, or similar).</p>
</div>
{/if}
{#if error}
<div class="error">{error}</div>
{/if}
{#if success}
<div class="success">
Repository announcement published successfully! Redirecting...
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); submit(); }}>
<div class="form-group">
<label for="existing-repo-ref">
Load Existing Repository (optional)
<small>Enter hex event ID, nevent, or naddr to update an existing announcement</small>
</label>
<div class="input-group">
<input
id="existing-repo-ref"
type="text"
bind:value={existingRepoRef}
placeholder="hex event ID, nevent1..., or naddr1..."
disabled={loading || loadingExisting}
/>
<button
type="button"
onclick={loadExistingRepo}
disabled={loading || loadingExisting || !existingRepoRef.trim()}
>
{loadingExisting ? 'Loading...' : 'Load'}
</button>
</div>
</div>
<div class="form-group">
<label for="repo-name">
Repository Name *
<small>Will be used as the d-tag (normalized to lowercase with hyphens)</small>
</label>
<input
id="repo-name"
type="text"
bind:value={repoName}
placeholder="my-awesome-repo"
required
disabled={loading}
/>
</div>
<div class="form-group">
<label for="description">
Description
</label>
<textarea
id="description"
bind:value={description}
placeholder="Repository description"
rows={3}
disabled={loading}
/>
</div>
<div class="form-group">
<label>
Clone URLs
<small>{$page.data.gitDomain || 'localhost:6543'} will be added automatically</small>
</label>
{#each cloneUrls as url, index}
<div class="input-group">
<input
type="text"
value={url}
oninput={(e) => updateCloneUrl(index, e.currentTarget.value)}
placeholder="https://github.com/user/repo.git"
disabled={loading}
/>
{#if cloneUrls.length > 1}
<button
type="button"
onclick={() => removeCloneUrl(index)}
disabled={loading}
>
Remove
</button>
{/if}
</div>
{/each}
<button
type="button"
onclick={addCloneUrl}
disabled={loading}
class="add-button"
>
+ Add Clone URL
</button>
</div>
<div class="form-actions">
<button
type="submit"
disabled={loading || !nip07Available}
>
{loading ? 'Publishing...' : 'Publish Repository Announcement'}
</button>
<a href="/" class="cancel-link">Cancel</a>
</div>
</form>
</main>
</div>
<style>
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
}
nav {
display: flex;
gap: 1rem;
}
nav a {
text-decoration: none;
color: #3b82f6;
}
.warning {
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1.5rem;
}
.error {
background: #fee2e2;
color: #991b1b;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
}
.success {
background: #d1fae5;
color: #065f46;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
}
form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
font-weight: 500;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
label small {
font-weight: normal;
color: #6b7280;
font-size: 0.875rem;
}
input, textarea {
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
}
input:disabled, textarea:disabled {
background: #f3f4f6;
cursor: not-allowed;
}
.input-group {
display: flex;
gap: 0.5rem;
align-items: center;
}
.input-group input {
flex: 1;
}
button {
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
}
button:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.add-button {
background: #10b981;
margin-top: 0.5rem;
}
.form-actions {
display: flex;
gap: 1rem;
align-items: center;
}
.form-actions button[type="submit"] {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.cancel-link {
color: #6b7280;
text-decoration: none;
}
</style>

12
svelte.config.js

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
export default config;

16
tsconfig.json

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
}
}

6
vite.config.ts

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});
Loading…
Cancel
Save