Browse Source

refactor

main
Silberengel 4 weeks ago
parent
commit
4f2fd74309
  1. 158
      src/lib/services/service-registry.ts
  2. 151
      src/lib/utils/api-auth.ts
  3. 162
      src/lib/utils/api-context.ts
  4. 289
      src/lib/utils/api-handlers.ts
  5. 90
      src/routes/api/repos/[npub]/[repo]/branch-protection/+server.ts
  6. 93
      src/routes/api/repos/[npub]/[repo]/branches/+server.ts
  7. 48
      src/routes/api/repos/[npub]/[repo]/commits/+server.ts
  8. 47
      src/routes/api/repos/[npub]/[repo]/diff/+server.ts
  9. 73
      src/routes/api/repos/[npub]/[repo]/download/+server.ts
  10. 93
      src/routes/api/repos/[npub]/[repo]/highlights/+server.ts
  11. 85
      src/routes/api/repos/[npub]/[repo]/issues/+server.ts
  12. 48
      src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts
  13. 84
      src/routes/api/repos/[npub]/[repo]/prs/+server.ts
  14. 59
      src/routes/api/repos/[npub]/[repo]/raw/+server.ts
  15. 57
      src/routes/api/repos/[npub]/[repo]/readme/+server.ts
  16. 122
      src/routes/api/repos/[npub]/[repo]/settings/+server.ts
  17. 102
      src/routes/api/repos/[npub]/[repo]/tags/+server.ts
  18. 111
      src/routes/api/repos/[npub]/[repo]/transfer/+server.ts
  19. 59
      src/routes/api/repos/[npub]/[repo]/tree/+server.ts
  20. 66
      src/routes/api/repos/[npub]/[repo]/verify/+server.ts

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

@ -0,0 +1,158 @@
/**
* Service Registry
* Provides singleton instances of commonly used services
* Reduces memory usage and ensures consistent service configuration across API routes
*/
import { FileManager } from './git/file-manager.js';
import { RepoManager } from './git/repo-manager.js';
import { MaintainerService } from './nostr/maintainer-service.js';
import { NostrClient } from './nostr/nostr-client.js';
import { OwnershipTransferService } from './nostr/ownership-transfer-service.js';
import { BranchProtectionService } from './nostr/branch-protection-service.js';
import { IssuesService } from './nostr/issues-service.js';
import { ForkCountService } from './nostr/fork-count-service.js';
import { PRsService } from './nostr/prs-service.js';
import { HighlightsService } from './nostr/highlights-service.js';
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } from '../config.js';
// Get repo root from environment or use default
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? process.env.GIT_REPO_ROOT
: '/repos';
// Lazy initialization - services are created on first access
let _fileManager: FileManager | null = null;
let _repoManager: RepoManager | null = null;
let _maintainerService: MaintainerService | null = null;
let _nostrClient: NostrClient | null = null;
let _nostrSearchClient: NostrClient | null = null;
let _ownershipTransferService: OwnershipTransferService | null = null;
let _branchProtectionService: BranchProtectionService | null = null;
let _issuesService: IssuesService | null = null;
let _forkCountService: ForkCountService | null = null;
let _prsService: PRsService | null = null;
let _highlightsService: HighlightsService | null = null;
/**
* Get singleton FileManager instance
*/
export function getFileManager(): FileManager {
if (!_fileManager) {
_fileManager = new FileManager(repoRoot);
}
return _fileManager;
}
/**
* Get singleton RepoManager instance
*/
export function getRepoManager(): RepoManager {
if (!_repoManager) {
_repoManager = new RepoManager(repoRoot);
}
return _repoManager;
}
/**
* Get singleton MaintainerService instance
*/
export function getMaintainerService(): MaintainerService {
if (!_maintainerService) {
_maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
}
return _maintainerService;
}
/**
* Get singleton NostrClient instance (default relays)
*/
export function getNostrClient(): NostrClient {
if (!_nostrClient) {
_nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
}
return _nostrClient;
}
/**
* Get singleton NostrClient instance (search relays)
*/
export function getNostrSearchClient(): NostrClient {
if (!_nostrSearchClient) {
_nostrSearchClient = new NostrClient(DEFAULT_NOSTR_SEARCH_RELAYS);
}
return _nostrSearchClient;
}
/**
* Get singleton OwnershipTransferService instance
*/
export function getOwnershipTransferService(): OwnershipTransferService {
if (!_ownershipTransferService) {
_ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS);
}
return _ownershipTransferService;
}
/**
* Get singleton BranchProtectionService instance
*/
export function getBranchProtectionService(): BranchProtectionService {
if (!_branchProtectionService) {
_branchProtectionService = new BranchProtectionService(DEFAULT_NOSTR_RELAYS);
}
return _branchProtectionService;
}
/**
* Get singleton IssuesService instance
*/
export function getIssuesService(): IssuesService {
if (!_issuesService) {
_issuesService = new IssuesService(DEFAULT_NOSTR_RELAYS);
}
return _issuesService;
}
/**
* Get singleton ForkCountService instance
*/
export function getForkCountService(): ForkCountService {
if (!_forkCountService) {
_forkCountService = new ForkCountService(DEFAULT_NOSTR_RELAYS);
}
return _forkCountService;
}
/**
* Get singleton PRsService instance
*/
export function getPRsService(): PRsService {
if (!_prsService) {
_prsService = new PRsService(DEFAULT_NOSTR_RELAYS);
}
return _prsService;
}
/**
* Get singleton HighlightsService instance
*/
export function getHighlightsService(): HighlightsService {
if (!_highlightsService) {
_highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS);
}
return _highlightsService;
}
// Convenience exports for direct access (common pattern)
export const fileManager = getFileManager();
export const repoManager = getRepoManager();
export const maintainerService = getMaintainerService();
export const nostrClient = getNostrClient();
export const nostrSearchClient = getNostrSearchClient();
export const ownershipTransferService = getOwnershipTransferService();
export const branchProtectionService = getBranchProtectionService();
export const issuesService = getIssuesService();
export const forkCountService = getForkCountService();
export const prsService = getPRsService();
export const highlightsService = getHighlightsService();

151
src/lib/utils/api-auth.ts

@ -0,0 +1,151 @@
/**
* API Authorization Helpers
* Reusable authorization functions for API routes
*/
import { error } from '@sveltejs/kit';
import type { NostrEvent } from '../types/nostr.js';
import { verifyNIP98Auth } from '../services/nostr/nip98-auth.js';
import { maintainerService } from '../services/service-registry.js';
import { fileManager } from '../services/service-registry.js';
import type { RepoContext, RequestContext, RepoRequestContext } from './api-context.js';
import { handleValidationError, handleAuthError, handleAuthorizationError, handleNotFoundError } from './error-handler.js';
/**
* Check if user has access to a repository (privacy check)
*
* @param repoContext - Repository context
* @param requestContext - Request context with user pubkey
* @param operation - Operation name for error context
* @returns void if access allowed, throws error if denied
*/
export async function requireRepoAccess(
repoContext: RepoContext,
requestContext: RequestContext,
operation?: string
): Promise<void> {
const canView = await maintainerService.canView(
requestContext.userPubkeyHex || null,
repoContext.repoOwnerPubkey,
repoContext.repo
);
if (!canView) {
throw handleAuthorizationError(
'This repository is private. Only owners and maintainers can view it.',
{ operation, npub: repoContext.npub, repo: repoContext.repo }
);
}
}
/**
* Check if repository exists
*
* @param repoContext - Repository context
* @param operation - Operation name for error context
* @returns void if exists, throws error if not found
*/
export function requireRepoExists(
repoContext: RepoContext,
operation?: string
): void {
if (!fileManager.repoExists(repoContext.npub, repoContext.repo)) {
throw handleNotFoundError(
'Repository not found',
{ operation, npub: repoContext.npub, repo: repoContext.repo }
);
}
}
/**
* Check if user is a maintainer of the repository
*
* @param repoContext - Repository context
* @param requestContext - Request context with user pubkey
* @param operation - Operation name for error context
* @returns void if maintainer, throws error if not
*/
export async function requireMaintainer(
repoContext: RepoContext,
requestContext: RequestContext,
operation?: string
): Promise<void> {
if (!requestContext.userPubkeyHex) {
throw handleAuthError(
'Authentication required. Please provide userPubkey.',
{ operation, npub: repoContext.npub, repo: repoContext.repo }
);
}
const isMaintainer = await maintainerService.isMaintainer(
requestContext.userPubkeyHex,
repoContext.repoOwnerPubkey,
repoContext.repo
);
if (!isMaintainer) {
throw handleAuthorizationError(
'Only repository maintainers can perform this action. Please submit a pull request instead.',
{ operation, npub: repoContext.npub, repo: repoContext.repo }
);
}
}
/**
* Verify NIP-98 authentication from request headers
*
* @param request - Request object
* @param url - URL object
* @param method - HTTP method
* @param operation - Operation name for error context
* @returns NIP-98 event if valid, throws error if invalid
*/
export function requireNIP98Auth(
request: Request,
url: URL,
method: string,
operation?: string
): NostrEvent {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Nostr ')) {
throw handleAuthError(
'NIP-98 authentication required',
{ operation }
);
}
// 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}`;
const authResult = verifyNIP98Auth(authHeader, requestUrl, method);
if (!authResult.valid || !authResult.event) {
throw handleAuthError(
authResult.error || 'Invalid NIP-98 authentication',
{ operation }
);
}
return authResult.event;
}
/**
* Combined check: repository exists and user has access
*
* @param repoContext - Repository context
* @param requestContext - Request context with user pubkey
* @param operation - Operation name for error context
* @returns void if all checks pass, throws error if any fail
*/
export async function requireRepoAccessWithExists(
repoContext: RepoContext,
requestContext: RequestContext,
operation?: string
): Promise<void> {
requireRepoExists(repoContext, operation);
await requireRepoAccess(repoContext, requestContext, operation);
}

162
src/lib/utils/api-context.ts

