NIP-78: 應用程式自訂資料
在 Nostr 中繼器上儲存任意應用程式資料
概述
NIP-78 定義了一種在 Nostr 中繼器上儲存任意應用程式資料的方法。 使用 kind 30078 可定址事件,應用程式可以將用戶偏好設定、配置資料或任何自訂資料 儲存在用戶選擇的中繼器上,實現「自帶資料庫」的模式。
事件結構
Kind 30078 是一個可定址的可替換事件,使用 d 標籤作為識別符。
{
"kind": 30078,
"tags": [
["d", "myapp/user-settings"]
],
"content": "{\"theme\":\"dark\",\"language\":\"zh-TW\",\"notifications\":true}",
"created_at": 1700000000
} 核心元素
| 元素 | 說明 | 範例 |
|---|---|---|
kind | 固定為 30078 | 30078 |
d 標籤 | 應用程式和資料的識別符 | ["d", "myapp/settings"] |
content | 任意格式的資料內容 | JSON、純文字、Base64 等 |
d 標籤命名慣例
d 標籤可以是任意字串,但建議使用有意義的命名來組織資料:
// 應用程式設定
["d", "com.example.myapp/settings"]
// 特定功能的資料
["d", "com.example.myapp/bookmarks"]
// 帶版本的配置
["d", "com.example.myapp/config/v2"]
// 遊戲存檔
["d", "com.example.game/save/slot1"]
// 用戶偏好
["d", "myapp:preferences:theme"] 設計哲學:NIP-78 刻意不規定資料格式,讓每個應用程式根據自身需求決定內容結構和標籤方案。 這優先考慮應用程式特定功能而非互操作性。
使用場景
1. 用戶偏好設定
儲存 Nostr 客戶端或其他應用程式的用戶設定:
{
"kind": 30078,
"tags": [["d", "nostr-client/preferences"]],
"content": "{\"theme\":\"dark\",\"fontSize\":16,\"autoLoadMedia\":false,\"defaultZapAmount\":1000}"
} 2. 應用程式配置分發
開發者可以透過 Nostr 分發配置更新,無需發布軟體更新:
{
"kind": 30078,
"tags": [
["d", "myapp/remote-config"],
["version", "2.1.0"]
],
"content": "{\"featureFlags\":{\"newUI\":true,\"betaFeatures\":false},\"apiEndpoints\":{\"primary\":\"https://api.example.com\",\"fallback\":\"https://backup.example.com\"}}"
} 3. 非 Nostr 應用程式的資料儲存
任何應用程式都可以使用 Nostr 作為個人資料儲存:
{
"kind": 30078,
"tags": [["d", "todo-app/tasks"]],
"content": "[{\"id\":1,\"text\":\"學習 Nostr\",\"done\":true},{\"id\":2,\"text\":\"建立應用程式\",\"done\":false}]"
} 4. 遊戲存檔
{
"kind": 30078,
"tags": [
["d", "pixel-adventure/save/slot1"],
["t", "game-save"]
],
"content": "{\"level\":15,\"score\":12500,\"inventory\":[\"sword\",\"shield\",\"potion\"],\"position\":{\"x\":100,\"y\":200}}"
} 5. 跨裝置同步
{
"kind": 30078,
"tags": [["d", "password-manager/encrypted-vault"]],
"content": "<encrypted-base64-data>"
} TypeScript 實作
儲存應用程式資料
import { finalizeEvent, generateSecretKey, SimplePool } from 'nostr-tools';
interface AppDataOptions {
appId: string;
dataKey: string;
data: any;
additionalTags?: string[][];
}
function createAppDataEvent(
options: AppDataOptions,
secretKey: Uint8Array
) {
const dTag = `${options.appId}/${options.dataKey}`;
const tags: string[][] = [['d', dTag]];
if (options.additionalTags) {
tags.push(...options.additionalTags);
}
const content =
typeof options.data === 'string'
? options.data
: JSON.stringify(options.data);
const event = {
kind: 30078,
content,
tags,
created_at: Math.floor(Date.now() / 1000),
};
return finalizeEvent(event, secretKey);
}
// 使用範例:儲存用戶設定
const sk = generateSecretKey();
const settingsEvent = createAppDataEvent(
{
appId: 'com.example.myapp',
dataKey: 'settings',
data: {
theme: 'dark',
language: 'zh-TW',
notifications: {
enabled: true,
sound: false,
},
},
},
sk
);
// 發布到中繼器
const pool = new SimplePool();
await pool.publish(['wss://relay.damus.io'], settingsEvent); 讀取應用程式資料
import { SimplePool, getPublicKey } from 'nostr-tools';
async function getAppData<T>(
pubkey: string,
appId: string,
dataKey: string,
relays: string[]
): Promise<T | null> {
const pool = new SimplePool();
const dTag = `${appId}/${dataKey}`;
const events = await pool.querySync(relays, {
kinds: [30078],
authors: [pubkey],
'#d': [dTag],
});
if (events.length === 0) {
return null;
}
// 取得最新的事件
const latestEvent = events.sort((a, b) => b.created_at - a.created_at)[0];
try {
return JSON.parse(latestEvent.content) as T;
} catch {
return latestEvent.content as T;
}
}
// 使用範例
interface UserSettings {
theme: 'light' | 'dark';
language: string;
notifications: {
enabled: boolean;
sound: boolean;
};
}
const pk = getPublicKey(sk);
const settings = await getAppData<UserSettings>(
pk,
'com.example.myapp',
'settings',
['wss://relay.damus.io', 'wss://nos.lol']
);
if (settings) {
console.log(`主題: ${settings.theme}`);
console.log(`語言: ${settings.language}`);
} 完整的應用程式資料管理類別
import { finalizeEvent, SimplePool, getPublicKey } from 'nostr-tools';
class NostrAppStorage {
private pool: SimplePool;
private relays: string[];
private appId: string;
private secretKey: Uint8Array;
private pubkey: string;
constructor(
appId: string,
secretKey: Uint8Array,
relays: string[]
) {
this.pool = new SimplePool();
this.appId = appId;
this.secretKey = secretKey;
this.pubkey = getPublicKey(secretKey);
this.relays = relays;
}
private getDTag(key: string): string {
return `${this.appId}/${key}`;
}
async set<T>(key: string, value: T): Promise<void> {
const event = {
kind: 30078,
content: JSON.stringify(value),
tags: [['d', this.getDTag(key)]],
created_at: Math.floor(Date.now() / 1000),
};
const signedEvent = finalizeEvent(event, this.secretKey);
await this.pool.publish(this.relays, signedEvent);
}
async get<T>(key: string): Promise<T | null> {
const events = await this.pool.querySync(this.relays, {
kinds: [30078],
authors: [this.pubkey],
'#d': [this.getDTag(key)],
});
if (events.length === 0) {
return null;
}
const latest = events.sort((a, b) => b.created_at - a.created_at)[0];
try {
return JSON.parse(latest.content) as T;
} catch {
return null;
}
}
async delete(key: string): Promise<void> {
// 發布一個空內容的事件來「刪除」資料
const event = {
kind: 30078,
content: '',
tags: [
['d', this.getDTag(key)],
['deleted', 'true'],
],
created_at: Math.floor(Date.now() / 1000),
};
const signedEvent = finalizeEvent(event, this.secretKey);
await this.pool.publish(this.relays, signedEvent);
}
async list(): Promise<string[]> {
const events = await this.pool.querySync(this.relays, {
kinds: [30078],
authors: [this.pubkey],
});
const keys = new Set<string>();
const prefix = `${this.appId}/`;
events.forEach((event) => {
const dTag = event.tags.find((t) => t[0] === 'd')?.[1];
if (dTag?.startsWith(prefix)) {
keys.add(dTag.slice(prefix.length));
}
});
return Array.from(keys);
}
}
// 使用範例
const storage = new NostrAppStorage(
'com.example.todoapp',
sk,
['wss://relay.damus.io', 'wss://nos.lol']
);
// 儲存資料
await storage.set('tasks', [
{ id: 1, text: '學習 NIP-78', done: true },
{ id: 2, text: '建立應用程式', done: false },
]);
// 讀取資料
const tasks = await storage.get<Array<{ id: number; text: string; done: boolean }>>('tasks');
// 列出所有鍵
const keys = await storage.list();
console.log('儲存的資料:', keys); 加密資料儲存
import { nip44, getPublicKey } from 'nostr-tools';
class EncryptedNostrStorage extends NostrAppStorage {
private conversationKey: Uint8Array;
constructor(
appId: string,
secretKey: Uint8Array,
relays: string[]
) {
super(appId, secretKey, relays);
// 使用自己的公鑰進行加密(自我加密)
const pubkey = getPublicKey(secretKey);
this.conversationKey = nip44.getConversationKey(secretKey, pubkey);
}
async setEncrypted<T>(key: string, value: T): Promise<void> {
const plaintext = JSON.stringify(value);
const encrypted = nip44.encrypt(plaintext, this.conversationKey);
await this.set(key, { encrypted, version: 'nip44' });
}
async getEncrypted<T>(key: string): Promise<T | null> {
const data = await this.get<{ encrypted: string; version: string }>(key);
if (!data || !data.encrypted) {
return null;
}
try {
const decrypted = nip44.decrypt(data.encrypted, this.conversationKey);
return JSON.parse(decrypted) as T;
} catch {
return null;
}
}
}
// 使用範例:儲存敏感資料
const encryptedStorage = new EncryptedNostrStorage(
'com.example.vault',
sk,
['wss://relay.damus.io']
);
await encryptedStorage.setEncrypted('passwords', [
{ site: 'example.com', username: 'user', password: 'secret123' },
]);
const passwords = await encryptedStorage.getEncrypted<Array<any>>('passwords'); 查詢範例
查詢特定應用程式的所有資料
const pool = new SimplePool();
// 查詢所有以特定前綴開頭的資料
const allAppData = await pool.querySync(relays, {
kinds: [30078],
authors: [pubkey],
});
// 過濾特定應用程式的資料
const myAppData = allAppData.filter((event) => {
const dTag = event.tags.find((t) => t[0] === 'd')?.[1];
return dTag?.startsWith('com.example.myapp/');
}); 查詢特定資料
const settings = await pool.querySync(relays, {
kinds: [30078],
authors: [pubkey],
'#d': ['com.example.myapp/settings'],
}); 最佳實踐
- 使用明確的命名空間:用反向域名格式(如
com.example.app)避免衝突 - 版本控制:在資料結構變更時加入版本標識
- 加密敏感資料:使用 NIP-44 加密私密資訊
- 合理的資料大小:避免儲存過大的資料,考慮分割或壓縮
- 選擇可靠的中繼器:使用支援長期儲存的中繼器
- 錯誤處理:處理資料解析失敗和中繼器不可用的情況
限制與考量
- 無互操作性:資料格式由應用程式自訂,不同應用程式無法共享
- 儲存限制:中繼器可能有大小限制或儲存政策
- 無即時同步:需要主動查詢更新,不像傳統資料庫有推送機制
- 公開性:未加密的資料對中繼器和其他用戶可見
相關 NIPs
參考資源
已複製連結