Browse Source

message forwarding

main
Silberengel 4 weeks ago
parent
commit
df1cff8fd4
  1. 2
      README.md
  2. 396
      docs/MESSAGING_FORWARDING.md
  3. 10
      package-lock.json
  4. 2
      package.json
  5. 348
      src/lib/components/ForwardingConfig.svelte
  6. 3
      src/lib/config.ts
  7. 635
      src/lib/services/messaging/event-forwarder.ts
  8. 31
      src/lib/services/messaging/nodemailer.d.ts
  9. 383
      src/lib/services/messaging/preferences-storage.ts
  10. 215
      src/lib/services/nostr/public-messages-service.ts
  11. 112
      src/lib/services/security/security-audit.md
  12. 1
      src/lib/types/nostr.ts
  13. 12
      src/routes/api/repos/[npub]/[repo]/highlights/+server.ts
  14. 10
      src/routes/api/repos/[npub]/[repo]/issues/+server.ts
  15. 10
      src/routes/api/repos/[npub]/[repo]/prs/+server.ts
  16. 211
      src/routes/api/user/messaging-preferences/+server.ts
  17. 58
      src/routes/api/user/messaging-preferences/summary/+server.ts
  18. 4
      src/routes/repos/[npub]/[repo]/+page.svelte
  19. 454
      src/routes/users/[npub]/+page.svelte

2
README.md

@ -347,7 +347,7 @@ See `docs/SECURITY.md` and `docs/SECURITY_IMPLEMENTATION.md` for detailed inform
- `NOSTRGIT_SECRET_KEY`: Server's nsec (bech32 or hex) for signing repo announcements and initial commits (optional) - `NOSTRGIT_SECRET_KEY`: Server's nsec (bech32 or hex) for signing repo announcements and initial commits (optional)
- `GIT_REPO_ROOT`: Path to store git repositories (default: `/repos`) - `GIT_REPO_ROOT`: Path to store git repositories (default: `/repos`)
- `GIT_DOMAIN`: Domain for git repositories (default: `localhost:6543`) - `GIT_DOMAIN`: Domain for git repositories (default: `localhost:6543`)
- `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com,wss://nostr.land,wss://relay.damus.io`) - `NOSTR_RELAYS`: Comma-separated list of Nostr relays (default: `wss://theforest.nostr1.com`)
- `TOR_SOCKS_PROXY`: Tor SOCKS proxy address (format: `host:port`, default: `127.0.0.1:9050`). Set to empty string to disable Tor support. When configured, the server will automatically route `.onion` addresses through Tor for both Nostr relay connections and git operations. - `TOR_SOCKS_PROXY`: Tor SOCKS proxy address (format: `host:port`, default: `127.0.0.1:9050`). Set to empty string to disable Tor support. When configured, the server will automatically route `.onion` addresses through Tor for both Nostr relay connections and git operations.
- `TOR_ONION_ADDRESS`: Tor hidden service .onion address (optional). If not set, the server will attempt to read it from Tor's hostname file. When configured, every repository will automatically get a `.onion` clone URL in addition to the regular domain URL, making repositories accessible via Tor even if the server is only running on localhost. - `TOR_ONION_ADDRESS`: Tor hidden service .onion address (optional). If not set, the server will attempt to read it from Tor's hostname file. When configured, every repository will automatically get a `.onion` clone URL in addition to the regular domain URL, making repositories accessible via Tor even if the server is only running on localhost.

396
docs/MESSAGING_FORWARDING.md