@ -0,0 +1,162 @@
/**
* API Context Utilities
* Extract and validate common request context from SvelteKit requests
*/
import type { RequestEvent } from '@sveltejs/kit';
import { requireNpubHex, decodeNpubToHex } from './npub-utils.js';
// Re-export RequestEvent for convenience
export type { RequestEvent };
/**
* Extracted request context
*/
export interface RequestContext {
userPubkey: string | null;
userPubkeyHex: string | null;
clientIp: string;
ref?: string;
path?: string;
branch?: string;
limit?: number;
[key: string]: unknown;
}
/**
* Repository context with validated parameters
*/
export interface RepoContext {
npub: string;
repo: string;
repoOwnerPubkey: string;
}
/**
* Combined context for repository operations
*/
export interface RepoRequestContext extends RequestContext, RepoContext {}
/**
* Extract common request context from a SvelteKit request
*
* @param event - SvelteKit request event
* @param url - URL object (can be extracted from event.url)
* @returns Extracted request context
*/
export function extractRequestContext(
event: RequestEvent,
url?: URL
): RequestContext {
const requestUrl = url || event.url;
// Extract user pubkey from query params or headers
const userPubkey = requestUrl.searchParams.get('userPubkey') ||
event.request.headers.get('x-user-pubkey') ||
null;
// Convert to hex if needed
const userPubkeyHex = userPubkey ? (decodeNpubToHex(userPubkey) || userPubkey) : null;
// Extract client IP
let clientIp: string;
try {
clientIp = event.getClientAddress();
} catch {
// Fallback for internal Vite dev server requests or when client address can't be determined
clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
event.request.headers.get('x-real-ip') ||
'127.0.0.1';
}
// Extract common query parameters
const ref = requestUrl.searchParams.get('ref') || undefined;
const path = requestUrl.searchParams.get('path') || undefined;
const branch = requestUrl.searchParams.get('branch') || undefined;
const limit = requestUrl.searchParams.get('limit')
? parseInt(requestUrl.searchParams.get('limit')!, 10)
: undefined;
return {
userPubkey,
userPubkeyHex,
clientIp,
ref,
path,
branch,
limit
};
}
/**
* Validate and extract repository context from route parameters
*
* @param params - Route parameters (from SvelteKit)
* @returns Validated repository context
* @throws Error if validation fails
*/
export function validateRepoParams(params: { npub?: string; repo?: string }): RepoContext {
const { npub, repo } = params;
if (!npub || !repo) {
throw new Error('Missing npub or repo parameter');
}
// Validate and convert npub to pubkey
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
throw new Error(`Invalid npub format: ${npub}`);
}
return {
npub,
repo,
repoOwnerPubkey
};
}
/**
* Get combined repository and request context
* Combines parameter validation with request context extraction
*
* @param event - SvelteKit request event
* @param params - Route parameters
* @returns Combined repository and request context
*/
export function getRepoContext(
event: RequestEvent,
params: { npub?: string; repo?: string }
): RepoRequestContext {
const requestContext = extractRequestContext(event);
const repoContext = validateRepoParams(params);
return {
...requestContext,
...repoContext
};
}
/**
* Extract user pubkey from request (convenience function)
*/
export function getUserPubkey(event: RequestEvent): string | null {
const url = event.url;
return url.searchParams.get('userPubkey') ||
event.request.headers.get('x-user-pubkey') ||
null;
}
/**
* Extract client IP from request (convenience function)
*/
export function getClientIp(event: RequestEvent): string {
try {
return event.getClientAddress();
} catch {
return event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
event.request.headers.get('x-real-ip') ||
'127.0.0.1';
}
}

289
src/lib/utils/api-handlers.ts

@ -0,0 +1,289 @@
/**
* API Handler Wrappers
* Higher-order functions to wrap SvelteKit request handlers with common logic
*/
import type { RequestHandler } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { handleApiError, handleValidationError, type ErrorContext } from './error-handler.js';
import { getRepoContext, extractRequestContext, validateRepoParams, type RepoContext, type RequestContext, type RepoRequestContext } from './api-context.js';
import { requireRepoAccess, requireRepoExists, requireMaintainer, requireNIP98Auth, requireRepoAccessWithExists } from './api-auth.js';
import { auditLogger } from '../services/security/audit-logger.js';
/**
* Handler function that receives validated context
*/
export type RepoHandler = (context: {
repoContext: RepoContext;
requestContext: RequestContext;
event: RequestEvent;
}) => Promise<Response>;
/**
* Handler function with full repo request context
*/
export type RepoRequestHandler = (context: {
repoRequestContext: RepoRequestContext;
event: RequestEvent;
}) => Promise<Response>;
/**
* Options for handler wrappers
*/
export interface HandlerOptions {
operation?: string;
requireAuth?: boolean;
requireMaintainer?: boolean;
requireNIP98?: boolean;
requireRepoExists?: boolean;
requireRepoAccess?: boolean;
auditLog?: boolean;
}
/**
* Wrap a handler with repository parameter validation
* Validates npub/repo params and converts npub to pubkey
*
* @param handler - Handler function that receives validated context
* @param options - Handler options
* @returns Wrapped SvelteKit RequestHandler
*/
export function withRepoValidation(
handler: RepoHandler,
options: HandlerOptions = {}
): RequestHandler {
return async (event) => {
const { params } = event;
const operation = options.operation || 'unknown';
try {
// Validate repo parameters
const repoContext = validateRepoParams(params);
// Extract request context
const requestContext = extractRequestContext(event);
// Check if repo exists (if required)
if (options.requireRepoExists !== false) {
requireRepoExists(repoContext, operation);
}
// Check repository access (if required)
if (options.requireRepoAccess !== false) {
await requireRepoAccess(repoContext, requestContext, operation);
}
// Check if user is maintainer (if required)
if (options.requireMaintainer) {
await requireMaintainer(repoContext, requestContext, operation);
}
// Check NIP-98 auth (if required)
if (options.requireNIP98) {
requireNIP98Auth(event.request, event.url, event.request.method, operation);
}
// Audit logging (if enabled)
if (options.auditLog) {
auditLogger.log({
user: requestContext.userPubkeyHex || undefined,
ip: requestContext.clientIp,
action: `api.${operation}`,
resource: `${repoContext.npub}/${repoContext.repo}`,
result: 'success'
});
}
// Call the handler with validated context
return await handler({
repoContext,
requestContext,
event
});
} catch (err) {
// If it's already a Response (from error handlers), return it
if (err instanceof Response) {
return err;
}
// If it's a SvelteKit HttpError (from error() function), re-throw it
// SvelteKit errors have a status property and body property
if (err && typeof err === 'object' && 'status' in err && 'body' in err) {
throw err;
}
// Otherwise, wrap in standard error handler
return handleApiError(
err,
{ operation, npub: params.npub, repo: params.repo },
`Failed to process ${operation}`
);
}
};
}
/**
* Create a simple GET handler for repository operations
* Automatically handles validation, access checks, and error handling
*
* @param handler - Handler function that receives repo request context
* @param options - Handler options
* @returns GET RequestHandler
*/
export function createRepoGetHandler(
handler: (context: RepoRequestContext, event: RequestEvent) => Promise<Response>,
options: HandlerOptions = {}
): RequestHandler {
return withRepoValidation(async ({ repoContext, requestContext, event }) => {
const repoRequestContext: RepoRequestContext = {
...repoContext,
...requestContext
};
return await handler(repoRequestContext, event);
}, {
requireRepoExists: true,
requireRepoAccess: true,
...options
});
}
/**
* Create a POST handler with maintainer requirement
*
* @param handler - Handler function that receives repo request context
* @param options - Handler options
* @returns POST RequestHandler
*/
export function createRepoPostHandler(
handler: (context: RepoRequestContext, event: RequestEvent) => Promise<Response>,
options: HandlerOptions = {}
): RequestHandler {
return withRepoValidation(async ({ repoContext, requestContext, event }) => {
const repoRequestContext: RepoRequestContext = {
...repoContext,
...requestContext
};
return await handler(repoRequestContext, event);
}, {
requireRepoExists: true,
requireMaintainer: true,
...options
});
}
/**
* Wrap handler with audit logging
*
* @param handler - Handler function
* @param operation - Operation name for audit log
* @returns Wrapped handler with audit logging
*/
export function withAuditLogging(
handler: RequestHandler,
operation: string
): RequestHandler {
return async (event) => {
const { params } = event;
const requestContext = extractRequestContext(event);
try {
const response = await handler(event);
// Log successful operation
if (params.npub && params.repo) {
auditLogger.log({
user: requestContext.userPubkeyHex || undefined,
ip: requestContext.clientIp,
action: `api.${operation}`,
resource: `${params.npub}/${params.repo}`,
result: 'success',
metadata: { status: response.status }
});
}
return response;
} catch (err) {
// Log failed operation
if (params.npub && params.repo) {
auditLogger.log({
user: requestContext.userPubkeyHex || undefined,
ip: requestContext.clientIp,
action: `api.${operation}`,
resource: `${params.npub}/${params.repo}`,
result: 'failure',
error: err instanceof Error ? err.message : String(err)
});
}
throw err;
}
};
}
/**
* Create a handler factory for common repository API patterns
*
* @param config - Handler configuration
* @returns RequestHandler factory function
*/
export function createRepoHandler(config: {
get?: (context: RepoRequestContext, event: RequestEvent) => Promise<Response>;
post?: (context: RepoRequestContext, event: RequestEvent) => Promise<Response>;
put?: (context: RepoRequestContext, event: RequestEvent) => Promise<Response>;
delete?: (context: RepoRequestContext, event: RequestEvent) => Promise<Response>;
options?: HandlerOptions;
}): {
GET?: RequestHandler;
POST?: RequestHandler;
PUT?: RequestHandler;
DELETE?: RequestHandler;
} {
const handlerOptions = config.options || {};
const result: {
GET?: RequestHandler;
POST?: RequestHandler;
PUT?: RequestHandler;
DELETE?: RequestHandler;
} = {};
if (config.get) {
result.GET = createRepoGetHandler(config.get, handlerOptions);
}
if (config.post) {
result.POST = createRepoPostHandler(config.post, handlerOptions);
}
if (config.put) {
result.PUT = withRepoValidation(async ({ repoContext, requestContext, event }) => {
const repoRequestContext: RepoRequestContext = {
...repoContext,
...requestContext
};
return await config.put!(repoRequestContext, event);
}, {
requireRepoExists: true,
requireMaintainer: true,
...handlerOptions
});
}
if (config.delete) {
result.DELETE = withRepoValidation(async ({ repoContext, requestContext, event }) => {
const repoRequestContext: RepoRequestContext = {
...repoContext,
...requestContext
};
return await config.delete!(repoRequestContext, event);
}, {
requireRepoExists: true,
requireMaintainer: true,
...handlerOptions
});
}
return result;
}

