Compare commits

..

No commits in common. '7bb1c8d66b2790c887f617705c728fb1ba4ce5e7' and 'e831e1f5b9a046fb7540a4f45523a54a7720efa0' have entirely different histories.

  1. 1
      docs/api-and-cli.md
  2. 10
      docs/editing-repos.md
  3. 3
      nostr/commit-signatures.jsonl
  4. 50
      src/hooks.server.ts
  5. 49
      src/lib/services/git/announcement-manager.ts
  6. 197
      src/lib/services/git/repo-manager.ts
  7. 278
      src/lib/services/nostr/repo-polling.ts
  8. 16
      src/lib/services/service-registry.ts
  9. 1
      src/lib/utils/nostr-utils.ts
  10. 30
      src/lib/utils/repo-poll-trigger.ts
  11. 43
      src/routes/api/openapi.json/openapi.json
  12. 31
      src/routes/api/repos/[npub]/[repo]/clone/+server.ts
  13. 2
      src/routes/api/repos/local/+server.ts
  14. 33
      src/routes/api/repos/poll/+server.ts
  15. 7
      src/routes/api/user/level/+server.ts
  16. 29
      src/routes/repos/+page.svelte
  17. 20
      src/routes/repos/[npub]/[repo]/services/repo-operations.ts

1
docs/api-and-cli.md

@ -116,6 +116,7 @@ View interactive documentation at `/api/openapi.json` or use any OpenAPI viewer. @@ -116,6 +116,7 @@ View interactive documentation at `/api/openapi.json` or use any OpenAPI viewer.
- `GET /api/config` - Get server configuration
- `GET /api/tor/onion` - Get Tor .onion address
- `POST /api/repos/poll` - Trigger repository polling (provisions new repos from Nostr)
- `GET /api/transfers/pending` - Get pending ownership transfers
#### Git HTTP Backend

10
docs/editing-repos.md

@ -2,18 +2,18 @@ @@ -2,18 +2,18 @@
This page covers all aspects of editing repositories: branch management, file management, auto-provisioning, file-editing permissions, and event-creation permissions.
## Adding Repositories
## Auto-Provisioning
Repositories must be explicitly added to the server using the clone endpoint. When you clone a repository:
When you create a repository announcement, GitRepublic automatically:
1. **Fetches the repository announcement** from Nostr relays
1. **Polls Nostr relays** for new announcements
2. **Creates a bare git repository** at `/repos/{npub}/{repo-name}.git`
3. **Fetches self-transfer event** for ownership verification (if available)
3. **Fetches self-transfer event** for ownership verification
4. **Creates initial commit** with README.md (if provided)
5. **Saves announcement and transfer events** to `nostr/repo-events.jsonl`
6. **Syncs from other remotes** if clone URLs are configured
The repository is ready to use immediately after cloning.
The repository is ready to use immediately after announcement.
## Branch Management

3
nostr/commit-signatures.jsonl

@ -118,6 +118,3 @@ @@ -118,6 +118,3 @@
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772264490,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","polling update"]],"content":"Signed commit: polling update","id":"42c1a2a63a4568c65d82d78701451b3b4363bdf9c8c57e804535b5f3f0d7b6fc","sig":"8e5f32ecb79da876ac41eba04c3b1541b21d039ae50d1b9fefa630d35f31c97dd29af64e4b695742fa7d4eaec17db8f4a066b4db99ce628aed596971975d4a87"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772267611,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor API"]],"content":"Signed commit: refactor API","id":"934f8809638cea0bc7b8158fca959bc60880e0cae9ab8ff653687313adcd2f57","sig":"c9d8e5b821ae8182f8d39599c50fd0a4db6040ead1d8d83730a608a1d94d5078770a6ccbfc525a98691e98fabd9f9d24f0298680fb564c6b76c2f34bed9889b5"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772269280,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","api refactor part 2"]],"content":"Signed commit: api refactor part 2","id":"ece894a60057bba46ebd4ac0dca2aca55ffce05e44671fe07b29516809fc86f6","sig":"176706a271659834e441ea5eab4bb1480667dad4468fe8315803284f4a183debf595523dd33d0d3cabe0c35013f4a72b9169b5f10afefaf8a82a721d8b0f3b08"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772270859,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes and fallback relay"]],"content":"Signed commit: bug-fixes and fallback relay","id":"1d85d0c5e1451c90bca5d59e08043f29adeaad4db4ac5495c8e9a4247775780f","sig":"a1960b76c78db9f64dad20378d26f500ffc09f1f6d137314db548470202712222a1d391f682146ba281fd23355c574fcbb260310db61b3458bba3dec0c724a18"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772271656,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"f4a5e0d3e2aa7d0d99803f26008ab68e40551e36362bb6d04acf639c5b78d959","sig":"59da9e59a6fb5648f4c889e0045b571e0d2d66a555100d60dec373455309a640bea89e4bb3a42a0e502aa4d2091e4b698203721e79b346ff30e6b2bcdc5f48b3"}
{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772274086,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"32794e047f06902ad610f918834efb113f41eace26a53a3f0fad083b9d8323dc","sig":"3859f0de3de0f8a742b6fbe7709c5a5625f4d5612a936fd81f38a7e1231ee810b50a69c1ed5d23c8a6670b4cbc9ea3d4bd39d6fa9e6207802f45995689b924a9"}

