nostr-git-client 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # nostr-git-client
2
+
3
+ Browser-based git sync via Nostr with optional Bitcoin anchoring.
4
+
5
+ Subscribe to NIP-34 repo state events (kind 30618) and sync git repositories to browser IndexedDB using isomorphic-git.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install nostr-git-client
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### Basic Git Sync
16
+
17
+ ```javascript
18
+ import { NostrGitClient } from 'nostr-git-client';
19
+
20
+ const client = new NostrGitClient({
21
+ repo: 'https://solid.social/mel/public/myapp',
22
+ repoId: 'myapp',
23
+ branch: 'main',
24
+ relays: ['wss://relay.damus.io', 'wss://nos.lol'],
25
+ trusted: ['pubkey-hex...'] // Only sync from trusted publishers
26
+ });
27
+
28
+ client.on('sync', (event) => {
29
+ if (event.status === 'complete') {
30
+ console.log('Synced to:', event.commit);
31
+ }
32
+ });
33
+
34
+ client.connect();
35
+ await client.sync();
36
+
37
+ // Read files from synced repo
38
+ const content = await client.readFile('index.html');
39
+ const files = await client.listFiles('src');
40
+ ```
41
+
42
+ ### State Management with Bitcoin Anchoring
43
+
44
+ ```javascript
45
+ import { NostrGitClient, StateManager } from 'nostr-git-client';
46
+
47
+ const client = new NostrGitClient({
48
+ repo: 'https://solid.social/mel/public/game',
49
+ trusted: ['pubkey...']
50
+ });
51
+
52
+ const state = new StateManager({
53
+ sync: client,
54
+ file: 'state.json',
55
+ anchor: {
56
+ privkey: 'your-bitcoin-privkey-hex',
57
+ network: 'testnet4'
58
+ },
59
+ onChange: (newState) => {
60
+ console.log('State updated:', newState);
61
+ }
62
+ });
63
+
64
+ await state.init();
65
+ client.connect();
66
+ await client.sync();
67
+
68
+ // Anchor current state to Bitcoin
69
+ const anchor = await state.anchor();
70
+ console.log('Anchored:', anchor.txid);
71
+ ```
72
+
73
+ ### Self-Deploying Pages
74
+
75
+ ```javascript
76
+ import { SelfDeploy } from 'nostr-git-client';
77
+
78
+ // Auto-reload when repo is updated
79
+ SelfDeploy.init({
80
+ repo: 'https://solid.social/mel/public/myapp',
81
+ trusted: ['pubkey...'],
82
+ autoReload: true,
83
+ onUpdate: (event) => {
84
+ console.log('Update detected:', event.commit);
85
+ }
86
+ });
87
+ ```
88
+
89
+ ## API
90
+
91
+ ### NostrGitClient
92
+
93
+ Main client for Nostr-based git sync.
94
+
95
+ **Constructor options:**
96
+ - `repo` - Git repository URL
97
+ - `repoId` - Repository identifier (default: derived from repo URL)
98
+ - `branch` - Branch to track (default: 'main')
99
+ - `relays` - Nostr relay URLs
100
+ - `trusted` - Trusted publisher pubkeys
101
+ - `corsProxy` - CORS proxy URL (optional)
102
+
103
+ **Methods:**
104
+ - `connect()` - Connect to relays and start listening
105
+ - `disconnect()` - Disconnect from relays
106
+ - `sync(commit?)` - Sync to specified commit or latest
107
+ - `readFile(path)` - Read file content as string
108
+ - `readFileBytes(path)` - Read file as Uint8Array
109
+ - `listFiles(path?)` - List directory contents
110
+ - `getAllFiles()` - Get all files recursively
111
+ - `getCommit()` - Get current commit info
112
+ - `clear()` - Delete local repository
113
+
114
+ **Events:**
115
+ - `sync` - Sync status changes
116
+ - `event` - Nostr events received
117
+ - `connect` - Connected to relay
118
+ - `disconnect` - Disconnected from relay
119
+ - `error` - Errors
120
+
121
+ ### StateManager
122
+
123
+ Manages JSON state with optional Bitcoin anchoring.
124
+
125
+ **Constructor options:**
126
+ - `sync` - NostrGitClient instance
127
+ - `file` - Path to JSON file (default: 'state.json')
128
+ - `anchor` - Bitcoin config: `{ privkey, network }`
129
+ - `onChange` - Callback on state changes
130
+
131
+ **Methods:**
132
+ - `init()` - Initialize (loads blocktrails if anchoring)
133
+ - `loadState()` - Load state from file
134
+ - `get()` - Get current state
135
+ - `anchor()` - Anchor state to Bitcoin
136
+ - `getAnchors()` - Get anchor history
137
+ - `verifyChain()` - Verify anchor chain integrity
138
+
139
+ ### SelfDeploy
140
+
141
+ Auto-updating pages.
142
+
143
+ **Static methods:**
144
+ - `init(options)` - Initialize self-deploy
145
+ - `loaderScript(options)` - Generate embed script
146
+ - `serviceWorkerCode()` - Generate SW code
147
+
148
+ **Options:**
149
+ - `repo` - Repository URL (required)
150
+ - `trusted` - Trusted pubkeys (required)
151
+ - `autoReload` - Auto reload on update (default: true)
152
+ - `reloadDelay` - Delay before reload in ms (default: 1000)
153
+ - `onUpdate` - Callback on update
154
+ - `onSync` - Callback after sync
155
+
156
+ ## Dependencies
157
+
158
+ - [isomorphic-git](https://isomorphic-git.org/) - Git in JavaScript
159
+ - [@isomorphic-git/lightning-fs](https://github.com/isomorphic-git/lightning-fs) - IndexedDB filesystem
160
+ - [nostr-tools](https://github.com/nbd-wtf/nostr-tools) - Nostr protocol
161
+
162
+ **Optional:**
163
+ - [blocktrails](https://www.npmjs.com/package/blocktrails) - Bitcoin state anchoring
164
+
165
+ ## License
166
+
167
+ MIT
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "nostr-git-client",
3
+ "version": "0.0.1",
4
+ "description": "Browser-based git sync via Nostr with optional Bitcoin anchoring",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "browser": "src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js"
10
+ },
11
+ "scripts": {
12
+ "test": "echo \"No tests yet\""
13
+ },
14
+ "dependencies": {
15
+ "@isomorphic-git/lightning-fs": "^4.6.0",
16
+ "isomorphic-git": "^1.27.1",
17
+ "nostr-tools": "^2.19.4"
18
+ },
19
+ "optionalDependencies": {
20
+ "blocktrails": "^0.0.11"
21
+ },
22
+ "keywords": [
23
+ "nostr",
24
+ "git",
25
+ "isomorphic-git",
26
+ "browser",
27
+ "sync",
28
+ "nip-34",
29
+ "bitcoin",
30
+ "anchor",
31
+ "decentralized"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/JavaScriptSolidServer/nostr-git-client"
36
+ },
37
+ "license": "MIT"
38
+ }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * NostrGitClient - Browser-based git sync via Nostr
3
+ *
4
+ * Subscribes to NIP-34 repo state events (kind 30618) and syncs
5
+ * git repositories to browser IndexedDB using isomorphic-git.
6
+ */
7
+
8
+ import git from 'isomorphic-git';
9
+ import { createFS, createHttpClient, parseRepoEvent, verifyEvent } from './utils.js';
10
+
11
+ export class NostrGitClient {
12
+ /**
13
+ * Create a new NostrGitClient
14
+ * @param {Object} options
15
+ * @param {string} options.repo - Git repository URL
16
+ * @param {string} options.repoId - Repository identifier (for Nostr d tag)
17
+ * @param {string} options.branch - Branch to track (default: 'main')
18
+ * @param {string[]} options.relays - Nostr relay URLs
19
+ * @param {string[]} options.trusted - Trusted publisher pubkeys
20
+ * @param {string} options.dir - Local directory path (default: /<repoId>)
21
+ * @param {LightningFS} options.fs - Filesystem instance (optional)
22
+ * @param {Object} options.http - HTTP client (optional)
23
+ * @param {string} options.corsProxy - CORS proxy URL (optional)
24
+ */
25
+ constructor(options) {
26
+ this.repo = options.repo;
27
+ this.repoId = options.repoId || options.repo.split('/').pop();
28
+ this.branch = options.branch || 'main';
29
+ this.relays = options.relays || ['wss://relay.damus.io', 'wss://nos.lol'];
30
+ this.trusted = options.trusted || [];
31
+ this.dir = options.dir || `/${this.repoId}`;
32
+
33
+ this.fs = options.fs || createFS();
34
+ this.pfs = this.fs.promises;
35
+ this.http = options.http || createHttpClient({ corsProxy: options.corsProxy });
36
+
37
+ this.websockets = [];
38
+ this.listeners = new Map();
39
+ this.currentCommit = null;
40
+ this.synced = false;
41
+ this.syncing = false;
42
+ }
43
+
44
+ /**
45
+ * Add event listener
46
+ * @param {string} event - Event name: 'sync', 'event', 'error', 'connect', 'disconnect'
47
+ * @param {Function} callback - Callback function
48
+ */
49
+ on(event, callback) {
50
+ if (!this.listeners.has(event)) {
51
+ this.listeners.set(event, []);
52
+ }
53
+ this.listeners.get(event).push(callback);
54
+ return this;
55
+ }
56
+
57
+ /**
58
+ * Remove event listener
59
+ */
60
+ off(event, callback) {
61
+ const listeners = this.listeners.get(event);
62
+ if (listeners) {
63
+ const idx = listeners.indexOf(callback);
64
+ if (idx >= 0) listeners.splice(idx, 1);
65
+ }
66
+ return this;
67
+ }
68
+
69
+ /**
70
+ * Emit event to listeners
71
+ */
72
+ emit(event, ...args) {
73
+ const listeners = this.listeners.get(event) || [];
74
+ listeners.forEach(cb => {
75
+ try { cb(...args); } catch (e) { console.error('Listener error:', e); }
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Connect to Nostr relays and start listening
81
+ */
82
+ connect() {
83
+ this.disconnect(); // Clean up existing connections
84
+
85
+ this.relays.forEach((url, i) => {
86
+ const ws = new WebSocket(url);
87
+
88
+ ws.onopen = () => {
89
+ this.emit('connect', url);
90
+
91
+ // Subscribe to repo state events
92
+ ws.send(JSON.stringify([
93
+ 'REQ',
94
+ `ngc-${i}`,
95
+ {
96
+ kinds: [30618],
97
+ '#d': [this.repoId],
98
+ limit: 1
99
+ }
100
+ ]));
101
+ };
102
+
103
+ ws.onmessage = (e) => {
104
+ try {
105
+ const msg = JSON.parse(e.data);
106
+ if (msg[0] === 'EVENT' && msg[2]) {
107
+ this.handleEvent(msg[2]);
108
+ }
109
+ } catch {}
110
+ };
111
+
112
+ ws.onclose = () => {
113
+ this.emit('disconnect', url);
114
+ };
115
+
116
+ ws.onerror = (err) => {
117
+ this.emit('error', new Error(`WebSocket error: ${url}`));
118
+ };
119
+
120
+ this.websockets.push(ws);
121
+ });
122
+
123
+ return this;
124
+ }
125
+
126
+ /**
127
+ * Disconnect from all relays
128
+ */
129
+ disconnect() {
130
+ this.websockets.forEach(ws => ws.close());
131
+ this.websockets = [];
132
+ return this;
133
+ }
134
+
135
+ /**
136
+ * Handle incoming Nostr event
137
+ */
138
+ async handleEvent(event) {
139
+ // Verify signature
140
+ if (!await verifyEvent(event)) {
141
+ this.emit('error', new Error('Invalid event signature'));
142
+ return;
143
+ }
144
+
145
+ // Check if from trusted publisher
146
+ if (this.trusted.length > 0 && !this.trusted.includes(event.pubkey)) {
147
+ this.emit('event', { type: 'untrusted', event });
148
+ return;
149
+ }
150
+
151
+ // Parse event
152
+ const parsed = parseRepoEvent(event);
153
+ if (!parsed) return;
154
+
155
+ this.emit('event', { type: 'repo', ...parsed });
156
+
157
+ // Check if we need to sync
158
+ if (parsed.repoId === this.repoId && parsed.branch === this.branch) {
159
+ if (this.currentCommit !== parsed.commit) {
160
+ await this.sync(parsed.commit);
161
+ }
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Sync repository to specified commit (or latest)
167
+ * @param {string} targetCommit - Commit hash to checkout (optional)
168
+ * @returns {Object} Sync result
169
+ */
170
+ async sync(targetCommit = null) {
171
+ if (this.syncing) {
172
+ return { success: false, error: 'Already syncing' };
173
+ }
174
+
175
+ this.syncing = true;
176
+ this.emit('sync', { status: 'start', commit: targetCommit });
177
+
178
+ try {
179
+ // Check if already cloned
180
+ let needsClone = true;
181
+ try {
182
+ await this.pfs.stat(`${this.dir}/.git`);
183
+ needsClone = false;
184
+ } catch {}
185
+
186
+ if (needsClone) {
187
+ // Ensure directory exists
188
+ try { await this.pfs.mkdir(this.dir); } catch {}
189
+
190
+ await git.clone({
191
+ fs: this.fs,
192
+ http: this.http,
193
+ dir: this.dir,
194
+ url: this.repo,
195
+ ref: this.branch,
196
+ singleBranch: true,
197
+ depth: 10
198
+ });
199
+ } else {
200
+ // Fetch updates
201
+ await git.fetch({
202
+ fs: this.fs,
203
+ http: this.http,
204
+ dir: this.dir,
205
+ url: this.repo,
206
+ ref: this.branch,
207
+ singleBranch: true
208
+ });
209
+ }
210
+
211
+ // Checkout to specific commit or branch head
212
+ const ref = targetCommit || this.branch;
213
+ await git.checkout({
214
+ fs: this.fs,
215
+ dir: this.dir,
216
+ ref,
217
+ force: true
218
+ });
219
+
220
+ // Get current commit info
221
+ const commits = await git.log({ fs: this.fs, dir: this.dir, depth: 1 });
222
+ const commit = commits[0];
223
+ this.currentCommit = commit?.oid;
224
+ this.synced = true;
225
+ this.syncing = false;
226
+
227
+ const result = {
228
+ success: true,
229
+ commit: commit?.oid,
230
+ message: commit?.commit.message,
231
+ author: commit?.commit.author.name,
232
+ date: new Date(commit?.commit.author.timestamp * 1000)
233
+ };
234
+
235
+ this.emit('sync', { status: 'complete', ...result });
236
+ return result;
237
+
238
+ } catch (err) {
239
+ this.syncing = false;
240
+ this.emit('sync', { status: 'error', error: err.message });
241
+ this.emit('error', err);
242
+ return { success: false, error: err.message };
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Read a file from the synced repository
248
+ * @param {string} path - File path relative to repo root
249
+ * @returns {string} File content
250
+ */
251
+ async readFile(path) {
252
+ return this.pfs.readFile(`${this.dir}/${path}`, 'utf8');
253
+ }
254
+
255
+ /**
256
+ * Read a file as bytes
257
+ * @param {string} path - File path
258
+ * @returns {Uint8Array} File content
259
+ */
260
+ async readFileBytes(path) {
261
+ return this.pfs.readFile(`${this.dir}/${path}`);
262
+ }
263
+
264
+ /**
265
+ * List files in directory
266
+ * @param {string} path - Directory path (default: repo root)
267
+ * @returns {Array} File entries
268
+ */
269
+ async listFiles(path = '') {
270
+ const fullPath = `${this.dir}/${path}`.replace(/\/+$/, '');
271
+ const entries = await this.pfs.readdir(fullPath);
272
+
273
+ const result = [];
274
+ for (const entry of entries) {
275
+ if (entry === '.git') continue;
276
+ const stat = await this.pfs.stat(`${fullPath}/${entry}`);
277
+ result.push({
278
+ name: entry,
279
+ path: path ? `${path}/${entry}` : entry,
280
+ type: stat.isDirectory() ? 'dir' : 'file'
281
+ });
282
+ }
283
+
284
+ return result.sort((a, b) => {
285
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
286
+ return a.name.localeCompare(b.name);
287
+ });
288
+ }
289
+
290
+ /**
291
+ * Get all files recursively
292
+ * @returns {Array} All file paths
293
+ */
294
+ async getAllFiles() {
295
+ const files = [];
296
+ const walk = async (path) => {
297
+ const entries = await this.listFiles(path);
298
+ for (const entry of entries) {
299
+ if (entry.type === 'dir') {
300
+ await walk(entry.path);
301
+ } else {
302
+ files.push(entry.path);
303
+ }
304
+ }
305
+ };
306
+ await walk('');
307
+ return files;
308
+ }
309
+
310
+ /**
311
+ * Get current commit info
312
+ * @returns {Object} Commit info
313
+ */
314
+ async getCommit() {
315
+ const commits = await git.log({ fs: this.fs, dir: this.dir, depth: 1 });
316
+ if (commits.length === 0) return null;
317
+
318
+ const c = commits[0];
319
+ return {
320
+ hash: c.oid,
321
+ message: c.commit.message,
322
+ author: c.commit.author.name,
323
+ email: c.commit.author.email,
324
+ date: new Date(c.commit.author.timestamp * 1000)
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Clear local repository from IndexedDB
330
+ */
331
+ async clear() {
332
+ const deleteRecursive = async (path) => {
333
+ try {
334
+ const entries = await this.pfs.readdir(path);
335
+ for (const entry of entries) {
336
+ const fullPath = `${path}/${entry}`;
337
+ const stat = await this.pfs.stat(fullPath);
338
+ if (stat.isDirectory()) {
339
+ await deleteRecursive(fullPath);
340
+ } else {
341
+ await this.pfs.unlink(fullPath);
342
+ }
343
+ }
344
+ await this.pfs.rmdir(path);
345
+ } catch {}
346
+ };
347
+
348
+ await deleteRecursive(this.dir);
349
+ this.currentCommit = null;
350
+ this.synced = false;
351
+ }
352
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * SelfDeploy - Auto-updating pages via Nostr git sync
3
+ *
4
+ * Enables pages to automatically reload when their source repo
5
+ * receives updates via Nostr announcements.
6
+ */
7
+
8
+ import { NostrGitClient } from './NostrGitClient.js';
9
+
10
+ export class SelfDeploy {
11
+ /**
12
+ * Initialize self-deploying page
13
+ *
14
+ * @param {Object} options
15
+ * @param {string} options.repo - Git repository URL
16
+ * @param {string} options.repoId - Repository identifier (optional, derived from repo)
17
+ * @param {string} options.branch - Branch to track (default: 'main')
18
+ * @param {string[]} options.relays - Nostr relay URLs
19
+ * @param {string[]} options.trusted - Trusted publisher pubkeys (required for auto-reload)
20
+ * @param {Function} options.onUpdate - Called when update detected (default: reload page)
21
+ * @param {Function} options.onSync - Called after sync completes
22
+ * @param {Function} options.onError - Called on errors
23
+ * @param {boolean} options.autoReload - Auto reload on update (default: true)
24
+ * @param {number} options.reloadDelay - Delay before reload in ms (default: 1000)
25
+ * @param {string} options.corsProxy - CORS proxy URL (optional)
26
+ * @returns {SelfDeploy} Instance
27
+ */
28
+ static init(options) {
29
+ return new SelfDeploy(options);
30
+ }
31
+
32
+ constructor(options) {
33
+ if (!options.repo) {
34
+ throw new Error('repo is required');
35
+ }
36
+
37
+ if (!options.trusted || options.trusted.length === 0) {
38
+ throw new Error('trusted pubkeys required for SelfDeploy');
39
+ }
40
+
41
+ this.options = {
42
+ autoReload: true,
43
+ reloadDelay: 1000,
44
+ branch: 'main',
45
+ ...options
46
+ };
47
+
48
+ this.client = new NostrGitClient({
49
+ repo: this.options.repo,
50
+ repoId: this.options.repoId,
51
+ branch: this.options.branch,
52
+ relays: this.options.relays,
53
+ trusted: this.options.trusted,
54
+ corsProxy: this.options.corsProxy
55
+ });
56
+
57
+ this.currentCommit = null;
58
+ this.setupListeners();
59
+ this.client.connect();
60
+ }
61
+
62
+ setupListeners() {
63
+ // Track sync events
64
+ this.client.on('sync', (event) => {
65
+ if (event.status === 'complete') {
66
+ const isUpdate = this.currentCommit && this.currentCommit !== event.commit;
67
+ this.currentCommit = event.commit;
68
+
69
+ if (this.options.onSync) {
70
+ this.options.onSync(event);
71
+ }
72
+
73
+ if (isUpdate) {
74
+ this.handleUpdate(event);
75
+ }
76
+ }
77
+
78
+ if (event.status === 'error' && this.options.onError) {
79
+ this.options.onError(new Error(event.error));
80
+ }
81
+ });
82
+
83
+ // Track repo events from trusted publishers
84
+ this.client.on('event', (event) => {
85
+ if (event.type === 'repo') {
86
+ // New commit announced - trigger sync
87
+ if (event.commit !== this.currentCommit) {
88
+ this.client.sync(event.commit);
89
+ }
90
+ }
91
+ });
92
+
93
+ // Forward errors
94
+ this.client.on('error', (err) => {
95
+ if (this.options.onError) {
96
+ this.options.onError(err);
97
+ }
98
+ });
99
+
100
+ // Log connections
101
+ this.client.on('connect', (url) => {
102
+ console.log(`[SelfDeploy] Connected to ${url}`);
103
+ });
104
+ }
105
+
106
+ handleUpdate(event) {
107
+ console.log(`[SelfDeploy] Update detected: ${event.commit?.slice(0, 8)}`);
108
+
109
+ if (this.options.onUpdate) {
110
+ this.options.onUpdate(event);
111
+ }
112
+
113
+ if (this.options.autoReload) {
114
+ console.log(`[SelfDeploy] Reloading in ${this.options.reloadDelay}ms...`);
115
+ setTimeout(() => {
116
+ location.reload();
117
+ }, this.options.reloadDelay);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Manually trigger sync
123
+ * @returns {Promise<Object>} Sync result
124
+ */
125
+ sync() {
126
+ return this.client.sync();
127
+ }
128
+
129
+ /**
130
+ * Get current commit info
131
+ * @returns {Promise<Object>} Commit info
132
+ */
133
+ getCommit() {
134
+ return this.client.getCommit();
135
+ }
136
+
137
+ /**
138
+ * Read a file from the synced repo
139
+ * @param {string} path - File path
140
+ * @returns {Promise<string>} File content
141
+ */
142
+ readFile(path) {
143
+ return this.client.readFile(path);
144
+ }
145
+
146
+ /**
147
+ * List files in directory
148
+ * @param {string} path - Directory path
149
+ * @returns {Promise<Array>} File entries
150
+ */
151
+ listFiles(path) {
152
+ return this.client.listFiles(path);
153
+ }
154
+
155
+ /**
156
+ * Check if synced
157
+ * @returns {boolean}
158
+ */
159
+ get synced() {
160
+ return this.client.synced;
161
+ }
162
+
163
+ /**
164
+ * Disconnect from relays
165
+ */
166
+ disconnect() {
167
+ this.client.disconnect();
168
+ }
169
+
170
+ /**
171
+ * Create a minimal loader script for embedding
172
+ * Returns HTML that can be injected to enable self-deploy
173
+ *
174
+ * @param {Object} options - Same as constructor options
175
+ * @returns {string} Script tag HTML
176
+ */
177
+ static loaderScript(options) {
178
+ const config = JSON.stringify(options);
179
+ return `<script type="module">
180
+ import { SelfDeploy } from 'nostr-git-client';
181
+ SelfDeploy.init(${config});
182
+ </script>`;
183
+ }
184
+
185
+ /**
186
+ * Create a service worker for offline-first self-deploy
187
+ * The service worker syncs in the background and notifies the page
188
+ *
189
+ * @returns {string} Service worker code
190
+ */
191
+ static serviceWorkerCode() {
192
+ return `
193
+ // SelfDeploy Service Worker
194
+ // Syncs git repo in background and caches files
195
+
196
+ const CACHE_NAME = 'selfdeploy-v1';
197
+
198
+ self.addEventListener('install', (event) => {
199
+ self.skipWaiting();
200
+ });
201
+
202
+ self.addEventListener('activate', (event) => {
203
+ event.waitUntil(clients.claim());
204
+ });
205
+
206
+ self.addEventListener('message', async (event) => {
207
+ if (event.data.type === 'SYNC') {
208
+ // Notify all clients of update
209
+ const clients = await self.clients.matchAll();
210
+ clients.forEach(client => {
211
+ client.postMessage({ type: 'UPDATE', commit: event.data.commit });
212
+ });
213
+ }
214
+ });
215
+
216
+ self.addEventListener('fetch', (event) => {
217
+ // Cache-first strategy for synced files
218
+ event.respondWith(
219
+ caches.match(event.request).then(cached => {
220
+ return cached || fetch(event.request);
221
+ })
222
+ );
223
+ });
224
+ `;
225
+ }
226
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * StateManager - JSON state management with optional Bitcoin anchoring
3
+ *
4
+ * Manages a JSON state file from a synced git repository.
5
+ * Optionally anchors state changes to Bitcoin via blocktrails.
6
+ */
7
+
8
+ export class StateManager {
9
+ /**
10
+ * Create a StateManager
11
+ * @param {Object} options
12
+ * @param {NostrGitClient} options.sync - NostrGitClient instance
13
+ * @param {string} options.file - Path to JSON state file (default: 'state.json')
14
+ * @param {Object} options.anchor - Bitcoin anchoring options (optional)
15
+ * @param {string} options.anchor.privkey - Private key hex for anchoring
16
+ * @param {string} options.anchor.network - 'testnet4' or 'mainnet' (default: 'testnet4')
17
+ * @param {Function} options.onChange - Callback when state changes
18
+ */
19
+ constructor(options) {
20
+ this.sync = options.sync;
21
+ this.file = options.file || 'state.json';
22
+ this.anchorConfig = options.anchor || null;
23
+ this.onChange = options.onChange || null;
24
+
25
+ this.state = null;
26
+ this.stateHash = null;
27
+ this.anchors = [];
28
+ this.blocktrails = null;
29
+
30
+ // Listen for sync events
31
+ this.sync.on('sync', async (event) => {
32
+ if (event.status === 'complete') {
33
+ await this.loadState();
34
+ }
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Initialize the state manager
40
+ * Loads blocktrails if anchoring is enabled
41
+ */
42
+ async init() {
43
+ // Try to load blocktrails for anchoring
44
+ if (this.anchorConfig) {
45
+ try {
46
+ const bt = await import('blocktrails');
47
+ this.blocktrails = bt;
48
+ } catch {
49
+ console.warn('blocktrails not available - anchoring disabled');
50
+ this.anchorConfig = null;
51
+ }
52
+ }
53
+
54
+ // Load initial state if synced
55
+ if (this.sync.synced) {
56
+ await this.loadState();
57
+ }
58
+
59
+ return this;
60
+ }
61
+
62
+ /**
63
+ * Load state from the synced file
64
+ */
65
+ async loadState() {
66
+ try {
67
+ const content = await this.sync.readFile(this.file);
68
+ const newState = JSON.parse(content);
69
+ const newHash = await this.hashState(newState);
70
+
71
+ if (newHash !== this.stateHash) {
72
+ this.state = newState;
73
+ this.stateHash = newHash;
74
+
75
+ if (this.onChange) {
76
+ this.onChange(this.state);
77
+ }
78
+ }
79
+
80
+ return this.state;
81
+ } catch (err) {
82
+ // File might not exist yet
83
+ if (err.code === 'ENOENT') {
84
+ this.state = {};
85
+ this.stateHash = null;
86
+ return this.state;
87
+ }
88
+ throw err;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Get current state
94
+ * @returns {Object} Current state
95
+ */
96
+ get() {
97
+ return this.state;
98
+ }
99
+
100
+ /**
101
+ * Hash state for anchoring/comparison
102
+ * @param {Object} state - State to hash
103
+ * @returns {string} SHA-256 hash hex
104
+ */
105
+ async hashState(state) {
106
+ const json = JSON.stringify(state, null, 2);
107
+ const bytes = new TextEncoder().encode(json);
108
+ const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
109
+ return Array.from(new Uint8Array(hashBuffer))
110
+ .map(b => b.toString(16).padStart(2, '0'))
111
+ .join('');
112
+ }
113
+
114
+ /**
115
+ * Anchor current state to Bitcoin
116
+ * Creates a P2TR output committing to the state hash
117
+ * @returns {Object} Anchor result with txid
118
+ */
119
+ async anchor() {
120
+ if (!this.blocktrails || !this.anchorConfig) {
121
+ throw new Error('Anchoring not configured');
122
+ }
123
+
124
+ if (!this.state || !this.stateHash) {
125
+ throw new Error('No state to anchor');
126
+ }
127
+
128
+ const bt = this.blocktrails;
129
+ const privkey = this.anchorConfig.privkey;
130
+ const network = this.anchorConfig.network || 'testnet4';
131
+
132
+ // Get previous anchor states for chaining
133
+ const prevStates = this.anchors.map(a => a.hash);
134
+ const allStates = [...prevStates, this.stateHash];
135
+
136
+ // Derive pubkey from privkey
137
+ const pubkeyBase = bt.getPublicKey(privkey);
138
+
139
+ // For first anchor, derive simple tweaked address
140
+ // For subsequent anchors, use chained derivation
141
+ let fromPubkey, toPubkey;
142
+
143
+ if (prevStates.length === 0) {
144
+ // First anchor - spend from base key to tweaked key
145
+ fromPubkey = pubkeyBase;
146
+ toPubkey = bt.derivePubkey(pubkeyBase, this.stateHash);
147
+ } else {
148
+ // Chained anchor - spend from previous tweaked to new tweaked
149
+ fromPubkey = bt.deriveChainedPubkey(pubkeyBase, prevStates);
150
+ toPubkey = bt.deriveChainedPubkey(pubkeyBase, allStates);
151
+ }
152
+
153
+ // Get UTXOs from previous address
154
+ const fromAddress = bt.pubkeyToAddress(fromPubkey, network);
155
+ const utxos = await bt.fetchUTXOs(fromAddress, network);
156
+
157
+ if (utxos.length === 0) {
158
+ throw new Error(`No funds at ${fromAddress}`);
159
+ }
160
+
161
+ // Use largest UTXO
162
+ const utxo = utxos.reduce((a, b) => a.value > b.value ? a : b);
163
+
164
+ // Create and broadcast transaction
165
+ const toAddress = bt.pubkeyToAddress(toPubkey, network);
166
+ const fee = 300; // Minimal fee for P2TR
167
+
168
+ const tx = bt.createAnchorTx({
169
+ utxo,
170
+ privkey,
171
+ prevStates,
172
+ newState: this.stateHash,
173
+ toAddress,
174
+ fee,
175
+ network
176
+ });
177
+
178
+ const txid = await bt.broadcastTx(tx, network);
179
+
180
+ // Record anchor
181
+ const anchor = {
182
+ txid,
183
+ hash: this.stateHash,
184
+ address: toAddress,
185
+ timestamp: Date.now(),
186
+ state: JSON.parse(JSON.stringify(this.state))
187
+ };
188
+
189
+ this.anchors.push(anchor);
190
+
191
+ return anchor;
192
+ }
193
+
194
+ /**
195
+ * Get all anchors
196
+ * @returns {Array} List of anchors
197
+ */
198
+ getAnchors() {
199
+ return this.anchors;
200
+ }
201
+
202
+ /**
203
+ * Verify anchor chain
204
+ * Checks that all anchors form a valid chain
205
+ * @returns {boolean} True if valid
206
+ */
207
+ async verifyChain() {
208
+ if (!this.blocktrails || this.anchors.length === 0) {
209
+ return true;
210
+ }
211
+
212
+ const bt = this.blocktrails;
213
+ const network = this.anchorConfig?.network || 'testnet4';
214
+ const pubkeyBase = bt.getPublicKey(this.anchorConfig.privkey);
215
+
216
+ // Verify each anchor has the expected address
217
+ const states = [];
218
+ for (const anchor of this.anchors) {
219
+ states.push(anchor.hash);
220
+ const expectedPubkey = bt.deriveChainedPubkey(pubkeyBase, states);
221
+ const expectedAddress = bt.pubkeyToAddress(expectedPubkey, network);
222
+
223
+ if (anchor.address !== expectedAddress) {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ return true;
229
+ }
230
+
231
+ /**
232
+ * Get anchor chain storage key
233
+ */
234
+ get storageKey() {
235
+ return `state-anchors-${this.sync.repoId}-${this.file}`;
236
+ }
237
+
238
+ /**
239
+ * Save anchors to localStorage
240
+ */
241
+ saveAnchors() {
242
+ if (typeof localStorage !== 'undefined') {
243
+ localStorage.setItem(this.storageKey, JSON.stringify(this.anchors));
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Load anchors from localStorage
249
+ */
250
+ loadAnchors() {
251
+ if (typeof localStorage !== 'undefined') {
252
+ try {
253
+ const saved = localStorage.getItem(this.storageKey);
254
+ if (saved) {
255
+ this.anchors = JSON.parse(saved);
256
+ }
257
+ } catch {}
258
+ }
259
+ }
260
+ }
package/src/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * nostr-git-client - Browser-based git sync via Nostr
3
+ *
4
+ * Usage:
5
+ * import { NostrGitClient, StateManager, SelfDeploy } from 'nostr-git-client';
6
+ */
7
+
8
+ export { NostrGitClient } from './NostrGitClient.js';
9
+ export { StateManager } from './StateManager.js';
10
+ export { SelfDeploy } from './SelfDeploy.js';
11
+ export { createFS, createHttpClient } from './utils.js';
package/src/utils.js ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Utility functions for nostr-git-client
3
+ */
4
+
5
+ import LightningFS from '@isomorphic-git/lightning-fs';
6
+
7
+ /**
8
+ * Create a LightningFS instance for IndexedDB storage
9
+ * @param {string} name - Database name
10
+ * @returns {LightningFS} Filesystem instance
11
+ */
12
+ export function createFS(name = 'nostr-git-client') {
13
+ return new LightningFS(name);
14
+ }
15
+
16
+ /**
17
+ * Create an HTTP client for isomorphic-git
18
+ * Works with JSS and other git HTTP servers
19
+ * @param {Object} options
20
+ * @param {string} options.corsProxy - CORS proxy URL (optional)
21
+ * @param {Object} options.headers - Additional headers
22
+ * @returns {Object} HTTP client for isomorphic-git
23
+ */
24
+ export function createHttpClient(options = {}) {
25
+ const { corsProxy, headers = {} } = options;
26
+
27
+ return {
28
+ async request({ url, method, headers: reqHeaders, body }) {
29
+ // Apply CORS proxy if specified
30
+ const finalUrl = corsProxy ? `${corsProxy}${encodeURIComponent(url)}` : url;
31
+
32
+ const res = await fetch(finalUrl, {
33
+ method,
34
+ headers: { ...reqHeaders, ...headers },
35
+ body
36
+ });
37
+
38
+ return {
39
+ url: res.url,
40
+ method,
41
+ statusCode: res.status,
42
+ statusMessage: res.statusText,
43
+ headers: Object.fromEntries(res.headers.entries()),
44
+ body: [new Uint8Array(await res.arrayBuffer())]
45
+ };
46
+ }
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Verify Nostr event signature
52
+ * @param {Object} event - Nostr event
53
+ * @returns {boolean} True if valid
54
+ */
55
+ export async function verifyEvent(event) {
56
+ try {
57
+ const { verifyEvent } = await import('nostr-tools/pure');
58
+ return verifyEvent(event);
59
+ } catch {
60
+ // Fallback: assume valid if nostr-tools not available
61
+ console.warn('nostr-tools not available for signature verification');
62
+ return true;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Parse a 30618 repo state event
68
+ * @param {Object} event - Nostr event
69
+ * @returns {Object|null} Parsed event data
70
+ */
71
+ export function parseRepoEvent(event) {
72
+ if (event.kind !== 30618) return null;
73
+
74
+ const repoId = event.tags.find(t => t[0] === 'd')?.[1];
75
+ const refTag = event.tags.find(t => t[0].startsWith('refs/'));
76
+ const anchorTag = event.tags.find(t => t[0] === 'c');
77
+
78
+ if (!repoId || !refTag) return null;
79
+
80
+ return {
81
+ pubkey: event.pubkey,
82
+ repoId,
83
+ ref: refTag[0],
84
+ branch: refTag[0].replace('refs/heads/', ''),
85
+ commit: refTag[1],
86
+ anchor: anchorTag?.[1] || null,
87
+ createdAt: event.created_at,
88
+ raw: event
89
+ };
90
+ }