90
src/routes/api/repos/[npub]/[repo]/branch-protection/+server.ts

@ -4,88 +4,47 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit';
import { BranchProtectionService } from '$lib/services/nostr/branch-protection-service.js'; import { branchProtectionService, ownershipTransferService, nostrClient } from '$lib/services/service-registry.js';
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; import { combineRelays } from '$lib/config.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js';
import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { nip19 } from 'nostr-tools';
import type { BranchProtectionRule } from '$lib/services/nostr/branch-protection-service.js'; import type { BranchProtectionRule } from '$lib/services/nostr/branch-protection-service.js';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js';
import { handleApiError, handleValidationError, handleAuthError, handleAuthorizationError } from '$lib/utils/error-handler.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleApiError, handleValidationError, handleAuthorizationError } from '$lib/utils/error-handler.js';
const branchProtectionService = new BranchProtectionService(DEFAULT_NOSTR_RELAYS);
const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
/** /**
* GET - Get branch protection rules * GET - Get branch protection rules
*/ */
export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => { export const GET: RequestHandler = createRepoGetHandler(
const { npub, repo } = params; async (context: RepoRequestContext) => {
const config = await branchProtectionService.getBranchProtection(context.repoOwnerPubkey, context.repo);
if (!npub || !repo) {
return handleValidationError('Missing npub or repo parameter', { operation: 'getBranchProtection' });
}
try {
// Decode npub to get pubkey
let ownerPubkey: string;
try {
ownerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'getBranchProtection', npub });
}
const config = await branchProtectionService.getBranchProtection(ownerPubkey, repo);
if (!config) { if (!config) {
return json({ rules: [] }); return json({ rules: [] });
} }
return json(config); return json(config);
} catch (err) { },
return handleApiError(err, { operation: 'getBranchProtection', npub, repo }, 'Failed to get branch protection'); { operation: 'getBranchProtection', requireRepoAccess: false } // Branch protection rules are public
} );
};
/** /**
* POST - Update branch protection rules * POST - Update branch protection rules
*/ */
export const POST: RequestHandler = async ({ params, request }: { params: { npub?: string; repo?: string }; request: Request }) => { export const POST: RequestHandler = createRepoPostHandler(
const { npub, repo } = params; async (context: RepoRequestContext, event: RequestEvent) => {
const body = await event.request.json();
if (!npub || !repo) { const { rules } = body;
return handleValidationError('Missing npub or repo parameter', { operation: 'updateBranchProtection' });
}
try {
const body = await request.json();
const { userPubkey, rules } = body;
if (!userPubkey) {
return handleAuthError('Authentication required', { operation: 'updateBranchProtection', npub, repo });
}
if (!Array.isArray(rules)) { if (!Array.isArray(rules)) {
return handleValidationError('Rules must be an array', { operation: 'updateBranchProtection', npub, repo }); return handleValidationError('Rules must be an array', { operation: 'updateBranchProtection', npub: context.npub, repo: context.repo });
} }
// Decode npub to get pubkey
let ownerPubkey: string;
try {
ownerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'updateBranchProtection', npub });
}
const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey;
// Check if user is owner // Check if user is owner
const currentOwner = await ownershipTransferService.getCurrentOwner(ownerPubkey, repo); const currentOwner = await ownershipTransferService.getCurrentOwner(context.repoOwnerPubkey, context.repo);
if (userPubkeyHex !== currentOwner) { if (context.userPubkeyHex !== currentOwner) {
return handleAuthorizationError('Only the repository owner can update branch protection', { operation: 'updateBranchProtection', npub, repo }); return handleAuthorizationError('Only the repository owner can update branch protection', { operation: 'updateBranchProtection', npub: context.npub, repo: context.repo });
} }
// Validate rules // Validate rules
@ -101,7 +60,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
// Create protection event // Create protection event
const protectionEvent = branchProtectionService.createProtectionEvent( const protectionEvent = branchProtectionService.createProtectionEvent(
currentOwner, currentOwner,
repo, context.repo,
validatedRules validatedRules
); );
@ -114,11 +73,10 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
const result = await nostrClient.publishEvent(signedEvent, combinedRelays); const result = await nostrClient.publishEvent(signedEvent, combinedRelays);
if (result.success.length === 0) { if (result.success.length === 0) {
return error(500, 'Failed to publish branch protection rules to relays'); throw handleApiError(new Error('Failed to publish branch protection rules to relays'), { operation: 'updateBranchProtection', npub: context.npub, repo: context.repo }, 'Failed to publish branch protection rules to relays');
} }
return json({ success: true, event: signedEvent, rules: validatedRules }); return json({ success: true, event: signedEvent, rules: validatedRules });
} catch (err) { },
return handleApiError(err, { operation: 'updateBranchProtection', npub, repo }, 'Failed to update branch protection'); { operation: 'updateBranchProtection', requireMaintainer: false } // Override to check owner instead
} );
};

93
src/routes/api/repos/[npub]/[repo]/branches/+server.ts

@ -2,84 +2,33 @@
* API endpoint for getting and creating repository branches * API endpoint for getting and creating repository branches
*/ */
import { json, error } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type // @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js'; import { fileManager } from '$lib/services/service-registry.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { nip19 } from 'nostr-tools'; import { handleValidationError } from '$lib/utils/error-handler.js';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthError, handleAuthorizationError } from '$lib/utils/error-handler.js'; export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => {
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const branches = await fileManager.getBranches(context.npub, context.repo);
const fileManager = new FileManager(repoRoot);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return handleValidationError('Missing npub or repo parameter', { operation: 'getBranches' });
}
try {
if (!fileManager.repoExists(npub, repo)) {
return handleNotFoundError('Repository not found', { operation: 'getBranches', npub, repo });
}
const branches = await fileManager.getBranches(npub, repo);
return json(branches); return json(branches);
} catch (err) { },
return handleApiError(err, { operation: 'getBranches', npub, repo }, 'Failed to get branches'); { operation: 'getBranches', requireRepoAccess: false } // Branches are public info
} );
};
export const POST: RequestHandler = async ({ params, request }: { params: { npub?: string; repo?: string }; request: Request }) => {
const { npub, repo } = params;
if (!npub || !repo) { export const POST: RequestHandler = createRepoPostHandler(
return handleValidationError('Missing npub or repo parameter', { operation: 'createBranch' }); async (context: RepoRequestContext, event: RequestEvent) => {
} const body = await event.request.json();
const { branchName, fromBranch } = body;
let branchName: string | undefined;
let fromBranch: string | undefined;
let userPubkey: string | undefined;
try {
const body = await request.json();
({ branchName, fromBranch, userPubkey } = body);
if (!branchName) { if (!branchName) {
return handleValidationError('Missing branchName parameter', { operation: 'createBranch', npub, repo }); throw handleValidationError('Missing branchName parameter', { operation: 'createBranch', npub: context.npub, repo: context.repo });
}
if (!userPubkey) {
return handleAuthError('Authentication required. Please provide userPubkey.', { operation: 'createBranch', npub, repo });
}
if (!fileManager.repoExists(npub, repo)) {
return handleNotFoundError('Repository not found', { operation: 'createBranch', npub, repo });
}
// Check if user is a maintainer
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'createBranch', npub });
}
// Convert userPubkey to hex if needed
const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey;
const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, repoOwnerPubkey, repo);
if (!isMaintainer) {
return handleAuthorizationError('Only repository maintainers can create branches. Please submit a pull request instead.', { operation: 'createBranch', npub, repo });
} }
await fileManager.createBranch(npub, repo, branchName, fromBranch || 'main'); await fileManager.createBranch(context.npub, context.repo, branchName, fromBranch || 'main');
return json({ success: true, message: 'Branch created successfully' }); return json({ success: true, message: 'Branch created successfully' });
} catch (err) { },
return handleApiError(err, { operation: 'createBranch', npub, repo, branchName }, 'Failed to create branch'); { operation: 'createBranch' }
} );
};

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

@ -2,40 +2,20 @@
* API endpoint for getting commit history * API endpoint for getting commit history
*/ */
import { json, error } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js'; import { fileManager } from '$lib/services/service-registry.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext } from '$lib/utils/api-context.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; export const GET: RequestHandler = createRepoGetHandler(
const fileManager = new FileManager(repoRoot); async (context: RepoRequestContext) => {
const branch = context.branch || 'main';
export const GET: RequestHandler = async ({ params, url, request }) => { const limit = context.limit || 50;
const { npub, repo } = params; const path = context.path;
const branch = url.searchParams.get('branch') || 'main';
const limit = parseInt(url.searchParams.get('limit') || '50', 10); const commits = await fileManager.getCommitHistory(context.npub, context.repo, branch, limit, path);
const path = url.searchParams.get('path') || undefined;
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) {
return handleValidationError('Missing npub or repo parameter', { operation: 'getCommits' });
}
try {
if (!fileManager.repoExists(npub, repo)) {
return handleNotFoundError('Repository not found', { operation: 'getCommits', npub, repo });
}
// Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) {
return handleAuthorizationError(access.error || 'Access denied', { operation: 'getCommits', npub, repo });
}
const commits = await fileManager.getCommitHistory(npub, repo, branch, limit, path);
return json(commits); return json(commits);
} catch (err) { },
return handleApiError(err, { operation: 'getCommits', npub, repo, branch }, 'Failed to get commit history'); { operation: 'getCommits' }
} );
};

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

@ -2,40 +2,25 @@
* API endpoint for getting diffs * API endpoint for getting diffs
*/ */
import { json, error } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js'; import { fileManager } from '$lib/services/service-registry.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleValidationError } from '$lib/utils/error-handler.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; export const GET: RequestHandler = createRepoGetHandler(
const fileManager = new FileManager(repoRoot); async (context: RepoRequestContext, event: RequestEvent) => {
const fromRef = event.url.searchParams.get('from');
const toRef = event.url.searchParams.get('to') || 'HEAD';
const filePath = event.url.searchParams.get('path') || undefined;
export const GET: RequestHandler = async ({ params, url, request }) => { if (!fromRef) {
const { npub, repo } = params; throw handleValidationError('Missing from parameter', { operation: 'getDiff', npub: context.npub, repo: context.repo });
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 handleValidationError('Missing npub, repo, or from parameter', { operation: 'getDiff' });
}
try {
if (!fileManager.repoExists(npub, repo)) {
return handleNotFoundError('Repository not found', { operation: 'getDiff', npub, repo });
}
// Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) {
return handleAuthorizationError(access.error || 'Access denied', { operation: 'getDiff', npub, repo });
} }
const diffs = await fileManager.getDiff(npub, repo, fromRef, toRef, filePath); const diffs = await fileManager.getDiff(context.npub, context.repo, fromRef, toRef, filePath);
return json(diffs); return json(diffs);
} catch (err) { },
return handleApiError(err, { operation: 'getDiff', npub, repo, fromRef, toRef }, 'Failed to get diff'); { operation: 'getDiff' }
} );
};