@ -0,0 +1,396 @@
# Messaging Forwarding Feature
This feature allows users with **unlimited access** to forward Nostr events to messaging platforms (Telegram, SimpleX, Email) and Git hosting platforms (GitHub, GitLab, Gitea, Codeberg, Forgejo) when they publish events.
## Security Architecture
### Multi-Layer Security
1. **Encrypted Salt Storage**: Each user's salt is encrypted with a separate key (`MESSAGING_SALT_ENCRYPTION_KEY`)
2. **HMAC Lookup Keys**: User pubkeys are hashed with HMAC before being used as database keys
3. **Rate Limiting**: Decryption attempts are rate-limited (10 attempts per 15 minutes)
4. **Per-User Key Derivation**: Encryption keys derived from master key + pubkey + salt
5. **AES-256-GCM**: Authenticated encryption with random IV per encryption
### Threat Mitigation
- **Database Compromise**: All data encrypted at rest with per-user keys
- **Key Leakage**: Per-user key derivation means master key leak doesn't expose all users
- **Brute Force**: Rate limiting prevents rapid decryption attempts
- **Lookup Attacks**: HMAC hashing prevents pubkey enumeration
## Environment Variables
### Required for Encryption
```bash
# Master encryption key (256-bit hex, generate with: openssl rand -hex 32)
MESSAGING_PREFS_ENCRYPTION_KEY=<256-bit-hex>
# Salt encryption key (256-bit hex, generate with: openssl rand -hex 32)
MESSAGING_SALT_ENCRYPTION_KEY=<256-bit-hex>
# HMAC secret for lookup key hashing (256-bit hex, generate with: openssl rand -hex 32)
MESSAGING_LOOKUP_SECRET=<256-bit-hex>
```
### Optional for Messaging Platforms
```bash
# Telegram Bot Configuration
TELEGRAM_BOT_TOKEN=<bot-token>
TELEGRAM_ENABLED=true
# Email SMTP Configuration
SMTP_HOST=<smtp-host>
SMTP_PORT=587
SMTP_USER=<smtp-username>
SMTP_PASSWORD=<smtp-password>
SMTP_FROM_ADDRESS=<from-email>
SMTP_FROM_NAME=GitRepublic
EMAIL_ENABLED=true
# OR use SMTP API (alternative to direct SMTP)
SMTP_API_URL=<smtp-api-url>
SMTP_API_KEY=<api-key>
# SimpleX API Configuration
SIMPLEX_API_URL=<simplex-api-url>
SIMPLEX_API_KEY=<api-key>
SIMPLEX_ENABLED=true
# Git Platforms Forwarding Configuration
GIT_PLATFORMS_ENABLED=true
```
## Setup
### 1. Generate Encryption Keys
```bash
# Generate all three required keys
openssl rand -hex 32 # For MESSAGING_PREFS_ENCRYPTION_KEY
openssl rand -hex 32 # For MESSAGING_SALT_ENCRYPTION_KEY
openssl rand -hex 32 # For MESSAGING_LOOKUP_SECRET
```
### 2. Store Keys Securely
**Development:**
- Store in `.env` file (never commit to git)
**Production:**
- Use secret management service (AWS Secrets Manager, HashiCorp Vault, etc.)
- Or environment variables in deployment platform
- Consider using Hardware Security Modules (HSM) for maximum security
### 3. Configure Messaging Platforms
#### Telegram
1. Create a bot with [@BotFather](https://t.me/botfather)
2. Get bot token
3. Users will provide their chat ID when configuring preferences
#### Email
1. Configure SMTP settings (host, port, credentials)
2. Or use an SMTP API service
3. Users will provide their email addresses (to/cc)
#### SimpleX
1. Set up SimpleX Chat API
2. Configure API URL and key
3. Users will provide their contact ID
#### Git Platforms (GitHub, GitLab, Gitea, Codeberg, Forgejo)
1. Users create Personal Access Tokens with appropriate scopes
2. Users provide platform, username/org, repository name, and token
3. Events will be forwarded as issues or PRs on the selected platform
4. Supports self-hosted instances via custom API URL
## User Flow
### 1. User Saves Preferences (Client-Side)
```typescript
// User encrypts preferences to self on Nostr (kind 30078)
const encryptedContent = await window.nostr.nip44.encrypt(
userPubkey,
JSON.stringify(preferences)
);
// Publish to Nostr (backup/sync)
const event = {
kind: 30078,
pubkey: userPubkey,
tags: [['d', 'gitrepublic-messaging'], ['enabled', 'true']],
content: encryptedContent
};
// Sign and publish
const signedEvent = await signEventWithNIP07(event);
await nostrClient.publishEvent(signedEvent);
// Send decrypted copy to server (over HTTPS)
await fetch('/api/user/messaging-preferences', {
method: 'POST',
body: JSON.stringify({
preferences,
proofEvent: signedEvent
})
});
```
### 2. Server Stores Securely
- Verifies proof event signature
- Checks user has unlimited access
- Generates random salt
- Encrypts salt with `MESSAGING_SALT_ENCRYPTION_KEY`
- Derives per-user encryption key
- Encrypts preferences with AES-256-GCM
- Stores using HMAC lookup key
### 3. Event Forwarding
When user publishes an event (issue, PR, highlight):
1. Server checks user has unlimited access
2. Retrieves encrypted preferences
3. Decrypts (with rate limiting)
4. Checks if forwarding enabled and event kind matches
5. Forwards to configured platforms
## API Endpoints
### POST `/api/user/messaging-preferences`
Save messaging preferences.
**Request:**
```json
{
"preferences": {
"telegram": "@username",
"simplex": "contact-id",
"email": {
"to": ["user@example.com"],
"cc": ["cc@example.com"]
},
"gitPlatforms": [
{
"platform": "github",
"owner": "username",
"repo": "repository-name",
"token": "ghp_xxxxxxxxxxxx"
},
{
"platform": "gitlab",
"owner": "username",
"repo": "repository-name",
"token": "glpat-xxxxxxxxxxxx"
},
{
"platform": "codeberg",
"owner": "username",
"repo": "repository-name",
"token": "xxxxxxxxxxxx"
},
{
"platform": "onedev",
"owner": "project-path",
"repo": "repository-name",
"token": "xxxxxxxxxxxx",
"apiUrl": "https://your-onedev-instance.com"
},
{
"platform": "custom",
"owner": "username",
"repo": "repository-name",
"token": "xxxxxxxxxxxx",
"apiUrl": "https://your-git-instance.com/api/v1"
}
],
"enabled": true,
"notifyOn": ["1621", "1618"]
},
"proofEvent": { /* Signed Nostr event (kind 30078) */ }
}
```
**Response:**
```json
{
"success": true
}
```
### GET `/api/user/messaging-preferences`
Get preferences status (without decrypting).
**Response:**
```json
{
"configured": true,
"rateLimit": {
"remaining": 10,
"resetAt": null
}
}
```
### DELETE `/api/user/messaging-preferences`
Delete messaging preferences.
**Response:**
```json
{
"success": true
}
```
## Security Best Practices
1. **Key Management**
- Never commit keys to git
- Rotate keys periodically
- Use secret management services in production
- Consider HSM for maximum security
2. **Monitoring**
- Monitor rate limit violations
- Alert on decryption failures
- Audit log all preference changes
3. **Access Control**
- Only users with unlimited access can use this feature
- Requires valid signed Nostr event proof
- Server verifies all inputs
4. **Data Protection**
- All data encrypted at rest
- Per-user key derivation
- HMAC lookup keys
- Rate limiting on decryption
## Troubleshooting
### "Decryption rate limit exceeded"
User has exceeded 10 decryption attempts in 15 minutes. Wait for the window to reset.
### "Messaging forwarding requires unlimited access"
User must have relay write access (unlimited level) to use this feature.
### "Failed to forward event"
Check:
- Messaging platform API credentials
- Network connectivity
- Platform-specific error logs
## Email Setup
### Option 1: Direct SMTP
Install nodemailer:
```bash
npm install nodemailer
```
Configure environment variables:
```bash
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
SMTP_FROM_ADDRESS=noreply@yourdomain.com
SMTP_FROM_NAME=GitRepublic
EMAIL_ENABLED=true
```
### Option 2: SMTP API
Use an SMTP API service (e.g., SendGrid, Mailgun, AWS SES):
```bash
SMTP_API_URL=https://api.sendgrid.com/v3/mail/send
SMTP_API_KEY=your-api-key
EMAIL_ENABLED=true
```
## Git Platforms Setup
### Supported Platforms
- **GitHub** (`github`) - github.com
- **GitLab** (`gitlab`) - gitlab.com (also supports self-hosted)
- **Gitea** (`gitea`) - Self-hosted instances
- **Codeberg** (`codeberg`) - codeberg.org
- **Forgejo** (`forgejo`) - Self-hosted instances
- **OneDev** (`onedev`) - Self-hosted instances (requires apiUrl)
- **Custom** (`custom`) - Any Gitea-compatible API with custom URL
### Creating Personal Access Tokens
#### GitHub
1. Go to Settings → Developer settings → Personal access tokens → Tokens (classic)
2. Click "Generate new token (classic)"
3. Select `repo` scope
4. Generate and copy token
#### GitLab
1. Go to Settings → Access Tokens
2. Create token with `api` scope
3. Generate and copy token
#### Gitea/Codeberg/Forgejo
1. Go to Settings → Applications → Generate New Token
2. Select `repo` scope
3. Generate and copy token
#### OneDev
1. Go to User Settings → Access Tokens
2. Create a new access token
3. Select appropriate scopes (typically `write:issue` and `write:pull-request`)
4. Generate and copy token
5. **Note**: OneDev is self-hosted, so you must provide the `apiUrl` (e.g., `https://your-onedev-instance.com`)
### User Configuration
Users provide:
- **Platform**: One of `github`, `gitlab`, `gitea`, `codeberg`, `forgejo`, `onedev`, or `custom`
- **Owner**: Username or organization name (project path for OneDev)
- **Repo**: Repository name (project name for OneDev)
- **Token**: Personal access token (stored encrypted)
- **API URL** (required for OneDev, optional for custom/self-hosted): Base URL of the instance (e.g., `https://your-onedev-instance.com`)
### Event Mapping
- **Nostr Issues (kind 1621)** → Platform Issues
- **Nostr PRs (kind 1618)** → Platform Pull Requests/Merge Requests (if branch info available) or Issues with PR label
- **Other events** → Platform Issues with event kind label
### Platform-Specific Notes
- **GitLab**: Uses `description` field instead of `body`, and `source_branch`/`target_branch` for PRs
- **OneDev**: Uses `description` field and `source_branch`/`target_branch` for PRs. Requires `apiUrl` (self-hosted). API endpoints: `/api/projects/{owner}/{repo}/issues` and `/api/projects/{owner}/{repo}/pull-requests`
- **GitHub**: Uses `body` field and `head`/`base` for PRs
- **Gitea/Codeberg/Forgejo**: Compatible with GitHub API format
- **Custom**: Must provide `apiUrl` pointing to Gitea-compatible API
### Security Note
All tokens are stored encrypted in the database. Users should:
- Use tokens with minimal required scopes
- Rotate tokens periodically
- Revoke tokens if compromised
## Future Enhancements
- [ ] Support for more messaging platforms
- [ ] User-configurable message templates
- [ ] Webhook support
- [ ] Encrypted preferences sync across devices
- [ ] Per-repository forwarding rules
- [ ] HTML email templates

10
package-lock.json generated

@ -19,6 +19,7 @@
"codemirror-asciidoc": "^2.0.1", "codemirror-asciidoc": "^2.0.1",
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"nodemailer": "^8.0.1",
"nostr-tools": "^2.22.1", "nostr-tools": "^2.22.1",
"pino": "^10.3.1", "pino": "^10.3.1",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
@ -3673,6 +3674,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",

2
package.json

@ -23,6 +23,7 @@
"codemirror-asciidoc": "^2.0.1", "codemirror-asciidoc": "^2.0.1",
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"nodemailer": "^8.0.1",
"nostr-tools": "^2.22.1", "nostr-tools": "^2.22.1",
"pino": "^10.3.1", "pino": "^10.3.1",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
@ -35,6 +36,7 @@
"@sveltejs/adapter-node": "^5.0.0", "@sveltejs/adapter-node": "^5.0.0",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/nodemailer": "^6.4.14",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",

348
src/lib/components/ForwardingConfig.svelte

@ -0,0 +1,348 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { nip19 } from 'nostr-tools';
interface ForwardingSummary {
configured: boolean;
enabled: boolean;
platforms: {
telegram?: boolean;
simplex?: boolean;
email?: boolean;
gitPlatforms?: Array<{
platform: string;
owner: string;
repo: string;
apiUrl?: string;
}>;
};
notifyOn?: string[];
}
interface Props {
userPubkeyHex?: string | null;
showTitle?: boolean;
compact?: boolean;
}
let { userPubkeyHex = null, showTitle = true, compact = false }: Props = $props();
let loading = $state(true);
let summary = $state<ForwardingSummary | null>(null);
let error = $state<string | null>(null);
let currentUserPubkey = $state<string | null>(null);
const KIND_NAMES: Record<string, string> = {
'1621': 'Issues',
'1618': 'Pull Requests',
'9802': 'Highlights',
'30617': 'Repository Announcements',
'1641': 'Ownership Transfers'
};
onMount(async () => {
await loadCurrentUser();
if (userPubkeyHex || currentUserPubkey) {
await loadForwardingSummary();
} else {
loading = false;
}
});
async function loadCurrentUser() {
if (userPubkeyHex) {
currentUserPubkey = userPubkeyHex;
return;
}
if (!isNIP07Available()) {
return;
}
try {
const pubkey = await getPublicKeyWithNIP07();
try {
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
currentUserPubkey = decoded.data as string;
} else {
currentUserPubkey = pubkey;
}
} catch {
currentUserPubkey = pubkey;
}
} catch (err) {
console.warn('Failed to load current user:', err);
}
}
async function loadForwardingSummary() {
const pubkey = userPubkeyHex || currentUserPubkey;
if (!pubkey) {
loading = false;
return;
}
loading = true;
error = null;
try {
const response = await fetch('/api/user/messaging-preferences/summary', {
headers: {
'X-User-Pubkey': pubkey
}
});
if (response.status === 401 || response.status === 403) {
// User not authenticated or doesn't have access
loading = false;
return;
}
if (!response.ok) {
throw new Error(`Failed to load forwarding summary: ${response.statusText}`);
}
summary = await response.json();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load forwarding configuration';
console.error('Error loading forwarding summary:', err);
} finally {
loading = false;
}
}
function getPlatformIcon(platform: string): string {
const icons: Record<string, string> = {
github: '🐙',
gitlab: '🦊',
gitea: '🐈',
codeberg: '🦫',
forgejo: '🔨',
onedev: '📦',
custom: '⚙'
};
return icons[platform.toLowerCase()] || '📦';
}
function getPlatformName(platform: string): string {
const names: Record<string, string> = {
github: 'GitHub',
gitlab: 'GitLab',
gitea: 'Gitea',
codeberg: 'Codeberg',
forgejo: 'Forgejo',
onedev: 'OneDev',
custom: 'Custom'
};
return names[platform.toLowerCase()] || platform;
}
</script>
{#if loading}
<div class="forwarding-config loading">
{#if showTitle}
<h3>Event Forwarding</h3>
{/if}
<p class="loading-text">Loading...</p>
</div>
{:else if error}
<div class="forwarding-config error">
{#if showTitle}
<h3>Event Forwarding</h3>
{/if}
<p class="error-text">{error}</p>
</div>
{:else if !summary || !summary.configured}
<div class="forwarding-config not-configured">
{#if showTitle}
<h3>Event Forwarding</h3>
{/if}
<p class="not-configured-text">
{compact ? 'Not configured' : 'No forwarding configured. Events will not be forwarded to external platforms.'}
</p>
</div>
{:else if !summary.enabled}
<div class="forwarding-config disabled">
{#if showTitle}
<h3>Event Forwarding</h3>
{/if}
<p class="disabled-text">Forwarding is disabled</p>
</div>
{:else}
<div class="forwarding-config configured" class:compact={compact}>
{#if showTitle}
<h3>Event Forwarding</h3>
{/if}
<div class="platforms">
{#if summary.platforms.telegram}
<div class="platform-item">
<span class="platform-icon">📱</span>
<span class="platform-name">Telegram</span>
</div>
{/if}
{#if summary.platforms.simplex}
<div class="platform-item">
<span class="platform-icon">💬</span>
<span class="platform-name">SimpleX</span>
</div>
{/if}
{#if summary.platforms.email}
<div class="platform-item">
<span class="platform-icon">📧</span>
<span class="platform-name">Email</span>
</div>
{/if}
{#if summary.platforms.gitPlatforms && summary.platforms.gitPlatforms.length > 0}
{#each summary.platforms.gitPlatforms as gitPlatform}
<div class="platform-item">
<span class="platform-icon">{getPlatformIcon(gitPlatform.platform)}</span>
<span class="platform-name">
{getPlatformName(gitPlatform.platform)}
{#if !compact}
<span class="platform-details">
({gitPlatform.owner}/{gitPlatform.repo}
{#if gitPlatform.apiUrl}
<span class="custom-url" title={gitPlatform.apiUrl}>*</span>
{/if})
</span>
{/if}
</span>
</div>
{/each}
{/if}
</div>
{#if summary.notifyOn && summary.notifyOn.length > 0 && !compact}
<div class="notify-on">
<strong>Forwarding events:</strong>
<span class="event-kinds">
{#each summary.notifyOn as kind}
<span class="event-kind">{KIND_NAMES[kind] || `Kind ${kind}`}</span>
{/each}
</span>
</div>
{/if}
</div>
{/if}
<style>
.forwarding-config {
padding: 1rem;
border-radius: 8px;
background: var(--bg-secondary, #f5f5f5);
border: 1px solid var(--border-color, #ddd);
margin: 1rem 0;
}
.forwarding-config h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
color: var(--text-primary, #333);
}
.forwarding-config.loading .loading-text,
.forwarding-config.not-configured .not-configured-text,
.forwarding-config.disabled .disabled-text,
.forwarding-config.error .error-text {
color: var(--text-secondary, #666);
font-size: 0.9rem;
margin: 0;
}
.forwarding-config.error .error-text {
color: var(--error-color, #d32f2f);
}
.forwarding-config.disabled .disabled-text {
color: var(--warning-color, #f57c00);
}
.platforms {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 0.5rem 0;
}
.platform-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.9rem;
}
.platform-icon {
font-size: 1.2rem;
}
.platform-name {
color: var(--text-primary, #333);
font-weight: 500;
}
.platform-details {
color: var(--text-secondary, #666);
font-weight: normal;
font-size: 0.85rem;
margin-left: 0.25rem;
}
.custom-url {
color: var(--accent, #1976d2);
font-weight: bold;
cursor: help;
}
.notify-on {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color, #ddd);
font-size: 0.9rem;
color: var(--text-secondary, #666);
}
.notify-on strong {
color: var(--text-primary, #333);
margin-right: 0.5rem;
}
.event-kinds {
display: inline-flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.event-kind {
padding: 0.25rem 0.5rem;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
font-size: 0.85rem;
color: var(--text-primary, #333);
}
/* Compact mode */
.forwarding-config.compact {
padding: 0.5rem;
}
.forwarding-config.compact h3 {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.forwarding-config.compact .platform-item {
padding: 0.25rem 0.5rem;
font-size: 0.85rem;
}
</style>

3
src/lib/config.ts

@ -21,7 +21,6 @@ export const DEFAULT_NOSTR_RELAYS =
? process.env.NOSTR_RELAYS.split(',').map(r => r.trim()).filter(r => r.length > 0) ? process.env.NOSTR_RELAYS.split(',').map(r => r.trim()).filter(r => r.length > 0)
: [ : [
'wss://theforest.nostr1.com', 'wss://theforest.nostr1.com',
'wss://nostr.land',
]; ];
/** /**
@ -32,12 +31,12 @@ export const DEFAULT_NOSTR_SEARCH_RELAYS =
typeof process !== 'undefined' && process.env?.NOSTR_SEARCH_RELAYS typeof process !== 'undefined' && process.env?.NOSTR_SEARCH_RELAYS
? process.env.NOSTR_SEARCH_RELAYS.split(',').map(r => r.trim()).filter(r => r.length > 0) ? process.env.NOSTR_SEARCH_RELAYS.split(',').map(r => r.trim()).filter(r => r.length > 0)
: [ : [
'wss://nostr.land',
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://nostr21.com', 'wss://nostr21.com',
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
"wss://relay.primal.net", "wss://relay.primal.net",
...DEFAULT_NOSTR_RELAYS,
]; ];
/** /**

635
src/lib/services/messaging/event-forwarder.ts

@ -0,0 +1,635 @@
/**
* Event forwarding service
* Forwards Nostr events to messaging platforms (Telegram, SimpleX, Email, Git platforms)
* Only for users with unlimited access and configured preferences
*/
import logger from '../logger.js';
import type { NostrEvent } from '../../types/nostr.js';
import { getPreferences } from './preferences-storage.js';
import { getCachedUserLevel } from '../security/user-level-cache.js';
import { KIND } from '../../types/nostr.js';
// ============================================================================
// Types & Interfaces
// ============================================================================
export interface MessagingConfig {
telegram?: {
botToken: string;
enabled: boolean;
};
simplex?: {
apiUrl: string;
apiKey: string;
enabled: boolean;
};
email?: {
smtpHost: string;
smtpPort: number;
smtpUser: string;
smtpPassword: string;
fromAddress: string;
fromName: string;
enabled: boolean;
};
gitPlatforms?: {
enabled: boolean;
};
}
type GitPlatform = 'github' | 'gitlab' | 'gitea' | 'codeberg' | 'forgejo' | 'onedev' | 'custom';
interface GitPlatformConfig {
baseUrl: string;
issuesPath: string;
pullsPath: string;
authHeader: 'Bearer' | 'token';
usesDescription: boolean; // true for GitLab/OneDev, false for GitHub/Gitea/etc
usesSourceTargetBranch: boolean; // true for GitLab/OneDev, false for GitHub/Gitea/etc
customHeaders?: Record<string, string>;
}
interface EventContent {
title: string;
body: string;
fullBody: string;
}
// ============================================================================
// Constants
// ============================================================================
const KIND_NAMES: Record<number, string> = {
1621: 'Issue',
1618: 'Pull Request',
9802: 'Highlight',
30617: 'Repository Announcement',
1641: 'Ownership Transfer',
24: 'Public Message'
};
const GIT_PLATFORM_CONFIGS: Record<string, Omit<GitPlatformConfig, 'baseUrl'>> = {
github: {
issuesPath: '/repos/{owner}/{repo}/issues',
pullsPath: '/repos/{owner}/{repo}/pulls',
authHeader: 'Bearer',
usesDescription: false,
usesSourceTargetBranch: false,
customHeaders: { 'Accept': 'application/vnd.github.v3+json' }
},
gitlab: {
issuesPath: '/projects/{owner}%2F{repo}/issues',
pullsPath: '/projects/{owner}%2F{repo}/merge_requests',
authHeader: 'Bearer',
usesDescription: true,
usesSourceTargetBranch: true
},
gitea: {
issuesPath: '/repos/{owner}/{repo}/issues',
pullsPath: '/repos/{owner}/{repo}/pulls',
authHeader: 'token',
usesDescription: false,
usesSourceTargetBranch: false
},
codeberg: {
issuesPath: '/repos/{owner}/{repo}/issues',
pullsPath: '/repos/{owner}/{repo}/pulls',
authHeader: 'token',
usesDescription: false,
usesSourceTargetBranch: false
},
forgejo: {
issuesPath: '/repos/{owner}/{repo}/issues',
pullsPath: '/repos/{owner}/{repo}/pulls',
authHeader: 'token',
usesDescription: false,
usesSourceTargetBranch: false
},
onedev: {
issuesPath: '/api/projects/{owner}/{repo}/issues',
pullsPath: '/api/projects/{owner}/{repo}/pull-requests',
authHeader: 'Bearer',
usesDescription: true,
usesSourceTargetBranch: true
}
};
const MESSAGING_CONFIG: MessagingConfig = {
telegram: {
botToken: process.env.TELEGRAM_BOT_TOKEN || '',
enabled: process.env.TELEGRAM_ENABLED === 'true'
},
simplex: {
apiUrl: process.env.SIMPLEX_API_URL || '',
apiKey: process.env.SIMPLEX_API_KEY || '',
enabled: process.env.SIMPLEX_ENABLED === 'true'
},
email: {
smtpHost: process.env.SMTP_HOST || '',
smtpPort: parseInt(process.env.SMTP_PORT || '587', 10),
smtpUser: process.env.SMTP_USER || '',
smtpPassword: process.env.SMTP_PASSWORD || '',
fromAddress: process.env.SMTP_FROM_ADDRESS || '',
fromName: process.env.SMTP_FROM_NAME || 'GitRepublic',
enabled: process.env.EMAIL_ENABLED === 'true'
},
gitPlatforms: {
enabled: process.env.GIT_PLATFORMS_ENABLED === 'true'
}
};
// ============================================================================
// Helper Functions
// ============================================================================
function formatEventMessage(event: NostrEvent, userPubkeyHex: string): string {
const kindName = KIND_NAMES[event.kind] || `Event ${event.kind}`;
const userShort = userPubkeyHex.slice(0, 16) + '...';
// Special formatting for public messages (kind 24)
if (event.kind === KIND.PUBLIC_MESSAGE) {
const recipients = event.tags
.filter(tag => tag[0] === 'p' && tag[1])
.map(tag => tag[1].slice(0, 16) + '...');
let message = `💬 Public Message from ${userShort}`;
if (recipients.length > 0) {
message += ` to ${recipients.join(', ')}`;
}
message += '\n\n';
message += event.content || 'No content';
return message;
}
let message = `🔔 ${kindName} published by ${userShort}\n\n`;
if (event.content) {
const content = event.content.length > 500
? event.content.slice(0, 500) + '...'
: event.content;
message += content;
} else {
message += 'No content';
}
return message;
}
function extractEventContent(event: NostrEvent): EventContent {
const titleTag = event.tags.find(t => t[0] === 'title' || t[0] === 'subject');
const title = titleTag?.[1] || (event.content ? event.content.split('\n')[0].slice(0, 100) : 'Untitled');
const body = event.content || '';
const metadata = `\n\n---\n*Forwarded from GitRepublic (Nostr)*\n*Event ID: ${event.id}*\n*Kind: ${event.kind}*`;
const fullBody = body + metadata;
return { title, body, fullBody };
}
function getGitPlatformConfig(
platform: GitPlatform,
customApiUrl?: string
): GitPlatformConfig {
if (platform === 'onedev') {
if (!customApiUrl) {
throw new Error('OneDev requires apiUrl to be provided (self-hosted instance)');
}
return {
...GIT_PLATFORM_CONFIGS.onedev,
baseUrl: customApiUrl
};
}
if (customApiUrl) {
// Custom platform - assume Gitea-compatible format
return {
baseUrl: customApiUrl,
issuesPath: '/repos/{owner}/{repo}/issues',
pullsPath: '/repos/{owner}/{repo}/pulls',
authHeader: 'Bearer',
usesDescription: false,
usesSourceTargetBranch: false
};
}
const config = GIT_PLATFORM_CONFIGS[platform];
if (!config) {
throw new Error(`Unsupported Git platform: ${platform}`);
}
// Get baseUrl from platform config or use default
const baseUrls: Record<string, string> = {
github: 'https://api.github.com',
gitlab: 'https://gitlab.com/api/v4',
gitea: 'https://codeberg.org/api/v1',
codeberg: 'https://codeberg.org/api/v1',
forgejo: 'https://forgejo.org/api/v1'
};
return {
...config,
baseUrl: baseUrls[platform] || ''
};
}
function buildGitPlatformUrl(
config: GitPlatformConfig,
owner: string,
repo: string,
pathType: 'issues' | 'pulls',
platform: GitPlatform
): string {
const path = pathType === 'issues' ? config.issuesPath : config.pullsPath;
if (platform === 'onedev') {
const projectPath = repo ? `${owner}/${repo}` : owner;
const endpoint = pathType === 'issues' ? 'issues' : 'pull-requests';
return `${config.baseUrl}/api/projects/${encodeURIComponent(projectPath)}/${endpoint}`;
}
const urlPath = path
.replace('{owner}', encodeURIComponent(owner))
.replace('{repo}', encodeURIComponent(repo));
return `${config.baseUrl}${urlPath}`;
}
function buildAuthHeader(config: GitPlatformConfig, token: string): string {
return config.authHeader === 'Bearer' ? `Bearer ${token}` : `token ${token}`;
}
function buildIssuePayload(
content: EventContent,
config: GitPlatformConfig,
prefix?: string
): Record<string, unknown> {
const title = prefix ? `${prefix}${content.title}` : content.title;
const payload: Record<string, unknown> = { title };
if (config.usesDescription) {
payload.description = content.fullBody;
} else {
payload.body = content.fullBody;
}
return payload;
}
function buildPullRequestPayload(
content: EventContent,
config: GitPlatformConfig,
headBranch: string,
baseBranch: string
): Record<string, unknown> {
const payload: Record<string, unknown> = {
title: content.title
};
if (config.usesDescription) {
payload.description = content.fullBody;
} else {
payload.body = content.fullBody;
}
if (config.usesSourceTargetBranch) {
payload.source_branch = headBranch;
payload.target_branch = baseBranch;
} else {
payload.head = headBranch;
payload.base = baseBranch;
}
return payload;
}
async function makeGitPlatformRequest(
url: string,
method: string,
headers: Record<string, string>,
payload: Record<string, unknown>,
platform: string
): Promise<void> {
const response = await fetch(url, {
method,
headers,
body: JSON.stringify(payload)
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Unknown error' }));
throw new Error(`${platform} API error: ${error.message || response.statusText}`);
}
}
// ============================================================================
// Platform Forwarding Functions
// ============================================================================
async function sendToTelegram(message: string, chatId: string): Promise<void> {
if (!MESSAGING_CONFIG.telegram?.enabled || !MESSAGING_CONFIG.telegram?.botToken) {
return;
}
try {
const url = `https://api.telegram.org/bot${MESSAGING_CONFIG.telegram.botToken}/sendMessage`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: message,
parse_mode: 'Markdown',
disable_web_page_preview: true
})
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(`Telegram API error: ${error.description || response.statusText}`);
}
} catch (error) {
logger.error({ error, chatId: chatId.slice(0, 10) + '...' }, 'Failed to send to Telegram');
throw error;
}
}
async function sendToSimpleX(message: string, contactId: string): Promise<void> {
if (!MESSAGING_CONFIG.simplex?.enabled || !MESSAGING_CONFIG.simplex?.apiUrl) {
return;
}
try {
const response = await fetch(`${MESSAGING_CONFIG.simplex.apiUrl}/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${MESSAGING_CONFIG.simplex.apiKey}`
},
body: JSON.stringify({
contact_id: contactId,
message: message
})
});
if (!response.ok) {
const error = await response.text().catch(() => 'Unknown error');
throw new Error(`SimpleX API error: ${error}`);
}
} catch (error) {
logger.error({ error, contactId: contactId.slice(0, 10) + '...' }, 'Failed to send to SimpleX');
throw error;
}
}
async function sendEmail(
subject: string,
message: string,
to: string[],
cc?: string[]
): Promise<void> {
if (!MESSAGING_CONFIG.email?.enabled || !MESSAGING_CONFIG.email?.smtpHost) {
return;
}
try {
const smtpUrl = process.env.SMTP_API_URL;
if (smtpUrl) {
// Use SMTP API if provided
const response = await fetch(smtpUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.SMTP_API_KEY || ''}`
},
body: JSON.stringify({
from: MESSAGING_CONFIG.email.fromAddress,
to,
cc: cc || [],
subject,
text: message
})
});
if (!response.ok) {
const error = await response.text().catch(() => 'Unknown error');
throw new Error(`Email API error: ${error}`);
}
} else {
// Direct SMTP using nodemailer
try {
const nodemailer = await import('nodemailer');
const { createTransport } = nodemailer;
const transporter = createTransport({
host: MESSAGING_CONFIG.email.smtpHost,
port: MESSAGING_CONFIG.email.smtpPort,
secure: MESSAGING_CONFIG.email.smtpPort === 465,
auth: {
user: MESSAGING_CONFIG.email.smtpUser,
pass: MESSAGING_CONFIG.email.smtpPassword
}
});
await transporter.sendMail({
from: `"${MESSAGING_CONFIG.email.fromName}" <${MESSAGING_CONFIG.email.fromAddress}>`,
to: to.join(', '),
cc: cc && cc.length > 0 ? cc.join(', ') : undefined,
subject,
text: message
});
} catch (importError) {
throw new Error(
'Email sending requires either SMTP_API_URL or nodemailer package. ' +
'Install with: npm install nodemailer'
);
}
}
} catch (error) {
logger.error({
error,
toCount: to.length,
ccCount: cc?.length || 0
}, 'Failed to send email');
throw error;
}
}
async function forwardToGitPlatform(
event: NostrEvent,
platform: GitPlatform,
owner: string,
repo: string,
token: string,
customApiUrl?: string
): Promise<void> {
if (!MESSAGING_CONFIG.gitPlatforms?.enabled) {
return;
}
try {
const config = getGitPlatformConfig(platform, customApiUrl);
const content = extractEventContent(event);
const authHeader = buildAuthHeader(config, token);
const headers: Record<string, string> = {
'Authorization': authHeader,
'Content-Type': 'application/json',
'User-Agent': 'GitRepublic',
...(config.customHeaders || {})
};
// Handle different event kinds
if (event.kind === KIND.ISSUE) {
const issuesUrl = buildGitPlatformUrl(config, owner, repo, 'issues', platform);
const payload = buildIssuePayload(content, config);
await makeGitPlatformRequest(issuesUrl, 'POST', headers, payload, platform);
} else if (event.kind === KIND.PULL_REQUEST) {
const headTag = event.tags.find(t => t[0] === 'head');
const baseTag = event.tags.find(t => t[0] === 'base');
if (headTag?.[1] && baseTag?.[1]) {
// Create actual PR with branch info
const pullsUrl = buildGitPlatformUrl(config, owner, repo, 'pulls', platform);
const payload = buildPullRequestPayload(content, config, headTag[1], baseTag[1]);
await makeGitPlatformRequest(pullsUrl, 'POST', headers, payload, platform);
} else {
// No branch info, create issue with PR label
const issuesUrl = buildGitPlatformUrl(config, owner, repo, 'issues', platform);
const payload = buildIssuePayload(content, config, '[PR] ');
if (Array.isArray(payload.labels)) {
payload.labels.push('pull-request');
} else {
payload.labels = ['pull-request'];
}
await makeGitPlatformRequest(issuesUrl, 'POST', headers, payload, platform);
}
} else {
// Other event types: create issue with event kind label
const issuesUrl = buildGitPlatformUrl(config, owner, repo, 'issues', platform);
const payload = buildIssuePayload(content, config, `[Event ${event.kind}] `);
payload.labels = [`nostr-kind-${event.kind}`];
await makeGitPlatformRequest(issuesUrl, 'POST', headers, payload, platform);
}
} catch (error) {
logger.error({
error,
platform,
owner,
repo: repo.slice(0, 10) + '...',
eventKind: event.kind
}, `Failed to forward to ${platform}`);
throw error;
}
}
// ============================================================================
// Main Export
// ============================================================================
/**
* Forward event to configured messaging platforms
* Only forwards if:
* - User has unlimited access
* - User has preferences configured and enabled
* - Event kind is in notifyOn list (if specified)
*/
export async function forwardEventIfEnabled(
event: NostrEvent,
userPubkeyHex: string
): Promise<void> {
try {
// Early returns for eligibility checks
const cached = getCachedUserLevel(userPubkeyHex);
if (!cached || cached.level !== 'unlimited') {
return;
}
const preferences = await getPreferences(userPubkeyHex);
if (!preferences || !preferences.enabled) {
return;
}
if (preferences.notifyOn && preferences.notifyOn.length > 0) {
if (!preferences.notifyOn.includes(event.kind.toString())) {
return;
}
}
// Prepare message content
const message = formatEventMessage(event, userPubkeyHex);
const kindName = KIND_NAMES[event.kind] || `Event ${event.kind}`;
const subject = `GitRepublic: ${kindName} Notification`;
// Collect all forwarding promises
const promises: Promise<void>[] = [];
// Messaging platforms
if (preferences.telegram) {
promises.push(
sendToTelegram(message, preferences.telegram)
.catch(err => logger.warn({ error: err }, 'Telegram forwarding failed'))
);
}
if (preferences.simplex) {
promises.push(
sendToSimpleX(message, preferences.simplex)
.catch(err => logger.warn({ error: err }, 'SimpleX forwarding failed'))
);
}
if (preferences.email?.to && preferences.email.to.length > 0) {
promises.push(
sendEmail(subject, message, preferences.email.to, preferences.email.cc)
.catch(err => logger.warn({ error: err }, 'Email forwarding failed'))
);
}
// Git platforms
if (preferences.gitPlatforms && preferences.gitPlatforms.length > 0) {
for (const gitPlatform of preferences.gitPlatforms) {
if (!gitPlatform.owner || !gitPlatform.repo || !gitPlatform.token) {
continue;
}
if (gitPlatform.platform === 'onedev' && !gitPlatform.apiUrl) {
logger.warn({ platform: 'onedev' }, 'OneDev requires apiUrl to be provided');
continue;
}
promises.push(
forwardToGitPlatform(
event,
gitPlatform.platform,
gitPlatform.owner,
gitPlatform.repo,
gitPlatform.token,
gitPlatform.apiUrl
)
.catch(err => logger.warn({ error: err, platform: gitPlatform.platform }, 'Git platform forwarding failed'))
);
}
}
// Execute all forwarding in parallel
await Promise.allSettled(promises);
logger.debug({
eventId: event.id.slice(0, 16) + '...',
userPubkeyHex: userPubkeyHex.slice(0, 16) + '...',
platforms: Object.keys(preferences).filter(k => k !== 'enabled' && k !== 'notifyOn' && preferences[k as keyof typeof preferences])
}, 'Forwarded event to messaging platforms');
} catch (error) {
// Log but don't throw - forwarding failures shouldn't break event publishing
logger.error({
error,
eventId: event.id?.slice(0, 16) + '...',
userPubkeyHex: userPubkeyHex.slice(0, 16) + '...'
}, 'Failed to forward event to messaging platforms');
}
}

31
src/lib/services/messaging/nodemailer.d.ts vendored

@ -0,0 +1,31 @@
/**
* Type declarations for nodemailer
* This file ensures TypeScript recognizes nodemailer types
*/
declare module 'nodemailer' {
export interface TransportOptions {
host?: string;
port?: number;
secure?: boolean;
auth?: {
user: string;
pass: string;
};
}
export interface MailOptions {
from?: string;
to?: string;
cc?: string;
subject?: string;
text?: string;
html?: string;
}
export interface Transporter {
sendMail(options: MailOptions): Promise<{ messageId: string }>;
}
export function createTransport(options: TransportOptions): Transporter;
}

383
src/lib/services/messaging/preferences-storage.ts

@ -0,0 +1,383 @@
/**
* Secure messaging preferences storage
*
* SECURITY FEATURES:
* - Encrypted salt storage (separate key)
* - HMAC-based lookup keys (pubkey never directly used as DB key)
* - Rate limiting on decryption attempts
* - Per-user key derivation (master key + pubkey + salt)
* - AES-256-GCM authenticated encryption
* - Random IV per encryption
*/
import {
createCipheriv,
createDecipheriv,
randomBytes,
scryptSync,
createHash,
createHmac
} from 'crypto';
import logger from '../logger.js';
import { getCachedUserLevel } from '../security/user-level-cache.js';
// Encryption keys from environment (NEVER commit these!)
const ENCRYPTION_KEY = process.env.MESSAGING_PREFS_ENCRYPTION_KEY;
const SALT_ENCRYPTION_KEY = process.env.MESSAGING_SALT_ENCRYPTION_KEY;
const LOOKUP_SECRET = process.env.MESSAGING_LOOKUP_SECRET;
if (!ENCRYPTION_KEY || !SALT_ENCRYPTION_KEY || !LOOKUP_SECRET) {
throw new Error(
'Missing required environment variables: ' +
'MESSAGING_PREFS_ENCRYPTION_KEY, MESSAGING_SALT_ENCRYPTION_KEY, MESSAGING_LOOKUP_SECRET'
);
}
export interface MessagingPreferences {
telegram?: string; // Chat ID or username
simplex?: string; // Contact ID
email?: {
to: string[]; // To: email addresses
cc?: string[]; // CC: email addresses (optional)
};
gitPlatforms?: Array<{
platform: 'github' | 'gitlab' | 'gitea' | 'codeberg' | 'forgejo' | 'onedev' | 'custom';
owner: string; // Repository owner (username or org)
repo: string; // Repository name
token: string; // Personal access token (encrypted)
apiUrl?: string; // Custom API URL (required for onedev and self-hosted platforms)
}>;
enabled: boolean;
notifyOn?: string[]; // Event kinds to forward (e.g., ['1621', '1618'])
}
interface StoredPreferences {
encryptedSalt: string; // Salt encrypted with SALT_ENCRYPTION_KEY
encrypted: string; // Preferences encrypted with derived key
}
// Rate limiting: track decryption attempts per pubkey
interface DecryptionAttempt {
count: number;
resetAt: number;
}
const decryptionAttempts = new Map<string, DecryptionAttempt>();
const MAX_DECRYPTION_ATTEMPTS = 10; // Max attempts per window
const DECRYPTION_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
// Cleanup expired rate limit entries
setInterval(() => {
const now = Date.now();
for (const [key, attempt] of decryptionAttempts.entries()) {
if (attempt.resetAt < now) {
decryptionAttempts.delete(key);
}
}
}, 60 * 1000); // Cleanup every minute
/**
* Generate HMAC-based lookup key from pubkey
* Prevents pubkey from being directly used as database key
*/
function getLookupKey(userPubkeyHex: string): string {
if (!LOOKUP_SECRET) {
throw new Error('LOOKUP_SECRET not configured');
}
return createHmac('sha256', LOOKUP_SECRET)
.update(userPubkeyHex)
.digest('hex');
}
/**
* Check and enforce rate limiting on decryption attempts
*/
function checkRateLimit(userPubkeyHex: string): { allowed: boolean; remaining: number } {
const lookupKey = getLookupKey(userPubkeyHex);
const now = Date.now();
const attempt = decryptionAttempts.get(lookupKey);
if (!attempt || attempt.resetAt < now) {
// New window or expired
decryptionAttempts.set(lookupKey, {
count: 1,
resetAt: now + DECRYPTION_WINDOW_MS
});
return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS - 1 };
}
if (attempt.count >= MAX_DECRYPTION_ATTEMPTS) {
logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' },
'Decryption rate limit exceeded');
return { allowed: false, remaining: 0 };
}
attempt.count++;
return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS - attempt.count };
}
/**
* Encrypt data with AES-256-GCM
*/
function encryptAES256GCM(key: Buffer, plaintext: string): string {
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return JSON.stringify({
iv: iv.toString('hex'),
tag: authTag.toString('hex'),
data: encrypted
});
}
/**
* Decrypt data with AES-256-GCM
*/
function decryptAES256GCM(key: Buffer, encryptedData: string): string {
const { iv, tag, data } = JSON.parse(encryptedData);
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex'));
decipher.setAuthTag(Buffer.from(tag, 'hex'));
let decrypted = decipher.update(data, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Encrypt salt with SALT_ENCRYPTION_KEY
*/
function encryptSalt(salt: string): string {
if (!SALT_ENCRYPTION_KEY) {
throw new Error('SALT_ENCRYPTION_KEY not configured');
}
const key = createHash('sha256').update(SALT_ENCRYPTION_KEY).digest();
return encryptAES256GCM(key, salt);
}
/**
* Decrypt salt with SALT_ENCRYPTION_KEY
*/
function decryptSalt(encryptedSalt: string): string {
if (!SALT_ENCRYPTION_KEY) {
throw new Error('SALT_ENCRYPTION_KEY not configured');
}
const key = createHash('sha256').update(SALT_ENCRYPTION_KEY).digest();
return decryptAES256GCM(key, encryptedSalt);
}
/**
* Derive per-user encryption key
* Uses: master key + user pubkey + salt
*/
function deriveUserKey(userPubkeyHex: string, salt: string): Buffer {
if (!ENCRYPTION_KEY) {
throw new Error('ENCRYPTION_KEY not configured');
}
// Combine pubkey and salt for key derivation
// Attacker needs: master key + pubkey + salt
const combinedSalt = `${userPubkeyHex}:${salt}`;
return scryptSync(
ENCRYPTION_KEY,
combinedSalt,
32, // 256-bit key
{ N: 16384, r: 8, p: 1 } // scrypt parameters
);
}
/**
* In-memory storage (in production, use Redis or database)
* Key: HMAC(pubkey), Value: {encryptedSalt, encrypted}
*/
const preferencesStore = new Map<string, string>();
/**
* Store user messaging preferences securely
*
* @param userPubkeyHex - User's public key (hex)
* @param preferences - Messaging preferences to store
* @throws Error if user doesn't have unlimited access
*/
export async function storePreferences(
userPubkeyHex: string,
preferences: MessagingPreferences
): Promise<void> {
// Verify user has unlimited access
const cached = getCachedUserLevel(userPubkeyHex);
if (!cached || cached.level !== 'unlimited') {
throw new Error('Messaging forwarding requires unlimited access');
}
// Generate random salt (unique per user, per save)
const salt = randomBytes(32).toString('hex');
// Encrypt salt with separate key
const encryptedSalt = encryptSalt(salt);
// Derive user-specific encryption key
const userKey = deriveUserKey(userPubkeyHex, salt);
// Encrypt preferences
const encrypted = encryptAES256GCM(userKey, JSON.stringify(preferences));
// Store using HMAC lookup key (not raw pubkey)
const lookupKey = getLookupKey(userPubkeyHex);
const stored: StoredPreferences = {
encryptedSalt,
encrypted
};
preferencesStore.set(lookupKey, JSON.stringify(stored));
logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' },
'Stored messaging preferences');
}
/**
* Retrieve and decrypt user messaging preferences
*
* @param userPubkeyHex - User's public key (hex)
* @returns Decrypted preferences or null if not found
* @throws Error if rate limit exceeded or decryption fails
*/
export async function getPreferences(
userPubkeyHex: string
): Promise<MessagingPreferences | null> {
// Check rate limit
const rateLimit = checkRateLimit(userPubkeyHex);
if (!rateLimit.allowed) {
throw new Error(
`Decryption rate limit exceeded. Try again in ${Math.ceil(
(decryptionAttempts.get(getLookupKey(userPubkeyHex))!.resetAt - Date.now()) / 1000 / 60
)} minutes.`
);
}
// Get stored data using HMAC lookup key
const lookupKey = getLookupKey(userPubkeyHex);
const storedJson = preferencesStore.get(lookupKey);
if (!storedJson) {
return null;
}
try {
const stored: StoredPreferences = JSON.parse(storedJson);
// Decrypt salt
const salt = decryptSalt(stored.encryptedSalt);
// Derive same encryption key
const userKey = deriveUserKey(userPubkeyHex, salt);
// Decrypt preferences
const decrypted = decryptAES256GCM(userKey, stored.encrypted);
const preferences: MessagingPreferences = JSON.parse(decrypted);
// Reset rate limit on successful decryption
decryptionAttempts.delete(lookupKey);
return preferences;
} catch (error) {
logger.error({
error,
userPubkeyHex: userPubkeyHex.slice(0, 16) + '...'
}, 'Failed to decrypt preferences');
throw new Error('Failed to decrypt preferences. Data may be corrupted.');
}
}
/**
* Delete user messaging preferences
*/
export async function deletePreferences(userPubkeyHex: string): Promise<void> {
const lookupKey = getLookupKey(userPubkeyHex);
preferencesStore.delete(lookupKey);
decryptionAttempts.delete(lookupKey);
logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' },
'Deleted messaging preferences');
}
/**
* Check if user has preferences configured
*/
export async function hasPreferences(userPubkeyHex: string): Promise<boolean> {
const lookupKey = getLookupKey(userPubkeyHex);
return preferencesStore.has(lookupKey);
}
/**
* Get rate limit status for a user
*/
export function getRateLimitStatus(userPubkeyHex: string): {
remaining: number;
resetAt: number | null;
} {
const lookupKey = getLookupKey(userPubkeyHex);
const attempt = decryptionAttempts.get(lookupKey);
if (!attempt) {
return { remaining: MAX_DECRYPTION_ATTEMPTS, resetAt: null };
}
if (attempt.resetAt < Date.now()) {
return { remaining: MAX_DECRYPTION_ATTEMPTS, resetAt: null };
}
return {
remaining: Math.max(0, MAX_DECRYPTION_ATTEMPTS - attempt.count),
resetAt: attempt.resetAt
};
}
/**
* Get a safe summary of user preferences (without sensitive tokens)
* This decrypts preferences but only returns safe information
*/
export async function getPreferencesSummary(userPubkeyHex: string): Promise<{
configured: boolean;
enabled: boolean;
platforms: {
telegram?: boolean;
simplex?: boolean;
email?: boolean;
gitPlatforms?: Array<{
platform: string;
owner: string;
repo: string;
apiUrl?: string;
}>;
};
notifyOn?: string[];
} | null> {
const preferences = await getPreferences(userPubkeyHex);
if (!preferences) {
return null;
}
return {
configured: true,
enabled: preferences.enabled,
platforms: {
telegram: !!preferences.telegram,
simplex: !!preferences.simplex,
email: !!preferences.email,
gitPlatforms: preferences.gitPlatforms?.map(gp => ({
platform: gp.platform,
owner: gp.owner,
repo: gp.repo,
apiUrl: gp.apiUrl
// token is intentionally omitted
}))
},
notifyOn: preferences.notifyOn
};
}

215
src/lib/services/nostr/public-messages-service.ts

@ -0,0 +1,215 @@
/**
* Service for handling NIP-24 public messages (kind 24)
* Public messages are direct messages that can be seen by anyone
*/
import { NostrClient } from './nostr-client.js';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/nostr.js';
import { getUserRelays } from './user-relays.js';
import { combineRelays } from '../../config.js';
import logger from '../logger.js';
import { verifyEvent } from 'nostr-tools';
export interface PublicMessage extends NostrEvent {
kind: typeof KIND.PUBLIC_MESSAGE;
}
export class PublicMessagesService {
private nostrClient: NostrClient;
constructor(relays: string[]) {
this.nostrClient = new NostrClient(relays);
}
/**
* Fetch public messages sent to a user (where user is in p tags)
*/
async getMessagesToUser(
userPubkey: string,
limit: number = 50
): Promise<PublicMessage[]> {
try {
const events = await this.nostrClient.fetchEvents([
{
kinds: [KIND.PUBLIC_MESSAGE],
'#p': [userPubkey], // Messages where user is a recipient
limit
}
]);
// Verify events and filter to only valid kind 24
const validMessages = events
.filter((e): e is PublicMessage => {
if (e.kind !== KIND.PUBLIC_MESSAGE) return false;
if (!verifyEvent(e)) {
logger.warn({ eventId: e.id.slice(0, 16) + '...' }, 'Invalid signature in public message');
return false;
}
return true;
})
.sort((a, b) => b.created_at - a.created_at); // Newest first
return validMessages;
} catch (error) {
logger.error({ error, userPubkey: userPubkey.slice(0, 16) + '...' }, 'Failed to fetch public messages to user');
throw error;
}
}
/**
* Fetch public messages sent by a user
*/
async getMessagesFromUser(
userPubkey: string,
limit: number = 50
): Promise<PublicMessage[]> {
try {
const events = await this.nostrClient.fetchEvents([
{
kinds: [KIND.PUBLIC_MESSAGE],
authors: [userPubkey],
limit
}
]);
// Verify events
const validMessages = events
.filter((e): e is PublicMessage => {
if (e.kind !== KIND.PUBLIC_MESSAGE) return false;
if (!verifyEvent(e)) {
logger.warn({ eventId: e.id.slice(0, 16) + '...' }, 'Invalid signature in public message');
return false;
}
return true;
})
.sort((a, b) => b.created_at - a.created_at); // Newest first
return validMessages;
} catch (error) {
logger.error({ error, userPubkey: userPubkey.slice(0, 16) + '...' }, 'Failed to fetch public messages from user');
throw error;
}
}
/**
* Fetch all public messages involving a user (sent to or from)
*/
async getAllMessagesForUser(
userPubkey: string,
limit: number = 50
): Promise<PublicMessage[]> {
try {
const [toUser, fromUser] = await Promise.all([
this.getMessagesToUser(userPubkey, limit),
this.getMessagesFromUser(userPubkey, limit)
]);
// Combine and deduplicate by event ID
const messageMap = new Map<string, PublicMessage>();
[...toUser, ...fromUser].forEach(msg => {
messageMap.set(msg.id, msg);
});
// Sort by created_at descending
return Array.from(messageMap.values())
.sort((a, b) => b.created_at - a.created_at)
.slice(0, limit);
} catch (error) {
logger.error({ error, userPubkey: userPubkey.slice(0, 16) + '...' }, 'Failed to fetch all public messages for user');
throw error;
}
}
/**
* Create and publish a public message
* Messages are sent to inbox relays of recipients and outbox relay of sender
*/
async sendPublicMessage(
senderPubkey: string,
content: string,
recipients: Array<{ pubkey: string; relay?: string }>,
senderRelays?: string[]
): Promise<PublicMessage> {
if (!content.trim()) {
throw new Error('Message content cannot be empty');
}
if (recipients.length === 0) {
throw new Error('At least one recipient is required');
}
// Build p tags for recipients
const pTags: string[][] = recipients.map(recipient => {
const tag: string[] = ['p', recipient.pubkey];
if (recipient.relay) {
tag.push(recipient.relay);
}
return tag;
});
// Create the event (will be signed by client)
const messageEvent: Omit<PublicMessage, 'id' | 'sig'> = {
pubkey: senderPubkey,
kind: KIND.PUBLIC_MESSAGE,
created_at: Math.floor(Date.now() / 1000),
tags: pTags,
content: content.trim()
};
// Get sender's outbox relays if not provided
let outboxRelays: string[] = senderRelays || [];
if (outboxRelays.length === 0) {
const { outbox } = await getUserRelays(senderPubkey, this.nostrClient);
outboxRelays = outbox;
}
// Get inbox relays for each recipient
const recipientInboxes = new Set<string>();
for (const recipient of recipients) {
if (recipient.relay) {
recipientInboxes.add(recipient.relay);
} else {
// Fetch recipient's inbox relays
try {
const { inbox } = await getUserRelays(recipient.pubkey, this.nostrClient);
inbox.forEach(relay => recipientInboxes.add(relay));
} catch (error) {
logger.warn({ error, recipient: recipient.pubkey.slice(0, 16) + '...' }, 'Failed to fetch recipient inbox relays');
}
}
}
// Combine all relays: sender's outbox + all recipient inboxes
const allRelays = combineRelays([...outboxRelays, ...Array.from(recipientInboxes)]);
// Return the event (client will sign and publish)
return messageEvent as PublicMessage;
}
/**
* Get recipients from a public message
*/
getRecipients(message: PublicMessage): Array<{ pubkey: string; relay?: string }> {
return message.tags
.filter(tag => tag[0] === 'p' && tag[1])
.map(tag => ({
pubkey: tag[1],
relay: tag[2] || undefined
}));
}
/**
* Check if a message is sent to a specific user
*/
isMessageToUser(message: PublicMessage, userPubkey: string): boolean {
return message.tags.some(tag => tag[0] === 'p' && tag[1] === userPubkey);
}
/**
* Check if a message is from a specific user
*/
isMessageFromUser(message: PublicMessage, userPubkey: string): boolean {
return message.pubkey === userPubkey;
}
}

112
src/lib/services/security/security-audit.md

@ -0,0 +1,112 @@
# Security Audit Report
## Overview
This document outlines the security measures implemented in the GitRepublic application.
## Security Measures Implemented
### 1. Server-Side Rate Limiting
- **Location**: `src/lib/services/security/rate-limiter.ts`
- **Implementation**: Server-side rate limiting in `hooks.server.ts`
- **Features**:
- Per-IP and per-user rate limiting
- Different limits for authenticated vs anonymous users
- Configurable via environment variables
- Automatic cleanup of expired entries
- **Security**: Client-side rate limiting removed (was insecure)
### 2. User Level Verification
- **Location**: `src/routes/api/user/level/+server.ts`
- **Implementation**: Server-side verification of relay write access
- **Features**:
- Verifies NIP-98 proof events server-side
- Cannot be bypassed by client manipulation
- Three-tier access levels: unlimited, rate_limited, strictly_rate_limited
- **Security**: Client-side checks are UI-only, actual enforcement is server-side
### 3. Input Validation
- **Location**: `src/lib/utils/input-validation.ts`
- **Features**:
- Repository name validation
- File path validation (prevents path traversal)
- Pubkey format validation
- Commit message validation
- Branch name validation
- String sanitization (XSS prevention)
### 4. Security Headers
- **Location**: `src/hooks.server.ts`
- **Headers Added**:
- `X-Content-Type-Options: nosniff` - Prevents MIME type sniffing
- `X-Frame-Options: DENY` - Prevents clickjacking
- `X-XSS-Protection: 1; mode=block` - XSS protection
- `Referrer-Policy: strict-origin-when-cross-origin` - Controls referrer information
- `Permissions-Policy` - Restricts browser features
- `Content-Security-Policy` - Restricts resource loading
### 5. Audit Logging
- **Location**: `src/lib/services/security/audit-logger.ts`
- **Features**:
- Comprehensive logging of security events
- Automatic log rotation
- Configurable retention period
- Sanitization of sensitive data
- Pubkey truncation for privacy
### 6. Session Management
- **Location**: `src/lib/services/activity-tracker.ts`
- **Features**:
- 24-hour session timeout
- Activity tracking (ONLY timestamp, no activity details)
- Automatic logout on expiry
- Secure storage in localStorage (with XSS protections)
- **Privacy**: Only stores timestamp of last activity. No information about what the user did is stored.
### 7. Authentication
- **NIP-98 Authentication**: Server-side verification of HTTP auth events
- **NIP-07 Integration**: Client-side key management (keys never leave browser)
- **Relay Write Proof**: Verifies users can write to Nostr relays
## Security Best Practices
### Client-Side Security
1. **No Sensitive Logic**: All security-critical operations are server-side
2. **Input Validation**: All user inputs are validated and sanitized
3. **XSS Prevention**: Content sanitization and CSP headers
4. **Session Management**: Secure session tracking with automatic expiry
### Server-Side Security
1. **Rate Limiting**: Prevents abuse and DoS attacks
2. **Input Validation**: Server-side validation of all inputs
3. **Path Traversal Prevention**: Strict path validation
4. **Audit Logging**: Comprehensive security event logging
5. **Error Handling**: Secure error messages that don't leak information
## Known Limitations
1. **localStorage Security**: Activity tracking uses localStorage which is vulnerable to XSS
- **Mitigation**: CSP headers and input sanitization reduce XSS risk
- **Future**: Consider httpOnly cookies for session management
2. **In-Memory Rate Limiting**: Current implementation uses in-memory storage
- **Mitigation**: Works for single-instance deployments
- **Future**: Use Redis for distributed rate limiting
3. **Client-Side User Level**: User level is determined client-side for UI
- **Mitigation**: Actual enforcement is server-side via rate limiting
- **Future**: Consider server-side session management
## Recommendations
1. **Implement CSRF Protection**: Add CSRF tokens for state-changing operations
2. **Add Request Signing**: Sign all API requests to prevent replay attacks
3. **Implement Rate Limiting Per Endpoint**: More granular rate limiting
4. **Add IP Reputation Checking**: Block known malicious IPs
5. **Implement WAF**: Web Application Firewall for additional protection
6. **Regular Security Audits**: Periodic security reviews and penetration testing
## Compliance
- **GDPR**: User data is minimal (pubkeys only), no personal information stored
- **Security Logging**: All security events are logged for compliance
- **Data Retention**: Configurable log retention periods

1
src/lib/types/nostr.ts

@ -47,6 +47,7 @@ export const KIND = {
RELAY_LIST: 10002, // NIP-65: Relay list metadata RELAY_LIST: 10002, // NIP-65: Relay list metadata
NIP98_AUTH: 27235, // NIP-98: HTTP authentication event NIP98_AUTH: 27235, // NIP-98: HTTP authentication event
HIGHLIGHT: 9802, // NIP-84: Highlight event HIGHLIGHT: 9802, // NIP-84: Highlight event
PUBLIC_MESSAGE: 24, // NIP-24: Public message (direct chat)
} as const; } as const;
export interface Issue extends NostrEvent { export interface Issue extends NostrEvent {

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

@ -13,6 +13,7 @@ import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handler
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.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'; import { decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js';
/** /**
* GET - Get highlights for a pull request * GET - Get highlights for a pull request
@ -85,6 +86,17 @@ export const POST: RequestHandler = withRepoValidation(
throw handleApiError(new Error('Failed to publish to all relays'), { operation: 'createHighlight', npub: repoContext.npub, repo: repoContext.repo }, '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');
} }
// Forward to messaging platforms if user has unlimited access and preferences configured
// Decode userPubkey if it's an npub
const userPubkeyHex = requestContext.userPubkeyHex || (userPubkey ? decodeNpubToHex(userPubkey) : null);
if (userPubkeyHex && result.success.length > 0) {
forwardEventIfEnabled(highlightEvent as NostrEvent, userPubkeyHex)
.catch(err => {
// Log but don't fail the request - forwarding is optional
console.error('Failed to forward event to messaging platforms:', err);
});
}
return json({ success: true, event: highlightEvent, published: result }); return json({ success: true, event: highlightEvent, published: result });
}, },
{ operation: 'createHighlight', requireRepoAccess: false } // Highlights can be created by anyone { operation: 'createHighlight', requireRepoAccess: false } // Highlights can be created by anyone

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

@ -9,6 +9,7 @@ import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handler
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleValidationError, handleApiError } from '$lib/utils/error-handler.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 { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js';
export const GET: RequestHandler = createRepoGetHandler( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => { async (context: RepoRequestContext) => {
@ -39,6 +40,15 @@ export const POST: RequestHandler = withRepoValidation(
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'); 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');
} }
// Forward to messaging platforms if user has unlimited access and preferences configured
if (requestContext.userPubkeyHex && result.success.length > 0) {
forwardEventIfEnabled(issueEvent, requestContext.userPubkeyHex)
.catch(err => {
// Log but don't fail the request - forwarding is optional
console.error('Failed to forward event to messaging platforms:', err);
});
}
return json({ success: true, event: issueEvent, published: result }); return json({ success: true, event: issueEvent, published: result });
}, },
{ operation: 'createIssue', requireRepoAccess: false } // Issues can be created by anyone with access { operation: 'createIssue', requireRepoAccess: false } // Issues can be created by anyone with access

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

@ -10,6 +10,7 @@ import { createRepoGetHandler, withRepoValidation } from '$lib/utils/api-handler
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { handleValidationError, handleApiError } from '$lib/utils/error-handler.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 { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js';
export const GET: RequestHandler = createRepoGetHandler( export const GET: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext) => { async (context: RepoRequestContext) => {
@ -40,6 +41,15 @@ export const POST: RequestHandler = withRepoValidation(
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'); 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');
} }
// Forward to messaging platforms if user has unlimited access and preferences configured
if (requestContext.userPubkeyHex && result.success.length > 0) {
forwardEventIfEnabled(prEvent, requestContext.userPubkeyHex)
.catch(err => {
// Log but don't fail the request - forwarding is optional
console.error('Failed to forward event to messaging platforms:', err);
});
}
return json({ success: true, event: prEvent, published: result }); return json({ success: true, event: prEvent, published: result });
}, },
{ operation: 'createPR', requireRepoAccess: false } // PRs can be created by anyone with access { operation: 'createPR', requireRepoAccess: false } // PRs can be created by anyone with access

211
src/routes/api/user/messaging-preferences/+server.ts

@ -0,0 +1,211 @@
/**
* API endpoint for managing messaging preferences
*
* SECURITY:
* - Requires unlimited user access level
* - Verifies signed Nostr event proof
* - Stores encrypted preferences with multiple security layers
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { verifyEvent } from 'nostr-tools';
import { storePreferences, getPreferences, deletePreferences, hasPreferences, getRateLimitStatus } from '$lib/services/messaging/preferences-storage.js';
import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
import { auditLogger } from '$lib/services/security/audit-logger.js';
import logger from '$lib/services/logger.js';
import type { MessagingPreferences } from '$lib/services/messaging/preferences-storage.js';
/**
* POST - Save messaging preferences
* Requires:
* - Signed Nostr event (kind 30078) as proof
* - User must have unlimited access level
*/
export const POST: RequestHandler = async (event) => {
const requestContext = extractRequestContext(event);
const clientIp = requestContext.clientIp || 'unknown';
try {
const body = await event.request.json();
const { preferences, proofEvent } = body;
// Validate input
if (!preferences || !proofEvent) {
auditLogger.log({
user: requestContext.userPubkeyHex || undefined,
ip: clientIp,
action: 'api.saveMessagingPreferences',
resource: 'messaging_preferences',
result: 'failure',
error: 'Missing preferences or proof event'
});
return error(400, 'Missing required fields: preferences and proofEvent');
}
// Verify proof event signature
if (!verifyEvent(proofEvent)) {
auditLogger.log({
user: requestContext.userPubkeyHex || undefined,
ip: clientIp,
action: 'api.saveMessagingPreferences',
resource: 'messaging_preferences',
result: 'failure',
error: 'Invalid proof event signature'
});
return error(400, 'Invalid proof event signature');
}
const userPubkeyHex = proofEvent.pubkey;
// Verify it's a valid preferences event (kind 30078, d tag = 'gitrepublic-messaging')
if (proofEvent.kind !== 30078) {
auditLogger.log({
user: userPubkeyHex,
ip: clientIp,
action: 'api.saveMessagingPreferences',
resource: 'messaging_preferences',
result: 'failure',
error: 'Invalid event kind'
});
return error(400, 'Proof event must be kind 30078');
}
const dTag = proofEvent.tags.find(t => t[0] === 'd');
if (dTag?.[1] !== 'gitrepublic-messaging') {
auditLogger.log({
user: userPubkeyHex,
ip: clientIp,
action: 'api.saveMessagingPreferences',
resource: 'messaging_preferences',
result: 'failure',
error: 'Invalid event d tag'
});
return error(400, 'Proof event must have d tag "gitrepublic-messaging"');
}
// Verify user has unlimited access
const cached = getCachedUserLevel(userPubkeyHex);
if (!cached || cached.level !== 'unlimited') {
auditLogger.log({
user: userPubkeyHex,
ip: clientIp,
action: 'api.saveMessagingPreferences',
resource: 'messaging_preferences',
result: 'failure',
error: 'Requires unlimited access'
});
return error(403, 'Messaging forwarding requires unlimited access level');
}
// Validate preferences structure
if (typeof preferences.enabled !== 'boolean') {
return error(400, 'Invalid preferences: enabled must be boolean');
}
// Store preferences (will encrypt and store securely)
await storePreferences(userPubkeyHex, preferences as MessagingPreferences);
auditLogger.log({
user: userPubkeyHex,
ip: clientIp,
action: 'api.saveMessagingPreferences',
resource: 'messaging_preferences',
result: 'success'
});
return json({ success: true });
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
logger.error({ error: err, clientIp }, '[API] Error saving messaging preferences');
auditLogger.log({
user: requestContext.userPubkeyHex || undefined,
ip: clientIp,
action: 'api.saveMessagingPreferences',
resource: 'messaging_preferences',
result: 'failure',
error: errorMessage
});
if (errorMessage.includes('rate limit')) {
return error(429, errorMessage);
}
return error(500, 'Failed to save messaging preferences');
}
};
/**
* GET - Retrieve messaging preferences
* Returns encrypted status only (for UI), not decrypted data
*/
export const GET: RequestHandler = async (event) => {
const requestContext = extractRequestContext(event);
const clientIp = requestContext.clientIp || 'unknown';
try {
if (!requestContext.userPubkeyHex) {
return error(401, 'Authentication required');
}
// Verify user has unlimited access
const cached = getCachedUserLevel(requestContext.userPubkeyHex);
if (!cached || cached.level !== 'unlimited') {
return error(403, 'Messaging forwarding requires unlimited access level');
}
// Check if preferences exist (without decrypting)
const exists = await hasPreferences(requestContext.userPubkeyHex);
// Get rate limit status
const rateLimit = getRateLimitStatus(requestContext.userPubkeyHex);
return json({
configured: exists,
rateLimit: {
remaining: rateLimit.remaining,
resetAt: rateLimit.resetAt
}
});
} catch (err) {
logger.error({ error: err, clientIp }, '[API] Error getting messaging preferences status');
return error(500, 'Failed to get messaging preferences status');
}
};
/**
* DELETE - Remove messaging preferences
*/
export const DELETE: RequestHandler = async (event) => {
const requestContext = extractRequestContext(event);
const clientIp = requestContext.clientIp || 'unknown';
try {
if (!requestContext.userPubkeyHex) {
return error(401, 'Authentication required');
}
// Verify user has unlimited access
const cached = getCachedUserLevel(requestContext.userPubkeyHex);
if (!cached || cached.level !== 'unlimited') {
return error(403, 'Messaging forwarding requires unlimited access level');
}
await deletePreferences(requestContext.userPubkeyHex);
auditLogger.log({
user: requestContext.userPubkeyHex,
ip: clientIp,
action: 'api.deleteMessagingPreferences',
resource: 'messaging_preferences',
result: 'success'
});
return json({ success: true });
} catch (err) {
logger.error({ error: err, clientIp }, '[API] Error deleting messaging preferences');
return error(500, 'Failed to delete messaging preferences');
}
};

58
src/routes/api/user/messaging-preferences/summary/+server.ts

@ -0,0 +1,58 @@
/**
* API endpoint for getting messaging preferences summary
* Returns safe summary without sensitive tokens
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPreferencesSummary } from '$lib/services/messaging/preferences-storage.js';
import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
import logger from '$lib/services/logger.js';
/**
* GET - Get messaging preferences summary (safe, no tokens)
*/
export const GET: RequestHandler = async (event) => {
const requestContext = extractRequestContext(event);
const clientIp = requestContext.clientIp || 'unknown';
try {
if (!requestContext.userPubkeyHex) {
return error(401, 'Authentication required');
}
// Verify user has unlimited access
const cached = getCachedUserLevel(requestContext.userPubkeyHex);
if (!cached || cached.level !== 'unlimited') {
return error(403, 'Messaging forwarding requires unlimited access level');
}
// Get safe summary (decrypts but only returns safe info)
const summary = await getPreferencesSummary(requestContext.userPubkeyHex);
if (!summary) {
return json({
configured: false,
enabled: false,
platforms: {}
});
}
return json(summary);
} catch (err) {
logger.error({ error: err, clientIp }, '[API] Error getting messaging preferences summary');
// If rate limit exceeded, return configured but no details
if (err instanceof Error && err.message.includes('rate limit')) {
return json({
configured: true,
enabled: false,
platforms: {},
error: 'Rate limit exceeded'
});
}
return error(500, 'Failed to get messaging preferences summary');
}
};

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

@ -5,6 +5,7 @@
import CodeEditor from '$lib/components/CodeEditor.svelte'; import CodeEditor from '$lib/components/CodeEditor.svelte';
import PRDetail from '$lib/components/PRDetail.svelte'; import PRDetail from '$lib/components/PRDetail.svelte';
import UserBadge from '$lib/components/UserBadge.svelte'; import UserBadge from '$lib/components/UserBadge.svelte';
import ForwardingConfig from '$lib/components/ForwardingConfig.svelte';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js';
@ -1457,6 +1458,9 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if pageData.repoOwnerPubkey && userPubkey === pageData.repoOwnerPubkey}
<ForwardingConfig userPubkeyHex={pageData.repoOwnerPubkey} />
{/if}
<div class="header-actions-bottom"> <div class="header-actions-bottom">
{#if userPubkey} {#if userPubkey}
<button onclick={forkRepository} disabled={forking} class="fork-button"> <button onclick={forkRepository} disabled={forking} class="fork-button">

454
src/routes/users/[npub]/+page.svelte

@ -3,11 +3,16 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } 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 { nip19 } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; import { getPublicKeyWithNIP07, isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import ForwardingConfig from '$lib/components/ForwardingConfig.svelte';
import { PublicMessagesService, type PublicMessage } from '$lib/services/nostr/public-messages-service.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import UserBadge from '$lib/components/UserBadge.svelte';
import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.js';
const npub = ($page.params as { npub?: string }).npub || ''; const npub = ($page.params as { npub?: string }).npub || '';
@ -18,6 +23,15 @@
let repos = $state<NostrEvent[]>([]); let repos = $state<NostrEvent[]>([]);
let userProfile = $state<{ name?: string; about?: string; picture?: string } | null>(null); let userProfile = $state<{ name?: string; about?: string; picture?: string } | null>(null);
// Messages tab
let activeTab = $state<'repos' | 'messages'>('repos');
let messages = $state<PublicMessage[]>([]);
let loadingMessages = $state(false);
let showSendMessageDialog = $state(false);
let newMessageContent = $state('');
let sendingMessage = $state(false);
let messagesService: PublicMessagesService | null = null;
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const gitDomain = $page.data.gitDomain || 'localhost:6543'; const gitDomain = $page.data.gitDomain || 'localhost:6543';
@ -26,6 +40,13 @@
await loadUserProfile(); await loadUserProfile();
}); });
// Load messages when messages tab is active
$effect(() => {
if (activeTab === 'messages' && userPubkey && messages.length === 0) {
loadMessages();
}
});
async function loadViewerPubkey() { async function loadViewerPubkey() {
if (!isNIP07Available()) { if (!isNIP07Available()) {
return; return;
@ -117,6 +138,118 @@
function getRepoId(event: NostrEvent): string { function getRepoId(event: NostrEvent): string {
return event.tags.find(t => t[0] === 'd')?.[1] || ''; return event.tags.find(t => t[0] === 'd')?.[1] || '';
} }
function getForwardingPubkey(): string | null {
if (userPubkey && viewerPubkeyHex && viewerPubkeyHex === userPubkey) {
return userPubkey;
}
return null;
}
async function loadMessages() {
if (!userPubkey) return;
loadingMessages = true;
error = null;
try {
if (!messagesService) {
messagesService = new PublicMessagesService(DEFAULT_NOSTR_RELAYS);
}
messages = await messagesService.getAllMessagesForUser(userPubkey, 100);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load messages';
console.error('Error loading messages:', err);
} finally {
loadingMessages = false;
}
}
async function sendMessage() {
if (!newMessageContent.trim() || !viewerPubkeyHex || !userPubkey) {
alert('Please enter a message and make sure you are logged in');
return;
}
if (viewerPubkeyHex === userPubkey) {
alert('You cannot send a message to yourself');
return;
}
sendingMessage = true;
error = null;
try {
if (!messagesService) {
messagesService = new PublicMessagesService(DEFAULT_NOSTR_RELAYS);
}
// Create the message event
const messageEvent = await messagesService.sendPublicMessage(
viewerPubkeyHex,
newMessageContent.trim(),
[{ pubkey: userPubkey }]
);
// Get user's relays for publishing
const { outbox } = await getUserRelays(viewerPubkeyHex, nostrClient);
const combinedRelays = combineRelays(outbox);
// Sign the event
const signedEvent = await signEventWithNIP07(messageEvent);
// Publish to relays
const result = await nostrClient.publishEvent(signedEvent, combinedRelays);
if (result.failed.length > 0 && result.success.length === 0) {
throw new Error('Failed to publish message to all relays');
}
// Forward to messaging platforms if user has unlimited access and preferences configured
if (result.success.length > 0 && viewerPubkeyHex) {
forwardEventIfEnabled(signedEvent, viewerPubkeyHex)
.catch(err => {
console.error('Failed to forward message to messaging platforms:', err);
});
}
// Reload messages
await loadMessages();
// Close dialog and clear content
showSendMessageDialog = false;
newMessageContent = '';
alert('Message sent successfully!');
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to send message';
console.error('Error sending message:', err);
alert(error);
} finally {
sendingMessage = false;
}
}
function getMessageRecipients(message: PublicMessage): string[] {
return message.tags
.filter(tag => tag[0] === 'p' && tag[1])
.map(tag => tag[1]);
}
function formatMessageTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
</script> </script>
<div class="container"> <div class="container">
@ -137,6 +270,9 @@
<p class="profile-npub">npub: {npub}</p> <p class="profile-npub">npub: {npub}</p>
</div> </div>
</div> </div>
{#if getForwardingPubkey()}
<ForwardingConfig userPubkeyHex={getForwardingPubkey()!} />
{/if}
</header> </header>
<main> <main>
@ -147,6 +283,26 @@
{#if loading} {#if loading}
<div class="loading">Loading profile...</div> <div class="loading">Loading profile...</div>
{:else} {:else}
<!-- Tabs -->
<div class="tabs">
<button
class="tab-button"
class:active={activeTab === 'repos'}
onclick={() => activeTab = 'repos'}
>
Repositories ({repos.length})
</button>
<button
class="tab-button"
class:active={activeTab === 'messages'}
onclick={() => activeTab = 'messages'}
>
Messages ({messages.length})
</button>
</div>
<!-- Repositories Tab -->
{#if activeTab === 'repos'}
<div class="repos-section"> <div class="repos-section">
<h2>Repositories ({repos.length})</h2> <h2>Repositories ({repos.length})</h2>
{#if repos.length === 0} {#if repos.length === 0}
@ -181,7 +337,101 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<!-- Messages Tab -->
{#if activeTab === 'messages'}
<div class="messages-section">
<div class="messages-header">
<h2>Public Messages</h2>
{#if viewerPubkeyHex && viewerPubkeyHex !== userPubkey}
<button onclick={() => showSendMessageDialog = true} class="send-message-button">
Send Message
</button>
{/if}
</div>
{#if loadingMessages}
<div class="loading">Loading messages...</div>
{:else if messages.length === 0}
<div class="empty">No messages found</div>
{:else}
<div class="messages-list">
{#each messages as message}
{@const isFromViewer = viewerPubkeyHex !== null && message.pubkey === viewerPubkeyHex}
{@const isToViewer = viewerPubkeyHex !== null && getMessageRecipients(message).includes(viewerPubkeyHex)}
{@const isFromUser = userPubkey !== null && message.pubkey === userPubkey}
{@const isToUser = userPubkey !== null && getMessageRecipients(message).includes(userPubkey)}
<div class="message-item" class:from-viewer={isFromViewer} class:to-viewer={isToViewer && !isFromViewer}>
<div class="message-header">
<UserBadge pubkey={message.pubkey} />
<span class="message-time">{formatMessageTime(message.created_at)}</span>
</div>
<div class="message-recipients">
{#if getMessageRecipients(message).length > 0}
<span class="recipients-label">To:</span>
{#each getMessageRecipients(message) as recipientPubkey}
<UserBadge pubkey={recipientPubkey} />
{/each}
{/if}
</div>
<div class="message-content">{message.content}</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</main> </main>
<!-- Send Message Dialog -->
{#if showSendMessageDialog}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Send message"
onclick={() => showSendMessageDialog = false}
onkeydown={(e) => e.key === 'Escape' && (showSendMessageDialog = false)}
tabindex="-1"
>
<div
class="modal"
role="document"
onclick={(e) => e.stopPropagation()}
>
<h3>Send Public Message</h3>
<p class="modal-note">This message will be publicly visible, but will usually not be displayed outside of notifications.</p>
<label>
Message:
<textarea
bind:value={newMessageContent}
rows="6"
placeholder="Type your message..."
disabled={sendingMessage}
></textarea>
</label>
<div class="modal-actions">
<button
onclick={() => { showSendMessageDialog = false; newMessageContent = ''; }}
class="cancel-button"
disabled={sendingMessage}
>
Cancel
</button>
<button
onclick={sendMessage}
disabled={!newMessageContent.trim() || sendingMessage}
class="send-button"
>
{sendingMessage ? 'Sending...' : 'Send Message'}
</button>
</div>
</div>
</div>
{/if}
</div> </div>
<style> <style>
@ -235,4 +485,204 @@
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem; gap: 1.5rem;
} }
.tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 2rem;
}
.tab-button {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 1rem;
color: var(--text-secondary);
transition: all 0.2s;
}
.tab-button:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.tab-button.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 500;
}
.messages-section {
margin-top: 1rem;
}
.messages-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.messages-header h2 {
margin: 0;
}
.send-message-button {
padding: 0.5rem 1rem;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s;
}
.send-message-button:hover {
background: var(--accent-dark);
}
.messages-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.message-item {
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.message-item.from-viewer {
background: var(--accent-light);
border-color: var(--accent);
}
.message-item.to-viewer {
border-left: 3px solid var(--accent);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.message-time {
color: var(--text-muted);
font-size: 0.85rem;
}
.message-recipients {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.recipients-label {
color: var(--text-muted);
font-size: 0.85rem;
font-weight: 500;
}
.message-content {
color: var(--text-primary);
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.5;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--card-bg);
border-radius: 8px;
padding: 2rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal h3 {
margin: 0 0 1rem 0;
}
.modal-note {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 4px;
}
.modal label {
display: block;
margin-bottom: 1rem;
}
.modal label textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit;
font-size: 1rem;
resize: vertical;
box-sizing: border-box;
}
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.cancel-button,
.send-button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.cancel-button {
background: var(--bg-secondary);
color: var(--text-primary);
}
.send-button {
background: var(--accent);
color: white;
}
.send-button:hover:not(:disabled) {
background: var(--accent-dark);
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style> </style>

Loading…
Cancel
Save