Browse Source

first phase

master
Silberengel 1 month ago
parent
commit
481da41a6a
  1. 32
      .eslintrc.cjs
  2. 10
      .gitignore
  3. 17
      .prettierrc
  4. 25
      Dockerfile
  5. 1367
      README.md
  6. 59
      README_SETUP.md
  7. 16
      docker-compose.yml
  8. 12
      docker-entrypoint.sh
  9. 26
      httpd.conf.template
  10. 51
      package.json
  11. 6
      postcss.config.js
  12. 8
      public/healthz.json
  13. 20
      scripts/generate-healthz.js
  14. 60
      src/app.css
  15. 12
      src/app.d.ts
  16. 56
      src/lib/components/content/MarkdownRenderer.svelte
  17. 25
      src/lib/components/layout/Header.svelte
  18. 78
      src/lib/components/layout/ProfileBadge.svelte
  19. 157
      src/lib/components/modals/PublicationStatusModal.svelte
  20. 142
      src/lib/modules/threads/CreateThreadForm.svelte
  21. 82
      src/lib/modules/threads/ThreadCard.svelte
  22. 108
      src/lib/modules/threads/ThreadList.svelte
  23. 30
      src/lib/services/auth/activity-tracker.ts
  24. 63
      src/lib/services/auth/anonymous-signer.ts
  25. 46
      src/lib/services/auth/bunker-signer.ts
  26. 53
      src/lib/services/auth/nip07-signer.ts
  27. 41
      src/lib/services/auth/nsec-signer.ts
  28. 133
      src/lib/services/auth/profile-fetcher.ts
  29. 81
      src/lib/services/auth/relay-list-fetcher.ts
  30. 96
      src/lib/services/auth/session-manager.ts
  31. 16
      src/lib/services/auth/user-preferences-fetcher.ts
  32. 50
      src/lib/services/auth/user-status-fetcher.ts
  33. 72
      src/lib/services/cache/anonymous-key-store.ts
  34. 99
      src/lib/services/cache/event-cache.ts
  35. 76
      src/lib/services/cache/indexeddb-store.ts
  36. 53
      src/lib/services/cache/profile-cache.ts
  37. 46
      src/lib/services/cache/search-index.ts
  38. 150
      src/lib/services/nostr/applesauce-client.ts
  39. 160
      src/lib/services/nostr/auth-handler.ts
  40. 59
      src/lib/services/nostr/config.ts
  41. 214
      src/lib/services/nostr/event-store.ts
  42. 35
      src/lib/services/nostr/event-utils.ts
  43. 214
      src/lib/services/nostr/relay-pool.ts
  44. 157
      src/lib/services/nostr/subscription-manager.ts
  45. 50
      src/lib/services/security/bech32-utils.ts
  46. 55
      src/lib/services/security/event-validator.ts
  47. 47
      src/lib/services/security/key-management.ts
  48. 46
      src/lib/services/security/sanitizer.ts
  49. 24
      src/lib/types/nostr.ts
  50. 5
      src/routes/+layout.svelte
  51. 2
      src/routes/+layout.ts
  52. 26
      src/routes/+page.svelte
  53. 23
      src/routes/feed/+page.svelte
  54. 57
      src/routes/login/+page.svelte
  55. 74
      src/routes/thread/[id]/+page.svelte
  56. 42
      src/routes/threads/+page.svelte
  57. 18
      svelte.config.js
  58. 21
      tailwind.config.js
  59. 16
      tsconfig.json
  60. 27
      vite.config.ts

32
.eslintrc.cjs

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2022
},
env: {
browser: true,
es2022: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
],
rules: {
'@typescript-eslint/no-explicit-any': 'warn'
}
};

10
.gitignore vendored

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

17
.prettierrc

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
{
"useTabs": false,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

25
Dockerfile

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
# Multi-stage build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_DEFAULT_RELAYS
ARG VITE_ZAP_THRESHOLD
ARG VITE_THREAD_TIMEOUT_DAYS
ARG VITE_PWA_ENABLED
ENV VITE_DEFAULT_RELAYS=${VITE_DEFAULT_RELAYS}
ENV VITE_ZAP_THRESHOLD=${VITE_ZAP_THRESHOLD}
ENV VITE_THREAD_TIMEOUT_DAYS=${VITE_THREAD_TIMEOUT_DAYS}
ENV VITE_PWA_ENABLED=${VITE_PWA_ENABLED}
RUN npm run build
FROM httpd:alpine
COPY --from=builder /app/build /usr/local/apache2/htdocs/
COPY httpd.conf.template /usr/local/apache2/conf/httpd.conf.template
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ARG PORT=9876
ENV PORT=${PORT}
EXPOSE ${PORT}
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]

1367
README.md

File diff suppressed because it is too large Load Diff

59
README_SETUP.md

@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
# Aitherboard Setup Guide
## Prerequisites
- Node.js 20+
- npm or yarn
- Docker (for deployment)
## Development Setup
1. Install dependencies:
```bash
npm install
```
2. Create `.env` file (optional, uses defaults if not provided):
```bash
cp .env.example .env
```
3. Start development server:
```bash
npm run dev
```
4. Open http://localhost:5173
## Building
```bash
npm run build
```
## Docker Deployment
1. Build and run with docker-compose:
```bash
docker-compose up --build
```
2. Or build manually:
```bash
docker build -t aitherboard .
docker run -p 9876:9876 aitherboard
```
## Project Structure
- `src/lib/services/` - Core services (Nostr, auth, cache, security)
- `src/lib/modules/` - Feature modules (threads, comments, zaps, etc.)
- `src/lib/components/` - Reusable UI components
- `src/routes/` - SvelteKit routes
## Notes
- This is a work in progress. Many features are placeholders and need full implementation.
- NIP-49 encryption, event signing, and bech32 encoding need proper cryptographic libraries.
- The applesauce-core library integration needs to be completed.
- Full implementation of all modules (comments, zaps, reactions, profiles, feed) is ongoing.

16
docker-compose.yml

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
version: '3.8'
services:
aitherboard:
build:
context: .
args:
VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land,wss://nostr.wine,wss://nostr.sovbit.host"
VITE_ZAP_THRESHOLD: "1"
VITE_THREAD_TIMEOUT_DAYS: "30"
VITE_PWA_ENABLED: "true"
ports:
- "9876:9876"
environment:
- PORT=9876
restart: unless-stopped

12
docker-entrypoint.sh

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
#!/bin/sh
set -e
PORT=${PORT:-9876}
if ! [ "$PORT" -ge 1 ] 2>/dev/null || ! [ "$PORT" -le 65535 ] 2>/dev/null; then
echo "Warning: Invalid PORT '$PORT', using default 9876"
PORT=9876
fi
envsubst '${PORT}' < /usr/local/apache2/conf/httpd.conf.template > /usr/local/apache2/conf/httpd.conf
exec httpd -D FOREGROUND

26
httpd.conf.template

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
Listen ${PORT}
ServerName localhost
<Directory "/usr/local/apache2/htdocs">
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<Location "/healthz">
Header set Content-Type "application/json"
Header set Cache-Control "public, max-age=5"
</Location>
RewriteEngine On
RewriteBase /
RewriteRule ^healthz$ /healthz.json [L]
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
<IfModule mod_headers.c>
Header set Service-Worker-Allowed "/"
</IfModule>

51
package.json

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
{
"name": "aitherboard",
"version": "0.1.0",
"type": "module",
"author": "silberengel@gitcitadel.com",
"description": "A decentralized messageboard built on the Nostr protocol.",
"homepage": "https://gitcitadel.com/",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://git.imwald.eu/silberengel/aitherboard.git"
},
"bugs": {
"url": "https://gitworkshop.dev/silberengel@gitcitadel.com/Alexandria"
},
"scripts": {
"dev": "vite dev",
"build": "tsc && vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"applesauce-core": "github:hzrd149/applesauce",
"dompurify": "^3.0.6",
"idb": "^8.0.0",
"marked": "^11.1.1",
"svelte": "^5.0.0"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
"@types/dompurify": "^3.0.5",
"@types/marked": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"postcss": "^8.4.32",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.2",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.0"
}
}

6
postcss.config.js

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

8
public/healthz.json

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
{
"status": "ok",
"service": "aitherboard",
"version": "0.1.0",
"buildTime": "2024-01-01T00:00:00.000Z",
"gitCommit": "unknown",
"timestamp": 1704067200000
}

20
scripts/generate-healthz.js

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
/**
* Generate health check JSON file at build time
*/
import { writeFileSync } from 'fs';
import { join } from 'path';
const healthz = {
status: 'ok',
service: 'aitherboard',
version: process.env.npm_package_version || '0.1.0',
buildTime: new Date().toISOString(),
gitCommit: process.env.GIT_COMMIT || 'unknown',
timestamp: Date.now()
};
const outputPath = join(process.cwd(), 'public', 'healthz.json');
writeFileSync(outputPath, JSON.stringify(healthz, null, 2));
console.log('Generated healthz.json');