73
src/routes/api/repos/[npub]/[repo]/download/+server.ts

@ -2,85 +2,61 @@
* API endpoint for downloading repository as ZIP * API endpoint for downloading repository as ZIP
*/ */
import { error } from '@sveltejs/kit'; import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js'; import { fileManager } from '$lib/services/service-registry.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { requireNpubHex } from '$lib/utils/npub-utils.js';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { mkdir, rm, readFile } from 'fs/promises'; import { mkdir, rm, readFile } from 'fs/promises';
import { existsSync } from 'fs';
import { join, resolve } from 'path'; import { join, resolve } from 'path';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import { isValidBranchName, sanitizeError } from '$lib/utils/security.js'; import { isValidBranchName, sanitizeError } from '$lib/utils/security.js';
import simpleGit from 'simple-git'; import simpleGit from 'simple-git';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; import { handleApiError } from '$lib/utils/error-handler.js';
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, request }) => {
const { npub, repo } = params;
const ref = url.searchParams.get('ref') || 'HEAD';
const format = url.searchParams.get('format') || 'zip'; // zip or tar.gz
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found');
}
// Check repository privacy const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
let repoOwnerPubkey: string; ? process.env.GIT_REPO_ROOT
try { : '/repos';
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return error(400, 'Invalid npub format');
}
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); export const GET: RequestHandler = createRepoGetHandler(
if (!canView) { async (context: RepoRequestContext, event: RequestEvent) => {
return error(403, 'This repository is private. Only owners and maintainers can view it.'); const ref = event.url.searchParams.get('ref') || 'HEAD';
} const format = event.url.searchParams.get('format') || 'zip'; // zip or tar.gz
// Security: Validate ref to prevent command injection // Security: Validate ref to prevent command injection
if (ref !== 'HEAD' && !isValidBranchName(ref)) { if (ref !== 'HEAD' && !isValidBranchName(ref)) {
return error(400, 'Invalid ref format'); throw error(400, 'Invalid ref format');
} }
// Security: Validate format // Security: Validate format
if (format !== 'zip' && format !== 'tar.gz') { if (format !== 'zip' && format !== 'tar.gz') {
return error(400, 'Invalid format. Must be "zip" or "tar.gz"'); throw error(400, 'Invalid format. Must be "zip" or "tar.gz"');
} }
const repoPath = join(repoRoot, npub, `${repo}.git`); const repoPath = join(repoRoot, context.npub, `${context.repo}.git`);
// Security: Ensure resolved path is within repoRoot // Security: Ensure resolved path is within repoRoot
const resolvedRepoPath = resolve(repoPath).replace(/\\/g, '/'); const resolvedRepoPath = resolve(repoPath).replace(/\\/g, '/');
const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/');
if (!resolvedRepoPath.startsWith(resolvedRoot + '/')) { if (!resolvedRepoPath.startsWith(resolvedRoot + '/')) {
return error(403, 'Invalid repository path'); throw error(403, 'Invalid repository path');
} }
const tempDir = join(repoRoot, '..', 'temp-downloads'); const tempDir = join(repoRoot, '..', 'temp-downloads');
const workDir = join(tempDir, `${npub}-${repo}-${Date.now()}`); const workDir = join(tempDir, `${context.npub}-${context.repo}-${Date.now()}`);
// Security: Ensure workDir is within tempDir // Security: Ensure workDir is within tempDir
const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/'); const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/');
const resolvedTempDir = resolve(tempDir).replace(/\\/g, '/'); const resolvedTempDir = resolve(tempDir).replace(/\\/g, '/');
if (!resolvedWorkDir.startsWith(resolvedTempDir + '/')) { if (!resolvedWorkDir.startsWith(resolvedTempDir + '/')) {
return error(500, 'Invalid work directory path'); throw error(500, 'Invalid work directory path');
} }
const archiveName = `${repo}-${ref}.${format === 'tar.gz' ? 'tar.gz' : 'zip'}`; const archiveName = `${context.repo}-${ref}.${format === 'tar.gz' ? 'tar.gz' : 'zip'}`;
const archivePath = join(tempDir, archiveName); const archivePath = join(tempDir, archiveName);
// Security: Ensure archive path is within tempDir // Security: Ensure archive path is within tempDir
const resolvedArchivePath = resolve(archivePath).replace(/\\/g, '/'); const resolvedArchivePath = resolve(archivePath).replace(/\\/g, '/');
if (!resolvedArchivePath.startsWith(resolvedTempDir + '/')) { if (!resolvedArchivePath.startsWith(resolvedTempDir + '/')) {
return error(500, 'Invalid archive path'); throw error(500, 'Invalid archive path');
} }
try { try {
@ -158,10 +134,9 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
await rm(workDir, { recursive: true, force: true }).catch(() => {}); await rm(workDir, { recursive: true, force: true }).catch(() => {});
await rm(archivePath, { force: true }).catch(() => {}); await rm(archivePath, { force: true }).catch(() => {});
const sanitizedError = sanitizeError(archiveError); const sanitizedError = sanitizeError(archiveError);
logger.error({ error: sanitizedError, npub, repo, ref, format }, 'Error creating archive'); logger.error({ error: sanitizedError, npub: context.npub, repo: context.repo, ref, format }, 'Error creating archive');
throw archiveError; throw archiveError;
} }
} catch (err) { },
return handleApiError(err, { operation: 'download', npub, repo, ref, format }, 'Failed to create repository archive'); { operation: 'download' }
} );
};

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

