作成日: 2025-12-27
最終更新: 2025-12-27
ステータス: ✅ 最新
- Haven 概要
- コアアーキテクチャ
- 4つのリレー機能
- Blossom メディアサーバー
- Web of Trust (WoT)
- Import 機能
- Blastr 機能
- バックアップ機能
- Rate Limiting
- データベース
- 設定項目
- Start9 統合状況
- 今後の実装計画
HAVEN - High Availability Vault for Events on Nostr
最も主権的なパーソナル Nostr リレー。eCash、プライベートチャット、ドラフトなどのセンシティブなノートを保存・バックアップするための「賢いリレー」。
┌─────────────────────────────────────────────────────┐
│ HAVEN │
│ High Availability Vault for Events on Nostr │
├─────────────────────────────────────────────────────┤
│ │
│ 🔒 4 Specialized Relays │
│ 🌸 Blossom Media Server │
│ 🕸️ Web of Trust Integration │
│ 📦 Import & Sync │
│ 🔫 Blastr Broadcasting │
│ ☁️ Cloud Backups │
│ 🧅 Tor-Only (Start9) │
│ │
└─────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Client Applications │
│ (Amethyst, Damus, Primal, Coracle, nostrudel, etc.) │
└────────────────────┬─────────────────────────────────────┘
│ WebSocket (wss://)
│
┌────────────────────▼─────────────────────────────────────┐
│ Haven Main Process (Go) │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ Dynamic Relay Handler (main.go) │ │
│ │ - Route requests to appropriate relay │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────┬─────────────┬──────────┬──────────┐ │
│ │ Private │ Chat │ Inbox │ Outbox │ │
│ │ /private │ /chat │ /inbox │ / │ │
│ │ (Auth) │ (Auth+WoT) │ (WoT) │ (Owner) │ │
│ └─────────────┴─────────────┴──────────┴──────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Blossom Media Server (blossom.go) │ │
│ │ - NIP-96 & BUD-02 compliant │ │
│ │ - File storage: /data/blossom/ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└────────────────────┬─────────────────────────────────────┘
│
┌────────────────────▼─────────────────────────────────────┐
│ Database Layer │
│ ┌──────────┬──────────┬──────────┬──────────┬────────┐│
│ │ Private │ Chat │ Inbox │ Outbox │Blossom ││
│ │ DB │ DB │ DB │ DB │ DB ││
│ │ (LMDB/ │ (LMDB/ │ (LMDB/ │ (LMDB/ │(LMDB/ ││
│ │ Badger) │ Badger) │ Badger) │ Badger) │Badger) ││
│ └──────────┴──────────┴──────────┴──────────┴────────┘│
│ │
│ Storage: /data/db/{private,chat,inbox,outbox,blossom} │
└──────────────────────────────────────────────────────────┘
| 要素 | 技術 |
|---|---|
| 言語 | Go 1.21+ |
| リレー実装 | khatru |
| データベース | LMDB (default) / BadgerDB |
| Nostr ライブラリ | go-nostr |
| Blossom | khatru/blossom |
| クラウドバックアップ | S3-compatible APIs |
| コンテナ | Docker |
| ネットワーク | Tor (Start9) |
/data/
├── db/
│ ├── private/ # Private relay database
│ ├── chat/ # Chat relay database
│ ├── inbox/ # Inbox relay database
│ ├── outbox/ # Outbox relay database
│ └── blossom/ # Blossom metadata database
├── blossom/ # Blossom media files (by SHA256)
│ ├── <sha256_hash_1>
│ ├── <sha256_hash_2>
│ └── ...
├── tor/
│ └── haven/ # Tor hidden service keys (persisted)
│ ├── hostname
│ ├── hs_ed25519_public_key
│ └── hs_ed25519_secret_key
├── logs/
│ ├── haven.log # Main application log
│ └── import-*.log # Import session logs
├── config/
│ └── .env # Configuration file
└── .import-request # Import flag (when import pending)
Haven は 4つの専門化されたリレーを1つのプロセスで提供します。
目的: オーナーのみがアクセスできる完全プライベートなリレー
┌────────────────────────────────────────────────────┐
│ Private Relay (/private) │
├────────────────────────────────────────────────────┤
│ アクセス: Owner Only (NIP-42 Auth Required) │
│ 読み取り: Owner Only │
│ 書き込み: Owner Only │
├────────────────────────────────────────────────────┤
│ 用途: │
│ - ドラフト (Kind 31234: Draft Long-form) │
│ - eCash トークン (Kind 7375, 7376) │
│ - プライベートブックマーク (Kind 10003) │
│ - プライベートリスト │
│ - 任意のプライベートノート │
├────────────────────────────────────────────────────┤
│ セキュリティ: │
│ ✅ NIP-42 (Auth) 必須 │
│ ✅ Owner 以外は読み取り・書き込み拒否 │
│ ✅ Rate limiting │
│ ✅ Empty/Complex filter 制限可能 │
└────────────────────────────────────────────────────┘
実装ファイル: init.go:96-177
主要コード:
privateRelay.RejectFilter = append(privateRelay.RejectFilter,
func(ctx context.Context, filter nostr.Filter) (bool, string) {
authenticatedUser := khatru.GetAuthed(ctx)
if authenticatedUser == nPubToPubkey(config.OwnerNpub) {
return false, ""
}
return true, "auth-required: this query requires you to be authenticated"
})
privateRelay.RejectEvent = append(privateRelay.RejectEvent,
func(ctx context.Context, event *nostr.Event) (bool, string) {
authenticatedUser := khatru.GetAuthed(ctx)
if authenticatedUser == nPubToPubkey(config.OwnerNpub) {
return false, ""
}
return true, "auth-required: publishing this event requires authentication"
})目的: Web of Trust 内のユーザーとの DM/チャット
┌────────────────────────────────────────────────────┐
│ Chat Relay (/chat) │
├────────────────────────────────────────────────────┤
│ アクセス: Web of Trust Members (NIP-42 Auth) │
│ 読み取り: WoT Members │
│ 書き込み: WoT Members │
├────────────────────────────────────────────────────┤
│ 許可される Kind: │
│ - Kind 14: Chat Message │
│ - Kind 1059: Gift Wrap (encrypted DMs) │
│ - Kind 9, 10, 11, 12: Simple Group kinds │
│ - Kind 29: Simple Group Metadata │
│ - Kind 39, 40, 41: Channel kinds │
├────────────────────────────────────────────────────┤
│ Web of Trust 判定: │
│ ✅ Owner のフォローリスト内 │
│ ✅ フォローリスト内のユーザーから N-hop 以内 │
│ ✅ 最小フォロワー数を満たす │
├────────────────────────────────────────────────────┤
│ セキュリティ: │
│ ✅ NIP-42 (Auth) 必須 │
│ ✅ WoT メンバー以外は拒否 │
│ ✅ Gift Wrap DM のみ受付 (古い NIP-04 は拒否) │
│ ✅ Rate limiting │
└────────────────────────────────────────────────────┘
実装ファイル: init.go:179-289
許可される Event Kinds:
allowedKinds := []int{
nostr.KindSimpleGroupChatMessage, // 14
nostr.KindSimpleGroupThreadedReply, // 1111
nostr.KindSimpleGroupThread, // 11
nostr.KindSimpleGroupReply, // 12
nostr.KindChannelMessage, // 42
nostr.KindChannelHideMessage, // 43
nostr.KindGiftWrap, // 1059
nostr.KindSimpleGroupPutUser, // 9000
nostr.KindSimpleGroupRemoveUser, // 9001
nostr.KindSimpleGroupEditMetadata, // 9002
nostr.KindSimpleGroupDeleteEvent, // 9005
nostr.KindSimpleGroupCreateGroup, // 9006
nostr.KindSimpleGroupDeleteGroup, // 9007
nostr.KindSimpleGroupCreateInvite, // 9009
nostr.KindSimpleGroupJoinRequest, // 9021
nostr.KindSimpleGroupLeaveRequest, // 9022
// Addressable kinds
nostr.KindSimpleGroupMetadata, // 39000
nostr.KindSimpleGroupAdmins, // 39001
nostr.KindSimpleGroupMembers, // 39002
nostr.KindSimpleGroupRoles, // 39003
}目的: オーナー宛のメンション・リアクション・Zap などを自動収集
┌────────────────────────────────────────────────────┐
│ Inbox Relay (/inbox) │
├────────────────────────────────────────────────────┤
│ 読み取り: Public │
│ 書き込み: WoT Members (with p-tag to owner) │
├────────────────────────────────────────────────────┤
│ 自動収集される Event: │
│ - Kind 1: Text Note (replies) │
│ - Kind 6: Repost │
│ - Kind 7: Reaction (likes, emojis) │
│ - Kind 9735: Zap │
│ - その他 owner を p-tag したイベント │
├────────────────────────────────────────────────────┤
│ 自動同期: │
│ ✅ Import seed relays から定期的に pull │
│ ✅ リアルタイム購読 (subscribeInboxAndChat) │
│ ✅ 重複チェック │
├────────────────────────────────────────────────────┤
│ フィルタリング: │
│ ✅ p-tag に owner pubkey が含まれる │
│ ✅ WoT メンバーのみ (Gift Wrap は除外) │
│ ✅ 古い NIP-04 DM は拒否 │
└────────────────────────────────────────────────────┘
実装ファイル: init.go:411-485, import.go:166-227
自動同期ロジック:
func subscribeInboxAndChat() {
ctx := context.Background()
wdbInbox := eventstore.RelayWrapper{Store: inboxDB}
wdbChat := eventstore.RelayWrapper{Store: chatDB}
filter := nostr.Filter{
Tags: nostr.TagMap{
"p": {nPubToPubkey(config.OwnerNpub)},
},
Since: &startTime,
}
for ev := range pool.SubscribeMany(ctx, config.ImportSeedRelays, filter) {
if !wotMap[ev.Event.PubKey] && ev.Event.Kind != nostr.KindGiftWrap {
continue
}
// Route to inbox or chat based on kind
if ev.Event.Kind == nostr.KindGiftWrap {
dbToPublish = wdbChat
} else {
dbToPublish = wdbInbox
}
if !isDuplicate(ctx, dbToPublish, ev.Event) {
dbToPublish.Publish(ctx, *ev.Event)
}
}
}目的: オーナーのパブリックノートを配信
┌────────────────────────────────────────────────────┐
│ Outbox Relay (/) │
├────────────────────────────────────────────────────┤
│ 読み取り: Public │
│ 書き込み: Owner Only │
├────────────────────────────────────────────────────┤
│ 配信される Event: │
│ - Kind 0: Metadata │
│ - Kind 1: Text Note │
│ - Kind 3: Contacts │
│ - Kind 6: Repost │
│ - Kind 7: Reaction │
│ - Kind 10002: Relay List Metadata │
│ - Kind 30023: Long-form Content │
│ - その他全ての owner のパブリックイベント │
├────────────────────────────────────────────────────┤
│ Blastr 統合: │
│ ✅ 全ての新規イベントを自動ブロードキャスト │
│ ✅ 設定した複数リレーに同時配信 │
│ ✅ Tor経由で外部リレーに送信 │
├────────────────────────────────────────────────────┤
│ Blossom 統合: │
│ ✅ 同じエンドポイントで Blossom API 提供 │
│ ✅ GET/POST /upload │
│ ✅ GET /<sha256> │
└────────────────────────────────────────────────────┘
実装ファイル: init.go:291-360
Blastr 連携:
outboxRelay.StoreEvent = append(outboxRelay.StoreEvent,
outboxDB.SaveEvent,
func(ctx context.Context, event *nostr.Event) error {
go blast(event) // 非同期でブロードキャスト
return nil
})Blossom は Nostr のための分散型メディアホスティングプロトコルです。Haven は完全な Blossom サーバーを統合しています。
┌──────────────────────────────────────────────────┐
│ Blossom Media Server │
│ (NIP-96 & BUD-02 Compliant) │
├──────────────────────────────────────────────────┤
│ Endpoints: │
│ - GET /<sha256> (Download blob) │
│ - POST /upload (Upload blob) │
│ - GET /list (List blobs) │
│ - DELETE /<sha256> (Delete blob) │
├──────────────────────────────────────────────────┤
│ Storage: │
│ - Location: /data/blossom/ │
│ - Filename: SHA256 hash (no extension) │
│ - Metadata: Stored in blossomDB │
├──────────────────────────────────────────────────┤
│ Authentication: │
│ ✅ Upload: Owner only (NIP-98 Auth) │
│ ✅ Download: Public │
│ ✅ Delete: Owner only │
├──────────────────────────────────────────────────┤
│ Supported Formats: │
│ - Images: jpg, jpeg, png, gif, webp │
│ - Videos: mp4, mov, avi, mkv │
│ - Audio: mp3, wav, ogg, flac │
│ - Documents: pdf │
│ - Any binary file │
└──────────────────────────────────────────────────┘
ファイル: init.go:362-409
bl := blossom.New(outboxRelay, serviceURL)
bl.Store = blossom.EventStoreBlobIndexWrapper{
Store: blossomDB,
ServiceURL: bl.ServiceURL,
}
// Upload (Store)
bl.StoreBlob = append(bl.StoreBlob,
func(ctx context.Context, sha256 string, ext string, body []byte) error {
file, err := fs.Create(config.BlossomPath + "/" + sha256)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(file, bytes.NewReader(body)); err != nil {
return err
}
return nil
})
// Download (Load)
bl.LoadBlob = append(bl.LoadBlob,
func(ctx context.Context, sha256 string, ext string) (io.ReadSeeker, error) {
file, err := fs.Open(config.BlossomPath + "/" + sha256)
if err != nil {
return nil, err
}
return file, nil
})
// Delete
bl.DeleteBlob = append(bl.DeleteBlob,
func(ctx context.Context, sha256 string, ext string) error {
err := fs.Remove(config.BlossomPath + "/" + sha256)
return err
})
// Access Control
bl.RejectUpload = append(bl.RejectUpload,
func(ctx context.Context, event *nostr.Event, size int, ext string) (bool, string, int) {
if event.PubKey == nPubToPubkey(config.OwnerNpub) {
return false, ext, size
}
return true, "only notes signed by the owner of this relay are allowed", 403
})| クライアント | Blossom 対応 | ステータス |
|---|---|---|
| Amethyst (Android) | ✅ Full Support | 完全動作確認済み |
| Damus (iOS) | NIP-96 のみ | |
| Primal | 画像のみ | |
| Coracle | ❌ No | - |
| nostrudel | ✅ Full Support | 完全動作確認済み |
Haven の WoT は、オーナーのソーシャルグラフに基づいてスパムをフィルタリングします。
┌──────────────────────────────────────────────────┐
│ Web of Trust Architecture │
└──────────────────────────────────────────────────┘
Owner (You)
│
├─ Follows (0-hop / Direct Followers)
│ │
│ ├─ Alice
│ ├─ Bob
│ └─ Charlie
│
└─ 1-hop Network (Friends of Friends)
│
├─ Alice follows → Dave, Eve
├─ Bob follows → Frank, Grace
└─ Charlie follows → Hannah, Isaac
┌──────────────────────────────────────────────────┐
│ Filtering Criteria: │
│ 1. Minimum Followers: 3 (configurable) │
│ 2. WoT Depth: 1 (configurable, 0=direct only) │
│ 3. Refresh Interval: 24 hours (configurable) │
└──────────────────────────────────────────────────┘
ファイル: wot.go
var (
pubkeyFollowerCount = make(map[string]int) // Follower count per pubkey
oneHopNetwork []string // 1-hop network pubkeys
wot []string // Final WoT list
wotMap map[string]bool // Fast lookup map
)
func refreshTrustNetwork() {
// 1. Fetch owner's follow list (Kind 3)
filter := nostr.Filter{
Authors: []string{ownerPubkey},
Kinds: []int{nostr.KindFollowList},
}
events := pool.FetchMany(ctx, config.ImportSeedRelays, filter)
for ev := range events {
for contact := range ev.Event.Tags.FindAll("p") {
pubkeyFollowerCount[contact[1]]++
appendOneHopNetwork(contact[1])
}
}
// 2. Fetch follow lists of 1-hop network
for i := 0; i < len(oneHopNetwork); i += 100 {
filter = nostr.Filter{
Authors: oneHopNetwork[i:end],
Kinds: []int{nostr.KindFollowList, nostr.KindRelayListMetadata},
}
events := pool.FetchMany(ctx, config.ImportSeedRelays, filter)
for ev := range events {
for contact := range ev.Tags.FindAll("p") {
pubkeyFollowerCount[contact[1]]++
}
}
}
// 3. Filter by minimum followers
updateWoTMap()
}
func updateWoTMap() {
wotMapTmp := make(map[string]bool)
for pubkey, count := range pubkeyFollowerCount {
if count >= config.ChatRelayMinimumFollowers {
wotMapTmp[pubkey] = true
appendPubkeyToWoT(pubkey)
}
}
wotMap = wotMapTmp
}| 項目 | 環境変数 | デフォルト値 | 説明 |
|---|---|---|---|
| WoT Depth | CHAT_RELAY_WOT_DEPTH |
0 |
0=direct, 1=1-hop, 2=2-hop |
| Minimum Followers | CHAT_RELAY_MINIMUM_FOLLOWERS |
0 |
最小フォロワー数 |
| Refresh Interval | CHAT_RELAY_WOT_REFRESH_INTERVAL_HOURS |
0 (disabled) |
WoT 更新間隔 |
| Fetch Timeout | WOT_FETCH_TIMEOUT_SECONDS |
30 |
フェッチタイムアウト |
-
Chat Relay (
/chat)- 読み取り・書き込み: WoT メンバーのみ
- Gift Wrap (Kind 1059) は例外
-
Inbox Relay (
/inbox)- 書き込み: WoT メンバーのみ (p-tag に owner)
- Gift Wrap は例外
// Chat relay filter check
chatRelay.RejectFilter = append(chatRelay.RejectFilter,
func(ctx context.Context, filter nostr.Filter) (bool, string) {
authenticatedUser := khatru.GetAuthed(ctx)
if !wotMap[authenticatedUser] {
return true, "you must be in the web of trust to chat with the relay owner"
}
return false, ""
})
// Inbox relay event check
inboxRelay.RejectEvent = append(inboxRelay.RejectEvent,
func(ctx context.Context, event *nostr.Event) (bool, string) {
if !wotMap[event.PubKey] {
return true, "you must be in the web of trust to post to this relay"
}
if event.Kind == nostr.KindEncryptedDirectMessage {
return true, "only gift wrapped DMs are supported"
}
if event.Tags.FindWithValue("p", inboxRelay.Info.PubKey) != nil {
return false, ""
}
return true, "you can only post notes if you've tagged the owner of this relay"
})Haven は過去のノートを外部リレーからインポートする機能を提供します。
┌──────────────────────────────────────────────────┐
│ Import Architecture │
└──────────────────────────────────────────────────┘
1. Owner Notes Import (importOwnerNotes)
┌─────────────────────────────────────┐
│ Import Seed Relays │
│ - relay.damus.io │
│ - nos.lol │
│ - relay.snort.social │
│ - ... │
└─────────────────┬───────────────────┘
│
│ Filter: authors=[owner_npub]
│ since=IMPORT_START_DATE
│ until=start+10days
▼
┌─────────────┐
│ Owner Notes │──→ Outbox DB
└─────────────┘
2. Tagged Notes Import (importTaggedNotes)
┌─────────────────────────────────────┐
│ Import Seed Relays │
└─────────────────┬───────────────────┘
│
│ Filter: #p=[owner_npub]
│ (all history)
▼
┌──────────────────┐
│ Tagged Notes │
│ - Replies │──→ Inbox DB
│ - Reactions │
│ - Zaps │
│ - Gift Wraps │──→ Chat DB
└──────────────────┘
3. Continuous Sync (subscribeInboxAndChat)
┌─────────────────────────────────────┐
│ Import Seed Relays │
└─────────────────┬───────────────────┘
│
│ Subscribe: #p=[owner_npub]
│ since=now-5min
▼
┌──────────────────┐
│ Real-time Events │
│ (auto-imported) │
└──────────────────┘
ファイル: import.go
func importOwnerNotes() {
wdb := eventstore.RelayWrapper{Store: outboxDB}
startTime, _ := time.Parse(layout, config.ImportStartDate)
endTime := startTime.Add(240 * time.Hour) // 10 days batch
for {
filter := nostr.Filter{
Authors: []string{nPubToPubkey(config.OwnerNpub)},
Since: &startTimestamp,
Until: &endTimestamp,
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
events := pool.FetchMany(ctx, config.ImportSeedRelays, filter)
for ev := range events {
if err := wdb.Publish(ctx, *ev.Event); err != nil {
log.Println("error importing note", ev.ID, ":", err)
}
}
startTime = startTime.Add(240 * time.Hour)
endTime = endTime.Add(240 * time.Hour)
if startTime.After(time.Now()) {
break
}
}
}特徴:
- 10日ごとのバッチ処理
- タイムアウト設定可能
- 進捗ログ
- 失敗カウント
func importTaggedNotes() {
wdbInbox := eventstore.RelayWrapper{Store: inboxDB}
wdbChat := eventstore.RelayWrapper{Store: chatDB}
filter := nostr.Filter{
Tags: nostr.TagMap{
"p": {nPubToPubkey(config.OwnerNpub)},
},
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
events := pool.FetchMany(ctx, config.ImportSeedRelays, filter)
for ev := range events {
// WoT check
if !wotMap[ev.PubKey] && ev.Kind != nostr.KindGiftWrap {
continue
}
// Route to appropriate DB
dbToWrite := wdbInbox
if ev.Kind == nostr.KindGiftWrap {
dbToWrite = wdbChat
}
if err := dbToWrite.Publish(ctx, *ev.Event); err != nil {
log.Println("error importing tagged note", ev.ID, ":", err)
}
}
}特徴:
- 全履歴を一度に取得
- WoT フィルタリング
- Gift Wrap は Chat DB へ
- タイムアウト設定可能
func subscribeInboxAndChat() {
ctx := context.Background()
wdbInbox := eventstore.RelayWrapper{Store: inboxDB}
wdbChat := eventstore.RelayWrapper{Store: chatDB}
startTime := nostr.Timestamp(time.Now().Add(-time.Minute * 5).Unix())
filter := nostr.Filter{
Tags: nostr.TagMap{
"p": {nPubToPubkey(config.OwnerNpub)},
},
Since: &startTime,
}
for ev := range pool.SubscribeMany(ctx, config.ImportSeedRelays, filter) {
// WoT check
if !wotMap[ev.Event.PubKey] && ev.Event.Kind != nostr.KindGiftWrap {
continue
}
// Duplicate check
if isDuplicate(ctx, dbToPublish, ev.Event) {
continue
}
// Route and publish
dbToPublish := wdbInbox
if ev.Event.Kind == nostr.KindGiftWrap {
dbToPublish = wdbChat
}
if err := dbToPublish.Publish(ctx, *ev.Event); err != nil {
log.Println("error importing tagged note", ev.Event.ID)
}
// Log notification
switch ev.Event.Kind {
case nostr.KindTextNote:
log.Println("📰 new note in your inbox")
case nostr.KindReaction:
log.Println(ev.Event.Content, "new reaction in your inbox")
case nostr.KindZap:
log.Println("⚡️ new zap in your inbox")
case nostr.KindGiftWrap:
log.Println("🎁🔒️✉️ new gift-wrapped message in your chat relay")
}
}
}Action: import-notes (manifest.yaml:203-245)
actions:
import-notes:
name: Import Past Notes
description: |
Configure Haven to import your past notes and mentions on next restart.
warning: |
⚠️ IMPORTANT: After running this action, you MUST restart Haven!
implementation:
type: docker
image: main
entrypoint: docker_entrypoint.sh
args: ["import-notes"]フロー:
- ユーザーが Start9 UI で "Import Past Notes" アクション実行
.import-requestフラグファイル作成- Haven を再起動
docker_entrypoint.shが--importフラグ付きで Haven を起動- Import 実行
- 完了後、Haven は通常モードで自動再起動
| 項目 | 環境変数 | デフォルト値 | 説明 |
|---|---|---|---|
| Start Date | IMPORT_START_DATE |
- | インポート開始日 (YYYY-MM-DD) |
| Owner Notes Timeout | IMPORT_OWNER_NOTES_FETCH_TIMEOUT_SECONDS |
30 |
Owner ノート取得タイムアウト |
| Tagged Notes Timeout | IMPORT_TAGGED_NOTES_FETCH_TIMEOUT_SECONDS |
120 |
Tagged ノート取得タイムアウト |
| Query Interval | IMPORT_QUERY_INTERVAL_SECONDS |
360000 |
定期クエリ間隔 (未使用) |
| Seed Relays | IMPORT_SEED_RELAYS_FILE |
- | インポート元リレーリスト (JSON) |
| Inbox Pull Interval | INBOX_PULL_INTERVAL_SECONDS |
3600 |
Inbox 同期間隔 |
Blastr は Haven の Outbox に投稿されたノートを自動的に複数の外部リレーにブロードキャストする機能です。
┌──────────────────────────────────────────────────┐
│ Blastr Architecture │
└──────────────────────────────────────────────────┘
Client
│
│ WebSocket Publish
▼
┌─────────────────┐
│ Outbox Relay │
│ (/) │
└────────┬────────┘
│
├─ Save to outboxDB
│
└─ blast(event) ──┐
│
▼
┌────────────────────────────┐
│ Blastr Relay Pool │
│ (config.BlastrRelays) │
└────────────────────────────┘
│
┌──────────┼──────────┐
▼ ▼ ▼
relay.damus.io nos.lol relay.snort.social
(IPv4) (IPv6) (Tor)
ファイル: blastr.go
func blast(ev *nostr.Event) {
ctx := context.Background()
for _, url := range config.BlastrRelays {
// Extended timeout for Tor connections (15 seconds)
ctx, cancel := context.WithTimeout(ctx, time.Second*15)
relay, err := pool.EnsureRelay(url)
if err != nil {
cancel()
log.Println("error connecting to relay", relay, err)
continue
}
if err := relay.Publish(ctx, *ev); err != nil {
log.Println("🚫 error publishing to relay", relay, err)
}
cancel()
}
log.Println("🔫 blasted", ev.ID, "to", len(config.BlastrRelays), "relays")
}特徴:
- 非同期実行 (
go blast(event)) - 15秒タイムアウト (Tor 対応)
- IPv6 サポート
- エラーハンドリング
- ログ出力
ファイル: init.go:324-327
outboxRelay.StoreEvent = append(outboxRelay.StoreEvent,
outboxDB.SaveEvent,
func(ctx context.Context, event *nostr.Event) error {
go blast(event) // 非同期でブロードキャスト
return nil
})Start9 UI: Config > Blastr Relay List (CSV format)
relay.damus.io,nos.lol,relay.snort.social,relay.nostr.band,nostr.mom,relay.primal.net,offchain.pub,nostr.bitcoiner.social
環境変数: BLASTR_RELAYS_FILE → relays_blastr.json
[
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://relay.nostr.band",
"wss://nostr.mom",
"wss://relay.primal.net",
"wss://offchain.pub",
"wss://nostr.bitcoiner.social"
]実装 (v1.1.3):
- Tor 設定:
ClientUseIPv6 1,ClientPreferIPv6ORPort 1,IPv6Exit 1 - Go の Happy Eyeballs v2 (RFC 8305) による自動フォールバック
- IPv6 優先、300ms 後に IPv4 にフォールバック
Haven はデータベースを定期的に S3 互換ストレージにバックアップします。
┌──────────────────────────────────────────────────┐
│ Backup Architecture │
└──────────────────────────────────────────────────┘
Haven Process
│
│ Every BACKUP_INTERVAL_HOURS
▼
┌─────────────────┐
│ backupDatabase()│
└────────┬────────┘
│
│ 1. ZIP database
│ db/ → db.zip
▼
┌──────────┐
│ db.zip │
└────┬─────┘
│
│ 2. Upload to cloud
▼
┌─────────────────────────┐
│ S3-Compatible Storage │
│ - AWS S3 │
│ - GCP Cloud Storage │
│ - DigitalOcean Spaces │
│ - Wasabi │
│ - Backblaze B2 │
│ - MinIO │
└─────────────────────────┘
│
│ 3. Delete local db.zip
▼
(Done)
ファイル: backup.go
func backupDatabase() {
if config.BackupProvider == "none" || config.BackupProvider == "" {
log.Println("🚫 no backup provider set")
return
}
ticker := time.NewTicker(time.Duration(config.BackupIntervalHours) * time.Hour)
defer ticker.Stop()
zipFileName := "db.zip"
for {
select {
case <-ticker.C:
// 1. ZIP the database
if err := ZipDirectory("db", zipFileName); err != nil {
log.Println("🚫 error zipping database folder:", err)
continue
}
// 2. Upload to cloud
switch config.BackupProvider {
case "s3":
S3Upload(zipFileName)
case "aws":
AwsUpload(zipFileName) // Deprecated
case "gcp":
GCPBucketUpload(zipFileName) // Deprecated
default:
log.Println("🚫 we only support AWS, GCP, and S3 at this time")
}
}
}
}func S3Upload(zipFileName string) {
// Create MinIO client (S3-compatible)
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secret, ""),
Region: region,
Secure: secure,
})
// Upload file
file, err := os.Open(zipFileName)
defer file.Close()
fileInfo, _ := file.Stat()
_, err = client.PutObject(
context.Background(),
bucketName,
zipFileName,
file,
fileInfo.Size(),
minio.PutObjectOptions{
ContentType: "application/octet-stream",
},
)
// Delete local file
os.Remove(zipFileName)
}func ZipDirectory(sourceDir, zipFileName string) error {
file, err := os.Create(zipFileName)
defer file.Close()
w := zip.NewWriter(file)
defer w.Close()
walker := func(path string, info os.FileInfo, err error) error {
if info.IsDir() {
return nil
}
file, err := os.Open(path)
defer file.Close()
f, err := w.Create(path)
_, err = io.Copy(f, file)
return nil
}
err = filepath.Walk(sourceDir, walker)
return nil
}| 項目 | 環境変数 | デフォルト値 | 説明 |
|---|---|---|---|
| Backup Provider | BACKUP_PROVIDER |
none |
s3, aws, gcp, none |
| Backup Interval | BACKUP_INTERVAL_HOURS |
24 |
バックアップ間隔 (時間) |
| S3 Access Key | S3_ACCESS_KEY_ID |
- | S3 アクセスキー |
| S3 Secret Key | S3_SECRET_KEY |
- | S3 シークレットキー |
| S3 Endpoint | S3_ENDPOINT |
- | S3 エンドポイント |
| S3 Region | S3_REGION |
- | S3 リージョン |
| S3 Bucket | S3_BUCKET_NAME |
- | S3 バケット名 |
Start9 ではバックアップは Start9 の内蔵バックアップシステム を使用します。Haven のクラウドバックアップは無効化されています (BACKUP_PROVIDER=none)。
Start9 バックアップ:
manifest.yaml:backup.createで定義- Duplicity を使用
- Start9 のバックアップ先 (外部 HDD, NAS, etc.) に保存
Haven は IP ベースのレート制限を実装しています。
┌──────────────────────────────────────────────────┐
│ Rate Limiting Architecture │
└──────────────────────────────────────────────────┘
Client Request
│
▼
┌─────────────────┐
│ Connection │───→ Connection Rate Limiter
│ Established │ (per IP, per minute)
└────────┬────────┘
│
▼
┌─────────────────┐
│ Event Published │───→ Event Rate Limiter
│ │ (per IP, per minute)
└─────────────────┘
ファイル: limits.go, init.go
Haven は Khatru のポリシーを使用:
policies.EventIPRateLimiterpolicies.ConnectionRateLimiter
// Event rate limiting
privateRelay.RejectEvent = append(privateRelay.RejectEvent,
policies.EventIPRateLimiter(
tokensPerInterval, // 新規トークン数
interval, // 補充間隔 (分)
maxTokens, // 最大トークン数
),
)
// Connection rate limiting
privateRelay.RejectConnection = append(privateRelay.RejectConnection,
policies.ConnectionRateLimiter(
tokensPerInterval, // 新規トークン数
interval, // 補充間隔 (分)
maxTokens, // 最大トークン数
),
)| Relay | Event Tokens/Min | Event Max | Conn Tokens/Min | Conn Max |
|---|---|---|---|---|
| Private | 50 | 100 | 3 | 9 |
| Chat | 50 | 100 | 3 | 9 |
| Inbox | 10 | 20 | 3 | 9 |
| Outbox | 10 | 100 | 3 | 9 |
type RelayLimits struct {
EventIPLimiterTokensPerInterval int
EventIPLimiterInterval int
EventIPLimiterMaxTokens int
AllowEmptyFilters bool // 空フィルター許可
AllowComplexFilters bool // 複雑フィルター許可
ConnectionRateLimiterTokensPerInterval int
ConnectionRateLimiterInterval int
ConnectionRateLimiterMaxTokens int
}Empty Filter: {} (全イベント取得)
Complex Filter: 複数の authors, kinds, #e, #p など
if !privateRelayLimits.AllowEmptyFilters {
privateRelay.RejectFilter = append(privateRelay.RejectFilter,
policies.NoEmptyFilters)
}
if !privateRelayLimits.AllowComplexFilters {
privateRelay.RejectFilter = append(privateRelay.RejectFilter,
policies.NoComplexFilters)
}環境変数例:
# Private Relay
PRIVATE_RELAY_EVENT_IP_LIMITER_TOKENS_PER_INTERVAL=50
PRIVATE_RELAY_EVENT_IP_LIMITER_INTERVAL=1
PRIVATE_RELAY_EVENT_IP_LIMITER_MAX_TOKENS=100
PRIVATE_RELAY_ALLOW_EMPTY_FILTERS=true
PRIVATE_RELAY_ALLOW_COMPLEX_FILTERS=true
PRIVATE_RELAY_CONNECTION_RATE_LIMITER_TOKENS_PER_INTERVAL=3
PRIVATE_RELAY_CONNECTION_RATE_LIMITER_INTERVAL=5
PRIVATE_RELAY_CONNECTION_RATE_LIMITER_MAX_TOKENS=9
# Chat Relay
CHAT_RELAY_EVENT_IP_LIMITER_TOKENS_PER_INTERVAL=50
CHAT_RELAY_EVENT_IP_LIMITER_INTERVAL=1
CHAT_RELAY_EVENT_IP_LIMITER_MAX_TOKENS=100
CHAT_RELAY_ALLOW_EMPTY_FILTERS=false
CHAT_RELAY_ALLOW_COMPLEX_FILTERS=false
...Haven は 2つのデータベースエンジンをサポートします:
- LMDB (Lightning Memory-Mapped Database) - デフォルト
- BadgerDB - 代替オプション
┌──────────────────────────────────────────────────┐
│ Database Architecture │
└──────────────────────────────────────────────────┘
DBBackend Interface
│
├─ Init() error
├─ Close()
├─ CountEvents(ctx, filter) (int64, error)
├─ DeleteEvent(ctx, evt) error
├─ QueryEvents(ctx, filter) (chan *nostr.Event, error)
├─ SaveEvent(ctx, evt) error
├─ ReplaceEvent(ctx, evt) error
└─ Serial() []byte
│
├─────────────────┬─────────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ LMDB │ │ BadgerDB│ │ (Future)│
│ Backend │ │ Backend │ │ │
└─────────┘ └─────────┘ └─────────┘
ファイル: init.go:42-71
type DBBackend interface {
Init() error
Close()
CountEvents(ctx context.Context, filter nostr.Filter) (int64, error)
DeleteEvent(ctx context.Context, evt *nostr.Event) error
QueryEvents(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error)
SaveEvent(ctx context.Context, evt *nostr.Event) error
ReplaceEvent(ctx context.Context, evt *nostr.Event) error
Serial() []byte
}
func newDBBackend(path string) DBBackend {
switch config.DBEngine {
case "lmdb":
return newLMDBBackend(path)
case "badger":
return &badger.BadgerBackend{
Path: path,
}
default:
return newLMDBBackend(path)
}
}
func newLMDBBackend(path string) *lmdb.LMDBBackend {
return &lmdb.LMDBBackend{
Path: path,
MapSize: config.LmdbMapSize,
}
}func initRelays() {
if err := privateDB.Init(); err != nil {
panic(err)
}
if err := chatDB.Init(); err != nil {
panic(err)
}
if err := outboxDB.Init(); err != nil {
panic(err)
}
if err := inboxDB.Init(); err != nil {
panic(err)
}
if err := blossomDB.Init(); err != nil {
panic(err)
}
// ... relay configuration ...
}| 特徴 | LMDB | BadgerDB |
|---|---|---|
| パフォーマンス | ⚡ Very Fast (NVMe) | ⚡ Fast |
| メモリ使用 | 🟢 Low | 🟡 Medium |
| ディスク使用 | 🟢 Efficient | 🟡 More overhead |
| Map Size | ✅ Auto-expand | |
| プラットフォーム | Linux, macOS, Windows | All |
| マイグレーション |
| 項目 | 環境変数 | デフォルト値 | 説明 |
|---|---|---|---|
| DB Engine | DB_ENGINE |
lmdb |
lmdb or badger |
| LMDB Map Size | LMDB_MAPSIZE |
0 (auto) |
LMDB のマップサイズ (bytes) |
重要: LMDB は事前に最大データベースサイズを設定する必要があります。
# デフォルト: 273 GB (自動計算)
LMDB_MAPSIZE=0
# カスタム: 100 GB
LMDB_MAPSIZE=107374182400
# Windows/macOS: 空きディスク容量より小さい値を設定
LMDB_MAPSIZE=10737418240 # 10 GBガイドライン:
- Linux: デフォルト (0) で OK
- macOS: 空きディスク容量の 50% 以下
- Windows: 空きディスク容量の 50% 以下
- 大規模データベース: 必要に応じて増加
| 項目 | 環境変数 | デフォルト値 | 必須 | 説明 |
|---|---|---|---|---|
| Owner Npub | OWNER_NPUB |
- | ✅ | リレーオーナーの npub |
| Relay URL | RELAY_URL |
- | ✅ | リレーの URL (Tor: .onion) |
| Relay Port | RELAY_PORT |
3355 |
- | リレーのポート |
| Relay Bind Address | RELAY_BIND_ADDRESS |
0.0.0.0 |
- | バインドアドレス |
| Log Level | HAVEN_LOG_LEVEL |
INFO |
- | DEBUG, INFO, WARN, ERROR |
| 項目 | 環境変数 | デフォルト値 | 必須 | 説明 |
|---|---|---|---|---|
| DB Engine | DB_ENGINE |
lmdb |
- | lmdb or badger |
| LMDB Map Size | LMDB_MAPSIZE |
0 |
- | LMDB マップサイズ (bytes) |
| 項目 | 環境変数 | デフォルト値 | 必須 | 説明 |
|---|---|---|---|---|
| Blossom Path | BLOSSOM_PATH |
blossom |
- | メディアファイル保存パス |
| 項目 | 環境変数 | デフォルト値 | 必須 | 説明 |
|---|---|---|---|---|
| Name | PRIVATE_RELAY_NAME |
- | ✅ | リレー名 |
| Npub | PRIVATE_RELAY_NPUB |
- | ✅ | リレーの npub |
| Description | PRIVATE_RELAY_DESCRIPTION |
- | ✅ | リレーの説明 |
| Icon | PRIVATE_RELAY_ICON |
- | - | リレーのアイコン URL |
| 項目 | 環境変数 | デフォルト値 | 必須 | 説明 |
|---|---|---|---|---|
| Name | CHAT_RELAY_NAME |
- | ✅ | リレー名 |
| Npub | CHAT_RELAY_NPUB |
- | ✅ | リレーの npub |
| Description | CHAT_RELAY_DESCRIPTION |
- | ✅ | リレーの説明 |
| Icon | CHAT_RELAY_ICON |
- | - | リレーのアイコン URL |
| WoT Depth | CHAT_RELAY_WOT_DEPTH |
0 |
- | WoT 深さ (0=direct, 1=1-hop) |
| WoT Refresh Interval | CHAT_RELAY_WOT_REFRESH_INTERVAL_HOURS |
0 |
- | WoT 更新間隔 (時間) |
| Minimum Followers | CHAT_RELAY_MINIMUM_FOLLOWERS |
0 |
- | 最小フォロワー数 |
| 項目 | 環境変数 | デフォルト値 | 必須 | 説明 |
|---|---|---|---|---|
| Name | INBOX_RELAY_NAME |
- | ✅ | リレー名 |
| Npub | INBOX_RELAY_NPUB |
- | ✅ | リレーの npub |
| Description | INBOX_RELAY_DESCRIPTION |
- | ✅ | リレーの説明 |
| Icon | INBOX_RELAY_ICON |
- | - | リレーのアイコン URL |
| Pull Interval | INBOX_PULL_INTERVAL_SECONDS |
3600 |
- | Inbox 同期間隔 (秒) |
| 項目 | 環境変数 | デフォルト値 | 必須 | 説明 |
|---|---|---|---|---|
| Name | OUTBOX_RELAY_NAME |
- | ✅ | リレー名 |
| Npub | OUTBOX_RELAY_NPUB |
- | ✅ | リレーの npub |
| Description | OUTBOX_RELAY_DESCRIPTION |
- | ✅ | リレーの説明 |
| Icon | OUTBOX_RELAY_ICON |
- | - | リレーのアイコン URL |
| 項目 | 環境変数 | デフォルト値 | 必須 | 説明 |
|---|---|---|---|---|
| Start Date | IMPORT_START_DATE |
- | - | インポート開始日 (YYYY-MM-DD) |
| Owner Notes Timeout | IMPORT_OWNER_NOTES_FETCH_TIMEOUT_SECONDS |
30 |
- | Owner ノート取得タイムアウト |
| Tagged Notes Timeout | IMPORT_TAGGED_NOTES_FETCH_TIMEOUT_SECONDS |
120 |
- | Tagged ノート取得タイムアウト |
| Query Interval | IMPORT_QUERY_INTERVAL_SECONDS |
360000 |
- | 定期クエリ間隔 |
| Seed Relays File | IMPORT_SEED_RELAYS_FILE |
- | - | インポート元リレーリスト |
| 項目 | 環境変数 | デフォルト値 | 必須 | 説明 |
|---|---|---|---|---|
| Blastr Relays File | BLASTR_RELAYS_FILE |
- | - | Blastr リレーリスト |
| 項目 | 環境変数 | デフォルト値 | 必須 | 説明 |
|---|---|---|---|---|
| Fetch Timeout | WOT_FETCH_TIMEOUT_SECONDS |
30 |
- | WoT フェッチタイムアウト |
| 項目 | 環境変数 | デフォルト値 | 必須 | 説明 |
|---|---|---|---|---|
| Backup Provider | BACKUP_PROVIDER |
none |
- | s3, aws, gcp, none |
| Backup Interval | BACKUP_INTERVAL_HOURS |
24 |
- | バックアップ間隔 (時間) |
| S3 Access Key | S3_ACCESS_KEY_ID |
- | - | S3 アクセスキー |
| S3 Secret Key | S3_SECRET_KEY |
- | - | S3 シークレットキー |
| S3 Endpoint | S3_ENDPOINT |
- | - | S3 エンドポイント |
| S3 Region | S3_REGION |
- | - | S3 リージョン |
| S3 Bucket | S3_BUCKET_NAME |
- | - | S3 バケット名 |
Private Relay:
PRIVATE_RELAY_EVENT_IP_LIMITER_TOKENS_PER_INTERVAL(default:50)PRIVATE_RELAY_EVENT_IP_LIMITER_INTERVAL(default:1)PRIVATE_RELAY_EVENT_IP_LIMITER_MAX_TOKENS(default:100)PRIVATE_RELAY_ALLOW_EMPTY_FILTERS(default:true)PRIVATE_RELAY_ALLOW_COMPLEX_FILTERS(default:true)PRIVATE_RELAY_CONNECTION_RATE_LIMITER_TOKENS_PER_INTERVAL(default:3)PRIVATE_RELAY_CONNECTION_RATE_LIMITER_INTERVAL(default:5)PRIVATE_RELAY_CONNECTION_RATE_LIMITER_MAX_TOKENS(default:9)
Chat Relay:
CHAT_RELAY_EVENT_IP_LIMITER_TOKENS_PER_INTERVAL(default:50)CHAT_RELAY_EVENT_IP_LIMITER_INTERVAL(default:1)CHAT_RELAY_EVENT_IP_LIMITER_MAX_TOKENS(default:100)CHAT_RELAY_ALLOW_EMPTY_FILTERS(default:false)CHAT_RELAY_ALLOW_COMPLEX_FILTERS(default:false)CHAT_RELAY_CONNECTION_RATE_LIMITER_TOKENS_PER_INTERVAL(default:3)CHAT_RELAY_CONNECTION_RATE_LIMITER_INTERVAL(default:3)CHAT_RELAY_CONNECTION_RATE_LIMITER_MAX_TOKENS(default:9)
Inbox Relay:
INBOX_RELAY_EVENT_IP_LIMITER_TOKENS_PER_INTERVAL(default:10)INBOX_RELAY_EVENT_IP_LIMITER_INTERVAL(default:1)INBOX_RELAY_EVENT_IP_LIMITER_MAX_TOKENS(default:20)INBOX_RELAY_ALLOW_EMPTY_FILTERS(default:false)INBOX_RELAY_ALLOW_COMPLEX_FILTERS(default:false)INBOX_RELAY_CONNECTION_RATE_LIMITER_TOKENS_PER_INTERVAL(default:3)INBOX_RELAY_CONNECTION_RATE_LIMITER_INTERVAL(default:1)INBOX_RELAY_CONNECTION_RATE_LIMITER_MAX_TOKENS(default:9)
Outbox Relay:
OUTBOX_RELAY_EVENT_IP_LIMITER_TOKENS_PER_INTERVAL(default:10)OUTBOX_RELAY_EVENT_IP_LIMITER_INTERVAL(default:60)OUTBOX_RELAY_EVENT_IP_LIMITER_MAX_TOKENS(default:100)OUTBOX_RELAY_ALLOW_EMPTY_FILTERS(default:false)OUTBOX_RELAY_ALLOW_COMPLEX_FILTERS(default:false)OUTBOX_RELAY_CONNECTION_RATE_LIMITER_TOKENS_PER_INTERVAL(default:3)OUTBOX_RELAY_CONNECTION_RATE_LIMITER_INTERVAL(default:1)OUTBOX_RELAY_CONNECTION_RATE_LIMITER_MAX_TOKENS(default:9)
| 機能 | 実装状況 | Start9 対応 | 備考 |
|---|---|---|---|
| Private Relay | ✅ 完了 | ✅ 完全対応 | NIP-42 Auth 完全動作 |
| Chat Relay | ✅ 完了 | ✅ 完全対応 | WoT + Auth 完全動作 |
| Inbox Relay | ✅ 完了 | ✅ 完全対応 | 自動同期動作確認済み |
| Outbox Relay | ✅ 完了 | ✅ 完全対応 | Blastr 統合済み |
| Blossom Server | ✅ 完了 | ✅ 完全対応 | Amethyst で動作確認 |
| Web of Trust | ✅ 完了 | ✅ 完全対応 | 自動更新動作 |
| Import (Owner) | ✅ 完了 | ✅ 完全対応 | Action 実装済み |
| Import (Tagged) | ✅ 完了 | ✅ 完全対応 | Action 実装済み |
| Import (Continuous) | ✅ 完了 | ✅ 完全対応 | バックグラウンド動作 |
| Blastr | ✅ 完了 | ✅ 完全対応 | IPv6 + Tor 対応 |
| Cloud Backup | ✅ 完了 | Start9 内蔵バックアップ使用 | |
| Tor Integration | ✅ 完了 | ✅ 完全対応 | .onion 固定化済み |
| Config UI | ✅ 完了 | ✅ 完全対応 | 全設定項目対応 |
| Health Check | ✅ 完了 | ✅ 完全対応 | Web interface チェック |
| Properties | ✅ 完了 | ✅ 完全対応 | .onion address 表示 |
実装: docker_entrypoint.sh, torrc
# Start Tor
echo "Starting Tor..."
/usr/local/bin/tor -f /etc/tor/torrc &
# Wait for Tor to generate .onion address
while [ ! -f /data/tor/haven/hostname ]; do
echo "Waiting for Tor hidden service to initialize..."
sleep 1
done
ONION_ADDRESS=$(cat /data/tor/haven/hostname)
echo "Tor hidden service: $ONION_ADDRESS"Tor 設定 (torrc):
HiddenServiceDir /data/tor/haven/
HiddenServicePort 80 127.0.0.1:3355
ClientUseIPv6 1
ClientPreferIPv6ORPort 1
IPv6Exit 1
Get Config: scripts/procedures/getConfig.ts
Set Config: scripts/procedures/setConfig.ts
Start9 UI と Haven の .env 設定を双方向同期。
実装: docker_entrypoint.sh
if [ "$1" = "import-notes" ]; then
echo "Setting up import request..."
touch /data/.import-request
echo "Import will run on next Haven restart."
exit 0
fi
# Check if import is requested
if [ -f /data/.import-request ]; then
echo "Import requested, starting Haven in import mode..."
rm /data/.import-request
cd /haven
./haven --import
echo "Import complete, restarting in normal mode..."
fi実装: scripts/procedures/properties.ts
export const properties: T.ExpectedExports.properties = async (effects) => {
const onionAddress = await effects.readFile({
path: '/data/tor/haven/hostname',
volumeId: 'main',
})
return {
'Haven Relay': {
type: 'string',
value: `${onionAddress.trim()}`,
description: 'Your private Nostr relay .onion address',
copyable: true,
qr: true,
masked: false,
},
// ... more properties
}
}概要: Haven にテキスト検索機能を追加
┌──────────────────────────────────────────────────┐
│ NIP-50 Search Architecture │
└──────────────────────────────────────────────────┘
Client
│
│ REQ ["search", {"search": "bitcoin", ...}]
▼
┌─────────────────┐
│ Search Handler │
└────────┬────────┘
│
▼
┌──────────┐
│ FTS Index│
│ (SQLite) │
└──────────┘
│
▼
┌──────────┐
│ Results │
└──────────┘
実装予定:
- Full-text search index (SQLite FTS5)
- Private/Chat/Inbox/Outbox 全リレーで検索可能
- 検索クエリ:
{"search": "keyword", "kinds": [1], ...}
優先度: 🔴 High
概要: Start9 の LAUNCH UI ボタンでアクセスできる Web ダッシュボード
機能:
- リレー統計 (イベント数、接続数)
- WoT 統計 (メンバー数、ネットワークサイズ)
- Blossom 統計 (ファイル数、ストレージ使用量)
- Import 履歴
- ログビューアー
優先度: 🟡 Medium
概要: 定期的に WoT を更新
現状: 手動または再起動時のみ
実装予定: 設定した間隔で自動更新 (CHAT_RELAY_WOT_REFRESH_INTERVAL_HOURS)
優先度: 🟢 Low (設定は既に存在)
概要: LMDB ↔ BadgerDB 間の移行ツール
実装予定:
- コマンドラインツール:
./haven --migrate-db lmdb badger - Start9 Action として統合
優先度: 🟢 Low
概要: 複数ユーザーの Haven インスタンスをサポート
実装予定:
- ユーザーごとの設定
- ユーザーごとの WoT
- 共有 Inbox (オプション)
優先度: ⚪ Future
- NIP-01: Basic protocol flow
- NIP-04: Encrypted Direct Message (Deprecated)
- NIP-42: Authentication
- NIP-50: Search Capability (未実装)
- NIP-59: Gift Wrap
- NIP-65: Relay List Metadata
- NIP-96: HTTP File Storage Integration
- NIP-98: HTTP Auth
- Khatru - Nostr relay framework
- go-nostr - Nostr client library
- eventstore - Database backends
| 日付 | バージョン | 変更内容 |
|---|---|---|
| 2025-12-27 | 1.0.0 | 初版作成 |
Haven - High Availability Vault for Events on Nostr
Built with ❤️ by the Nostr community