50
src/hooks.server.ts

@ -1,14 +1,23 @@ @@ -1,14 +1,23 @@
/**
* Server-side hooks for gitrepublic-web
* Initializes security middleware
* Initializes repo polling service and security middleware
*/
import type { Handle } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
import { RepoPollingService } from './lib/services/nostr/repo-polling.js';
import { GIT_DOMAIN, DEFAULT_NOSTR_RELAYS } from './lib/config.js';
import { setRepoPollingService } from './lib/services/service-registry.js';
import { rateLimiter } from './lib/services/security/rate-limiter.js';
import { auditLogger } from './lib/services/security/audit-logger.js';
import logger from './lib/services/logger.js';
// Initialize polling service
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const domain = GIT_DOMAIN;
let pollingService: RepoPollingService | null = null;
if (typeof process !== 'undefined') {
// Handle unhandled promise rejections to prevent crashes from relay errors
process.on('unhandledRejection', (reason, promise) => {
@ -20,9 +29,29 @@ if (typeof process !== 'undefined') { @@ -20,9 +29,29 @@ if (typeof process !== 'undefined') {
}
});
pollingService = new RepoPollingService(DEFAULT_NOSTR_RELAYS, repoRoot, domain);
// Register with service registry so it can be accessed from API endpoints
setRepoPollingService(pollingService);
// Start polling - the initial poll will complete asynchronously
// The local repos endpoint will skip cache for the first 10 seconds after startup
pollingService.start().then(() => {
logger.info({ service: 'repo-polling', relays: DEFAULT_NOSTR_RELAYS.length }, 'Repo polling service ready (initial poll completed)');
}).catch((err) => {
logger.error({ error: err, service: 'repo-polling' }, 'Initial repo poll failed, but continuing');
});
logger.info({ service: 'repo-polling', relays: DEFAULT_NOSTR_RELAYS.length }, 'Started repo polling service (initial poll in progress)');
// Cleanup on server shutdown
const cleanup = (signal: string) => {
logger.info({ signal }, 'Received shutdown signal, cleaning up...');
if (pollingService) {
logger.info('Stopping repo polling service...');
pollingService.stop();
pollingService = null;
}
// Give a moment for cleanup, then exit
setTimeout(() => {
process.exit(0);
@ -39,6 +68,25 @@ if (typeof process !== 'undefined') { @@ -39,6 +68,25 @@ if (typeof process !== 'undefined') {
process.exit(0);
}, 2000);
});
// Also cleanup on process exit (last resort)
process.on('exit', () => {
if (pollingService) {
pollingService.stop();
}
});
// Periodic zombie process cleanup check
// This helps catch any processes that weren't properly cleaned up
if (typeof setInterval !== 'undefined') {
setInterval(() => {
// Check for zombie processes by attempting to reap them
// Node.js handles this automatically via 'close' events, but this is a safety net
// We can't directly check for zombies, but we can ensure our cleanup is working
// The real cleanup happens in process handlers, this is just monitoring
logger.debug('Zombie cleanup check (process handlers should prevent zombies)');
}, 60000); // Check every minute
}
}
export const handle: Handle = async ({ event, resolve }) => {

49
src/lib/services/git/announcement-manager.ts

@ -190,7 +190,7 @@ export class AnnouncementManager { @@ -190,7 +190,7 @@ export class AnnouncementManager {
* Ensure announcement event is saved to nostr/repo-events.jsonl in the repository
* Only saves if not already present (avoids redundant entries)
*/
async ensureAnnouncementInRepo(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent, preferredDefaultBranch?: string): Promise<void> {
async ensureAnnouncementInRepo(repoPath: string, event: NostrEvent, selfTransferEvent?: NostrEvent): Promise<void> {
let isEmpty = false;
try {
// Create a temporary working directory
@ -218,8 +218,8 @@ export class AnnouncementManager { @@ -218,8 +218,8 @@ export class AnnouncementManager {
if (isEmpty) {
// Repo is empty - initialize worktree and create initial branch
// Use preferred branch, then environment, then try 'main' first, then 'master'
const defaultBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'main';
// Use default branch from environment or try 'main' first, then 'master'
const defaultBranch = process.env.DEFAULT_BRANCH || 'main';
// Initialize git in workdir
workGit = simpleGit(workDir);
@ -233,45 +233,6 @@ export class AnnouncementManager { @@ -233,45 +233,6 @@ export class AnnouncementManager {
await git.clone(repoPath, workDir);
// Create workGit instance after clone
workGit = simpleGit(workDir);
// Determine the correct default branch to commit to
let targetBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'main';
// Check if the preferred branch exists, if not try to find the actual default branch
try {
const branches = await workGit.branch(['-a']);
const branchList = branches.all
.map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '').replace(/^refs\/heads\//, ''))
.filter(b => b && !b.includes('HEAD'));
// If preferred branch exists, use it; otherwise try main/master, then first branch
if (preferredDefaultBranch && branchList.includes(preferredDefaultBranch)) {
targetBranch = preferredDefaultBranch;
} else if (branchList.includes('main')) {
targetBranch = 'main';
} else if (branchList.includes('master')) {
targetBranch = 'master';
} else if (branchList.length > 0) {
targetBranch = branchList[0];
}
// Checkout the target branch
try {
await workGit.checkout(targetBranch);
logger.debug({ repoPath, targetBranch }, 'Checked out target branch for announcement commit');
} catch (checkoutErr) {
// If checkout fails, try to create the branch
logger.debug({ repoPath, targetBranch, error: checkoutErr }, 'Failed to checkout branch, will try to create it');
try {
await workGit.checkout(['-b', targetBranch]);
logger.debug({ repoPath, targetBranch }, 'Created and checked out new branch for announcement commit');
} catch (createErr) {
logger.warn({ repoPath, targetBranch, error: createErr }, 'Failed to create branch, will commit to current branch');
}
}
} catch (branchErr) {
logger.warn({ repoPath, error: branchErr }, 'Failed to determine or checkout branch, will commit to current branch');
}
}
// Check if announcement already exists in nostr/repo-events.jsonl
@ -355,8 +316,8 @@ export class AnnouncementManager { @@ -355,8 +316,8 @@ export class AnnouncementManager {
logger.info({ repoPath, commitHash, objectCount: objectEntries.length }, 'Objects verified after commit');
// Push back to bare repo
// Use preferred branch, then environment, then try 'main' first, then 'master'
const defaultBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'main';
// Use default branch from environment or try 'main' first, then 'master'
const defaultBranch = process.env.DEFAULT_BRANCH || 'main';
if (isEmpty) {
// For empty repos, directly copy objects and update refs (more reliable than push)

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

@ -71,9 +71,8 @@ export class RepoManager { @@ -71,9 +71,8 @@ export class RepoManager {
* @param selfTransferEvent - Optional self-transfer event to include in initial commit
* @param isExistingRepo - Whether this is an existing repo being added to the server
* @param allowMissingDomainUrl - In development, allow provisioning even if domain URL isn't in announcement
* @param preferredDefaultBranch - Preferred default branch name (e.g., from user settings)
*/
async provisionRepo(event: NostrEvent, selfTransferEvent?: NostrEvent, isExistingRepo: boolean = false, allowMissingDomainUrl: boolean = false, preferredDefaultBranch?: string): Promise<void> {
async provisionRepo(event: NostrEvent, selfTransferEvent?: NostrEvent, isExistingRepo: boolean = false, allowMissingDomainUrl: boolean = false): Promise<void> {
const cloneUrls = this.urlParser.extractCloneUrls(event);
let domainUrl = cloneUrls.find(url => url.includes(this.domain));
@ -162,30 +161,25 @@ export class RepoManager { @@ -162,30 +161,25 @@ export class RepoManager {
if (!hasBranches) {
// No branches exist - create initial branch and README (which includes announcement)
await this.createInitialBranchAndReadme(repoPath.fullPath, repoPath.npub, repoPath.repoName, event, preferredDefaultBranch);
await this.createInitialBranchAndReadme(repoPath.fullPath, repoPath.npub, repoPath.repoName, event);
} else {
// Branches exist (from sync) - ensure README exists and announcement is committed to the default branch
// Check if README exists, and create it if missing
await this.ensureReadmeExists(repoPath.fullPath, repoPath.npub, repoPath.repoName, event, preferredDefaultBranch);
// Ensure announcement is committed to the default branch
// Branches exist (from sync) - ensure announcement is committed to the default branch
// This must happen after syncing so we can commit it to the existing default branch
// Make it blocking so the commit is complete before returning
try {
await this.announcementManager.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent, preferredDefaultBranch);
logger.info({ repoPath: repoPath.fullPath, eventId: event.id }, 'Announcement committed to repository');
} catch (err) {
logger.warn({ error: err, repoPath: repoPath.fullPath, eventId: event.id },
'Failed to save announcement to repo (announcement available from relays)');
}
// Non-blocking: fire and forget - we have the announcement from relays, so this is just for offline papertrail
this.announcementManager.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent)
.catch((err) => {
logger.warn({ error: err, repoPath: repoPath.fullPath, eventId: event.id },
'Failed to save announcement to repo (non-blocking, announcement available from relays)');
});
}
} else {
// For existing repos, check if announcement exists in repo
// If not, try to fetch from relays and save it
// Note: We have the announcement from the clone request (event parameter), so we can use that
// Note: We have the announcement from polling (event parameter), so we can use that
// Non-blocking: fire and forget - we have the announcement from relays, so this is just for offline papertrail
const hasAnnouncement = await this.announcementManager.hasAnnouncementInRepoFile(repoPath.fullPath);
if (!hasAnnouncement) {
// We have the event from the clone request, so use it directly (no need to fetch from relays again)
// We have the event from polling, so use it directly (no need to fetch from relays again)
// Save announcement to repo asynchronously
this.announcementManager.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent)
.catch((err) => {
@ -210,13 +204,12 @@ export class RepoManager { @@ -210,13 +204,12 @@ export class RepoManager {
repoPath: string,
npub: string,
repoName: string,
announcementEvent: NostrEvent,
preferredDefaultBranch?: string
announcementEvent: NostrEvent
): Promise<void> {
try {
// Get default branch from preferred branch, git config, environment, or use 'master'
// Check preferred branch first (from user settings), then git's init.defaultBranch config
let defaultBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'master';
// Get default branch from git config, environment, or use 'master'
// Check git's init.defaultBranch config first (respects user's git settings)
let defaultBranch = process.env.DEFAULT_BRANCH || 'master';
try {
const git = simpleGit();
@ -240,25 +233,18 @@ export class RepoManager { @@ -240,25 +233,18 @@ export class RepoManager {
// If branches exist, check if one matches our default branch preference
if (existingBranches.length > 0) {
// If we have a preferred branch and it exists, use it
if (preferredDefaultBranch && existingBranches.includes(preferredDefaultBranch)) {
defaultBranch = preferredDefaultBranch;
} else {
// Prefer existing branches that match common defaults, prioritizing preferred branch
const preferredBranches = preferredDefaultBranch
? [preferredDefaultBranch, defaultBranch, 'main', 'master', 'dev']
: [defaultBranch, 'main', 'master', 'dev'];
for (const preferred of preferredBranches) {
if (existingBranches.includes(preferred)) {
defaultBranch = preferred;
break;
}
}
// If no match, use the first existing branch
if (!existingBranches.includes(defaultBranch)) {
defaultBranch = existingBranches[0];
// Prefer existing branches that match common defaults
const preferredBranches = [defaultBranch, 'main', 'master', 'dev'];
for (const preferred of preferredBranches) {
if (existingBranches.includes(preferred)) {
defaultBranch = preferred;
break;
}
}
// If no match, use the first existing branch
if (!existingBranches.includes(defaultBranch)) {
defaultBranch = existingBranches[0];
}
}
} catch {
// No branches exist, use the determined default
@ -351,130 +337,6 @@ Your commits will all be signed by your Nostr keys and saved to the event files @@ -351,130 +337,6 @@ Your commits will all be signed by your Nostr keys and saved to the event files
}
}
/**
* Ensure README.md exists in the repository, creating it if missing
* This is called when branches exist from sync but README might be missing
*/
private async ensureReadmeExists(
repoPath: string,
npub: string,
repoName: string,
announcementEvent: NostrEvent,
preferredDefaultBranch?: string
): Promise<void> {
try {
// Get default branch
const { FileManager } = await import('./file-manager.js');
const fileManager = new FileManager(this.repoRoot);
let defaultBranch = preferredDefaultBranch;
if (!defaultBranch) {
try {
defaultBranch = await fileManager.getDefaultBranch(npub, repoName);
} catch {
// If getDefaultBranch fails, try to determine from git
const repoGit = simpleGit(repoPath);
try {
const branches = await repoGit.branch(['-a']);
const branchList = branches.all
.map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '').replace(/^refs\/heads\//, ''))
.filter(b => b && !b.includes('HEAD'));
if (branchList.length > 0) {
// Prefer preferred branch, then main, then master, then first branch
if (preferredDefaultBranch && branchList.includes(preferredDefaultBranch)) {
defaultBranch = preferredDefaultBranch;
} else if (branchList.includes('main')) {
defaultBranch = 'main';
} else if (branchList.includes('master')) {
defaultBranch = 'master';
} else {
defaultBranch = branchList[0];
}
}
} catch {
defaultBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'master';
}
}
}
if (!defaultBranch) {
defaultBranch = preferredDefaultBranch || process.env.DEFAULT_BRANCH || 'master';
}
// Check if README.md already exists
const workDir = await fileManager.getWorktree(repoPath, defaultBranch, npub, repoName);
const { readFile: readFileFs, writeFile: writeFileFs } = await import('fs/promises');
const { join } = await import('path');
const readmePath = join(workDir, 'README.md');
try {
await readFileFs(readmePath, 'utf-8');
// README exists, nothing to do
await fileManager.removeWorktree(repoPath, workDir);
logger.debug({ npub, repoName, branch: defaultBranch }, 'README.md already exists');
return;
} catch {
// README doesn't exist, create it
}
// Get repo name from d-tag or use repoName from path
const dTag = announcementEvent.tags.find(t => t[0] === 'd')?.[1] || repoName;
// Get name tag for README title, fallback to d-tag
const nameTag = announcementEvent.tags.find(t => t[0] === 'name')?.[1] || dTag;
// Get author info from user profile (fetch from relays)
const { fetchUserProfile, extractProfileData, getUserName, getUserEmail } = await import('../../utils/user-profile.js');
const { nip19 } = await import('nostr-tools');
const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js');
const userNpub = nip19.npubEncode(announcementEvent.pubkey);
const profileEvent = await fetchUserProfile(announcementEvent.pubkey, DEFAULT_NOSTR_RELAYS);
const profile = extractProfileData(profileEvent);
const authorName = getUserName(profile, announcementEvent.pubkey, userNpub);
const authorEmail = getUserEmail(profile, announcementEvent.pubkey, userNpub);
// Create README.md content
const readmeContent = `# ${nameTag}
Welcome to your new GitRepublic repo.
You can use this read-me file to explain the purpose of this repo to everyone who looks at it. You can also make a ReadMe.adoc file and delete this one, if you prefer. GitRepublic supports both markups.
Your commits will all be signed by your Nostr keys and saved to the event files in the ./nostr folder.
`;
// Write README.md
await writeFileFs(readmePath, readmeContent, 'utf-8');
// Stage and commit README.md
const workGit = simpleGit(workDir);
await workGit.add('README.md');
// Configure git user.name and user.email for this repository
try {
await workGit.addConfig('user.name', 'GitRepublic', false, 'local');
await workGit.addConfig('user.email', 'gitrepublic@gitrepublic.web', false, 'local');
} catch (configError) {
logger.warn({ repoPath, npub, repoName, error: configError }, 'Failed to set git config');
}
// Commit README.md
await workGit.commit('Add README.md', ['README.md'], {
'--author': `${authorName} <${authorEmail}>`
});
// Clean up worktree
await fileManager.removeWorktree(repoPath, workDir);
logger.info({ npub, repoName, branch: defaultBranch }, 'Created README.md in existing repository');
} catch (err) {
// Log but don't fail - README creation is nice-to-have
const sanitizedErr = sanitizeError(err);
logger.warn({ error: sanitizedErr, repoPath, npub, repoName }, 'Failed to ensure README exists, continuing anyway');
}
}
/**
* Sync repository from multiple remote URLs (parallelized for efficiency)
*/
@ -508,8 +370,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files @@ -508,8 +370,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files
async fetchRepoOnDemand(
npub: string,
repoName: string,
announcementEvent?: NostrEvent,
preferredDefaultBranch?: string
announcementEvent?: NostrEvent
): Promise<{ success: boolean; needsAnnouncement?: boolean; announcement?: NostrEvent; error?: string; cloneUrls?: string[]; remoteUrls?: string[] }> {
const repoPath = join(this.repoRoot, npub, `${repoName}.git`);
@ -657,7 +518,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files @@ -657,7 +518,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files
// No remote URLs - this is an empty repo, provision it instead
logger.info({ npub, repoName, cloneUrls, announcementEventId: announcementEvent.id }, 'No remote clone URLs found - provisioning empty repository');
try {
await this.provisionRepo(announcementEvent, undefined, false, false, preferredDefaultBranch);
await this.provisionRepo(announcementEvent, undefined, false);
logger.info({ npub, repoName }, 'Empty repository provisioned successfully');
return { success: true, cloneUrls, remoteUrls: [] };
} catch (err) {
@ -678,7 +539,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files @@ -678,7 +539,7 @@ Your commits will all be signed by your Nostr keys and saved to the event files
logger.info({ npub, repoName, url: remoteUrls[0] }, 'Localhost URL specified but repo does not exist locally - provisioning instead');
try {
// In development, allow provisioning even if domain URL isn't in announcement
await this.provisionRepo(announcementEvent, undefined, false, true, preferredDefaultBranch);
await this.provisionRepo(announcementEvent, undefined, false, true);
logger.info({ npub, repoName }, 'Repository provisioned successfully on localhost');
return { success: true, cloneUrls, remoteUrls: [] };
} catch (err) {

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

@ -0,0 +1,278 @@ @@ -0,0 +1,278 @@
/**
* 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';
import { OwnershipTransferService } from './ownership-transfer-service.js';
import { getCachedUserLevel } from '../security/user-level-cache.js';
import logger from '../logger.js';
import { extractCloneUrls } from '../../utils/nostr-utils.js';
export class RepoPollingService {
private nostrClient: NostrClient;
private repoManager: RepoManager;
private pollingInterval: number;
private intervalId: NodeJS.Timeout | null = null;
private domain: string;
private relays: string[];
private initialPollPromise: Promise<void> | null = null;
private isInitialPollComplete: boolean = false;
constructor(
relays: string[],
repoRoot: string,
domain: string,
pollingInterval: number = 60000 // 1 minute
) {
this.relays = relays;
this.nostrClient = new NostrClient(relays);
this.repoManager = new RepoManager(repoRoot, domain);
this.pollingInterval = pollingInterval;
this.domain = domain;
}
/**
* Start polling for repo announcements
* Returns a promise that resolves when the initial poll completes
*/
start(): Promise<void> {
if (this.intervalId) {
this.stop();
}
// Poll immediately and wait for it to complete
this.initialPollPromise = this.poll();
// Then poll at intervals
this.intervalId = setInterval(() => {
this.poll();
}, this.pollingInterval);
return this.initialPollPromise;
}
/**
* Wait for initial poll to complete (useful for server startup)
*/
async waitForInitialPoll(): Promise<void> {
if (this.initialPollPromise) {
await this.initialPollPromise;
}
}
/**
* Check if initial poll has completed
*/
isReady(): boolean {
return this.isInitialPollComplete;
}
/**
* Stop polling and cleanup resources
*/
stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
// Close Nostr client connections
if (this.nostrClient) {
this.nostrClient.close();
}
}
/**
* Trigger a manual poll (useful after user verification)
*/
async triggerPoll(): Promise<void> {
logger.info('Manual poll triggered');
return this.poll();
}
/**
* Poll for new repo announcements and provision repos
*/
private async poll(): Promise<void> {
try {
logger.debug('Starting repo poll...');
const events = await this.nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
limit: 100
}
]);
// Filter for repos that list our domain
const relevantEvents = events.filter(event => {
// Skip local-only forks (synthetic announcements not published to Nostr)
const isLocalOnly = event.tags.some(t => t[0] === 'local-only' && t[1] === 'true');
if (isLocalOnly) {
return false;
}
const cloneUrls = this.extractCloneUrls(event);
const listsDomain = cloneUrls.some(url => url.includes(this.domain));
if (listsDomain) {
logger.debug({
eventId: event.id,
pubkey: event.pubkey.slice(0, 16) + '...',
cloneUrls: cloneUrls.slice(0, 3) // Log first 3 URLs
}, 'Found repo announcement that lists this domain');
}
return listsDomain;
});
logger.info({
totalEvents: events.length,
relevantEvents: relevantEvents.length,
domain: this.domain
}, 'Filtered repo announcements');
// Provision each repo
for (const event of relevantEvents) {
try {
// Extract repo ID from d-tag
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) {
logger.warn({ eventId: event.id }, 'Repo announcement 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 = `${KIND.REPO_ANNOUNCEMENT}:${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) {
// Security: Truncate pubkey in logs
const truncatedPubkey = event.pubkey.length > 16 ? `${event.pubkey.slice(0, 8)}...${event.pubkey.slice(-4)}` : event.pubkey;
logger.info({ repoId: dTag, pubkey: truncatedPubkey }, 'Existing repo 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;
logger.warn({ repoId: dTag, pubkey: event.pubkey }, 'Self-transfer event template created. Owner should sign and publish it to relays.');
} catch (err) {
logger.error({ error: err, repoId: dTag }, 'Failed to create self-transfer event template');
}
}
// Check if user has unlimited access before provisioning new repos
// This prevents spam and abuse
if (!isExistingRepo) {
const userLevel = getCachedUserLevel(event.pubkey);
const { hasUnlimitedAccess } = await import('../../utils/user-access.js');
const hasAccess = hasUnlimitedAccess(userLevel?.level);
logger.debug({
eventId: event.id,
pubkey: event.pubkey.slice(0, 16) + '...',
cachedLevel: userLevel?.level || 'none',
hasAccess,
isExistingRepo
}, 'Checking user access for repo provisioning');
if (!hasAccess) {
logger.warn({
eventId: event.id,
pubkey: event.pubkey.slice(0, 16) + '...',
level: userLevel?.level || 'none',
cacheExists: !!userLevel
}, 'Skipping repo provisioning: user does not have unlimited access');
continue;
}
}
// Provision the repo with self-transfer event if available
await this.repoManager.provisionRepo(event, selfTransferEvent, isExistingRepo);
logger.info({ eventId: event.id, isExistingRepo }, 'Provisioned repo from announcement');
} catch (error) {
logger.error({ error, eventId: event.id }, 'Failed to provision repo from announcement');
}
}
// Mark initial poll as complete
if (!this.isInitialPollComplete) {
this.isInitialPollComplete = true;
logger.info('Initial repo poll completed');
}
} catch (error) {
logger.error({ error }, 'Error polling for repo announcements');
// Still mark as complete even on error (to prevent blocking)
if (!this.isInitialPollComplete) {
this.isInitialPollComplete = true;
logger.warn('Initial repo poll completed with errors');
}
}
}
/**
* Extract clone URLs from a NIP-34 repo announcement
* Uses shared utility (without normalization)
*/
private extractCloneUrls(event: NostrEvent): string[] {
return extractCloneUrls(event, false);
}
}

16
src/lib/services/service-registry.ts

@ -15,6 +15,7 @@ import { ForkCountService } from './nostr/fork-count-service.js'; @@ -15,6 +15,7 @@ import { ForkCountService } from './nostr/fork-count-service.js';
import { PRsService } from './nostr/prs-service.js';
import { HighlightsService } from './nostr/highlights-service.js';
import { ReleasesService } from './nostr/releases-service.js';
import { RepoPollingService } from './nostr/repo-polling.js';
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, GIT_DOMAIN } from '../config.js';
// Get repo root from environment or use default
@ -35,6 +36,7 @@ let _forkCountService: ForkCountService | null = null; @@ -35,6 +36,7 @@ let _forkCountService: ForkCountService | null = null;
let _prsService: PRsService | null = null;
let _highlightsService: HighlightsService | null = null;
let _releasesService: ReleasesService | null = null;
let _repoPollingService: RepoPollingService | null = null;
/**
* Get singleton FileManager instance
@ -156,6 +158,20 @@ export function getReleasesService(): ReleasesService { @@ -156,6 +158,20 @@ export function getReleasesService(): ReleasesService {
return _releasesService;
}
/**
* Get singleton RepoPollingService instance
* Note: This should be initialized in hooks.server.ts on startup
*/
export function getRepoPollingService(): RepoPollingService | null {
return _repoPollingService;
}
/**
* Set the RepoPollingService instance (called from hooks.server.ts)
*/
export function setRepoPollingService(service: RepoPollingService): void {
_repoPollingService = service;
}
// Convenience exports for direct access (common pattern)
export const fileManager = getFileManager();

1
src/lib/utils/nostr-utils.ts

@ -12,6 +12,7 @@ import { KIND } from '../types/nostr.js'; @@ -12,6 +12,7 @@ import { KIND } from '../types/nostr.js';
* This is a shared utility to avoid code duplication across:
* - RepoManager (with URL normalization)
* - Git API endpoint (for performance, without normalization)
* - RepoPollingService
*
* @param event - The Nostr repository announcement event
* @param normalize - Whether to normalize URLs (add .git suffix if needed). Default: false

30
src/lib/utils/repo-poll-trigger.ts

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
/**
* Shared utility for triggering repo polls
* This provides a consistent interface for triggering polls from anywhere in the codebase
*/
import { getRepoPollingService } from '../services/service-registry.js';
import logger from '../services/logger.js';
/**
* Trigger a repo poll
* This is the single source of truth for triggering polls
* @param context Optional context string for logging (e.g., 'user-verification', 'manual-refresh')
* @returns Promise that resolves when poll is triggered (not when it completes)
*/
export async function triggerRepoPoll(context?: string): Promise<void> {
const pollingService = getRepoPollingService();
if (!pollingService) {
logger.warn({ context }, 'Poll request received but polling service not initialized');
throw new Error('Polling service not available');
}
// Trigger poll asynchronously (non-blocking)
// The poll will complete in the background
pollingService.triggerPoll().catch((err) => {
logger.error({ error: err, context }, 'Failed to trigger poll');
});
logger.info({ context }, 'Repo poll triggered');
}

43
src/routes/api/openapi.json/openapi.json

@ -286,6 +286,49 @@ @@ -286,6 +286,49 @@
}
}
},
"/api/repos/poll": {
"post": {
"summary": "Trigger repository polling",
"description": "Manually trigger repository polling to provision new repos from Nostr announcements. This endpoint fetches NIP-34 repo announcements from relays and provisions repositories that list this server's domain in their clone URLs. The poll runs asynchronously and does not block the request.",
"tags": ["Infrastructure"],
"responses": {
"200": {
"description": "Poll triggered successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {"type": "boolean"},
"message": {"type": "string"}
}
}
}
}
},
"503": {
"description": "Polling service not available",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Error triggering poll",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/repos/{npub}/{repo}/files": {
"get": {
"summary": "Get file content, list files, or get raw file",

31
src/routes/api/repos/[npub]/[repo]/clone/+server.ts

@ -51,30 +51,6 @@ export const POST: RequestHandler = async (event) => { @@ -51,30 +51,6 @@ export const POST: RequestHandler = async (event) => {
hasUnlimitedAccess: userLevel ? hasUnlimitedAccess(userLevel.level) : false
}, 'Checking user access level for clone operation');
// Extract defaultBranch from request body if present (before body is consumed)
let preferredDefaultBranch: string | undefined;
const contentType = event.request.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
try {
// Clone the request to read body without consuming it
const clonedRequest = event.request.clone();
const bodyText = await clonedRequest.text().catch(() => '');
if (bodyText) {
try {
const body = JSON.parse(bodyText);
if (body.defaultBranch && typeof body.defaultBranch === 'string') {
preferredDefaultBranch = body.defaultBranch;
logger.debug({ preferredDefaultBranch }, 'Extracted defaultBranch from request body');
}
} catch {
// Not valid JSON or missing defaultBranch - continue
}
}
} catch {
// Body reading failed - continue
}
}
// If cache is empty, try to verify from NIP-98 auth header first (doesn't consume body), then proof event in body
if (!userLevel || !hasUnlimitedAccess(userLevel.level)) {
let verification: { valid: boolean; error?: string; relay?: string; relayDown?: boolean } | null = null;
@ -90,6 +66,7 @@ export const POST: RequestHandler = async (event) => { @@ -90,6 +66,7 @@ export const POST: RequestHandler = async (event) => {
// If auth header didn't work, try to get proof event from request body (if content-type is JSON)
// Note: This consumes the body, but only if auth header is not present
if (!verification) {
const contentType = event.request.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
try {
// Read body only if auth header verification failed
@ -108,10 +85,6 @@ export const POST: RequestHandler = async (event) => { @@ -108,10 +85,6 @@ export const POST: RequestHandler = async (event) => {
logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, 'Invalid proof event in request body');
}
}
// Also extract defaultBranch if not already extracted
if (!preferredDefaultBranch && body.defaultBranch && typeof body.defaultBranch === 'string') {
preferredDefaultBranch = body.defaultBranch;
}
} catch (parseErr) {
// Not valid JSON or missing proofEvent - continue
logger.debug({ error: parseErr }, 'Request body is not valid JSON or missing proofEvent');
@ -303,7 +276,7 @@ export const POST: RequestHandler = async (event) => { @@ -303,7 +276,7 @@ export const POST: RequestHandler = async (event) => {
}, 'Repository announcement clone URLs');
// Attempt to clone the repository
const result = await repoManager.fetchRepoOnDemand(npub, repo, announcementEvent, preferredDefaultBranch);
const result = await repoManager.fetchRepoOnDemand(npub, repo, announcementEvent);
if (!result.success) {
if (result.needsAnnouncement) {

2
src/routes/api/repos/local/+server.ts

@ -35,7 +35,7 @@ let cache: CacheEntry | null = null; @@ -35,7 +35,7 @@ let cache: CacheEntry | null = null;
// Track server startup time to invalidate cache on first request after startup
let serverStartTime = Date.now();
const STARTUP_GRACE_PERIOD = 1000; // 1 second - minimal grace period for cache
const STARTUP_GRACE_PERIOD = 10000; // 10 seconds - allow time for initial poll
/**
* Invalidate cache (internal use only - not exported to avoid SvelteKit build errors)

33
src/routes/api/repos/poll/+server.ts

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
/**
* API endpoint for manually triggering a repo poll
* This allows users to refresh the repo list and trigger provisioning of new repos
*
* This is the public API interface for triggering polls.
* All poll triggers should go through this endpoint or the shared triggerRepoPoll utility.
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { triggerRepoPoll } from '$lib/utils/repo-poll-trigger.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
export const POST: RequestHandler = async (event) => {
const requestContext = extractRequestContext(event);
const clientIp = requestContext.clientIp || 'unknown';
try {
await triggerRepoPoll('api-endpoint');
return json({
success: true,
message: 'Poll triggered successfully'
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
return json({
success: false,
error: errorMessage
}, { status: err instanceof Error && errorMessage.includes('not available') ? 503 : 500 });
}
};

7
src/routes/api/user/level/+server.ts

@ -17,6 +17,7 @@ import { extractRequestContext } from '$lib/utils/api-context.js'; @@ -17,6 +17,7 @@ import { extractRequestContext } from '$lib/utils/api-context.js';
import { sanitizeError } from '$lib/utils/security.js';
import { verifyEvent } from 'nostr-tools';
import logger from '$lib/services/logger.js';
import { triggerRepoPoll } from '$lib/utils/repo-poll-trigger.js';
export const POST: RequestHandler = async (event) => {
const requestContext = extractRequestContext(event);
@ -143,6 +144,12 @@ export const POST: RequestHandler = async (event) => { @@ -143,6 +144,12 @@ export const POST: RequestHandler = async (event) => {
// Cache the successful verification
cacheUserLevel(userPubkeyHex, 'unlimited');
// Trigger a repo poll to provision repos now that user is verified
// This is non-blocking - we don't wait for it to complete
triggerRepoPoll('user-verification').catch((err) => {
logger.warn({ error: err, userPubkeyHex }, 'Failed to trigger poll after user verification (non-blocking)');
});
auditLogger.logAuth(
userPubkeyHex,
clientIp,

29
src/routes/repos/+page.svelte

@ -308,7 +308,7 @@ @@ -308,7 +308,7 @@
}
}
async function loadRepos() {
async function loadRepos(triggerPoll = false) {
loading = true;
error = null;
@ -357,6 +357,31 @@ @@ -357,6 +357,31 @@
loadForkCounts(registeredRepos.map(r => r.event)).catch(err => {
console.warn('[RepoList] Failed to load some fork counts:', err);
});
// If triggerPoll is true, trigger a poll and then refresh the list
if (triggerPoll) {
try {
// Trigger poll (non-blocking)
const pollResponse = await fetch('/api/repos/poll', {
method: 'POST',
headers: userPubkeyHex ? {
'X-User-Pubkey': userPubkeyHex
} : {}
});
if (pollResponse.ok) {
// Wait a bit for the poll to process (lazy - don't wait for full completion)
// Give it 2-3 seconds to provision repos
await new Promise(resolve => setTimeout(resolve, 2500));
// Refresh the list after poll
await loadRepos(false);
}
} catch (pollErr) {
// Don't fail the whole operation if poll fails
console.warn('[RepoList] Failed to trigger poll:', pollErr);
}
}
} catch (e) {
error = String(e);
console.error('[RepoList] Failed to load repos:', e);
@ -777,7 +802,7 @@ @@ -777,7 +802,7 @@
<div class="repos-header">
<h2>Repositories on {$page.data.gitDomain || 'localhost:6543'}</h2>
<button onclick={() => loadRepos()} disabled={loading}>
<button onclick={() => loadRepos(true)} disabled={loading}>
{loading ? 'Loading...' : 'Refresh'}
</button>
</div>

20
src/routes/repos/[npub]/[repo]/services/repo-operations.ts

@ -193,27 +193,11 @@ export async function cloneRepository( @@ -193,27 +193,11 @@ export async function cloneRepository(
logger.debug({ error: proofErr }, '[Clone] Failed to create proof event, will rely on cache');
}
// Get user's default branch preference from settings
let defaultBranch: string | undefined;
try {
const { settingsStore } = await import('$lib/services/settings-store.js');
const settings = await settingsStore.getSettings();
if (settings.defaultBranch) {
defaultBranch = settings.defaultBranch;
logger.debug({ defaultBranch }, '[Clone] Using default branch from user settings');
}
} catch (settingsErr) {
logger.debug({ error: settingsErr }, '[Clone] Failed to get default branch from settings');
}
// Send clone request with proof event and defaultBranch in body (if available)
const requestBody: { proofEvent?: NostrEvent; defaultBranch?: string } = {};
// Send clone request with proof event in body (if available)
const requestBody: { proofEvent?: NostrEvent } = {};
if (proofEvent) {
requestBody.proofEvent = proofEvent;
}
if (defaultBranch) {
requestBody.defaultBranch = defaultBranch;
}
const data = await apiPost<{ alreadyExists?: boolean }>(`/api/repos/${state.npub}/${state.repo}/clone`, requestBody);

Loading…
Cancel
Save