@ -4,44 +4,27 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { HighlightsService } from '$lib/services/nostr/highlights-service.js'; import { highlightsService, nostrClient } from '$lib/services/service-registry.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js';
import { nip19 } from 'nostr-tools'; import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { verifyEvent } from 'nostr-tools'; import { verifyEvent } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
import { combineRelays } from '$lib/config.js'; import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js';
import { decodeNpubToHex } from '$lib/utils/npub-utils.js';
const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
/** /**
* GET - Get highlights for a pull request * GET - Get highlights for a pull request
* Query params: prId, prAuthor * Query params: prId, prAuthor
*/ */
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = createRepoGetHandler(
const { npub, repo } = params; async (context: RepoRequestContext, event: RequestEvent) => {
const prId = url.searchParams.get('prId'); const prId = event.url.searchParams.get('prId');
const prAuthor = url.searchParams.get('prAuthor'); const prAuthor = event.url.searchParams.get('prAuthor');
if (!npub || !repo) {
return handleValidationError('Missing npub or repo parameter', { operation: 'getHighlights' });
}
if (!prId || !prAuthor) { if (!prId || !prAuthor) {
return handleValidationError('Missing prId or prAuthor parameter', { operation: 'getHighlights', npub, repo }); return handleValidationError('Missing prId or prAuthor parameter', { operation: 'getHighlights', npub: context.npub, repo: context.repo });
}
try {
// Decode npub to get pubkey
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'getHighlights', npub });
} }
// Decode prAuthor if it's an npub // Decode prAuthor if it's an npub
@ -51,8 +34,8 @@ export const GET: RequestHandler = async ({ params, url }) => {
const highlights = await highlightsService.getHighlightsForPR( const highlights = await highlightsService.getHighlightsForPR(
prId, prId,
prAuthorPubkey, prAuthorPubkey,
repoOwnerPubkey, context.repoOwnerPubkey,
repo context.repo
); );
// Also get top-level comments on the PR // Also get top-level comments on the PR
@ -62,55 +45,47 @@ export const GET: RequestHandler = async ({ params, url }) => {
highlights, highlights,
comments: prComments comments: prComments
}); });
} catch (err) { },
return handleApiError(err, { operation: 'getHighlights', npub, repo, prId }, 'Failed to fetch highlights'); { operation: 'getHighlights', requireRepoAccess: false } // Highlights are public
} );
};
/** /**
* POST - Create a highlight or comment * POST - Create a highlight or comment
* Body: { type: 'highlight' | 'comment', event, userPubkey } * Body: { type: 'highlight' | 'comment', event, userPubkey }
*/ */
export const POST: RequestHandler = async ({ params, request }) => { export const POST: RequestHandler = withRepoValidation(
const { npub, repo } = params; async ({ repoContext, requestContext, event }) => {
const body = await event.request.json();
if (!npub || !repo) { const { type, event: highlightEvent, userPubkey } = body;
return handleValidationError('Missing npub or repo parameter', { operation: 'createHighlight' });
}
try {
const body = await request.json();
const { type, event, userPubkey } = body;
if (!type || !event || !userPubkey) { if (!type || !highlightEvent || !userPubkey) {
return handleValidationError('Missing type, event, or userPubkey in request body', { operation: 'createHighlight', npub, repo }); throw handleValidationError('Missing type, event, or userPubkey in request body', { operation: 'createHighlight', npub: repoContext.npub, repo: repoContext.repo });
} }
if (type !== 'highlight' && type !== 'comment') { if (type !== 'highlight' && type !== 'comment') {
return handleValidationError('Type must be "highlight" or "comment"', { operation: 'createHighlight', npub, repo }); throw handleValidationError('Type must be "highlight" or "comment"', { operation: 'createHighlight', npub: repoContext.npub, repo: repoContext.repo });
} }
// Verify the event is properly signed // Verify the event is properly signed
if (!event.sig || !event.id) { if (!highlightEvent.sig || !highlightEvent.id) {
return handleValidationError('Invalid event: missing signature or ID', { operation: 'createHighlight', npub, repo }); throw handleValidationError('Invalid event: missing signature or ID', { operation: 'createHighlight', npub: repoContext.npub, repo: repoContext.repo });
} }
if (!verifyEvent(event)) { if (!verifyEvent(highlightEvent)) {
return handleValidationError('Invalid event signature', { operation: 'createHighlight', npub, repo }); throw handleValidationError('Invalid event signature', { operation: 'createHighlight', npub: repoContext.npub, repo: repoContext.repo });
} }
// Get user's relays and publish // Get user's relays and publish
const { outbox } = await getUserRelays(userPubkey, nostrClient); const { outbox } = await getUserRelays(userPubkey, nostrClient);
const combinedRelays = combineRelays(outbox); const combinedRelays = combineRelays(outbox);
const result = await highlightsService['nostrClient'].publishEvent(event as NostrEvent, combinedRelays); const result = await nostrClient.publishEvent(highlightEvent as NostrEvent, combinedRelays);
if (result.failed.length > 0 && result.success.length === 0) { if (result.failed.length > 0 && result.success.length === 0) {
return error(500, 'Failed to publish to all relays'); throw handleApiError(new Error('Failed to publish to all relays'), { operation: 'createHighlight', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish to all relays');
} }
return json({ success: true, event, published: result }); return json({ success: true, event: highlightEvent, published: result });
} catch (err) { },
return handleApiError(err, { operation: 'createHighlight', npub, repo }, 'Failed to create highlight/comment'); { operation: 'createHighlight', requireRepoAccess: false } // Highlights can be created by anyone
} );
};

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

@ -2,79 +2,44 @@
* API endpoint for Issues (NIP-34 kind 1621) * API endpoint for Issues (NIP-34 kind 1621)
*/ */
import { json, error } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { IssuesService } from '$lib/services/nostr/issues-service.js'; import { issuesService, nostrClient } from '$lib/services/service-registry.js';
import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleValidationError, handleApiError } from '$lib/utils/error-handler.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
import { requireNpubHex } from '$lib/utils/npub-utils.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js';
export const GET: RequestHandler = async ({ params, url, request }) => { export const GET: RequestHandler = createRepoGetHandler(
const { npub, repo } = params; async (context: RepoRequestContext) => {
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); const issues = await issuesService.getIssues(context.repoOwnerPubkey, context.repo);
if (!npub || !repo) {
return handleValidationError('Missing npub or repo parameter', { operation: 'getIssues' });
}
try {
// Convert npub to pubkey
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'getIssues', npub });
}
// Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) {
return handleAuthorizationError(access.error || 'Access denied', { operation: 'getIssues', npub, repo });
}
const issuesService = new IssuesService(DEFAULT_NOSTR_RELAYS);
const issues = await issuesService.getIssues(repoOwnerPubkey, repo);
return json(issues); return json(issues);
} catch (err) { },
return handleApiError(err, { operation: 'getIssues', npub, repo }, 'Failed to fetch issues'); { operation: 'getIssues' }
} );
};
export const POST: RequestHandler = async ({ params, request }) => {
// For creating issues, we accept a pre-signed event from the client
// since NIP-07 signing must happen client-side
const { npub, repo } = params;
if (!npub || !repo) {
return handleValidationError('Missing npub or repo parameter', { operation: 'createIssue' });
}
try { export const POST: RequestHandler = withRepoValidation(
const body = await request.json(); async ({ repoContext, requestContext, event }) => {
const { event } = body; const body = await event.request.json();
const { event: issueEvent } = body;
if (!event) { if (!issueEvent) {
return handleValidationError('Missing event in request body', { operation: 'createIssue', npub, repo }); throw handleValidationError('Missing event in request body', { operation: 'createIssue', npub: repoContext.npub, repo: repoContext.repo });
} }
// Verify the event is properly signed (basic check) // Verify the event is properly signed (basic check)
if (!event.sig || !event.id) { if (!issueEvent.sig || !issueEvent.id) {
return handleValidationError('Invalid event: missing signature or ID', { operation: 'createIssue', npub, repo }); throw handleValidationError('Invalid event: missing signature or ID', { operation: 'createIssue', npub: repoContext.npub, repo: repoContext.repo });
} }
// Publish the event to relays // Publish the event to relays
const issuesService = new IssuesService(DEFAULT_NOSTR_RELAYS); const result = await nostrClient.publishEvent(issueEvent, DEFAULT_NOSTR_RELAYS);
const result = await issuesService['nostrClient'].publishEvent(event, DEFAULT_NOSTR_RELAYS);
if (result.failed.length > 0 && result.success.length === 0) { if (result.failed.length > 0 && result.success.length === 0) {
return handleApiError(new Error('Failed to publish issue to all relays'), { operation: 'createIssue', npub, repo }, 'Failed to publish issue to all relays'); throw handleApiError(new Error('Failed to publish issue to all relays'), { operation: 'createIssue', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish issue to all relays');
} }
return json({ success: true, event, published: result }); return json({ success: true, event: issueEvent, published: result });
} catch (err) { },
return handleApiError(err, { operation: 'createIssue', npub, repo }, 'Failed to create issue'); { operation: 'createIssue', requireRepoAccess: false } // Issues can be created by anyone with access
} );
};

48
src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts

@ -2,51 +2,29 @@
* API endpoint for checking maintainer status * API endpoint for checking maintainer status
*/ */
import { json, error } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type // @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { maintainerService } from '$lib/services/service-registry.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import { nip19 } from 'nostr-tools'; import type { RepoRequestContext } from '$lib/utils/api-context.js';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js';
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => {
export const GET: RequestHandler = async ({ params, url }: { params: { npub?: string; repo?: string }; url: URL }) => { const { maintainers, owner } = await maintainerService.getMaintainers(context.repoOwnerPubkey, context.repo);
const { npub, repo } = params;
const userPubkey = url.searchParams.get('userPubkey');
if (!npub || !repo) {
return handleValidationError('Missing npub or repo parameter', { operation: 'getMaintainers' });
}
try {
// Convert npub to pubkey
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'getMaintainers', npub });
}
const { maintainers, owner } = await maintainerService.getMaintainers(repoOwnerPubkey, repo);
// If userPubkey provided, check if they're a maintainer // If userPubkey provided, check if they're a maintainer
if (userPubkey) { if (context.userPubkeyHex) {
const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey; const isMaintainer = maintainers.includes(context.userPubkeyHex);
const isMaintainer = maintainers.includes(userPubkeyHex);
return json({ return json({
maintainers, maintainers,
owner, owner,
isMaintainer, isMaintainer,
userPubkey: userPubkeyHex userPubkey: context.userPubkeyHex
}); });
} }
return json({ maintainers, owner }); return json({ maintainers, owner });
} catch (err) { },
return handleApiError(err, { operation: 'getMaintainers', npub, repo }, 'Failed to check maintainers'); { operation: 'getMaintainers', requireRepoAccess: false } // Maintainer list is public info
} );
};

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

@ -2,79 +2,45 @@
* API endpoint for Pull Requests (NIP-34 kind 1618) * API endpoint for Pull Requests (NIP-34 kind 1618)
*/ */
import { json, error } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type // @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { PRsService } from '$lib/services/nostr/prs-service.js'; import { prsService, nostrClient } from '$lib/services/service-registry.js';
import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleValidationError, handleApiError } from '$lib/utils/error-handler.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
import { requireNpubHex } from '$lib/utils/npub-utils.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js';
export const GET: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => { export const GET: RequestHandler = createRepoGetHandler(
const { npub, repo } = params; async (context: RepoRequestContext) => {
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); const prs = await prsService.getPullRequests(context.repoOwnerPubkey, context.repo);
if (!npub || !repo) {
return handleValidationError('Missing npub or repo parameter', { operation: 'getPRs' });
}
try {
// Convert npub to pubkey
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'getPRs', npub });
}
// Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) {
return handleAuthorizationError(access.error || 'Access denied', { operation: 'getPRs', npub, repo });
}
const prsService = new PRsService(DEFAULT_NOSTR_RELAYS);
const prs = await prsService.getPullRequests(repoOwnerPubkey, repo);
return json(prs); return json(prs);
} catch (err) { },
return handleApiError(err, { operation: 'getPRs', npub, repo }, 'Failed to fetch pull requests'); { operation: 'getPRs' }
} );
};
export const POST: RequestHandler = async ({ params, request }: { params: { npub?: string; repo?: string }; request: Request }) => {
// For creating PRs, we accept a pre-signed event from the client
const { npub, repo } = params;
if (!npub || !repo) {
return handleValidationError('Missing npub or repo parameter', { operation: 'createPR' });
}
try { export const POST: RequestHandler = withRepoValidation(
const body = await request.json(); async ({ repoContext, requestContext, event }) => {
const { event } = body; const body = await event.request.json();
const { event: prEvent } = body;
if (!event) { if (!prEvent) {
return handleValidationError('Missing event in request body', { operation: 'createPR', npub, repo }); throw handleValidationError('Missing event in request body', { operation: 'createPR', npub: repoContext.npub, repo: repoContext.repo });
} }
// Verify the event is properly signed // Verify the event is properly signed
if (!event.sig || !event.id) { if (!prEvent.sig || !prEvent.id) {
return handleValidationError('Invalid event: missing signature or ID', { operation: 'createPR', npub, repo }); throw handleValidationError('Invalid event: missing signature or ID', { operation: 'createPR', npub: repoContext.npub, repo: repoContext.repo });
} }
// Publish the event to relays // Publish the event to relays
const prsService = new PRsService(DEFAULT_NOSTR_RELAYS); const result = await nostrClient.publishEvent(prEvent, DEFAULT_NOSTR_RELAYS);
const result = await prsService['nostrClient'].publishEvent(event, DEFAULT_NOSTR_RELAYS);
if (result.failed.length > 0 && result.success.length === 0) { if (result.failed.length > 0 && result.success.length === 0) {
return handleApiError(new Error('Failed to publish pull request to all relays'), { operation: 'createPR', npub, repo }, 'Failed to publish pull request to all relays'); throw handleApiError(new Error('Failed to publish pull request to all relays'), { operation: 'createPR', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish pull request to all relays');
} }
return json({ success: true, event, published: result }); return json({ success: true, event: prEvent, published: result });
} catch (err) { },
return handleApiError(err, { operation: 'createPR', npub, repo }, 'Failed to create pull request'); { operation: 'createPR', requireRepoAccess: false } // PRs can be created by anyone with access
} );
};

59
src/routes/api/repos/[npub]/[repo]/raw/+server.ts

@ -2,49 +2,23 @@
* API endpoint for raw file access * API endpoint for raw file access
*/ */
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js'; import { fileManager } from '$lib/services/service-registry.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { nip19 } from 'nostr-tools'; import { handleValidationError } from '$lib/utils/error-handler.js';
import { requireNpubHex } from '$lib/utils/npub-utils.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext, event: RequestEvent) => {
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const filePath = context.path || event.url.searchParams.get('path');
const fileManager = new FileManager(repoRoot); const ref = context.ref || 'HEAD';
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
if (!filePath) {
export const GET: RequestHandler = async ({ params, url, request }) => { throw handleValidationError('Missing path parameter', { operation: 'getRawFile', npub: context.npub, repo: context.repo });
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 handleValidationError('Missing npub, repo, or path parameter', { operation: 'getRawFile' });
}
try {
if (!fileManager.repoExists(npub, repo)) {
return handleNotFoundError('Repository not found', { operation: 'getRawFile', npub, repo });
}
// Check repository privacy
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'getRawFile', npub });
}
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo);
if (!canView) {
return handleAuthorizationError('This repository is private. Only owners and maintainers can view it.', { operation: 'getRawFile', npub, repo });
} }
// Get file content // Get file content
const fileData = await fileManager.getFileContent(npub, repo, filePath, ref); const fileData = await fileManager.getFileContent(context.npub, context.repo, filePath, ref);
// Determine content type based on file extension // Determine content type based on file extension
const ext = filePath.split('.').pop()?.toLowerCase(); const ext = filePath.split('.').pop()?.toLowerCase();
@ -78,7 +52,6 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
'Cache-Control': 'public, max-age=3600' 'Cache-Control': 'public, max-age=3600'
} }
}); });
} catch (err) { },
return handleApiError(err, { operation: 'getRawFile', npub, repo, filePath }, 'Failed to get raw file'); { operation: 'getRawFile' }
} );
};

