wiki-plugin-lucille 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/CLAUDE.md ADDED
@@ -0,0 +1,201 @@
1
+ # wiki-plugin-lucille — Developer Documentation
2
+
3
+ P2P video hosting for Federated Wiki, backed by DigitalOcean Spaces (cold storage) and WebTorrent (live delivery).
4
+
5
+ **Current version**: 0.0.1
6
+
7
+ ## Architecture
8
+
9
+ Follows the Service-Bundling Plugin Pattern. The wiki plugin (`wiki-plugin-lucille`) spawns and proxies a standalone Node.js service (`lucille`). Each wiki instance gets its own DO Spaces bucket and its own BitTorrent tracker.
10
+
11
+ ```
12
+ Federated Wiki
13
+ └── wiki-plugin-lucille (CommonJS plugin)
14
+ ├── server/server.js ← spawns lucille, proxies /plugin/lucille/*
15
+ ├── client/lucille.js ← renders items in the wiki journal
16
+ └── node_modules/lucille ← the lucille service (ESM)
17
+ ├── lucille.js ← Express API + tracker + seeder bootstrap
18
+ └── src/
19
+ ├── config.js ← tiers, federation peers
20
+ ├── seeder.js ← WebTorrent seeder
21
+ ├── tracker.js ← bittorrent-tracker server
22
+ ├── spaces.js ← DO Spaces upload/presign
23
+ └── persistence/ ← file-based key-value DB
24
+ ```
25
+
26
+ ### Service modes
27
+
28
+ The lucille service supports three modes via `LUCILLE_MODE` env var:
29
+
30
+ | Mode | Description |
31
+ |------|-------------|
32
+ | `all` | API + tracker + seeder (default for wiki plugin) |
33
+ | `tracker` | Tracker only (no API, no seeding) |
34
+ | `api` | API only (no tracker, no seeder) |
35
+
36
+ ### Storage and Seeding Flow
37
+
38
+ 1. Client uploads video → `PUT /user/:uuid/video/:title/file`
39
+ 2. Server writes to temp file, hashes (SHA-256), uploads to DO Spaces
40
+ 3. `seedVideo(spacesKey)` downloads the file from Spaces and seeds via WebTorrent
41
+ 4. Magnet URI + infoHash stored in video record
42
+ 5. Player at `GET /watch/:videoId` loads player.html, which fetches magnet from `GET /video/:videoId/watch` and streams via WebTorrent.js
43
+
44
+ ### Federation
45
+
46
+ Each lucille instance publishes its tracker URL at `GET /federation/trackers`. On startup, the seeder fetches all peer tracker URLs from configured federation peers and builds a unified announce list. This means every video is announced on every peer's tracker — no central registry needed.
47
+
48
+ ```
49
+ Instance A startup:
50
+ 1. Start own tracker (ws://wiki-a.example.com:8000)
51
+ 2. Fetch wiki-b.example.com/plugin/lucille/federation/trackers
52
+ 3. Build announce list: [ws://wiki-a:8000, ws://wiki-b:9000]
53
+ 4. seedAll() — each torrent announces to both trackers
54
+ ```
55
+
56
+ ## Tiers
57
+
58
+ Wiki owners configure their own tiers. Defaults:
59
+
60
+ | Tier | Price | Storage | Videos | Duration |
61
+ |------|-------|---------|--------|----------|
62
+ | Free | 0 MP | 500 MB | 5 | 10 min |
63
+ | Creator | 500 MP | 10 GB | 50 | 1 hr |
64
+ | Pro | 2000 MP | unlimited | unlimited | unlimited |
65
+
66
+ `0` in storageLimit/videoLimit/durationLimit means unlimited.
67
+
68
+ Quota is enforced at:
69
+ - `PUT /user/:uuid/video/:title` — video count check
70
+ - `PUT /user/:uuid/video/:title/file` — storage check + video count check
71
+
72
+ Quota errors return HTTP 402 with `{ error: "...", upgradeRequired: true }`.
73
+
74
+ ## Setup / Onboarding
75
+
76
+ Wiki owner visits `/plugin/lucille/setup` to configure:
77
+ - **DO Spaces credentials** — key, secret, region, bucket, CDN endpoint
78
+ - **Tracker** — port (must be open in firewall) + public hostname for federation
79
+ - **Tiers** — JSON array of tier definitions
80
+ - **Federation peers** — newline-separated base URLs of other lucille instances
81
+ - **Sanora URL** — for tier upgrade payments via Addie
82
+
83
+ Config stored at `~/.lucille/config.json`.
84
+
85
+ ## Routes
86
+
87
+ ### Plugin (served by wiki plugin server.js)
88
+
89
+ | Method | Path | Description |
90
+ |--------|------|-------------|
91
+ | `GET` | `/plugin/lucille/setup` | Onboarding UI |
92
+ | `POST` | `/plugin/lucille/setup` | Save config + (re)launch lucille |
93
+ | `GET` | `/plugin/lucille/setup/status` | Config + running status JSON |
94
+ | `GET` | `/plugin/lucille/version-status` | Installed vs npm version |
95
+ | `POST` | `/plugin/lucille/update` | `npm install lucille@latest` |
96
+ | `*` | `/plugin/lucille/*` | Proxied to lucille service |
97
+
98
+ ### Lucille service (proxied at `/plugin/lucille/*`)
99
+
100
+ | Method | Path | Auth | Description |
101
+ |--------|------|------|-------------|
102
+ | `GET` | `/health` | — | Health check |
103
+ | `GET` | `/status` | — | Seeder/tracker status |
104
+ | `GET` | `/config` | — | Current config (no credentials) |
105
+ | `POST` | `/config` | — | Update tiers/peers/sanoraUrl |
106
+ | `GET` | `/tiers` | — | Tier list |
107
+ | `GET` | `/federation/trackers` | — | Own tracker URL + lucille base URL |
108
+ | `PUT` | `/user/create` | Sessionless | Create user |
109
+ | `GET` | `/user/:uuid` | Sessionless | Get user + tier details |
110
+ | `PUT` | `/user/:uuid/tier` | Sessionless | Upgrade tier |
111
+ | `PUT` | `/user/:uuid/video/:title` | Sessionless | Register video metadata |
112
+ | `GET` | `/user/:uuid/videos` | Sessionless | List user's videos (private) |
113
+ | `GET` | `/videos/:uuid` | Public | List user's videos (public) |
114
+ | `PUT` | `/user/:uuid/video/:title/file` | Sessionless (headers) | Upload video file |
115
+ | `GET` | `/watch/:videoId` | Public | Player page (iframe-embeddable) |
116
+ | `GET` | `/video/:videoId/watch` | Public | Magnet + tracker JSON |
117
+ | `GET` | `/magnet?key=videos/…` | Internal | Magnet for a Spaces key |
118
+ | `POST` | `/seed` | Internal | Notify of new video key |
119
+
120
+ ### Sessionless auth
121
+
122
+ All authed endpoints use the sessionless pattern:
123
+ - Body (PUT): `{ timestamp, pubKey, signature }` where `signature = sign(timestamp + pubKey)`
124
+ - Query (GET): `?timestamp=…&signature=…` where `signature = sign(timestamp + user.pubKey)`
125
+ - File upload (PUT `…/file`): signature in headers `x-pn-timestamp`, `x-pn-signature`
126
+
127
+ ## Wiki item format
128
+
129
+ ```json
130
+ {
131
+ "type": "lucille",
132
+ "videoId": "abc123",
133
+ "text": "My Video Title",
134
+ "description": "Short description",
135
+ "price": 0,
136
+ "playerBase": "/plugin/lucille"
137
+ }
138
+ ```
139
+
140
+ The client plugin renders a thumbnail with a play button. Clicking opens an iframe lightbox pointing to `/plugin/lucille/watch/:videoId`.
141
+
142
+ ## Client plugin
143
+
144
+ `client/lucille.js` follows the standard `window.plugins.NAME = { emit, bind }` pattern.
145
+
146
+ - **emit** — renders the video card (thumbnail + title + description + price)
147
+ - **bind** — adds a traffic-light ◉ status indicator showing version state (gray=loading, red=not installed, yellow=update available, green=current)
148
+
149
+ The shared lightbox (`lucilleOpen`/`lucilleClose`) is injected once into `document.body` and reused across all items on the page.
150
+
151
+ ## Environment variables (lucille service)
152
+
153
+ | Var | Default | Description |
154
+ |-----|---------|-------------|
155
+ | `PORT` | `5444` | API port |
156
+ | `LUCILLE_MODE` | `all` | `all` / `tracker` / `api` |
157
+ | `DO_SPACES_KEY` | — | DO Spaces access key |
158
+ | `DO_SPACES_SECRET` | — | DO Spaces secret |
159
+ | `DO_SPACES_REGION` | `nyc3` | DO Spaces region |
160
+ | `DO_SPACES_BUCKET` | — | Bucket name |
161
+ | `DO_SPACES_CDN_ENDPOINT` | — | CDN URL (optional) |
162
+ | `TRACKER_PORT` | `8000` | BitTorrent tracker WebSocket port |
163
+ | `TRACKER_URL` | `ws://localhost:8000` | Public tracker URL (used in announce lists) |
164
+ | `SANORA_URL` | — | Sanora base URL for payments |
165
+ | `ALLOWED_TIME_DIFFERENCE` | `600000` | Max timestamp skew (ms) |
166
+
167
+ ## Dependencies
168
+
169
+ ### wiki-plugin-lucille (CommonJS)
170
+ ```json
171
+ {
172
+ "http-proxy": "^1.18.1",
173
+ "lucille": "file:../../lucille",
174
+ "node-fetch": "^2.6.1"
175
+ }
176
+ ```
177
+
178
+ ### lucille service (ESM)
179
+ ```json
180
+ {
181
+ "@aws-sdk/client-s3": "^3.705.0",
182
+ "@aws-sdk/s3-request-presigner": "^3.705.0",
183
+ "bittorrent-tracker": "latest",
184
+ "cors": "^2.8.5",
185
+ "express": "^4.21.2",
186
+ "express-fileupload": "^1.5.1",
187
+ "sessionless-node": "latest",
188
+ "webtorrent": "^2.5.1"
189
+ }
190
+ ```
191
+
192
+ ## Storage
193
+
194
+ - `~/.lucille/config.json` — plugin config (DO Spaces credentials, tiers, federation peers)
195
+ - Lucille service uses its own file-based DB in `src/persistence/` for users, videos, and keys
196
+
197
+ ## nginx / firewall requirements
198
+
199
+ - Tracker port (default 8000) must be open for WebSocket connections for federation to work
200
+ - The wiki's nginx should not proxy-pass the tracker port (it runs on its own port, not through the wiki)
201
+ - For large video uploads, set `client_max_body_size` appropriately (e.g. `500M`)
@@ -0,0 +1,198 @@
1
+ // wiki-plugin-lucille — client-side plugin
2
+ // Renders lucille items in the fedwiki journal.
3
+ // Item data: { type: 'lucille', videoId, title, description, price }
4
+
5
+ window.plugins = window.plugins || {};
6
+
7
+ window.plugins.lucille = (function() {
8
+
9
+ // ── Traffic light colors (matching plugmatic) ─────────────────────────────
10
+ const color = {
11
+ gray: '#ccc',
12
+ red: '#f55',
13
+ yellow: '#fb0',
14
+ green: '#0e0'
15
+ };
16
+
17
+ // ── Emit: render the item ─────────────────────────────────────────────────
18
+ function emit($item, item) {
19
+ const videoId = item.videoId || '';
20
+ const title = item.text || item.title || 'Untitled';
21
+ const description = item.description || '';
22
+ const price = item.price || 0;
23
+ const playerBase = item.playerBase || '/plugin/lucille';
24
+
25
+ // Check owner status and render config section if owner
26
+ fetch('/plugin/lucille/setup/status', { credentials: 'include' })
27
+ .then(r => r.json())
28
+ .then(data => {
29
+ if (!data.isOwner) return;
30
+ const configDiv = document.createElement('div');
31
+ configDiv.style.cssText = 'border:1px solid rgba(167,139,250,0.3);border-radius:8px;padding:12px;margin-top:8px;background:rgba(167,139,250,0.06);font-family:system-ui;';
32
+ const stripeStatus = data.stripeOnboarded
33
+ ? '<span style="color:#0e0;font-size:0.75rem;">✅ Stripe payouts enabled</span>'
34
+ : data.serverAddieReady
35
+ ? '<a href="/plugin/lucille/setup/stripe" target="_blank" style="color:#fb0;font-size:0.75rem;">⚠️ Complete Stripe setup →</a>'
36
+ : '';
37
+ configDiv.innerHTML = `
38
+ <div style="font-size:0.8rem;color:#a78bfa;margin-bottom:8px;font-weight:600;">Allyabase connection (owner only)</div>
39
+ <div style="display:flex;gap:6px;align-items:center;">
40
+ <input id="luc-url-input" value="${escapeHtml(data.allyabaseUrl || '')}" placeholder="https://dev.allyabase.com"
41
+ style="flex:1;background:#0d001a;border:1px solid rgba(167,139,250,0.3);border-radius:4px;padding:6px 8px;color:#e0d0ff;font-size:0.8rem;">
42
+ <button id="luc-url-btn"
43
+ style="background:#7c3aed;border:none;border-radius:4px;padding:6px 12px;color:white;cursor:pointer;font-size:0.8rem;white-space:nowrap;">
44
+ Save
45
+ </button>
46
+ </div>
47
+ <div id="luc-url-status" style="margin-top:6px;font-size:0.75rem;"></div>
48
+ ${stripeStatus ? '<div style="margin-top:6px;">' + stripeStatus + '</div>' : ''}`;
49
+ $item.append(configDiv);
50
+ configDiv.querySelector('#luc-url-btn').addEventListener('click', function() {
51
+ const input = configDiv.querySelector('#luc-url-input');
52
+ const btn = configDiv.querySelector('#luc-url-btn');
53
+ const status = configDiv.querySelector('#luc-url-status');
54
+ const base = (input.value || '').trim().replace(/\/$/, '');
55
+ if (!base) { status.textContent = 'Enter an allyabase URL first'; return; }
56
+ btn.disabled = true; btn.textContent = 'Saving…';
57
+ fetch('/plugin/lucille/setup', {
58
+ method: 'POST',
59
+ credentials: 'include',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify({ allyabaseUrl: base })
62
+ })
63
+ .then(r => r.json())
64
+ .then(d => {
65
+ if (d.error) { status.innerHTML = '<span style="color:#f55;">' + escapeHtml(d.error) + '</span>'; return; }
66
+ status.innerHTML = '<span style="color:#0e0;">Saved</span>';
67
+ })
68
+ .catch(e => { status.innerHTML = '<span style="color:#f55;">' + escapeHtml(e.message) + '</span>'; })
69
+ .finally(() => { btn.disabled = false; btn.textContent = 'Save'; });
70
+ });
71
+ })
72
+ .catch(() => {});
73
+
74
+ if (!videoId) {
75
+ // No video yet — show setup prompt
76
+ $item.append(`
77
+ <div style="
78
+ background: rgba(167,139,250,0.08);
79
+ border: 2px dashed rgba(167,139,250,0.3);
80
+ border-radius: 10px;
81
+ padding: 20px;
82
+ text-align: center;
83
+ color: #a78bfa;
84
+ font-family: system-ui;
85
+ ">
86
+ <div style="font-size: 2rem; margin-bottom: 8px;">🎬</div>
87
+ <div style="font-weight: 600;">${escapeHtml(title)}</div>
88
+ <div style="font-size: 0.85rem; margin-top: 8px; opacity: 0.7;">No video uploaded yet</div>
89
+ </div>
90
+ `);
91
+ return;
92
+ }
93
+
94
+ const playerUrl = `${playerBase}/watch/${encodeURIComponent(videoId)}`;
95
+
96
+ $item.append(`
97
+ <div class="lucille-item" style="font-family: system-ui;">
98
+ <div class="lucille-thumb" style="
99
+ position: relative;
100
+ background: #111;
101
+ border-radius: 8px;
102
+ overflow: hidden;
103
+ aspect-ratio: 16/9;
104
+ cursor: pointer;
105
+ " onclick="window.lucilleOpen('${escapeAttr(playerUrl)}', '${escapeAttr(title)}')">
106
+ <div style="
107
+ position: absolute; inset: 0;
108
+ display: flex; align-items: center; justify-content: center;
109
+ background: linear-gradient(135deg, #1a0033 0%, #0a001a 100%);
110
+ ">
111
+ <span style="font-size: 48px; opacity: 0.7; filter: drop-shadow(0 0 12px #a78bfa);">▶</span>
112
+ </div>
113
+ <div style="
114
+ position: absolute; bottom: 0; left: 0; right: 0;
115
+ padding: 10px 12px;
116
+ background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
117
+ color: white;
118
+ font-size: 0.9rem;
119
+ font-weight: 600;
120
+ ">${escapeHtml(title)}</div>
121
+ </div>
122
+ ${description ? `<div style="font-size: 0.85rem; color: #666; margin-top: 6px; padding: 0 2px;">${escapeHtml(description)}</div>` : ''}
123
+ ${price > 0 ? `<div style="font-size: 0.8rem; color: #a78bfa; margin-top: 4px; padding: 0 2px;">${price} MP</div>` : ''}
124
+ </div>
125
+ `);
126
+ }
127
+
128
+ // ── Bind: editor & version status ─────────────────────────────────────────
129
+ function bind($item, item) {
130
+ // Version status indicator (◉ traffic light — matches plugmatic style)
131
+ const $status = $('<span>').text('◉ ').css({ color: color.gray, cursor: 'pointer' })
132
+ .attr('title', 'Lucille service status');
133
+
134
+ $item.find('.item-buttons').prepend($status);
135
+
136
+ // Check service status
137
+ fetch('/plugin/lucille/version-status')
138
+ .then(r => r.json())
139
+ .then(data => {
140
+ if (!data.installed) {
141
+ $status.css('color', color.red).attr('title', 'Lucille not installed');
142
+ } else if (data.updateAvailable) {
143
+ $status.css('color', color.yellow).attr('title', `Update available: ${data.installed} → ${data.published}`);
144
+ $status.on('click', () => {
145
+ if (confirm(`Update lucille from ${data.installed} to ${data.published}?\nWiki will need a restart.`)) {
146
+ fetch('/plugin/lucille/update', { method: 'POST' })
147
+ .then(r => r.json())
148
+ .then(result => {
149
+ if (result.success) alert(`Updated to ${result.version}. Please restart the wiki server.`);
150
+ else alert('Update failed: ' + result.error);
151
+ });
152
+ }
153
+ });
154
+ } else {
155
+ $status.css('color', color.green).attr('title', `Lucille ${data.installed} — up to date`);
156
+ }
157
+ })
158
+ .catch(() => {
159
+ $status.css('color', color.red).attr('title', 'Could not reach lucille service');
160
+ });
161
+ }
162
+
163
+ // ── Helpers ───────────────────────────────────────────────────────────────
164
+ function escapeHtml(str) {
165
+ return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
166
+ }
167
+ function escapeAttr(str) {
168
+ return String(str).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
169
+ }
170
+
171
+ return { emit, bind };
172
+ }());
173
+
174
+ // ── Video lightbox (shared, injected once) ────────────────────────────────────
175
+ if (!window.lucilleOpen) {
176
+ // Inject modal into body
177
+ const modal = document.createElement('div');
178
+ modal.id = 'lucille-modal';
179
+ modal.style.cssText = 'display:none;position:fixed;inset:0;z-index:9999;align-items:center;justify-content:center;background:rgba(0,0,0,0.88);';
180
+ modal.innerHTML = `
181
+ <div style="position:relative;width:90vw;max-width:960px;aspect-ratio:16/9;background:#000;border-radius:10px;overflow:hidden;box-shadow:0 24px 80px rgba(0,0,0,0.7);">
182
+ <button onclick="lucilleClose()" style="position:absolute;top:10px;right:12px;z-index:2;background:rgba(0,0,0,0.5);border:none;color:#fff;font-size:20px;padding:4px 10px;border-radius:6px;cursor:pointer;">✕</button>
183
+ <iframe id="lucille-iframe" src="" allowfullscreen allow="autoplay" style="width:100%;height:100%;border:none;display:block;"></iframe>
184
+ </div>`;
185
+ document.body.appendChild(modal);
186
+
187
+ window.lucilleOpen = function(url, title) {
188
+ document.getElementById('lucille-iframe').src = url;
189
+ modal.style.display = 'flex';
190
+ document.title = title || document.title;
191
+ };
192
+ window.lucilleClose = function() {
193
+ modal.style.display = 'none';
194
+ document.getElementById('lucille-iframe').src = '';
195
+ };
196
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') window.lucilleClose(); });
197
+ modal.addEventListener('click', e => { if (e.target === modal) window.lucilleClose(); });
198
+ }
package/factory.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "lucille",
3
+ "creator": "Planet Nine",
4
+ "category": "media",
5
+ "type": "video",
6
+ "description": "P2P video hosting with WebTorrent and DigitalOcean Spaces. Each wiki gets its own tracker, federated with the wiki neighborhood for maximum reach."
7
+ }
package/index.js ADDED
@@ -0,0 +1,4 @@
1
+ (function() {
2
+ const { startServer } = require('./server/server.js');
3
+ module.exports = { startServer };
4
+ }).call(this);
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "wiki-plugin-lucille",
3
+ "version": "0.0.1",
4
+ "description": "Federated Wiki plugin — P2P video hosting via Lucille (WebTorrent + DO Spaces)",
5
+ "keywords": ["wiki", "plugin", "video", "webtorrent", "planet-nine"],
6
+ "author": "Planet Nine",
7
+ "license": "MIT",
8
+ "type": "commonjs",
9
+ "main": "index.js",
10
+ "dependencies": {
11
+ "http-proxy": "^1.18.1",
12
+ "lucille": "latest",
13
+ "node-fetch": "^2.6.1",
14
+ "sessionless-node": "latest"
15
+ }
16
+ }