Files
client/docs/SETTINGS.md
T
2026-04-17 16:52:51 -04:00

545 lines
20 KiB
Markdown

# Settings System — Kind 30078
This document is the definitive specification for how user settings and application-specific data are stored, encrypted, synced, and consumed across all projects in this ecosystem (client web pages, Didactyl agent, and future apps).
## Table of Contents
1. [Overview](#1-overview)
2. [NIP-78 Context](#2-nip-78-context)
3. [User-Centric Layout](#3-user-centric-layout)
4. [Centralized User Settings Event](#4-centralized-user-settings-event)
5. [Worker Lifecycle](#5-worker-lifecycle)
6. [Page-Level API](#6-page-level-api)
7. [Standalone Kind 30078 Events](#7-standalone-kind-30078-events)
8. [Cross-Project Alignment: Didactyl](#8-cross-project-alignment-didactyl)
9. [Encryption Standard](#9-encryption-standard)
10. [Adding a New Settings Namespace](#10-adding-a-new-settings-namespace)
11. [Audit Findings and Migration Plan](#11-audit-findings-and-migration-plan)
12. [Key Source Files](#12-key-source-files)
---
## 1. Overview
The app uses **NIP-78 Application-specific Data** (`kind:30078`) for two distinct purposes:
1. **Centralized User Settings** — A single addressable event (`d:user-settings`) containing a namespaced JSON object. This is the user's portable preference file, designed to work across apps.
2. **Standalone Page Data** — Individual addressable events with page-specific `d` tags for large or specialized data stores.
Both are **parameterized replaceable events**: for a given `pubkey + kind + d-tag`, only the latest event is retained by relays.
```mermaid
graph TD
subgraph Centralized - d:user-settings
W[SharedWorker ndk-worker.js]
W -->|hydrate on init| C[IndexedDB cache]
W -->|fetch + decrypt| R[Relays]
W -->|NIP-44 encrypt + publish| R
P1[Page A] -->|getUserSettings| W
P2[Page B] -->|patchUserSettings| W
P3[Page C] -->|onUserSettings| W
D[Didactyl Agent] -->|reads llm from user prefs| R
end
subgraph Standalone - per-page d-tags
S1[todo.html] -->|d:todo| R2[Relays]
S2[cal.html] -->|d:calorie_foods / d:calorie_diary| R2
S3[links.html] -->|d:links| R2
S4[ai.html] -->|d:convo-id with t:client-ai-chat-v1| R2
S5[keep-alive.html] -->|d:relay-list-N with t:relay-list| R2
S6[post.html] -->|d:viewed| R2
end
```
---
## 2. NIP-78 Context
[NIP-78](../reference_repos/nips/78.md) specifies kind `30078` as an addressable event with a `d` tag containing "some reference to the app name and context — or any other arbitrary string." Content and tags can be anything.
**NIP-78 is intentionally a blank canvas.** No NIP defines:
- A standard schema for user preferences
- A convention for d-tag naming
- A cross-app settings format
This means our `d:user-settings` convention is ours to define. If it proves useful across the Nostr ecosystem, it could eventually become a NIP proposal for standardized user preferences.
**Related NIPs:**
- **NIP-44** — The required encryption standard for settings content (NIP-04 is deprecated)
- **NIP-51** — Defines structured lists (kinds 10000-30007) but not app settings
- **NIP-37** — Defines draft wraps (kind 31234) with separate relay lists for private content
---
## 3. User-Centric Layout
From the user's npub perspective, their kind 30078 events should be organized into three categories:
### Category 1: Cross-App User Preferences (`d:user-settings`)
Settings that any compatible app should respect. These follow the user across devices and apps:
| Namespace | Purpose | Example consumers |
|-----------|---------|-------------------|
| `global_llm` | LLM provider, model, API key, multi-provider config | AI pages, skills-edit, Didactyl agent |
| `global_zaps` | Default zap amount, comment, preferred method, mint allowlist | Cashu page, post interactions, any zap button |
| `global_ui` | Theme, language, accessibility preferences | All pages |
| `global_relays` | Relay preferences | All pages |
| `global_experimental` | Feature flags | All pages |
The `global_` prefix distinguishes cross-app settings from page-specific ones at a glance.
### Category 2: Page-Specific Settings (also in `d:user-settings`)
Layout and UI state that only matters to specific pages within client:
| Namespace | Purpose | Owning page |
|-----------|---------|-------------|
| `feed` | Video autoplay | `feed.html` |
| `post` | Viewed scroll behavior | `post.html` |
| `notifications` | Filters, readAt timestamp | `notifications.html` |
| `blobs` | Grid columns, rows per page | `blobs.html` |
| `vjPage` | Streaming sites, columns, draft | `vj.html` |
| `strudel` | Strudel music coding settings | strudel pages |
No prefix — these are clearly page-local by their names.
### Category 3: Standalone Data Events (separate d-tags)
Large data stores that would bloat the centralized settings event:
| d-tag | Purpose | Why standalone |
|-------|---------|---------------|
| `todo` | Todo list items | Can grow large |
| `calorie_foods` / `calorie_diary` | Calorie tracking data | Separate data domains |
| `links` | Bookmarks (Netscape HTML) | Large, compressed |
| `viewed` | Read/unread tracking per follow | Updates frequently |
| `{conversation-id}` | AI chat conversations | Many events, large |
| `relay-list-{N}` | Relay registry chunks | Intentionally public |
| `show-playlist:{show}:{ts}` | VJ episode playlists | Intentionally public |
---
## 4. Centralized User Settings Event
### Nostr Event Shape
```json
{
"kind": 30078,
"tags": [["d", "user-settings"]],
"content": "<NIP-44 encrypted JSON>",
"created_at": 1708646400
}
```
- **`d` tag**: Always `"user-settings"`
- **Encryption**: NIP-44 self-encrypt (sender = recipient = user pubkey)
- **Replaceability**: Addressable — publishing a new one replaces the previous on relays
### Target Schema (v2)
```json
{
"v": 2,
"updatedAt": 1708646400,
"global_llm": {
"provider": "ppq",
"api_key": "sk-...",
"model": "claude-opus-4.6",
"base_url": "https://api.ppq.ai",
"max_tokens": 200000,
"temperature": 0.7,
"providers": [
{
"name": "ppq",
"base_url": "https://api.ppq.ai",
"api_key": "sk-...",
"models": ["claude-opus-4.6", "claude-haiku-4.5"]
}
],
"favorites": ["claude-opus-4.6"]
},
"global_zaps": {
"defaultAmountSats": 21,
"defaultComment": "",
"preferredMethod": "auto",
"receiveMintAllowlist": []
},
"global_ui": {},
"global_relays": {},
"global_experimental": {},
"feed": { "videoAutoplay": false },
"post": { "viewed": { "scrollToMark": false } },
"notifications": { "filters": {}, "readAt": 0 },
"blobs": { "gridColumns": 4, "rowsPerPage": 10 },
"vjPage": {
"streamingSites": [],
"selectedSiteName": "",
"streamDraft": {},
"autoAnnounce": {},
"columns": {}
},
"strudel": {}
}
```
### Current Schema (v1) — Supported
The current implementation uses these namespace names without the `global_` prefix:
| Current key | Target key | Status |
|-------------|------------|--------|
| `ui` | `global_ui` | Rename pending |
| `relays` | `global_relays` | Rename pending |
| `experimental` | `global_experimental` | Rename pending |
| `zaps` | `global_zaps` | Rename pending |
| `ai` | `global_llm` | Rename + schema alignment pending |
| `post` | `post` | No change |
| `feed` | `feed` | No change |
| `notifications` | `notifications` | No change |
| `blobs` | `blobs` | No change |
| `vjPage` | `vjPage` | No change |
| `strudel` | `strudel` | No change |
### Default Settings
Defined in [`getDefaultUserSettings()`](../www/ndk-worker.js:324):
```js
{
v: 1, // SETTINGS_SCHEMA_VERSION
updatedAt: 0,
ui: {},
post: { viewed: { scrollToMark: false } },
strudel: {},
relays: {},
experimental: {}
}
```
Feature namespaces not in the defaults (e.g. `feed`, `notifications`, `zaps`, `blobs`, `ai`, `vjPage`) are created on first patch. The [`deepMerge()`](../www/ndk-worker.js:344) function ensures new keys are added without destroying existing ones.
### Normalization
[`normalizeUserSettings()`](../www/ndk-worker.js:359) deep-merges any input with the defaults, then forces:
- `v` to the current `SETTINGS_SCHEMA_VERSION`
- `updatedAt` to a numeric value
---
## 5. Worker Lifecycle
### Hydration (startup)
Called by [`hydrateUserSettingsForPubkey()`](../www/ndk-worker.js:3992) during [`handleInit()`](../www/ndk-worker.js:4271):
```
1. Read from IndexedDB cache (ndk-shared-settings DB, kv store)
2. Normalize and broadcast to all connected tabs
3. Fetch kind 30078 d:user-settings from relays
4. NIP-44 decrypt the content
5. Compare updatedAt timestamps — relay wins if >= local
6. Write winner to IndexedDB cache
7. Broadcast final settings to all tabs
```
### Publishing
Called by [`publishUserSettingsNow()`](../www/ndk-worker.js:4018):
```
1. Set updatedAt to current unix timestamp
2. Normalize the settings object
3. JSON.stringify the normalized object
4. NIP-44 encrypt (self-encrypt: sender = recipient = user pubkey)
5. Create NDKEvent with kind:30078, tags:[['d','user-settings']]
6. Sign and publish to connected relays
```
### Debounced Publish
[`scheduleUserSettingsPublish()`](../www/ndk-worker.js:4055) debounces rapid patches with a 250-350ms delay so multiple quick UI changes result in a single relay publish.
---
## 6. Page-Level API
All functions are exported from [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs) and communicate with the SharedWorker via `postMessage`.
### `getUserSettings()` -> `Promise<Object>`
[Source](../www/js/init-ndk.mjs:956). Returns the current merged settings object. Times out after 7 seconds.
### `patchUserSettings(patch, options?)` -> `Promise<Object>`
[Source](../www/js/init-ndk.mjs:985). Deep-merges `patch` into current settings, writes to cache, broadcasts to all tabs, and (unless `options.publish === false`) schedules a debounced relay publish.
**Convention**: Each page patches only its own namespace:
```js
await patchUserSettings({
myFeature: { someSetting: true }
});
```
### `onUserSettings(callback)` -> `unsubscribe function`
[Source](../www/js/init-ndk.mjs:1015). Subscribes to live settings updates via the `ndkUserSettings` custom DOM event. Returns an unsubscribe function.
### Standard Page Pattern
```js
// 1. Import
import { getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs';
// 2. Initial read
let pageSettings = {};
try {
pageSettings = await getUserSettings();
} catch (error) {
pageSettings = {};
}
// 3. Subscribe to live updates
const unsubscribe = onUserSettings((settings) => {
pageSettings = settings || {};
applySettings(pageSettings);
});
// 4. Patch your namespace
await patchUserSettings({ myFeature: { key: value } });
```
---
## 7. Standalone Kind 30078 Events
These are **not** part of the centralized user-settings object. Each page manages its own `d`-tagged event independently.
| d-tag | Page | Encryption | Content Format | Description |
|-------|------|------------|----------------|-------------|
| `todo` | [`todo.html`](../www/todo.html:831) | NIP-04 (legacy) | `{ rows: [todo items] }` | User todo list |
| `calorie_foods` | [`cal.html`](../www/cal.html:975) | NIP-04 (legacy) | `{ rows: [food items] }` | Calorie food database |
| `calorie_diary` | [`cal.html`](../www/cal.html:975) | NIP-04 (legacy) | `{ rows: [diary entries] }` | Daily food diary |
| `links` | [`links.html`](../www/links.html:497) | NIP-04 + LZW (legacy) | Netscape bookmark HTML | Saved bookmarks |
| `viewed` | [`post.html`](../www/post.html) | NIP-44 | `{ v:1, lastGlobalView, follows: {} }` | Read/unread tracking |
| `relay-list-{N}` | [`keep-alive.html`](../www/keep-alive.html:1162) | Plaintext | JSON relay metadata chunks | Relay registry |
| `{conversation-id}` | [`ai.html`](../www/ai.html:788) | NIP-44 | `{ id, title, messages, ... }` | AI chat conversations |
| `show-playlist:{show}:{ts}` | [`vj-stream.mjs`](../www/js/vj-stream.mjs:5) | Plaintext | Episode playlist with track tags | VJ episode playlists |
### Why Standalone?
- **Size**: Todo lists, bookmarks, and conversations can be large
- **Update frequency**: Conversations and playlists update frequently and independently
- **Different encryption**: Some use NIP-04 (legacy), some NIP-44, some are plaintext
- **Different audiences**: Relay registry and playlists are intentionally public
---
## 8. Cross-Project Alignment: Didactyl
### Didactyl's Kind 30078 Usage
The Didactyl agent (a C binary with its own Nostr identity) uses kind 30078 with NIP-44 self-encrypted payloads:
| d-tag | Source | Content |
|-------|--------|---------|
| `user-settings` | [`main.c`](/home/user/lt/didactyl/src/main.c) | `{ v, updatedAt, global_llm, didactyl }` |
| `tasks` | [`tool_task.c:14`](/home/user/lt/didactyl/src/tools/tool_task.c:14) | Agent task memory |
| `memory` | [`tool_memory.c:14`](/home/user/lt/didactyl/src/tools/tool_memory.c:14) | Agent long-term memory |
| Any d-tag | [`tool_config.c:128`](/home/user/lt/didactyl/src/tools/tool_config.c:128) | Generic config_store/config_recall |
### Didactyl `user-settings` Shape
Didactyl now stores runtime LLM + agent metadata in a single `d:user-settings` event under the **agent's own pubkey**:
```json
{
"v": 2,
"updatedAt": 1712345678,
"global_llm": {
"provider": "ppq",
"api_key": "sk-...",
"model": "claude-opus-4.6",
"base_url": "https://api.ppq.ai",
"max_tokens": 200000,
"temperature": 0.7
},
"didactyl": {
"admin_pubkey": "npub1...",
"dm_protocol": "nip04"
}
}
```
`model_set` performs read-modify-write of this event by patching `global_llm` while preserving other namespaces.
### Cross-Project Reading
Didactyl does **not** read admin/user web `user-settings` for runtime startup. It only reads/writes:
```
kind:30078, authors:[agent_pubkey], #d:[user-settings]
```
If a web page wants to inspect agent runtime settings, it should query the agent pubkey's `d:user-settings` event and read:
- `global_llm` for model/provider/api settings
- `didactyl` for agent-specific runtime metadata
---
## 9. Encryption Standard
| Context | Method | Rationale |
|---------|--------|-----------|
| Centralized user-settings | NIP-44 self-encrypt | Modern standard; worker handles via messageSigner |
| `viewed` (post read state) | NIP-44 self-encrypt | Privacy — relays cannot see read state |
| AI conversations | NIP-44 self-encrypt | Contains private chat history |
| Didactyl configs | NIP-44 self-encrypt | Contains API keys |
| `todo` | **NIP-04 (legacy — migrate)** | Predates NIP-44 |
| `calorie_foods` / `calorie_diary` | **NIP-04 (legacy — migrate)** | Predates NIP-44 |
| `links` | **NIP-04 + LZW (legacy — migrate)** | Predates NIP-44 |
| `relay-list-{N}` | Plaintext | Intentionally public |
| VJ episode playlists | Plaintext | Intentionally public |
**NIP-04 is deprecated.** All legacy pages should migrate to NIP-44.
---
## 10. Adding a New Settings Namespace
### Step 1: Choose centralized vs standalone
- Small config that benefits from cross-tab sync -> centralized namespace
- Large data, high-frequency updates, or different encryption needs -> standalone d-tag
- Cross-app portable preference -> centralized with `global_` prefix
### Step 2: For centralized (recommended for most page settings)
```js
import { getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs';
// Read on init
const settings = await getUserSettings();
const myConfig = settings?.myNewFeature || {};
// Subscribe to live updates
onUserSettings((s) => {
const updated = s?.myNewFeature || {};
applyMyConfig(updated);
});
// Save changes
await patchUserSettings({
myNewFeature: { option1: true, option2: 'value' }
});
```
### Step 3: For standalone
```js
import { subscribe, publishEvent } from './js/init-ndk.mjs';
// Read
subscribe(
{ kinds: [30078], authors: [pubkey], '#d': ['my-feature-data'] },
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
);
// Write (use NIP-44 encryption via worker messageSigner)
await publishEvent({
kind: 30078,
tags: [['d', 'my-feature-data']],
content: encryptedContent,
created_at: Math.floor(Date.now() / 1000)
});
```
### Step 4: Document
Add the new namespace to the appropriate table in this file.
---
## 11. Audit Findings and Migration Plan
### Issue 1: Duplicate AI Config Storage — RESOLVED
**Previous problem**: AI provider config was stored in two places:
- `skills-edit.html` used standalone user-pubkey `d:llm_config`
- Other AI pages used centralized user settings (`settings.ai` / `global_llm`) via `patchUserSettings`
**Resolution implemented**:
- `skills-edit.html` now reads from centralized user settings `global_llm` (with `ai` fallback for v1 compatibility)
- `skills-edit.html` now writes LLM updates via `patchUserSettings({ global_llm: ... })`
- Standalone user-pubkey `d:llm_config` is deprecated
- Agent-pubkey standalone `d:llm_config` remains the Didactyl runtime config format
### Issue 2: NIP-04 Legacy Encryption — HIGH PRIORITY
**Problem**: Three pages use deprecated NIP-04 encryption:
- [`todo.html`](../www/todo.html:822) — `window.nostr.nip04.encrypt()`
- [`cal.html`](../www/cal.html:972) — `window.nostr.nip04.encrypt()`
- [`links.html`](../www/links.html:501) — `window.nostr.nip04.encrypt()` + LZW
**Migration**: Switch to NIP-44. Add one-time migration: read NIP-04, re-encrypt with NIP-44, republish.
### Issue 3: Direct `window.nostr` Calls — MEDIUM PRIORITY
**Problem**: Standalone pages call `window.nostr.nip04.encrypt/decrypt` directly, bypassing the worker's `messageSigner` infrastructure. This breaks with remote signers/bunkers.
**Migration**: Route encryption through the worker's `messageSigner` for consistency.
### Issue 4: Inconsistent Filter Syntax — LOW PRIORITY
**Problem**: [`cal.html`](../www/cal.html:997) passes filter as an array `[{ kinds: [30078], ... }]` instead of a plain object.
**Migration**: Normalize to object form.
### Issue 5: Missing `cacheUsage: 'CACHE_FIRST'` — LOW PRIORITY
**Problem**: [`todo.html`](../www/todo.html:863), [`cal.html`](../www/cal.html:998), [`links.html`](../www/links.html:434) subscribe without `cacheUsage: 'CACHE_FIRST'`.
**Migration**: Add `cacheUsage: 'CACHE_FIRST'` for faster load times.
### Issue 6: Merge `calorie_foods` + `calorie_diary` — LOW PRIORITY
**Problem**: [`cal.html`](../www/cal.html:1103) publishes two separate events that are always loaded and saved together.
**Migration**: Combine into single `d:calorie` event: `{ foods: {...}, diary: {...} }`.
### Issue 7: Rename to `global_` Prefix — DEFERRED
**Problem**: Current cross-app namespaces (`ai`, `zaps`, `ui`, `relays`, `experimental`) lack the `global_` prefix.
**Migration**: Bump schema to v2. In `normalizeUserSettings()`, detect v1 and migrate: copy `ai` -> `global_llm`, `zaps` -> `global_zaps`, etc. Support reading both during transition.
### Migration Priority Order
1. Merge `skills-edit.html` `d:llm_config` into centralized settings (Issue 1)
2. Migrate `todo.html`, `cal.html`, `links.html` from NIP-04 to NIP-44 (Issue 2)
3. Route standalone encryption through worker messageSigner (Issue 3)
4. Fix `cal.html` filter syntax + add CACHE_FIRST everywhere (Issues 4, 5)
5. Merge calorie events (Issue 6)
6. Rename to `global_` prefix with v2 schema migration (Issue 7)
---
## 12. Key Source Files
| File | Role |
|------|------|
| [`www/ndk-worker.js`](../www/ndk-worker.js:311) | Settings state, hydration, publish, cache, normalize |
| [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs:956) | Page-facing API: getUserSettings, patchUserSettings, onUserSettings |
| [`reference_repos/nips/78.md`](../reference_repos/nips/78.md) | NIP-78 specification for kind 30078 |
| [`reference_repos/nips/44.md`](../reference_repos/nips/44.md) | NIP-44 encryption specification |
| `/home/user/lt/didactyl/src/main.c` | Didactyl agent llm_config and agent_config publish/recall |
| `/home/user/lt/didactyl/src/tools/tool_config.c` | Didactyl generic config_store/config_recall tool |
| `/home/user/lt/didactyl/src/tools/tool_model.c` | Didactyl model_set tool (persists to d:llm_config) |