57
src/routes/api/repos/[npub]/[repo]/readme/+server.ts

@ -2,18 +2,11 @@
* API endpoint for getting README content * API endpoint for getting README content
*/ */
import { json, error } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js'; import { fileManager } from '$lib/services/service-registry.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import type { RepoRequestContext } from '$lib/utils/api-context.js';
import { nip19 } from 'nostr-tools';
import { requireNpubHex } from '$lib/utils/npub-utils.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
const README_PATTERNS = [ const README_PATTERNS = [
'README.md', 'README.md',
@ -26,33 +19,10 @@ const README_PATTERNS = [
'readme' 'readme'
]; ];
export const GET: RequestHandler = async ({ params, url, request }) => { export const GET: RequestHandler = createRepoGetHandler(
const { npub, repo } = params; async (context: RepoRequestContext) => {
const ref = url.searchParams.get('ref') || 'HEAD'; const ref = context.ref || 'HEAD';
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) {
return handleValidationError('Missing npub or repo parameter', { operation: 'getReadme' });
}
try {
if (!fileManager.repoExists(npub, repo)) {
return handleNotFoundError('Repository not found', { operation: 'getReadme', npub, repo });
}
// Check repository privacy
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'getReadme', npub });
}
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo);
if (!canView) {
return handleAuthorizationError('This repository is private. Only owners and maintainers can view it.', { operation: 'getReadme', npub, repo });
}
// Try to find README file // Try to find README file
let readmeContent: string | null = null; let readmeContent: string | null = null;
let readmePath: string | null = null; let readmePath: string | null = null;
@ -60,14 +30,14 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
for (const pattern of README_PATTERNS) { for (const pattern of README_PATTERNS) {
try { try {
// Try root directory first // Try root directory first
const content = await fileManager.getFileContent(npub, repo, pattern, ref); const content = await fileManager.getFileContent(context.npub, context.repo, pattern, ref);
readmeContent = content.content; readmeContent = content.content;
readmePath = pattern; readmePath = pattern;
break; break;
} catch { } catch {
// Try in root directory with different paths // Try in root directory with different paths
try { try {
const content = await fileManager.getFileContent(npub, repo, `/${pattern}`, ref); const content = await fileManager.getFileContent(context.npub, context.repo, `/${pattern}`, ref);
readmeContent = content.content; readmeContent = content.content;
readmePath = `/${pattern}`; readmePath = `/${pattern}`;
break; break;
@ -87,7 +57,6 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
path: readmePath, path: readmePath,
isMarkdown: readmePath?.toLowerCase().endsWith('.md') || readmePath?.toLowerCase().endsWith('.markdown') isMarkdown: readmePath?.toLowerCase().endsWith('.md') || readmePath?.toLowerCase().endsWith('.markdown')
}); });
} catch (err) { },
return handleApiError(err, { operation: 'getReadme', npub, repo }, 'Failed to get README'); { operation: 'getReadme' }
} );
};

122
src/routes/api/repos/[npub]/[repo]/settings/+server.ts

