60 changed files with 4262 additions and 584 deletions
@ -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' |
||||
} |
||||
}; |
||||
@ -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-* |
||||
@ -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" |
||||
} |
||||
} |
||||
] |
||||
} |
||||
@ -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"] |
||||
@ -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. |
||||
@ -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 |
||||
@ -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 |
||||
@ -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> |
||||
@ -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" |
||||
} |
||||
} |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
export default { |
||||
plugins: { |
||||
tailwindcss: {}, |
||||
autoprefixer: {} |
||||
} |
||||
}; |
||||
@ -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 |
||||
} |
||||
@ -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'); |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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 {}; |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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'; |
||||
} |
||||
@ -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}`; |
||||
} |
||||
@ -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'); |
||||
} |
||||
@ -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(); |
||||
} |
||||
@ -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'; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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)] |
||||
}; |
||||
} |
||||
@ -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(); |
||||
@ -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; |
||||
} |
||||
@ -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]); |
||||
} |
||||
@ -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); |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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(); |
||||
@ -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([]); |
||||
} |
||||
@ -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(); |
||||
@ -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(); |
||||
@ -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 }; |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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(); |
||||
@ -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); |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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(''); |
||||
} |
||||
@ -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); |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
<script lang="ts"> |
||||
import '../app.css'; |
||||
</script> |
||||
|
||||
<slot /> |
||||
@ -0,0 +1,2 @@
@@ -0,0 +1,2 @@
|
||||
export const prerender = true; |
||||
export const ssr = false; |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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> |
||||
@ -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; |
||||
@ -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: [] |
||||
}; |
||||
@ -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"] |
||||
} |
||||
} |
||||
@ -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…
Reference in new issue