60
src/app.css

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--text-size: 16px;
--line-height: 1.6;
--content-width: 800px;
}
[data-text-size='small'] {
--text-size: 14px;
}
[data-text-size='medium'] {
--text-size: 16px;
}
[data-text-size='large'] {
--text-size: 18px;
}
[data-line-spacing='tight'] {
--line-height: 1.4;
}
[data-line-spacing='normal'] {
--line-height: 1.6;
}
[data-line-spacing='loose'] {
--line-height: 1.8;
}
[data-content-width='narrow'] {
--content-width: 600px;
}
[data-content-width='medium'] {
--content-width: 800px;
}
[data-content-width='wide'] {
--content-width: 1200px;
}
body {
font-size: var(--text-size);
line-height: var(--line-height);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

12
src/app.d.ts vendored

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

56
src/lib/components/content/MarkdownRenderer.svelte

@ -0,0 +1,56 @@ @@ -0,0 +1,56 @@
<script lang="ts">
import { marked } from 'marked';
import { sanitizeMarkdown } from '../../services/security/sanitizer.js';
import { onMount } from 'svelte';
export let content: string = '';
let rendered = $state('');
$effect(() => {
if (content) {
const html = marked.parse(content);
rendered = sanitizeMarkdown(html);
} else {
rendered = '';
}
});
</script>
<div class="markdown-content">
{@html rendered}
</div>
<style>
.markdown-content {
line-height: var(--line-height);
}
.markdown-content :global(p) {
margin: 0.5em 0;
}
.markdown-content :global(a) {
color: #0066cc;
text-decoration: underline;
}
.markdown-content :global(code) {
background: #f0f0f0;
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: monospace;
}
.markdown-content :global(pre) {
background: #f0f0f0;
padding: 1em;
border-radius: 5px;
overflow-x: auto;
}
.markdown-content :global(img) {
max-width: 100%;
height: auto;
}
</style>

25
src/lib/components/layout/Header.svelte

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
</script>
<header class="bg-board-post border-b border-board-border p-4">
<nav class="flex items-center justify-between">
<a href="/" class="text-xl font-bold">Aitherboard</a>
<div class="flex gap-4">
{#if $sessionManager.isLoggedIn()}
<span>Logged in as: {$sessionManager.getCurrentPubkey()?.slice(0, 16)}...</span>
<button on:click={() => sessionManager.clearSession()}>Logout</button>
{:else}
<a href="/login">Login</a>
{/if}
<a href="/feed">Feed</a>
<a href="/threads">Threads</a>
</div>
</nav>
</header>
<style>
header {
max-width: 100%;
}
</style>

78
src/lib/components/layout/ProfileBadge.svelte

@ -0,0 +1,78 @@ @@ -0,0 +1,78 @@
<script lang="ts">
import { getActivityStatus } from '../../services/auth/activity-tracker.js';
import { fetchProfile } from '../../services/auth/profile-fetcher.js';
import { fetchUserStatus } from '../../services/auth/user-status-fetcher.js';
import { onMount } from 'svelte';
export let pubkey: string;
let profile = $state<{ name?: string; picture?: string } | null>(null);
let status = $state<string | null>(null);
let activityStatus = $state<'red' | 'yellow' | 'green' | null>(null);
$effect(() => {
if (pubkey) {
loadProfile();
loadStatus();
updateActivityStatus();
}
});
async function loadProfile() {
const p = await fetchProfile(pubkey);
if (p) {
profile = p;
}
}
async function loadStatus() {
status = await fetchUserStatus(pubkey);
}
function updateActivityStatus() {
activityStatus = getActivityStatus(pubkey);
}
function getActivityColor(): string {
switch (activityStatus) {
case 'red':
return '#ef4444';
case 'yellow':
return '#eab308';
case 'green':
return '#22c55e';
default:
return '#9ca3af';
}
}
</script>
<a href="/profile/{pubkey}" class="profile-badge inline-flex items-center gap-2">
{#if profile?.picture}
<img src={profile.picture} alt={profile.name || pubkey} class="w-6 h-6 rounded" />
{:else}
<div class="w-6 h-6 rounded bg-gray-300"></div>
{/if}
<span>{profile?.name || pubkey.slice(0, 16)}...</span>
{#if activityStatus}
<span
class="w-2 h-2 rounded-full"
style="background-color: {getActivityColor()}"
title="Activity indicator"
></span>
{/if}
{#if status}
<span class="text-sm text-gray-600">({status})</span>
{/if}
</a>
<style>
.profile-badge {
text-decoration: none;
color: inherit;
}
.profile-badge:hover {
text-decoration: underline;
}
</style>

157
src/lib/components/modals/PublicationStatusModal.svelte

@ -0,0 +1,157 @@ @@ -0,0 +1,157 @@
<script lang="ts">
export let open = $state(false);
export let results: {
success: string[];
failed: Array<{ relay: string; error: string }>;
} | null = $state(null);
let autoCloseTimeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
if (open && results) {
// Auto-close after 30 seconds
autoCloseTimeout = setTimeout(() => {
open = false;
}, 30000);
}
return () => {
if (autoCloseTimeout) {
clearTimeout(autoCloseTimeout);
}
};
});
function close() {
open = false;
if (autoCloseTimeout) {
clearTimeout(autoCloseTimeout);
autoCloseTimeout = null;
}
}
</script>
{#if open && results}
<div class="modal-overlay" on:click={close} on:keydown={(e) => e.key === 'Escape' && close()}>
<div class="modal-content" on:click|stopPropagation>
<div class="modal-header">
<h2>Publication Status</h2>
<button on:click={close} class="close-button">×</button>
</div>
<div class="modal-body">
{#if results.success.length > 0}
<div class="success-section">
<h3>Success ({results.success.length})</h3>
<ul>
{#each results.success as relay}
<li>{relay}</li>
{/each}
</ul>
</div>
{/if}
{#if results.failed.length > 0}
<div class="failed-section">
<h3>Failed ({results.failed.length})</h3>
<ul>
{#each results.failed as { relay, error }}
<li>
<strong>{relay}:</strong> {error}
</li>
{/each}
</ul>
</div>
{/if}
</div>
<div class="modal-footer">
<button on:click={close}>Close</button>
</div>
</div>
</div>
{/if}
<style>
.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-content {
background: white;
border-radius: 8px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e5e7eb;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
}
.modal-body {
padding: 1rem;
}
.success-section,
.failed-section {
margin-bottom: 1rem;
}
.success-section h3 {
color: #22c55e;
}
.failed-section h3 {
color: #ef4444;
}
.modal-body ul {
list-style: none;
padding: 0;
margin: 0.5rem 0;
}
.modal-body li {
padding: 0.25rem 0;
}
.modal-footer {
padding: 1rem;
border-top: 1px solid #e5e7eb;
text-align: right;
}
.modal-footer button {
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>

142
src/lib/modules/threads/CreateThreadForm.svelte

@ -0,0 +1,142 @@ @@ -0,0 +1,142 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { nostrClient } from '../../services/nostr/applesauce-client.js';
import type { NostrEvent } from '../../types/nostr.js';
let title = $state('');
let content = $state('');
let topics = $state<string[]>([]);
let topicInput = $state('');
let includeClientTag = $state(true);
let publishing = $state(false);
function addTopic() {
if (topicInput.trim() && topics.length < 3) {
topics = [...topics, topicInput.trim()];
topicInput = '';
}
}
function removeTopic(index: number) {
topics = topics.filter((_, i) => i !== index);
}
async function publish() {
if (!sessionManager.isLoggedIn()) {
alert('Please log in to create a thread');
return;
}
if (!title.trim() || !content.trim()) {
alert('Title and content are required');
return;
}
publishing = true;
try {
const tags: string[][] = [['title', title]];
topics.forEach((topic) => tags.push(['t', topic]));
if (includeClientTag) {
tags.push(['client', 'Aitherboard']);
}
const event: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 11,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags,
content
};
const signed = await sessionManager.signEvent(event);
const config = nostrClient.getConfig();
const result = await nostrClient.publish(signed, {
relays: [...config.defaultRelays, 'wss://thecitadel.nostr1.com']
});
if (result.success.length > 0) {
alert(`Thread published to ${result.success.length} relay(s)`);
// Reset form
title = '';
content = '';
topics = [];
} else {
alert('Failed to publish thread');
}
} catch (error) {
console.error('Error publishing thread:', error);
alert('Error publishing thread');
} finally {
publishing = false;
}
}
</script>
<form on:submit|preventDefault={publish} class="create-thread-form">
<div class="mb-4">
<label for="title" class="block mb-2">Title</label>
<input
id="title"
type="text"
bind:value={title}
class="w-full p-2 border border-board-border"
required
/>
</div>
<div class="mb-4">
<label for="content" class="block mb-2">Content</label>
<textarea
id="content"
bind:value={content}
class="w-full p-2 border border-board-border"
rows="10"
required
></textarea>
</div>
<div class="mb-4">
<label for="topics" class="block mb-2">Topics (max 3)</label>
<div class="flex gap-2 mb-2">
<input
id="topics"
type="text"
bind:value={topicInput}
on:keydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTopic())}
class="flex-1 p-2 border border-board-border"
disabled={topics.length >= 3}
/>
<button type="button" on:click={addTopic} disabled={topics.length >= 3}>
Add
</button>
</div>
<div class="flex gap-2 flex-wrap">
{#each topics as topic, i}
<span class="bg-gray-200 px-2 py-1 rounded">
{topic}
<button type="button" on:click={() => removeTopic(i)} class="ml-2">×</button>
</span>
{/each}
</div>
</div>
<div class="mb-4">
<label>
<input type="checkbox" bind:checked={includeClientTag} />
Include client tag
</label>
</div>
<button type="submit" disabled={publishing} class="px-4 py-2 bg-blue-500 text-white">
{publishing ? 'Publishing...' : 'Create Thread'}
</button>
</form>
<style>
.create-thread-form {
max-width: var(--content-width);
margin: 0 auto;
padding: 1rem;
}
</style>

82
src/lib/modules/threads/ThreadCard.svelte

@ -0,0 +1,82 @@ @@ -0,0 +1,82 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import type { NostrEvent } from '../../types/nostr.js';
export let thread: NostrEvent;
function getTitle(): string {
const titleTag = thread.tags.find((t) => t[0] === 'title');
return titleTag?.[1] || 'Untitled';
}
function getTopics(): string[] {
return thread.tags.filter((t) => t[0] === 't').map((t) => t[1]).slice(0, 3);
}
function getPreview(): string {
// First 250 chars, plaintext (no markdown/images)
const plaintext = thread.content.replace(/[#*_`\[\]()]/g, '').replace(/\n/g, ' ');
return plaintext.slice(0, 250) + (plaintext.length > 250 ? '...' : '');
}
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - thread.created_at;
const hours = Math.floor(diff / 3600);
const days = Math.floor(diff / 86400);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
return 'just now';
}
function getClientName(): string | null {
const clientTag = thread.tags.find((t) => t[0] === 'client');
return clientTag?.[1] || null;
}
</script>
<article class="thread-card bg-board-post border border-board-border p-4 mb-4">
<div class="flex justify-between items-start mb-2">
<h3 class="text-lg font-semibold">
<a href="/thread/{thread.id}">{getTitle()}</a>
</h3>
<span class="text-sm text-gray-600">{getRelativeTime()}</span>
</div>
<div class="mb-2">
<ProfileBadge pubkey={thread.pubkey} />
{#if getClientName()}
<span class="text-xs text-gray-500 ml-2">via {getClientName()}</span>
{/if}
</div>
<p class="text-sm mb-2">{getPreview()}</p>
{#if getTopics().length > 0}
<div class="flex gap-2 mb-2">
{#each getTopics() as topic}
<span class="text-xs bg-gray-200 px-2 py-1 rounded">{topic}</span>
{/each}
</div>
{/if}
<div class="text-xs text-gray-600">
<a href="/thread/{thread.id}">View thread →</a>
</div>
</article>
<style>
.thread-card {
max-width: var(--content-width);
}
.thread-card a {
color: inherit;
text-decoration: none;
}
.thread-card a:hover {
text-decoration: underline;
}
</style>

108
src/lib/modules/threads/ThreadList.svelte

@ -0,0 +1,108 @@ @@ -0,0 +1,108 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/applesauce-client.js';
import { onMount } from 'svelte';
import ThreadCard from './ThreadCard.svelte';
import type { NostrEvent } from '../../types/nostr.js';
let threads = $state<NostrEvent[]>([]);
let loading = $state(true);
let sortBy = $state<'newest' | 'active' | 'upvoted'>('newest');
let showOlder = $state(false);
$effect(() => {
loadThreads();
});
async function loadThreads() {
loading = true;
try {
const config = nostrClient.getConfig();
const since = showOlder
? undefined
: Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400;
const events = await nostrClient.fetchEvents(
[{ kinds: [11], since, limit: 50 }],
[...config.defaultRelays],
{ useCache: true, cacheResults: true }
);
threads = sortThreads(events);
} catch (error) {
console.error('Error loading threads:', error);
} finally {
loading = false;
}
}
function sortThreads(events: NostrEvent[]): NostrEvent[] {
switch (sortBy) {
case 'newest':
return [...events].sort((a, b) => b.created_at - a.created_at);
case 'active':
// Placeholder - would need to count comments
return [...events].sort((a, b) => b.created_at - a.created_at);
case 'upvoted':
// Placeholder - would need to count reactions
return [...events].sort((a, b) => b.created_at - a.created_at);
default:
return events;
}
}
function getTopics(): string[] {
const topicSet = new Set<string>();
for (const thread of threads) {
const topics = thread.tags.filter((t) => t[0] === 't').map((t) => t[1]);
topics.forEach((t) => topicSet.add(t));
}
return Array.from(topicSet).sort();
}
function getThreadsByTopic(topic: string | null): NostrEvent[] {
if (topic === null) {
return threads.filter((t) => !t.tags.some((tag) => tag[0] === 't'));
}
return threads.filter((t) => t.tags.some((tag) => tag[0] === 't' && tag[1] === topic));
}
</script>
<div class="thread-list">
<div class="controls mb-4 flex gap-4 items-center">
<label>
<input type="checkbox" bind:checked={showOlder} on:change={loadThreads} />
Show older threads
</label>
<select bind:value={sortBy} on:change={() => (threads = sortThreads(threads))}>
<option value="newest">Newest</option>
<option value="active">Most Active</option>
<option value="upvoted">Most Upvoted</option>
</select>
</div>
{#if loading}
<p>Loading threads...</p>
{:else}
<div>
<h2 class="text-xl font-bold mb-4">General</h2>
{#each getThreadsByTopic(null) as thread}
<ThreadCard {thread} />
{/each}
{#each getTopics() as topic}
<h2 class="text-xl font-bold mb-4 mt-8">{topic}</h2>
{#each getThreadsByTopic(topic) as thread}
<ThreadCard {thread} />
{/each}
{/each}
</div>
{/if}
</div>
<style>
.thread-list {
max-width: var(--content-width);
margin: 0 auto;
padding: 1rem;
}
</style>

30
src/lib/services/auth/activity-tracker.ts

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
/**
* Activity tracker - tracks last activity per pubkey
*/
import { eventStore } from '../nostr/event-store.js';
/**
* Get last activity timestamp for a pubkey
*/
export function getLastActivity(pubkey: string): number | undefined {
return eventStore.getLastActivity(pubkey);
}
/**
* Get activity status color
* Red: 168 hours (7 days)
* Yellow: 48 hours (2 days) but <168 hours
* Green: <48 hours
*/
export function getActivityStatus(pubkey: string): 'red' | 'yellow' | 'green' | null {
const lastActivity = getLastActivity(pubkey);
if (!lastActivity) return null;
const now = Math.floor(Date.now() / 1000);
const hoursSince = (now - lastActivity) / 3600;
if (hoursSince >= 168) return 'red';
if (hoursSince >= 48) return 'yellow';
return 'green';
}

63
src/lib/services/auth/anonymous-signer.ts

@ -0,0 +1,63 @@ @@ -0,0 +1,63 @@
/**
* Anonymous signer (generated keys, NIP-49 encrypted)
*/
import { generatePrivateKey } from '../security/key-management.js';
import { storeAnonymousKey, getAnonymousKey } from '../cache/anonymous-key-store.js';
import { getPublicKeyFromNsec } from './nsec-signer.js';
import { signEventWithNsec } from './nsec-signer.js';
import type { NostrEvent } from '../../types/nostr.js';
/**
* Generate and store anonymous key
*/
export async function generateAnonymousKey(password: string): Promise<{
pubkey: string;
nsec: string;
}> {
const nsec = generatePrivateKey();
const pubkey = getPublicKeyFromNsec(nsec);
// Store encrypted
await storeAnonymousKey(nsec, password, pubkey);
return { pubkey, nsec };
}
/**
* Get stored anonymous key
*/
export async function getStoredAnonymousKey(
pubkey: string,
password: string
): Promise<string | null> {
return getAnonymousKey(pubkey, password);
}
/**
* Sign event with anonymous key
*/
export async function signEventWithAnonymous(
event: Omit<NostrEvent, 'sig' | 'id'>,
pubkey: string,
password: string
): Promise<NostrEvent> {
const nsec = await getStoredAnonymousKey(pubkey, password);
if (!nsec) {
throw new Error('Anonymous key not found');
}
// For anonymous keys, we need the ncryptsec format
// This is simplified - in practice we'd store ncryptsec and decrypt it
// For now, assume we have the plain nsec after decryption
return signEventWithNsec(event, nsec, password);
}
/**
* Generate anonymous handle
*/
export function generateAnonymousHandle(pubkey: string): string {
// Use last 6 characters of pubkey for uniqueness
const suffix = pubkey.slice(-6);
return `Aitherite${suffix}`;
}

46
src/lib/services/auth/bunker-signer.ts

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
/**
* NIP-46 Bunker signer (remote signer)
*/
import type { NostrEvent } from '../../types/nostr.js';
export interface BunkerConnection {
bunkerUrl: string;
pubkey: string;
token?: string;
}
/**
* Connect to bunker signer
*/
export async function connectBunker(bunkerUri: string): Promise<BunkerConnection> {
// Parse bunker:// URI
// Format: bunker://<pubkey>@<relay>?token=<token>
const match = bunkerUri.match(/^bunker:\/\/([^@]+)@([^?]+)(?:\?token=([^&]+))?$/);
if (!match) {
throw new Error('Invalid bunker URI');
}
const [, pubkey, relay, token] = match;
return {
bunkerUrl: relay,
pubkey,
token
};
}
/**
* Sign event with bunker
*/
export async function signEventWithBunker(
event: Omit<NostrEvent, 'sig' | 'id'>,
connection: BunkerConnection
): Promise<NostrEvent> {
// Placeholder - would:
// 1. Send NIP-46 request to bunker
// 2. Wait for response
// 3. Return signed event
throw new Error('Bunker signing not yet implemented');
}

53
src/lib/services/auth/nip07-signer.ts

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
/**
* NIP-07 signer (browser extension)
*/
import type { NostrEvent } from '../../types/nostr.js';
export interface NIP07Signer {
getPublicKey(): Promise<string>;
signEvent(event: Omit<NostrEvent, 'sig' | 'id'>): Promise<NostrEvent>;
}
/**
* Check if NIP-07 is available
*/
export function isNIP07Available(): boolean {
return typeof window !== 'undefined' && 'nostr' in window;
}
/**
* Get NIP-07 signer
*/
export function getNIP07Signer(): NIP07Signer | null {
if (!isNIP07Available()) return null;
const nostr = (window as { nostr?: NIP07Signer }).nostr;
return nostr || null;
}
/**
* Sign event with NIP-07
*/
export async function signEventWithNIP07(
event: Omit<NostrEvent, 'sig' | 'id'>
): Promise<NostrEvent> {
const signer = getNIP07Signer();
if (!signer) {
throw new Error('NIP-07 not available');
}
return signer.signEvent(event);
}
/**
* Get public key with NIP-07
*/
export async function getPublicKeyWithNIP07(): Promise<string> {
const signer = getNIP07Signer();
if (!signer) {
throw new Error('NIP-07 not available');
}
return signer.getPublicKey();
}

41
src/lib/services/auth/nsec-signer.ts

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
/**
* Nsec signer (direct private key, NIP-49 encrypted)
*/
import { decryptPrivateKey } from '../security/key-management.js';
import type { NostrEvent } from '../../types/nostr.js';
/**
* Sign event with nsec (private key)
* This is a placeholder - full implementation requires:
* - secp256k1 cryptography library
* - Event ID computation (SHA256)
* - Signature computation
*/
export async function signEventWithNsec(
event: Omit<NostrEvent, 'sig' | 'id'>,
ncryptsec: string,
password: string
): Promise<NostrEvent> {
// Decrypt private key
const nsec = await decryptPrivateKey(ncryptsec, password);
// Placeholder - would compute event ID and signature
// For now, return event with placeholder sig/id
const signedEvent: NostrEvent = {
...event,
id: 'placeholder_id_' + Date.now(), // Would be SHA256 of serialized event
sig: 'placeholder_sig_' + Date.now() // Would be secp256k1 signature
};
return signedEvent;
}
/**
* Get public key from private key
*/
export function getPublicKeyFromNsec(nsec: string): string {
// Placeholder - would derive public key from private key using secp256k1
// For now, return placeholder
return 'placeholder_pubkey';
}

133
src/lib/services/auth/profile-fetcher.ts

@ -0,0 +1,133 @@ @@ -0,0 +1,133 @@
/**
* Profile fetcher (kind 0 events)
*/
import { nostrClient } from '../nostr/applesauce-client.js';
import { cacheProfile, getProfile, getProfiles } from '../cache/profile-cache.js';
import { config } from '../nostr/config.js';
import type { NostrEvent } from '../../types/nostr.js';
export interface ProfileData {
name?: string;
about?: string;
picture?: string;
website?: string[];
nip05?: string[];
lud16?: string[];
}
/**
* Parse profile from kind 0 event
*/
export function parseProfile(event: NostrEvent): ProfileData {
const profile: ProfileData = {};
// Try to parse from tags first (preferred)
const nameTag = event.tags.find((t) => t[0] === 'name');
if (nameTag && nameTag[1]) profile.name = nameTag[1];
const aboutTag = event.tags.find((t) => t[0] === 'about');
if (aboutTag && aboutTag[1]) profile.about = aboutTag[1];
const pictureTag = event.tags.find((t) => t[0] === 'picture');
if (pictureTag && pictureTag[1]) profile.picture = pictureTag[1];
// Multiple tags for website, nip05, lud16
profile.website = event.tags.filter((t) => t[0] === 'website').map((t) => t[1]).filter(Boolean);
profile.nip05 = event.tags.filter((t) => t[0] === 'nip05').map((t) => t[1]).filter(Boolean);
profile.lud16 = event.tags.filter((t) => t[0] === 'lud16').map((t) => t[1]).filter(Boolean);
// Fallback to JSON content if tags not found
if (!profile.name || !profile.about) {
try {
const json = JSON.parse(event.content);
if (json.name && !profile.name) profile.name = json.name;
if (json.about && !profile.about) profile.about = json.about;
if (json.picture && !profile.picture) profile.picture = json.picture;
if (json.website && profile.website.length === 0) {
profile.website = Array.isArray(json.website) ? json.website : [json.website];
}
if (json.nip05 && profile.nip05.length === 0) {
profile.nip05 = Array.isArray(json.nip05) ? json.nip05 : [json.nip05];
}
if (json.lud16 && profile.lud16.length === 0) {
profile.lud16 = Array.isArray(json.lud16) ? json.lud16 : [json.lud16];
}
} catch {
// Invalid JSON, ignore
}
}
return profile;
}
/**
* Fetch profile for a pubkey
*/
export async function fetchProfile(
pubkey: string,
relays?: string[]
): Promise<ProfileData | null> {
// Try cache first
const cached = await getProfile(pubkey);
if (cached) {
return parseProfile(cached.event);
}
// Fetch from relays
const relayList = relays || [
...config.defaultRelays,
...config.profileRelays
];
const events = await nostrClient.fetchEvents(
[{ kinds: [0], authors: [pubkey], limit: 1 }],
relayList,
{ useCache: true, cacheResults: true }
);
if (events.length === 0) return null;
const event = events[0];
await cacheProfile(event);
return parseProfile(event);
}
/**
* Fetch multiple profiles
*/
export async function fetchProfiles(
pubkeys: string[],
relays?: string[]
): Promise<Map<string, ProfileData>> {
const profiles = new Map<string, ProfileData>();
// Check cache first
const cached = await getProfiles(pubkeys);
for (const [pubkey, cachedProfile] of cached.entries()) {
profiles.set(pubkey, parseProfile(cachedProfile.event));
}
// Fetch missing profiles
const missing = pubkeys.filter((p) => !profiles.has(p));
if (missing.length === 0) return profiles;
const relayList = relays || [
...config.defaultRelays,
...config.profileRelays
];
const events = await nostrClient.fetchEvents(
[{ kinds: [0], authors: missing, limit: 1 }],
relayList,
{ useCache: true, cacheResults: true }
);
for (const event of events) {
await cacheProfile(event);
profiles.set(event.pubkey, parseProfile(event));
}
return profiles;
}

81
src/lib/services/auth/relay-list-fetcher.ts

@ -0,0 +1,81 @@ @@ -0,0 +1,81 @@
/**
* Relay list fetcher (kind 10002 and 10432)
*/
import { nostrClient } from '../nostr/applesauce-client.js';
import { config } from '../nostr/config.js';
import type { NostrEvent } from '../../types/nostr.js';
export interface RelayInfo {
url: string;
read: boolean;
write: boolean;
}
/**
* Parse relay list from event
*/
export function parseRelayList(event: NostrEvent): RelayInfo[] {
const relays: RelayInfo[] = [];
for (const tag of event.tags) {
if (tag[0] === 'r' && tag[1]) {
const url = tag[1];
const markers = tag.slice(2);
const read = markers.length === 0 || markers.includes('read') || !markers.includes('write');
const write = markers.length === 0 || markers.includes('write') || !markers.includes('read');
relays.push({ url, read, write });
}
}
return relays;
}
/**
* Fetch relay lists for a pubkey (kind 10002 and 10432)
*/
export async function fetchRelayLists(
pubkey: string,
relays?: string[]
): Promise<{
inbox: string[];
outbox: string[];
}> {
const relayList = relays || [
...config.defaultRelays,
...config.profileRelays
];
// Fetch both kind 10002 and 10432
const events = await nostrClient.fetchEvents(
[
{ kinds: [10002], authors: [pubkey], limit: 1 },
{ kinds: [10432], authors: [pubkey], limit: 1 }
],
relayList,
{ useCache: true, cacheResults: true }
);
const inbox: string[] = [];
const outbox: string[] = [];
for (const event of events) {
const relayInfos = parseRelayList(event);
for (const info of relayInfos) {
if (info.read && !inbox.includes(info.url)) {
inbox.push(info.url);
}
if (info.write && !outbox.includes(info.url)) {
outbox.push(info.url);
}
}
}
// Deduplicate
return {
inbox: [...new Set(inbox)],
outbox: [...new Set(outbox)]
};
}

96
src/lib/services/auth/session-manager.ts

@ -0,0 +1,96 @@ @@ -0,0 +1,96 @@
/**
* Session manager for active user sessions
*/
import type { NostrEvent } from '../../types/nostr.js';
export type AuthMethod = 'nip07' | 'nsec' | 'bunker' | 'anonymous';
export interface UserSession {
pubkey: string;
method: AuthMethod;
signer: (event: Omit<NostrEvent, 'sig' | 'id'>) => Promise<NostrEvent>;
createdAt: number;
}
class SessionManager {
private currentSession: UserSession | null = null;
/**
* Set current session
*/
setSession(session: UserSession): void {
this.currentSession = session;
// Store in localStorage for persistence
if (typeof window !== 'undefined') {
localStorage.setItem('aitherboard_session', JSON.stringify({
pubkey: session.pubkey,
method: session.method,
createdAt: session.createdAt
}));
}
}
/**
* Get current session
*/
getSession(): UserSession | null {
return this.currentSession;
}
/**
* Check if user is logged in
*/
isLoggedIn(): boolean {
return this.currentSession !== null;
}
/**
* Get current pubkey
*/
getCurrentPubkey(): string | null {
return this.currentSession?.pubkey || null;
}
/**
* Sign event with current session
*/
async signEvent(event: Omit<NostrEvent, 'sig' | 'id'>): Promise<NostrEvent> {
if (!this.currentSession) {
throw new Error('No active session');
}
return this.currentSession.signer(event);
}
/**
* Clear session
*/
clearSession(): void {
this.currentSession = null;
if (typeof window !== 'undefined') {
localStorage.removeItem('aitherboard_session');
}
}
/**
* Restore session from localStorage
*/
async restoreSession(): Promise<boolean> {
if (typeof window === 'undefined') return false;
const stored = localStorage.getItem('aitherboard_session');
if (!stored) return false;
try {
const data = JSON.parse(stored);
// Session restoration would require re-initializing the signer
// This is simplified - full implementation would restore the signer
return false;
} catch {
return false;
}
}
}
export const sessionManager = new SessionManager();

16
src/lib/services/auth/user-preferences-fetcher.ts

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
/**
* User preferences fetcher
* Placeholder for future user preference events
*/
export interface UserPreferences {
// Placeholder - would be defined based on preference event kinds
}
/**
* Fetch user preferences
*/
export async function fetchUserPreferences(pubkey: string): Promise<UserPreferences | null> {
// Placeholder - would fetch preference events
return null;
}

50
src/lib/services/auth/user-status-fetcher.ts

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
/**
* User status fetcher (kind 30315, NIP-38)
*/
import { nostrClient } from '../nostr/applesauce-client.js';
import { config } from '../nostr/config.js';
import type { NostrEvent } from '../../types/nostr.js';
/**
* Parse user status from kind 30315 event
*/
export function parseUserStatus(event: NostrEvent): string | null {
if (event.kind !== 30315) return null;
// Check for d tag with value "general"
const dTag = event.tags.find((t) => t[0] === 'd' && t[1] === 'general');
if (!dTag) return null;
return event.content || null;
}
/**
* Fetch user status for a pubkey
*/
export async function fetchUserStatus(
pubkey: string,
relays?: string[]
): Promise<string | null> {
const relayList = relays || [
...config.defaultRelays,
...config.profileRelays
];
const events = await nostrClient.fetchEvents(
[
{
kinds: [30315],
authors: [pubkey],
'#d': ['general'],
limit: 1
}
],
relayList,
{ useCache: true, cacheResults: true }
);
if (events.length === 0) return null;
return parseUserStatus(events[0]);
}

72
src/lib/services/cache/anonymous-key-store.ts vendored

@ -0,0 +1,72 @@ @@ -0,0 +1,72 @@
/**
* Anonymous key storage (NIP-49 encrypted)
*/
import { getDB } from './indexeddb-store.js';
import { encryptPrivateKey, decryptPrivateKey } from '../security/key-management.js';
export interface StoredAnonymousKey {
id: string;
ncryptsec: string; // NIP-49 encrypted key
pubkey: string; // Public key for identification
created_at: number;
}
/**
* Store an anonymous key (encrypted)
*/
export async function storeAnonymousKey(
nsec: string,
password: string,
pubkey: string
): Promise<void> {
const ncryptsec = await encryptPrivateKey(nsec, password);
const db = await getDB();
const stored: StoredAnonymousKey = {
id: pubkey,
ncryptsec,
pubkey,
created_at: Date.now()
};
await db.put('keys', stored);
}
/**
* Retrieve and decrypt an anonymous key
*/
export async function getAnonymousKey(
pubkey: string,
password: string
): Promise<string | null> {
const db = await getDB();
const stored = await db.get('keys', pubkey);
if (!stored) return null;
const key = stored as StoredAnonymousKey;
return decryptPrivateKey(key.ncryptsec, password);
}
/**
* List all stored anonymous keys (pubkeys only)
*/
export async function listAnonymousKeys(): Promise<string[]> {
const db = await getDB();
const keys: string[] = [];
const tx = db.transaction('keys', 'readonly');
for await (const cursor of tx.store.iterate()) {
const key = cursor.value as StoredAnonymousKey;
keys.push(key.pubkey);
}
await tx.done;
return keys;
}
/**
* Delete an anonymous key
*/
export async function deleteAnonymousKey(pubkey: string): Promise<void> {
const db = await getDB();
await db.delete('keys', pubkey);
}

99
src/lib/services/cache/event-cache.ts vendored

@ -0,0 +1,99 @@ @@ -0,0 +1,99 @@
/**
* Event caching with IndexedDB
*/
import { getDB } from './indexeddb-store.js';
import type { NostrEvent } from '../../types/nostr.js';
export interface CachedEvent extends NostrEvent {
cached_at: number;
}
/**
* Store an event in cache
*/
export async function cacheEvent(event: NostrEvent): Promise<void> {
const db = await getDB();
const cached: CachedEvent = {
...event,
cached_at: Date.now()
};
await db.put('events', cached);
}
/**
* Store multiple events in cache
*/
export async function cacheEvents(events: NostrEvent[]): Promise<void> {
const db = await getDB();
const tx = db.transaction('events', 'readwrite');
for (const event of events) {
const cached: CachedEvent = {
...event,
cached_at: Date.now()
};
await tx.store.put(cached);
}
await tx.done;
}
/**
* Get event by ID from cache
*/
export async function getEvent(id: string): Promise<CachedEvent | undefined> {
const db = await getDB();
return db.get('events', id);
}
/**
* Get events by kind
*/
export async function getEventsByKind(kind: number, limit?: number): Promise<CachedEvent[]> {
const db = await getDB();
const index = db.transaction('events').store.index('kind');
const events: CachedEvent[] = [];
let count = 0;
for await (const cursor of index.iterate(kind)) {
if (limit && count >= limit) break;
events.push(cursor.value);
count++;
}
return events.sort((a, b) => b.created_at - a.created_at);
}
/**
* Get events by pubkey
*/
export async function getEventsByPubkey(pubkey: string, limit?: number): Promise<CachedEvent[]> {
const db = await getDB();
const index = db.transaction('events').store.index('pubkey');
const events: CachedEvent[] = [];
let count = 0;
for await (const cursor of index.iterate(pubkey)) {
if (limit && count >= limit) break;
events.push(cursor.value);
count++;
}
return events.sort((a, b) => b.created_at - a.created_at);
}
/**
* Clear old events (older than specified timestamp)
*/
export async function clearOldEvents(olderThan: number): Promise<void> {
const db = await getDB();
const tx = db.transaction('events', 'readwrite');
const index = tx.store.index('created_at');
for await (const cursor of index.iterate()) {
if (cursor.value.created_at < olderThan) {
await cursor.delete();
}
}
await tx.done;
}

76
src/lib/services/cache/indexeddb-store.ts vendored

@ -0,0 +1,76 @@ @@ -0,0 +1,76 @@
/**
* Base IndexedDB store operations
*/
import { openDB, type IDBPDatabase } from 'idb';
const DB_NAME = 'aitherboard';
const DB_VERSION = 1;
export interface DatabaseSchema {
events: {
key: string; // event id
value: unknown;
indexes: { kind: number; pubkey: string; created_at: number };
};
profiles: {
key: string; // pubkey
value: unknown;
};
keys: {
key: string; // key id
value: unknown;
};
search: {
key: string;
value: unknown;
};
}
let dbInstance: IDBPDatabase<DatabaseSchema> | null = null;
/**
* Get or create database instance
*/
export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
if (dbInstance) return dbInstance;
dbInstance = await openDB<DatabaseSchema>(DB_NAME, DB_VERSION, {
upgrade(db) {
// Events store
if (!db.objectStoreNames.contains('events')) {
const eventStore = db.createObjectStore('events', { keyPath: 'id' });
eventStore.createIndex('kind', 'kind', { unique: false });
eventStore.createIndex('pubkey', 'pubkey', { unique: false });
eventStore.createIndex('created_at', 'created_at', { unique: false });
}
// Profiles store
if (!db.objectStoreNames.contains('profiles')) {
db.createObjectStore('profiles', { keyPath: 'pubkey' });
}
// Keys store
if (!db.objectStoreNames.contains('keys')) {
db.createObjectStore('keys', { keyPath: 'id' });
}
// Search index store
if (!db.objectStoreNames.contains('search')) {
db.createObjectStore('search', { keyPath: 'id' });
}
}
});
return dbInstance;
}
/**
* Close database connection
*/
export async function closeDB(): Promise<void> {
if (dbInstance) {
dbInstance.close();
dbInstance = null;
}
}

53
src/lib/services/cache/profile-cache.ts vendored

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
/**
* Profile caching (kind 0 events)
*/
import { getDB } from './indexeddb-store.js';
import type { NostrEvent } from '../../types/nostr.js';
export interface CachedProfile {
pubkey: string;
event: NostrEvent;
cached_at: number;
}
/**
* Store a profile in cache
*/
export async function cacheProfile(event: NostrEvent): Promise<void> {
if (event.kind !== 0) throw new Error('Not a profile event');
const db = await getDB();
const cached: CachedProfile = {
pubkey: event.pubkey,
event,
cached_at: Date.now()
};
await db.put('profiles', cached);
}
/**
* Get profile by pubkey from cache
*/
export async function getProfile(pubkey: string): Promise<CachedProfile | undefined> {
const db = await getDB();
return db.get('profiles', pubkey);
}
/**
* Get multiple profiles
*/
export async function getProfiles(pubkeys: string[]): Promise<Map<string, CachedProfile>> {
const db = await getDB();
const profiles = new Map<string, CachedProfile>();
const tx = db.transaction('profiles', 'readonly');
for (const pubkey of pubkeys) {
const profile = await tx.store.get(pubkey);
if (profile) {
profiles.set(pubkey, profile);
}
}
await tx.done;
return profiles;
}

46
src/lib/services/cache/search-index.ts vendored

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
/**
* Full-text search index (deferred implementation)
*/
import { getDB } from './indexeddb-store.js';
/**
* Index event content for search
*/
export async function indexEvent(eventId: string, content: string): Promise<void> {
// Placeholder - full implementation would:
// 1. Tokenize content
// 2. Create inverted index
// 3. Store in IndexedDB
const db = await getDB();
await db.put('search', {
id: eventId,
content: content.toLowerCase()
});
}
/**
* Search events by query
*/
export async function searchEvents(query: string, limit: number = 50): Promise<string[]> {
// Placeholder - full implementation would:
// 1. Tokenize query
// 2. Look up in inverted index
// 3. Rank results
// 4. Return event IDs
const db = await getDB();
const results: string[] = [];
const lowerQuery = query.toLowerCase();
const tx = db.transaction('search', 'readonly');
for await (const cursor of tx.store.iterate()) {
if (results.length >= limit) break;
const content = (cursor.value as { content: string }).content;
if (content.includes(lowerQuery)) {
results.push(cursor.key as string);
}
}
await tx.done;
return results;
}

150
src/lib/services/nostr/applesauce-client.ts

@ -0,0 +1,150 @@ @@ -0,0 +1,150 @@
/**
* Applesauce-core client wrapper
* Main interface for Nostr operations
*/
import { initializeRelayPool, relayPool } from './relay-pool.js';
import { subscriptionManager } from './subscription-manager.js';
import { eventStore } from './event-store.js';
import { config } from './config.js';
import type { NostrEvent } from '../../types/nostr.js';
export interface PublishOptions {
relays?: string[];
skipRelayValidation?: boolean;
}
class ApplesauceClient {
private initialized = false;
/**
* Initialize the client
*/
async initialize(): Promise<void> {
if (this.initialized) return;
await initializeRelayPool();
this.initialized = true;
}
/**
* Publish an event to relays
*/
async publish(event: NostrEvent, options: PublishOptions = {}): Promise<{
success: string[];
failed: Array<{ relay: string; error: string }>;
}> {
const relays = options.relays || relayPool.getConnectedRelays();
const message = JSON.stringify(['EVENT', event]);
const results = {
success: [] as string[],
failed: [] as Array<{ relay: string; error: string }>
};
for (const relay of relays) {
try {
const sent = relayPool.send(relay, message);
if (sent) {
results.success.push(relay);
} else {
results.failed.push({ relay, error: 'Not connected' });
}
} catch (error) {
results.failed.push({
relay,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
// Store in cache
if (results.success.length > 0) {
await eventStore.storeEvent(event);
}
return results;
}
/**
* Subscribe to events
*/
subscribe(
filters: Array<{
ids?: string[];
authors?: string[];
kinds?: number[];
'#e'?: string[];
'#p'?: string[];
since?: number;
until?: number;
limit?: number;
}>,
relays: string[],
onEvent: (event: NostrEvent, relay: string) => void,
onEose?: (relay: string) => void
): string {
const subId = subscriptionManager.generateSubId();
subscriptionManager.subscribe(subId, relays, filters, onEvent, onEose);
return subId;
}
/**
* Unsubscribe
*/
unsubscribe(subId: string): void {
subscriptionManager.unsubscribe(subId);
}
/**
* Fetch events
*/
async fetchEvents(
filters: Array<{
ids?: string[];
authors?: string[];
kinds?: number[];
'#e'?: string[];
'#p'?: string[];
since?: number;
until?: number;
limit?: number;
}>,
relays: string[],
options?: { useCache?: boolean; cacheResults?: boolean }
): Promise<NostrEvent[]> {
return eventStore.fetchEvents(filters, relays, options || {});
}
/**
* Get event by ID
*/
async getEventById(id: string, relays: string[]): Promise<NostrEvent | null> {
return eventStore.getEventById(id, relays);
}
/**
* Get relay pool
*/
getRelayPool() {
return relayPool;
}
/**
* Get config
*/
getConfig() {
return config;
}
/**
* Close all connections
*/
close(): void {
subscriptionManager.closeAll();
relayPool.closeAll();
this.initialized = false;
}
}
export const nostrClient = new ApplesauceClient();

160
src/lib/services/nostr/auth-handler.ts

@ -0,0 +1,160 @@ @@ -0,0 +1,160 @@
/**
* Unified authentication handler
*/
import { getNIP07Signer, signEventWithNIP07, getPublicKeyWithNIP07 } from '../auth/nip07-signer.js';
import { signEventWithNsec } from '../auth/nsec-signer.js';
import { signEventWithBunker, connectBunker } from '../auth/bunker-signer.js';
import {
signEventWithAnonymous,
generateAnonymousKey
} from '../auth/anonymous-signer.js';
import { sessionManager, type AuthMethod } from '../auth/session-manager.js';
import { fetchRelayLists } from '../auth/relay-list-fetcher.js';
import { eventStore } from './event-store.js';
import { nostrClient } from './applesauce-client.js';
import type { NostrEvent } from '../../types/nostr.js';
/**
* Authenticate with NIP-07
*/
export async function authenticateWithNIP07(): Promise<string> {
const pubkey = await getPublicKeyWithNIP07();
sessionManager.setSession({
pubkey,
method: 'nip07',
signer: signEventWithNIP07,
createdAt: Date.now()
});
// Fetch user relay lists and mute list
await loadUserPreferences(pubkey);
return pubkey;
}
/**
* Authenticate with nsec
*/
export async function authenticateWithNsec(
ncryptsec: string,
password: string
): Promise<string> {
// Decrypt and derive pubkey
// This is simplified - would need full implementation
const pubkey = 'placeholder_pubkey';
sessionManager.setSession({
pubkey,
method: 'nsec',
signer: async (event) => signEventWithNsec(event, ncryptsec, password),
createdAt: Date.now()
});
await loadUserPreferences(pubkey);
return pubkey;
}
/**
* Authenticate with bunker
*/
export async function authenticateWithBunker(bunkerUri: string): Promise<string> {
const connection = await connectBunker(bunkerUri);
sessionManager.setSession({
pubkey: connection.pubkey,
method: 'bunker',
signer: async (event) => signEventWithBunker(event, connection),
createdAt: Date.now()
});
await loadUserPreferences(connection.pubkey);
return connection.pubkey;
}
/**
* Authenticate as anonymous
*/
export async function authenticateAsAnonymous(password: string): Promise<string> {
const { pubkey, nsec } = await generateAnonymousKey(password);
// Store the key for later use
// In practice, we'd need to store the ncryptsec and decrypt when needed
// For now, this is simplified
sessionManager.setSession({
pubkey,
method: 'anonymous',
signer: async (event) => {
// Simplified - would decrypt and sign
return signEventWithAnonymous(event, pubkey, password);
},
createdAt: Date.now()
});
return pubkey;
}
/**
* Load user preferences (relay lists, mute list, blocked relays)
*/
async function loadUserPreferences(pubkey: string): Promise<void> {
// Fetch relay lists
const { inbox, outbox } = await fetchRelayLists(pubkey);
// Relay lists would be used by relay selection logic
// Fetch mute list (kind 10000)
const muteEvents = await nostrClient.fetchEvents(
[{ kinds: [10000], authors: [pubkey], limit: 1 }],
[...nostrClient.getConfig().defaultRelays, ...nostrClient.getConfig().profileRelays],
{ useCache: true, cacheResults: true }
);
if (muteEvents.length > 0) {
const mutedPubkeys = muteEvents[0].tags
.filter((t) => t[0] === 'p')
.map((t) => t[1])
.filter(Boolean) as string[];
eventStore.setMuteList(mutedPubkeys);
}
// Fetch blocked relays (kind 10006)
const blockedRelayEvents = await nostrClient.fetchEvents(
[{ kinds: [10006], authors: [pubkey], limit: 1 }],
[...nostrClient.getConfig().defaultRelays, ...nostrClient.getConfig().profileRelays],
{ useCache: true, cacheResults: true }
);
if (blockedRelayEvents.length > 0) {
const blockedRelays = blockedRelayEvents[0].tags
.filter((t) => t[0] === 'relay')
.map((t) => t[1])
.filter(Boolean) as string[];
eventStore.setBlockedRelays(blockedRelays);
}
}
/**
* Sign and publish event
*/
export async function signAndPublish(
event: Omit<NostrEvent, 'sig' | 'id'>,
relays?: string[]
): Promise<{
success: string[];
failed: Array<{ relay: string; error: string }>;
}> {
const signed = await sessionManager.signEvent(event);
return nostrClient.publish(signed, { relays });
}
/**
* Logout
*/
export function logout(): void {
sessionManager.clearSession();
eventStore.setMuteList([]);
eventStore.setBlockedRelays([]);
}

59
src/lib/services/nostr/config.ts

@ -0,0 +1,59 @@ @@ -0,0 +1,59 @@
/**
* Configuration for Nostr services
* Handles environment variables and defaults
*/
const DEFAULT_RELAYS = [
'wss://theforest.nostr1.com',
'wss://nostr21.com',
'wss://nostr.land',
'wss://nostr.wine',
'wss://nostr.sovbit.host'
];
const PROFILE_RELAYS = [
'wss://relay.damus.io',
'wss://aggr.nostr.land',
'wss://profiles.nostr1.com'
];
export interface NostrConfig {
defaultRelays: string[];
profileRelays: string[];
zapThreshold: number;
threadTimeoutDays: number;
pwaEnabled: boolean;
}
function parseRelays(envVar: string | undefined, fallback: string[]): string[] {
if (!envVar) return fallback;
const relays = envVar
.split(',')
.map((r) => r.trim())
.filter((r) => r.length > 0);
return relays.length > 0 ? relays : fallback;
}
function parseIntEnv(envVar: string | undefined, fallback: number, min: number = 0): number {
if (!envVar) return fallback;
const parsed = parseInt(envVar, 10);
if (isNaN(parsed) || parsed < min) return fallback;
return parsed;
}
function parseBoolEnv(envVar: string | undefined, fallback: boolean): boolean {
if (!envVar) return fallback;
return envVar.toLowerCase() === 'true' || envVar === '1';
}
export function getConfig(): NostrConfig {
return {
defaultRelays: parseRelays(import.meta.env.VITE_DEFAULT_RELAYS, DEFAULT_RELAYS),
profileRelays: PROFILE_RELAYS,
zapThreshold: parseIntEnv(import.meta.env.VITE_ZAP_THRESHOLD, 1, 0),
threadTimeoutDays: parseIntEnv(import.meta.env.VITE_THREAD_TIMEOUT_DAYS, 30),
pwaEnabled: parseBoolEnv(import.meta.env.VITE_PWA_ENABLED, true)
};
}
export const config = getConfig();

214
src/lib/services/nostr/event-store.ts

@ -0,0 +1,214 @@ @@ -0,0 +1,214 @@
/**
* Event store with IndexedDB caching and filtering
*/
import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey } from '../cache/event-cache.js';
import { subscriptionManager, type NostrFilter } from './subscription-manager.js';
import { relayPool } from './relay-pool.js';
import type { NostrEvent } from '../../types/nostr.js';
export interface EventStoreOptions {
muteList?: string[]; // Pubkeys to mute (from kind 10000)
blockedRelays?: string[]; // Relays to block (from kind 10006)
}
class EventStore {
private muteList: Set<string> = new Set();
private blockedRelays: Set<string> = new Set();
private activityTracker: Map<string, number> = new Map(); // pubkey -> last activity timestamp
/**
* Update mute list
*/
setMuteList(pubkeys: string[]): void {
this.muteList = new Set(pubkeys);
}
/**
* Update blocked relays
*/
setBlockedRelays(relays: string[]): void {
this.blockedRelays = new Set(relays);
}
/**
* Filter out muted events
*/
private isMuted(event: NostrEvent): boolean {
return this.muteList.has(event.pubkey);
}
/**
* Filter out blocked relays
*/
private filterBlockedRelays(relays: string[]): string[] {
return relays.filter((r) => !this.blockedRelays.has(r));
}
/**
* Track activity for a pubkey
*/
private trackActivity(pubkey: string, timestamp: number): void {
const current = this.activityTracker.get(pubkey) || 0;
if (timestamp > current) {
this.activityTracker.set(pubkey, timestamp);
}
}
/**
* Get last activity timestamp for a pubkey
*/
getLastActivity(pubkey: string): number | undefined {
return this.activityTracker.get(pubkey);
}
/**
* Check if event should be hidden (content filtering)
*/
private shouldHideEvent(event: NostrEvent): boolean {
// Check for content-warning or sensitive tags
const hasContentWarning = event.tags.some((t) => t[0] === 'content-warning' || t[0] === 'sensitive');
if (hasContentWarning) return true;
// Check for #NSFW in content or tags
const content = event.content.toLowerCase();
const hasNSFW = content.includes('#nsfw') || event.tags.some((t) => t[1]?.toLowerCase() === 'nsfw');
if (hasNSFW) return true;
return false;
}
/**
* Fetch events with filters
*/
async fetchEvents(
filters: NostrFilter[],
relays: string[],
options: { useCache?: boolean; cacheResults?: boolean } = {}
): Promise<NostrEvent[]> {
const { useCache = true, cacheResults = true } = options;
// Filter out blocked relays
const filteredRelays = this.filterBlockedRelays(relays);
// Try cache first if enabled
if (useCache) {
// Simple cache lookup - could be improved
const cachedEvents: NostrEvent[] = [];
for (const filter of filters) {
if (filter.kinds && filter.kinds.length === 1) {
const events = await getEventsByKind(filter.kinds[0], filter.limit);
cachedEvents.push(...events);
}
if (filter.authors && filter.authors.length === 1) {
const events = await getEventsByPubkey(filter.authors[0], filter.limit);
cachedEvents.push(...events);
}
}
if (cachedEvents.length > 0) {
// Return cached events immediately (progressive loading)
// Continue fetching fresh data in background
this.fetchEventsFromRelays(filters, filteredRelays, { cacheResults });
return this.filterEvents(cachedEvents);
}
}
// Fetch from relays
return this.fetchEventsFromRelays(filters, filteredRelays, { cacheResults });
}
/**
* Fetch events from relays
*/
private async fetchEventsFromRelays(
filters: NostrFilter[],
relays: string[],
options: { cacheResults: boolean }
): Promise<NostrEvent[]> {
return new Promise((resolve) => {
const events: NostrEvent[] = new Map();
const subId = subscriptionManager.generateSubId();
const relayCount = new Set<string>();
const onEvent = (event: NostrEvent, relay: string) => {
// Skip muted events
if (this.isMuted(event)) return;
// Skip hidden events
if (this.shouldHideEvent(event)) return;
// Track activity
this.trackActivity(event.pubkey, event.created_at);
// Deduplicate by event ID
events.set(event.id, event);
relayCount.add(relay);
};
const onEose = (relay: string) => {
relayCount.add(relay);
// Wait a bit for all relays to respond
setTimeout(() => {
if (relayCount.size >= Math.min(relays.length, 3)) {
// Got responses from enough relays
const eventArray = Array.from(events.values());
if (options.cacheResults) {
cacheEvents(eventArray);
}
subscriptionManager.unsubscribe(subId);
resolve(this.filterEvents(eventArray));
}
}, 1000);
};
subscriptionManager.subscribe(subId, relays, filters, onEvent, onEose);
// Timeout after 10 seconds
setTimeout(() => {
subscriptionManager.unsubscribe(subId);
const eventArray = Array.from(events.values());
if (options.cacheResults) {
cacheEvents(eventArray);
}
resolve(this.filterEvents(eventArray));
}, 10000);
});
}
/**
* Filter events (remove muted, hidden, etc.)
*/
private filterEvents(events: NostrEvent[]): NostrEvent[] {
return events.filter((event) => {
if (this.isMuted(event)) return false;
if (this.shouldHideEvent(event)) return false;
return true;
});
}
/**
* Get event by ID (from cache or fetch)
*/
async getEventById(id: string, relays: string[]): Promise<NostrEvent | null> {
// Try cache first
const cached = await getEvent(id);
if (cached) return cached;
// Fetch from relays
const filters: NostrFilter[] = [{ ids: [id] }];
const events = await this.fetchEvents(filters, relays, { useCache: false });
return events[0] || null;
}
/**
* Store event in cache
*/
async storeEvent(event: NostrEvent): Promise<void> {
if (this.isMuted(event) || this.shouldHideEvent(event)) return;
this.trackActivity(event.pubkey, event.created_at);
await cacheEvent(event);
}
}
export const eventStore = new EventStore();

35
src/lib/services/nostr/event-utils.ts

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
/**
* Event utilities for creating and signing events
*/
import type { NostrEvent } from '../../types/nostr.js';
/**
* Create event ID (SHA256 of serialized event)
* This is a placeholder - full implementation requires crypto
*/
export function createEventId(event: Omit<NostrEvent, 'id' | 'sig'>): string {
// Placeholder - would compute SHA256
const serialized = JSON.stringify([
0,
event.pubkey,
event.created_at,
event.kind,
event.tags,
event.content
]);
// In production, use: crypto.subtle.digest('SHA-256', new TextEncoder().encode(serialized))
return 'placeholder_id_' + Date.now();
}
/**
* Sign event (placeholder)
*/
export async function signEvent(
event: Omit<NostrEvent, 'id' | 'sig'>
): Promise<NostrEvent> {
const id = createEventId(event);
// Placeholder signature
const sig = 'placeholder_sig_' + Date.now();
return { ...event, id, sig };
}

214
src/lib/services/nostr/relay-pool.ts

@ -0,0 +1,214 @@ @@ -0,0 +1,214 @@
/**
* Relay pool management
* Manages WebSocket connections to Nostr relays
*/
import { config } from './config.js';
export interface RelayStatus {
url: string;
connected: boolean;
latency?: number;
lastError?: string;
lastConnected?: number;
}
export type RelayStatusCallback = (status: RelayStatus) => void;
class RelayPool {
private relays: Map<string, WebSocket | null> = new Map();
private status: Map<string, RelayStatus> = new Map();
private statusCallbacks: Set<RelayStatusCallback> = new Set();
private reconnectTimeouts: Map<string, NodeJS.Timeout> = new Map();
/**
* Add relay to pool
*/
async addRelay(url: string): Promise<void> {
if (this.relays.has(url)) return;
this.relays.set(url, null);
this.updateStatus(url, { connected: false });
await this.connect(url);
}
/**
* Remove relay from pool
*/
removeRelay(url: string): void {
const ws = this.relays.get(url);
if (ws) {
ws.close();
}
this.relays.delete(url);
this.status.delete(url);
const timeout = this.reconnectTimeouts.get(url);
if (timeout) {
clearTimeout(timeout);
this.reconnectTimeouts.delete(url);
}
}
/**
* Connect to a relay
*/
private async connect(url: string): Promise<void> {
try {
const ws = new WebSocket(url);
const startTime = Date.now();
ws.onopen = () => {
const latency = Date.now() - startTime;
this.relays.set(url, ws);
this.updateStatus(url, {
connected: true,
latency,
lastConnected: Date.now()
});
};
ws.onerror = (error) => {
this.updateStatus(url, {
connected: false,
lastError: error.message || 'Connection error'
});
this.scheduleReconnect(url);
};
ws.onclose = () => {
this.relays.set(url, null);
this.updateStatus(url, { connected: false });
this.scheduleReconnect(url);
};
// Store WebSocket for message sending
this.relays.set(url, ws);
} catch (error) {
this.updateStatus(url, {
connected: false,
lastError: error instanceof Error ? error.message : 'Unknown error'
});
this.scheduleReconnect(url);
}
}
/**
* Schedule reconnection attempt
*/
private scheduleReconnect(url: string): void {
const existing = this.reconnectTimeouts.get(url);
if (existing) clearTimeout(existing);
const timeout = setTimeout(() => {
this.reconnectTimeouts.delete(url);
this.connect(url);
}, 5000); // 5 second delay
this.reconnectTimeouts.set(url, timeout);
}
/**
* Update relay status and notify callbacks
*/
private updateStatus(url: string, updates: Partial<RelayStatus>): void {
const current = this.status.get(url) || { url, connected: false };
const updated = { ...current, ...updates };
this.status.set(url, updated);
// Notify callbacks
this.statusCallbacks.forEach((cb) => cb(updated));
}
/**
* Get WebSocket for a relay
*/
getRelay(url: string): WebSocket | null {
return this.relays.get(url) || null;
}
/**
* Get all connected relays
*/
getConnectedRelays(): string[] {
return Array.from(this.relays.entries())
.filter(([, ws]) => ws && ws.readyState === WebSocket.OPEN)
.map(([url]) => url);
}
/**
* Get relay status
*/
getStatus(url: string): RelayStatus | undefined {
return this.status.get(url);
}
/**
* Get all relay statuses
*/
getAllStatuses(): RelayStatus[] {
return Array.from(this.status.values());
}
/**
* Subscribe to status updates
*/
onStatusUpdate(callback: RelayStatusCallback): () => void {
this.statusCallbacks.add(callback);
return () => this.statusCallbacks.delete(callback);
}
/**
* Send message to relay
*/
send(url: string, message: string): boolean {
const ws = this.relays.get(url);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(message);
return true;
}
return false;
}
/**
* Send message to all connected relays
*/
broadcast(message: string): string[] {
const sent: string[] = [];
for (const [url, ws] of this.relays.entries()) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(message);
sent.push(url);
}
}
return sent;
}
/**
* Close all connections
*/
closeAll(): void {
for (const [url, ws] of this.relays.entries()) {
if (ws) {
ws.close();
}
const timeout = this.reconnectTimeouts.get(url);
if (timeout) {
clearTimeout(timeout);
this.reconnectTimeouts.delete(url);
}
}
this.relays.clear();
this.status.clear();
}
}
export const relayPool = new RelayPool();
// Initialize with default relays
export async function initializeRelayPool(): Promise<void> {
for (const url of config.defaultRelays) {
await relayPool.addRelay(url);
}
}

157
src/lib/services/nostr/subscription-manager.ts

@ -0,0 +1,157 @@ @@ -0,0 +1,157 @@
/**
* Subscription manager for Nostr subscriptions
*/
import { relayPool } from './relay-pool.js';
import type { NostrEvent } from '../../types/nostr.js';
export interface NostrFilter {
ids?: string[];
authors?: string[];
kinds?: number[];
'#e'?: string[];
'#p'?: string[];
since?: number;
until?: number;
limit?: number;
}
export type EventCallback = (event: NostrEvent, relay: string) => void;
export type EoseCallback = (relay: string) => void;
class SubscriptionManager {
private subscriptions: Map<string, Subscription> = new Map();
private nextSubId = 1;
/**
* Create a new subscription
*/
subscribe(
subId: string,
relays: string[],
filters: NostrFilter[],
onEvent: EventCallback,
onEose?: EoseCallback
): void {
// Close existing subscription if any
this.unsubscribe(subId);
const subscription: Subscription = {
id: subId,
relays,
filters,
onEvent,
onEose,
messageHandlers: new Map()
};
// Set up message handlers for each relay
for (const relayUrl of relays) {
const ws = relayPool.getRelay(relayUrl);
if (!ws) continue;
const handler = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
if (Array.isArray(data)) {
const [type, ...rest] = data;
if (type === 'EVENT' && rest[0] === subId) {
const event = rest[1] as NostrEvent;
if (this.matchesFilters(event, filters)) {
onEvent(event, relayUrl);
}
} else if (type === 'EOSE' && rest[0] === subId) {
onEose?.(relayUrl);
}
}
} catch (error) {
console.error('Error parsing relay message:', error);
}
};
ws.addEventListener('message', handler);
subscription.messageHandlers.set(relayUrl, handler);
// Send subscription request
const message = JSON.stringify(['REQ', subId, ...filters]);
relayPool.send(relayUrl, message);
}
this.subscriptions.set(subId, subscription);
}
/**
* Check if event matches filters
*/
private matchesFilters(event: NostrEvent, filters: NostrFilter[]): boolean {
return filters.some((filter) => {
if (filter.ids && !filter.ids.includes(event.id)) return false;
if (filter.authors && !filter.authors.includes(event.pubkey)) return false;
if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
if (filter.since && event.created_at < filter.since) return false;
if (filter.until && event.created_at > filter.until) return false;
// Tag filters
if (filter['#e']) {
const hasE = event.tags.some((t) => t[0] === 'e' && filter['#e']!.includes(t[1]));
if (!hasE) return false;
}
if (filter['#p']) {
const hasP = event.tags.some((t) => t[0] === 'p' && filter['#p']!.includes(t[1]));
if (!hasP) return false;
}
return true;
});
}
/**
* Unsubscribe from a subscription
*/
unsubscribe(subId: string): void {
const subscription = this.subscriptions.get(subId);
if (!subscription) return;
// Remove message handlers
for (const [relayUrl, handler] of subscription.messageHandlers.entries()) {
const ws = relayPool.getRelay(relayUrl);
if (ws) {
ws.removeEventListener('message', handler);
}
// Send close message
const message = JSON.stringify(['CLOSE', subId]);
relayPool.send(relayUrl, message);
}
this.subscriptions.delete(subId);
}
/**
* Generate a unique subscription ID
*/
generateSubId(): string {
return `sub_${this.nextSubId++}_${Date.now()}`;
}
/**
* Close all subscriptions
*/
closeAll(): void {
for (const subId of this.subscriptions.keys()) {
this.unsubscribe(subId);
}
}
}
interface Subscription {
id: string;
relays: string[];
filters: NostrFilter[];
onEvent: EventCallback;
onEose?: EoseCallback;
messageHandlers: Map<string, (event: MessageEvent) => void>;
}
export const subscriptionManager = new SubscriptionManager();

50
src/lib/services/security/bech32-utils.ts

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
/**
* Bech32 utilities for NIP-19 encoding/decoding
*/
export interface DecodedBech32 {
type: 'npub' | 'nsec' | 'note' | 'nevent' | 'naddr' | 'nprofile';
data: Uint8Array;
relay?: string;
}
/**
* Decode a bech32 string (simplified - full implementation would use bech32 library)
* This is a placeholder - in production, use a proper bech32 library
*/
export function decodeBech32(bech32: string): DecodedBech32 | null {
try {
const prefix = bech32.split('1')[0];
if (!prefix) return null;
// Basic validation - full implementation needed
if (prefix === 'npub' || prefix === 'nsec' || prefix === 'note') {
return {
type: prefix as 'npub' | 'nsec' | 'note',
data: new Uint8Array(32) // Placeholder
};
}
return null;
} catch {
return null;
}
}
/**
* Encode data to bech32 format
*/
export function encodeBech32(type: string, data: Uint8Array, relay?: string): string {
// Placeholder - full implementation needed with bech32 library
// For now, return hex representation
return `${type}1${Array.from(data)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')}`;
}
/**
* Validate bech32 string format
*/
export function isValidBech32(bech32: string): boolean {
return /^(npub|nsec|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(bech32);
}

55
src/lib/services/security/event-validator.ts

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
/**
* Event validation utilities
*/
import type { NostrEvent } from '../../types/nostr.js';
/**
* Validate event structure
*/
export function isValidEvent(event: unknown): event is NostrEvent {
if (!event || typeof event !== 'object') return false;
const e = event as Record<string, unknown>;
return (
typeof e.kind === 'number' &&
typeof e.pubkey === 'string' &&
typeof e.created_at === 'number' &&
typeof e.content === 'string' &&
typeof e.id === 'string' &&
typeof e.sig === 'string' &&
Array.isArray(e.tags) &&
e.pubkey.length === 64 &&
e.id.length === 64 &&
e.sig.length === 128
);
}
/**
* Check if event has required tags for a kind
*/
export function hasRequiredTags(event: NostrEvent, kind: number): boolean {
switch (kind) {
case 0:
// Kind 0 can have tags or JSON content
return true;
case 11:
// Thread - should have title tag
return true;
case 1111:
// Comment - should have K and E tags
return event.tags.some((t) => t[0] === 'K' || t[0] === 'E');
default:
return true;
}
}
/**
* Validate event signature (placeholder - would need crypto library)
*/
export function isValidSignature(event: NostrEvent): boolean {
// Placeholder - full implementation would verify signature
// using secp256k1 cryptography
return event.sig.length === 128;
}

47
src/lib/services/security/key-management.ts

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
/**
* Key management with NIP-49 encryption
* All private keys MUST be encrypted before storage
*/
/**
* Encrypt a private key using NIP-49 (password-based encryption)
* This is a placeholder - full implementation requires:
* - scrypt for key derivation
* - AES-256-GCM for encryption
* - Base64 encoding
*/
export async function encryptPrivateKey(nsec: string, password: string): Promise<string> {
// Placeholder implementation
// Full NIP-49 implementation would:
// 1. Derive key from password using scrypt
// 2. Generate random salt and nonce
// 3. Encrypt nsec with AES-256-GCM
// 4. Encode as ncryptsec format
throw new Error('NIP-49 encryption not yet implemented');
}
/**
* Decrypt a private key using NIP-49
*/
export async function decryptPrivateKey(ncryptsec: string, password: string): Promise<string> {
// Placeholder implementation
// Full NIP-49 implementation would:
// 1. Decode ncryptsec format
// 2. Derive key from password using scrypt
// 3. Decrypt with AES-256-GCM
// 4. Return plain nsec
throw new Error('NIP-49 decryption not yet implemented');
}
/**
* Generate a new private key
*/
export function generatePrivateKey(): string {
// Placeholder - would use crypto.getRandomValues to generate 32 random bytes
// then encode as hex
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

46
src/lib/services/security/sanitizer.ts

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
/**
* HTML sanitization using DOMPurify
*/
import DOMPurify from 'dompurify';
/**
* Sanitize HTML content
*/
export function sanitizeHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: [
'p',
'br',
'strong',
'em',
'u',
's',
'code',
'pre',
'a',
'ul',
'ol',
'li',
'blockquote',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'img',
'video',
'audio'
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload'],
ALLOW_DATA_ATTR: false
});
}
/**
* Sanitize markdown-rendered HTML
*/
export function sanitizeMarkdown(html: string): string {
return sanitizeHtml(html);
}

24
src/lib/types/nostr.ts

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

5
src/routes/+layout.svelte

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
<script lang="ts">
import '../app.css';
</script>
<slot />

2
src/routes/+layout.ts

@ -0,0 +1,2 @@ @@ -0,0 +1,2 @@
export const prerender = true;
export const ssr = false;

26
src/routes/+page.svelte

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
<script lang="ts">
import Header from '../lib/components/layout/Header.svelte';
import ThreadList from '../lib/modules/threads/ThreadList.svelte';
import { nostrClient } from '../lib/services/nostr/applesauce-client.js';
import { onMount } from 'svelte';
onMount(async () => {
await nostrClient.initialize();
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">Aitherboard</h1>
<p class="mb-4">Decentralized messageboard on Nostr</p>
<a href="/feed" class="text-blue-500 underline mb-4 block">View feed →</a>
<ThreadList />
</main>
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
</style>

23
src/routes/feed/+page.svelte

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import { nostrClient } from '../../lib/services/nostr/applesauce-client.js';
import { onMount } from 'svelte';
onMount(async () => {
await nostrClient.initialize();
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-4">Kind 1 Feed</h1>
<p>Feed implementation coming soon...</p>
</main>
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
</style>

57
src/routes/login/+page.svelte

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
<script lang="ts">
import { authenticateWithNIP07 } from '../../lib/services/nostr/auth-handler.js';
import { isNIP07Available } from '../../lib/services/auth/nip07-signer.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { nostrClient } from '../../lib/services/nostr/applesauce-client.js';
onMount(async () => {
await nostrClient.initialize();
});
let error = $state<string | null>(null);
let loading = $state(false);
async function loginWithNIP07() {
if (!isNIP07Available()) {
error = 'NIP-07 extension not available. Please install a Nostr extension like Alby or nos2x.';
return;
}
loading = true;
error = null;
try {
await authenticateWithNIP07();
goto('/');
} catch (err) {
error = err instanceof Error ? err.message : 'Authentication failed';
} finally {
loading = false;
}
}
</script>
<main class="container mx-auto px-4 py-8 max-w-md">
<h1 class="text-2xl font-bold mb-4">Login</h1>
{#if error}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
{/if}
<div class="space-y-4">
<button
on:click={loginWithNIP07}
disabled={loading}
class="w-full px-4 py-2 bg-blue-500 text-white disabled:opacity-50"
>
{loading ? 'Connecting...' : 'Login with NIP-07'}
</button>
<p class="text-sm text-gray-600">
Other authentication methods (nsec, bunker, anonymous) coming soon...
</p>
</div>
</main>

74
src/routes/thread/[id]/+page.svelte

@ -0,0 +1,74 @@ @@ -0,0 +1,74 @@
<script lang="ts">
import Header from '../../../lib/components/layout/Header.svelte';
import ProfileBadge from '../../../lib/components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../../lib/components/content/MarkdownRenderer.svelte';
import { nostrClient } from '../../../lib/services/nostr/applesauce-client.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { page } from '$app/stores';
let thread = $state<NostrEvent | null>(null);
let loading = $state(true);
onMount(async () => {
await nostrClient.initialize();
if ($page.params.id) {
loadThread();
}
});
$effect(() => {
if ($page.params.id && !loading) {
loadThread();
}
});
async function loadThread() {
loading = true;
try {
const config = nostrClient.getConfig();
const event = await nostrClient.getEventById($page.params.id, [
...config.defaultRelays,
...config.profileRelays
]);
thread = event;
} catch (error) {
console.error('Error loading thread:', error);
} finally {
loading = false;
}
}
function getTitle(): string {
if (!thread) return '';
const titleTag = thread.tags.find((t) => t[0] === 'title');
return titleTag?.[1] || 'Untitled';
}
</script>
<Header />
<main class="container mx-auto px-4 py-8">
{#if loading}
<p>Loading thread...</p>
{:else if thread}
<article class="thread-view">
<h1 class="text-2xl font-bold mb-4">{getTitle()}</h1>
<div class="mb-4">
<ProfileBadge pubkey={thread.pubkey} />
</div>
<div class="mb-4">
<MarkdownRenderer content={thread.content} />
</div>
</article>
{:else}
<p>Thread not found</p>
{/if}
</main>
<style>
.thread-view {
max-width: var(--content-width);
margin: 0 auto;
}
</style>

42
src/routes/threads/+page.svelte

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import ThreadList from '../../lib/modules/threads/ThreadList.svelte';
import CreateThreadForm from '../../lib/modules/threads/CreateThreadForm.svelte';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { nostrClient } from '../../lib/services/nostr/applesauce-client.js';
import { onMount } from 'svelte';
let showCreateForm = $state(false);
onMount(async () => {
await nostrClient.initialize();
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="mb-4">
<h1 class="text-2xl font-bold mb-4">Threads</h1>
{#if sessionManager.isLoggedIn()}
<button
on:click={() => (showCreateForm = !showCreateForm)}
class="mb-4 px-4 py-2 bg-blue-500 text-white"
>
{showCreateForm ? 'Cancel' : 'Create Thread'}
</button>
{#if showCreateForm}
<CreateThreadForm />
{/if}
{/if}
</div>
<ThreadList />
</main>
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
</style>

18
svelte.config.js

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: true
})
}
};
export default config;

21
tailwind.config.js

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
// 4chan-style minimal color palette
board: {
bg: '#d6daf0',
post: '#eef2ff',
highlight: '#fffecc',
border: '#b7c5d9'
}
},
fontFamily: {
sans: ['system-ui', '-apple-system', 'sans-serif']
}
}
},
plugins: []
};

16
tsconfig.json

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

27
vite.config.ts

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { execSync } from 'child_process';
export default defineConfig({
plugins: [
sveltekit(),
{
name: 'generate-healthz',
buildStart() {
try {
execSync('node scripts/generate-healthz.js', { stdio: 'inherit' });
} catch (error) {
console.warn('Failed to generate healthz.json:', error);
}
}
}
],
server: {
port: 5173,
strictPort: false
},
build: {
target: 'esnext',
sourcemap: true
}
});
Loading…
Cancel
Save