@ -4,52 +4,28 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { nostrClient, maintainerService, ownershipTransferService } from '$lib/services/service-registry.js';
import { DEFAULT_NOSTR_RELAYS, combineRelays, getGitUrl } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { KIND } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js';
import logger from '$lib/services/logger.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthError, handleAuthorizationError } from '$lib/utils/error-handler.js';
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS);
/** /**
* GET - Get repository settings * GET - Get repository settings
*/ */
export const GET: RequestHandler = async ({ params, url, request }) => { export const GET: RequestHandler = createRepoGetHandler(
const { npub, repo } = params; async (context: RepoRequestContext) => {
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) {
return handleValidationError('Missing npub or repo parameter', { operation: 'getSettings' });
}
try {
// Decode npub to get pubkey
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'getSettings', npub });
}
// Check if user is owner // Check if user is owner
if (!userPubkey) { if (!context.userPubkeyHex) {
return handleAuthError('Authentication required', { operation: 'getSettings', npub, repo }); throw handleApiError(new Error('Authentication required'), { operation: 'getSettings', npub: context.npub, repo: context.repo }, 'Authentication required');
} }
const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey; const currentOwner = await ownershipTransferService.getCurrentOwner(context.repoOwnerPubkey, context.repo);
if (context.userPubkeyHex !== currentOwner) {
const currentOwner = await ownershipTransferService.getCurrentOwner(repoOwnerPubkey, repo); throw handleAuthorizationError('Only the repository owner can access settings', { operation: 'getSettings', npub: context.npub, repo: context.repo });
if (userPubkeyHex !== currentOwner) {
return handleAuthorizationError('Only the repository owner can access settings', { operation: 'getSettings', npub, repo });
} }
// Get repository announcement // Get repository announcement
@ -57,17 +33,17 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
{ {
kinds: [KIND.REPO_ANNOUNCEMENT], kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [currentOwner], authors: [currentOwner],
'#d': [repo], '#d': [context.repo],
limit: 1 limit: 1
} }
]); ]);
if (events.length === 0) { if (events.length === 0) {
return handleNotFoundError('Repository announcement not found', { operation: 'getSettings', npub, repo }); throw handleNotFoundError('Repository announcement not found', { operation: 'getSettings', npub: context.npub, repo: context.repo });
} }
const announcement = events[0]; const announcement = events[0];
const name = announcement.tags.find(t => t[0] === 'name')?.[1] || repo; const name = announcement.tags.find(t => t[0] === 'name')?.[1] || context.repo;
const description = announcement.tags.find(t => t[0] === 'description')?.[1] || ''; const description = announcement.tags.find(t => t[0] === 'description')?.[1] || '';
const cloneUrls = announcement.tags const cloneUrls = announcement.tags
.filter(t => t[0] === 'clone') .filter(t => t[0] === 'clone')
@ -77,7 +53,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
.filter(t => t[0] === 'maintainers') .filter(t => t[0] === 'maintainers')
.flatMap(t => t.slice(1)) .flatMap(t => t.slice(1))
.filter(m => m && typeof m === 'string') as string[]; .filter(m => m && typeof m === 'string') as string[];
const privacyInfo = await maintainerService.getPrivacyInfo(currentOwner, repo); const privacyInfo = await maintainerService.getPrivacyInfo(currentOwner, context.repo);
const isPrivate = privacyInfo.isPrivate; const isPrivate = privacyInfo.isPrivate;
return json({ return json({
@ -87,45 +63,28 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
maintainers, maintainers,
isPrivate, isPrivate,
owner: currentOwner, owner: currentOwner,
npub npub: context.npub
}); });
} catch (err) { },
return handleApiError(err, { operation: 'getSettings', npub, repo }, 'Failed to get repository settings'); { operation: 'getSettings', requireRepoAccess: false } // Override to check owner instead
} );
};
/** /**
* POST - Update repository settings * POST - Update repository settings
*/ */
export const POST: RequestHandler = async ({ params, request }) => { export const POST: RequestHandler = withRepoValidation(
const { npub, repo } = params; async ({ repoContext, requestContext, event }) => {
if (!requestContext.userPubkeyHex) {
if (!npub || !repo) { throw handleApiError(new Error('Authentication required'), { operation: 'updateSettings', npub: repoContext.npub, repo: repoContext.repo }, 'Authentication required');
return handleValidationError('Missing npub or repo parameter', { operation: 'updateSettings' });
}
try {
const body = await request.json();
const { userPubkey, name, description, cloneUrls, maintainers, isPrivate } = body;
if (!userPubkey) {
return handleAuthError('Authentication required', { operation: 'updateSettings', npub, repo });
}
// Decode npub to get pubkey
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'updateSettings', npub });
} }
const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey; const body = await event.request.json();
const { name, description, cloneUrls, maintainers, isPrivate } = body;
// Check if user is owner // Check if user is owner
const currentOwner = await ownershipTransferService.getCurrentOwner(repoOwnerPubkey, repo); const currentOwner = await ownershipTransferService.getCurrentOwner(repoContext.repoOwnerPubkey, repoContext.repo);
if (userPubkeyHex !== currentOwner) { if (requestContext.userPubkeyHex !== currentOwner) {
return handleAuthorizationError('Only the repository owner can update settings', { operation: 'updateSettings', npub, repo }); throw handleAuthorizationError('Only the repository owner can update settings', { operation: 'updateSettings', npub: repoContext.npub, repo: repoContext.repo });
} }
// Get existing announcement // Get existing announcement
@ -133,13 +92,13 @@ export const POST: RequestHandler = async ({ params, request }) => {
{ {
kinds: [KIND.REPO_ANNOUNCEMENT], kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [currentOwner], authors: [currentOwner],
'#d': [repo], '#d': [repoContext.repo],
limit: 1 limit: 1
} }
]); ]);
if (events.length === 0) { if (events.length === 0) {
return handleNotFoundError('Repository announcement not found', { operation: 'updateSettings', npub, repo }); throw handleNotFoundError('Repository announcement not found', { operation: 'updateSettings', npub: repoContext.npub, repo: repoContext.repo });
} }
const existingAnnouncement = events[0]; const existingAnnouncement = events[0];
@ -148,11 +107,11 @@ export const POST: RequestHandler = async ({ params, request }) => {
const gitDomain = process.env.GIT_DOMAIN || 'localhost:6543'; const gitDomain = process.env.GIT_DOMAIN || 'localhost:6543';
const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1'); const isLocalhost = gitDomain.startsWith('localhost') || gitDomain.startsWith('127.0.0.1');
const protocol = isLocalhost ? 'http' : 'https'; const protocol = isLocalhost ? 'http' : 'https';
const gitUrl = `${protocol}://${gitDomain}/${npub}/${repo}.git`; const gitUrl = `${protocol}://${gitDomain}/${repoContext.npub}/${repoContext.repo}.git`;
// Get Tor .onion URL if available // Get Tor .onion URL if available
const { getTorGitUrl } = await import('$lib/services/tor/hidden-service.js'); const { getTorGitUrl } = await import('$lib/services/tor/hidden-service.js');
const torOnionUrl = await getTorGitUrl(npub, repo); const torOnionUrl = await getTorGitUrl(repoContext.npub, repoContext.repo);
// Filter user-provided clone URLs (exclude localhost and .onion duplicates) // Filter user-provided clone URLs (exclude localhost and .onion duplicates)
const userCloneUrls = (cloneUrls || []).filter((url: string) => { const userCloneUrls = (cloneUrls || []).filter((url: string) => {
@ -181,12 +140,12 @@ export const POST: RequestHandler = async ({ params, request }) => {
// Validate: If using localhost, require either Tor .onion URL or at least one other clone URL // Validate: If using localhost, require either Tor .onion URL or at least one other clone URL
if (isLocalhost && !torOnionUrl && userCloneUrls.length === 0) { if (isLocalhost && !torOnionUrl && userCloneUrls.length === 0) {
return error(400, 'Cannot update with only localhost. You need either a Tor .onion address or at least one other clone URL.'); throw error(400, 'Cannot update with only localhost. You need either a Tor .onion address or at least one other clone URL.');
} }
const tags: string[][] = [ const tags: string[][] = [
['d', repo], ['d', repoContext.repo],
['name', name || repo], ['name', name || repoContext.repo],
...(description ? [['description', description]] : []), ...(description ? [['description', description]] : []),
['clone', ...cloneUrlList], ['clone', ...cloneUrlList],
['relays', ...DEFAULT_NOSTR_RELAYS], ['relays', ...DEFAULT_NOSTR_RELAYS],
@ -220,11 +179,10 @@ export const POST: RequestHandler = async ({ params, request }) => {
const result = await nostrClient.publishEvent(signedEvent, combinedRelays); const result = await nostrClient.publishEvent(signedEvent, combinedRelays);
if (result.success.length === 0) { if (result.success.length === 0) {
return error(500, 'Failed to publish updated announcement to relays'); throw error(500, 'Failed to publish updated announcement to relays');
} }
return json({ success: true, event: signedEvent }); return json({ success: true, event: signedEvent });
} catch (err) { },
return handleApiError(err, { operation: 'updateSettings', npub, repo }, 'Failed to update repository settings'); { operation: 'updateSettings', requireRepoAccess: false } // Override to check owner instead
} );
};

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

@ -2,93 +2,33 @@
* API endpoint for getting and creating tags * API endpoint for getting and creating tags
*/ */
import { json, error } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type // @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js'; import { fileManager } from '$lib/services/service-registry.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { nip19 } from 'nostr-tools'; import { handleValidationError } from '$lib/utils/error-handler.js';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthError, handleAuthorizationError } from '$lib/utils/error-handler.js'; export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => {
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const tags = await fileManager.getTags(context.npub, context.repo);
const fileManager = new FileManager(repoRoot);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
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 handleValidationError('Missing npub or repo parameter', { operation: 'getTags' });
}
try {
if (!fileManager.repoExists(npub, repo)) {
return handleNotFoundError('Repository not found', { operation: 'getTags', npub, repo });
}
// Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) {
return handleAuthorizationError(access.error || 'Access denied', { operation: 'getTags', npub, repo });
}
const tags = await fileManager.getTags(npub, repo);
return json(tags); return json(tags);
} catch (err) { },
return handleApiError(err, { operation: 'getTags', npub, repo }, 'Failed to get tags'); { operation: 'getTags' }
} );
};
export const POST: RequestHandler = async ({ params, request }: { params: { npub?: string; repo?: string }; request: Request }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return handleValidationError('Missing npub or repo parameter', { operation: 'createTag' });
}
let tagName: string | undefined; export const POST: RequestHandler = createRepoPostHandler(
let ref: string | undefined; async (context: RepoRequestContext, event: RequestEvent) => {
let message: string | undefined; const body = await event.request.json();
let userPubkey: string | undefined; const { tagName, ref, message } = body;
try {
const body = await request.json();
({ tagName, ref, message, userPubkey } = body);
if (!tagName) { if (!tagName) {
return handleValidationError('Missing tagName parameter', { operation: 'createTag', npub, repo }); throw handleValidationError('Missing tagName parameter', { operation: 'createTag', npub: context.npub, repo: context.repo });
}
if (!userPubkey) {
return handleAuthError('Authentication required. Please provide userPubkey.', { operation: 'createTag', npub, repo });
}
if (!fileManager.repoExists(npub, repo)) {
return handleNotFoundError('Repository not found', { operation: 'createTag', npub, repo });
}
// Check if user is a maintainer
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'createTag', npub });
}
// Convert userPubkey to hex if needed
const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey;
const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, repoOwnerPubkey, repo);
if (!isMaintainer) {
return handleAuthorizationError('Only repository maintainers can create tags.', { operation: 'createTag', npub, repo });
} }
await fileManager.createTag(npub, repo, tagName, ref || 'HEAD', message); await fileManager.createTag(context.npub, context.repo, tagName, ref || 'HEAD', message);
return json({ success: true, message: 'Tag created successfully' }); return json({ success: true, message: 'Tag created successfully' });
} catch (err) { },
return handleApiError(err, { operation: 'createTag', npub, repo, tagName }, 'Failed to create tag'); { operation: 'createTag' }
} );
};

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

@ -4,44 +4,27 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; import { ownershipTransferService, nostrClient } from '$lib/services/service-registry.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { combineRelays } from '$lib/config.js';
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { verifyEvent } from 'nostr-tools'; import { verifyEvent } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { handleApiError, handleValidationError, handleAuthError, handleAuthorizationError } from '$lib/utils/error-handler.js'; import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext } from '$lib/utils/api-context.js';
const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); import type { RequestEvent } from '@sveltejs/kit';
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); import { handleApiError, handleValidationError, handleAuthorizationError } from '$lib/utils/error-handler.js';
/** /**
* GET - Get current owner and transfer history * GET - Get current owner and transfer history
*/ */
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = createRepoGetHandler(
const { npub, repo } = params; async (context: RepoRequestContext) => {
if (!npub || !repo) {
return handleValidationError('Missing npub or repo parameter', { operation: 'getOwnership' });
}
try {
// Decode npub to get pubkey
let originalOwnerPubkey: string;
try {
originalOwnerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'getOwnership', npub });
}
// Get current owner (may be different if transferred) // Get current owner (may be different if transferred)
const currentOwner = await ownershipTransferService.getCurrentOwner(originalOwnerPubkey, repo); const currentOwner = await ownershipTransferService.getCurrentOwner(context.repoOwnerPubkey, context.repo);
// Fetch transfer events for history // Fetch transfer events for history
const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`; const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${context.repoOwnerPubkey}:${context.repo}`;
const transferEvents = await nostrClient.fetchEvents([ const transferEvents = await nostrClient.fetchEvents([
{ {
kinds: [KIND.OWNERSHIP_TRANSFER], kinds: [KIND.OWNERSHIP_TRANSFER],
@ -54,9 +37,9 @@ export const GET: RequestHandler = async ({ params }) => {
transferEvents.sort((a, b) => b.created_at - a.created_at); transferEvents.sort((a, b) => b.created_at - a.created_at);
return json({ return json({
originalOwner: originalOwnerPubkey, originalOwner: context.repoOwnerPubkey,
currentOwner, currentOwner,
transferred: currentOwner !== originalOwnerPubkey, transferred: currentOwner !== context.repoOwnerPubkey,
transfers: transferEvents.map(event => { transfers: transferEvents.map(event => {
const pTag = event.tags.find(t => t[0] === 'p'); const pTag = event.tags.find(t => t[0] === 'p');
return { return {
@ -68,87 +51,76 @@ export const GET: RequestHandler = async ({ params }) => {
}; };
}) })
}); });
} catch (err) { },
return handleApiError(err, { operation: 'getOwnership', npub, repo }, 'Failed to fetch ownership info'); { operation: 'getOwnership', requireRepoAccess: false } // Ownership info is public
} );
};
/** /**
* POST - Initiate ownership transfer * POST - Initiate ownership transfer
* Requires a pre-signed NIP-98 authenticated event from the current owner * Requires a pre-signed NIP-98 authenticated event from the current owner
*/ */
export const POST: RequestHandler = async ({ params, request }) => { export const POST: RequestHandler = withRepoValidation(
const { npub, repo } = params; async ({ repoContext, requestContext, event }) => {
if (!requestContext.userPubkeyHex) {
if (!npub || !repo) { throw handleApiError(new Error('Authentication required'), { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }, 'Authentication required');
return handleValidationError('Missing npub or repo parameter', { operation: 'transferOwnership' }); }
}
try { const body = await event.request.json();
const body = await request.json(); const { transferEvent } = body;
const { transferEvent, userPubkey } = body;
if (!transferEvent || !userPubkey) { if (!transferEvent) {
return handleValidationError('Missing transferEvent or userPubkey in request body', { operation: 'transferOwnership', npub, repo }); return handleValidationError('Missing transferEvent in request body', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
} }
// Verify the event is properly signed // Verify the event is properly signed
if (!transferEvent.sig || !transferEvent.id) { if (!transferEvent.sig || !transferEvent.id) {
return handleValidationError('Invalid event: missing signature or ID', { operation: 'transferOwnership', npub, repo }); throw handleValidationError('Invalid event: missing signature or ID', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
} }
if (!verifyEvent(transferEvent)) { if (!verifyEvent(transferEvent)) {
return handleValidationError('Invalid event signature', { operation: 'transferOwnership', npub, repo }); throw handleValidationError('Invalid event signature', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
}
// Decode npub to get original owner pubkey
let originalOwnerPubkey: string;
try {
originalOwnerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'transferOwnership', npub });
} }
// Verify user is the current owner // Verify user is the current owner
const canTransfer = await ownershipTransferService.canTransfer( const canTransfer = await ownershipTransferService.canTransfer(
userPubkey, requestContext.userPubkeyHex,
originalOwnerPubkey, repoContext.repoOwnerPubkey,
repo repoContext.repo
); );
if (!canTransfer) { if (!canTransfer) {
return handleAuthorizationError('Only the current repository owner can transfer ownership', { operation: 'transferOwnership', npub, repo }); throw handleAuthorizationError('Only the current repository owner can transfer ownership', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
} }
// Verify the transfer event is from the current owner // Verify the transfer event is from the current owner
if (transferEvent.pubkey !== userPubkey) { if (transferEvent.pubkey !== requestContext.userPubkeyHex) {
return handleAuthorizationError('Transfer event must be signed by the current owner', { operation: 'transferOwnership', npub, repo }); throw handleAuthorizationError('Transfer event must be signed by the current owner', { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
} }
// Verify it's an ownership transfer event // Verify it's an ownership transfer event
if (transferEvent.kind !== KIND.OWNERSHIP_TRANSFER) { if (transferEvent.kind !== KIND.OWNERSHIP_TRANSFER) {
return handleValidationError(`Event must be kind ${KIND.OWNERSHIP_TRANSFER} (ownership transfer)`, { operation: 'transferOwnership', npub, repo }); throw handleValidationError(`Event must be kind ${KIND.OWNERSHIP_TRANSFER} (ownership transfer)`, { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
} }
// Verify the 'a' tag references this repo // Verify the 'a' tag references this repo
const aTag = transferEvent.tags.find(t => t[0] === 'a'); const aTag = transferEvent.tags.find(t => t[0] === 'a');
const expectedRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`; const expectedRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${repoContext.repoOwnerPubkey}:${repoContext.repo}`;
if (!aTag || aTag[1] !== expectedRepoTag) { if (!aTag || aTag[1] !== expectedRepoTag) {
return handleValidationError("Transfer event 'a' tag does not match this repository", { operation: 'transferOwnership', npub, repo }); throw handleValidationError("Transfer event 'a' tag does not match this repository", { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo });
} }
// Get user's relays and publish // Get user's relays and publish
const { outbox } = await getUserRelays(userPubkey, nostrClient); const { outbox } = await getUserRelays(requestContext.userPubkeyHex, nostrClient);
const combinedRelays = combineRelays(outbox); const combinedRelays = combineRelays(outbox);
const result = await nostrClient.publishEvent(transferEvent as NostrEvent, combinedRelays); const result = await nostrClient.publishEvent(transferEvent as NostrEvent, combinedRelays);
if (result.success.length === 0) { if (result.success.length === 0) {
return error(500, 'Failed to publish transfer event to any relays'); throw handleApiError(new Error('Failed to publish transfer event to any relays'), { operation: 'transferOwnership', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish transfer event to any relays');
} }
// Clear cache so new owner is recognized immediately // Clear cache so new owner is recognized immediately
ownershipTransferService.clearCache(originalOwnerPubkey, repo); ownershipTransferService.clearCache(repoContext.repoOwnerPubkey, repoContext.repo);
return json({ return json({
success: true, success: true,
@ -156,7 +128,6 @@ export const POST: RequestHandler = async ({ params, request }) => {
published: result, published: result,
message: 'Ownership transfer initiated successfully' message: 'Ownership transfer initiated successfully'
}); });
} catch (err) { },
return handleApiError(err, { operation: 'transferOwnership', npub, repo }, 'Failed to transfer ownership'); { operation: 'transferOwnership', requireRepoAccess: false } // Override to check owner instead
} );
};

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

@ -2,50 +2,19 @@
* API endpoint for listing files and directories in a repository * API endpoint for listing files and directories in a repository
*/ */
import { json, error } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js'; import { fileManager } from '$lib/services/service-registry.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { nip19 } from 'nostr-tools';
import { requireNpubHex } from '$lib/utils/npub-utils.js'; export const GET: RequestHandler = createRepoGetHandler(
import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; async (context: RepoRequestContext) => {
const ref = context.ref || 'HEAD';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const path = context.path || '';
const fileManager = new FileManager(repoRoot);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); const files = await fileManager.listFiles(context.npub, context.repo, ref, path);
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 handleValidationError('Missing npub or repo parameter', { operation: 'listFiles' });
}
try {
if (!fileManager.repoExists(npub, repo)) {
return handleNotFoundError('Repository not found', { operation: 'listFiles', npub, repo });
}
// Check repository privacy
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'listFiles', npub });
}
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo);
if (!canView) {
return handleAuthorizationError('This repository is private. Only owners and maintainers can view it.', { operation: 'listFiles', npub, repo });
}
const files = await fileManager.listFiles(npub, repo, ref, path);
return json(files); return json(files);
} catch (err) { },
return handleApiError(err, { operation: 'listFiles', npub, repo, path, ref }, 'Failed to list files'); { operation: 'listFiles' }
} );
};

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

@ -5,52 +5,35 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type // @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js'; import { fileManager } from '$lib/services/service-registry.js';
import { verifyRepositoryOwnership, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js'; import { verifyRepositoryOwnership, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { nostrClient } from '$lib/services/service-registry.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 { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import logger from '$lib/services/logger.js';
import { join } from 'path'; import { join } from 'path';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; import { decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { handleApiError, handleValidationError, handleNotFoundError } from '$lib/utils/error-handler.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext } from '$lib/utils/api-context.js';
import { handleApiError } from '$lib/utils/error-handler.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
const fileManager = new FileManager(repoRoot); ? process.env.GIT_REPO_ROOT
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); : '/repos';
const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => { export const GET: RequestHandler = createRepoGetHandler(
const { npub, repo } = params; async (context: RepoRequestContext) => {
// Check if repository exists
if (!npub || !repo) { const repoPath = join(repoRoot, context.npub, `${context.repo}.git`);
return handleValidationError('Missing npub or repo parameter', { operation: 'verifyRepo' });
}
try {
// Decode npub to get pubkey
let ownerPubkey: string;
try {
ownerPubkey = requireNpubHex(npub);
} catch {
return handleValidationError('Invalid npub format', { operation: 'verifyRepo', npub });
}
// Check if repository exists (using FileManager's internal method)
const repoPath = join(repoRoot, npub, `${repo}.git`);
if (!existsSync(repoPath)) { if (!existsSync(repoPath)) {
return handleNotFoundError('Repository not found', { operation: 'verifyRepo', npub, repo }); throw handleApiError(new Error('Repository not found'), { operation: 'verifyRepo', npub: context.npub, repo: context.repo }, 'Repository not found');
} }
// Fetch the repository announcement // Fetch the repository announcement
const events = await nostrClient.fetchEvents([ const events = await nostrClient.fetchEvents([
{ {
kinds: [KIND.REPO_ANNOUNCEMENT], kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [ownerPubkey], authors: [context.repoOwnerPubkey],
'#d': [repo], '#d': [context.repo],
limit: 1 limit: 1
} }
]); ]);
@ -66,7 +49,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
const announcement = events[0]; const announcement = events[0];
// Check for ownership transfer events (including self-transfer for initial ownership) // Check for ownership transfer events (including self-transfer for initial ownership)
const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${ownerPubkey}:${repo}`; const repoTag = `${KIND.REPO_ANNOUNCEMENT}:${context.repoOwnerPubkey}:${context.repo}`;
const transferEvents = await nostrClient.fetchEvents([ const transferEvents = await nostrClient.fetchEvents([
{ {
kinds: [KIND.OWNERSHIP_TRANSFER], kinds: [KIND.OWNERSHIP_TRANSFER],
@ -90,8 +73,8 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
} }
} }
return event.pubkey === ownerPubkey && return event.pubkey === context.repoOwnerPubkey &&
toPubkey === ownerPubkey; toPubkey === context.repoOwnerPubkey;
}); });
// Verify ownership - prefer self-transfer event, fall back to verification file // Verify ownership - prefer self-transfer event, fall back to verification file
@ -113,7 +96,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
} else { } else {
// Fall back to verification file method (for backward compatibility) // Fall back to verification file method (for backward compatibility)
try { try {
const verificationFile = await fileManager.getFileContent(npub, repo, VERIFICATION_FILE_PATH, 'HEAD'); const verificationFile = await fileManager.getFileContent(context.npub, context.repo, VERIFICATION_FILE_PATH, 'HEAD');
const verification = verifyRepositoryOwnership(announcement, verificationFile.content); const verification = verifyRepositoryOwnership(announcement, verificationFile.content);
verified = verification.valid; verified = verification.valid;
verificationError = verification.error; verificationError = verification.error;
@ -129,7 +112,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
return json({ return json({
verified: true, verified: true,
announcementId: announcement.id, announcementId: announcement.id,
ownerPubkey: ownerPubkey, ownerPubkey: context.repoOwnerPubkey,
verificationMethod, verificationMethod,
selfTransferEventId: selfTransfer?.id, selfTransferEventId: selfTransfer?.id,
message: 'Repository ownership verified successfully' message: 'Repository ownership verified successfully'
@ -143,7 +126,6 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
message: 'Repository ownership verification failed' message: 'Repository ownership verification failed'
}); });
} }
} catch (err) { },
return handleApiError(err, { operation: 'verifyRepo', npub, repo }, 'Failed to verify repository'); { operation: 'verifyRepo', requireRepoAccess: false } // Verification is public
} );
};

Loading…
Cancel
Save