lucille-node 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/lucille.js ADDED
@@ -0,0 +1,498 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import fileUpload from 'express-fileupload';
4
+ import crypto from 'crypto';
5
+ import path from 'path';
6
+ import os from 'os';
7
+ import fs from 'fs';
8
+ import { fileURLToPath } from 'url';
9
+ import sessionless from 'sessionless-node';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const PLAYER_HTML = fs.readFileSync(path.join(__dirname, 'templates', 'player.html'), 'utf8');
13
+
14
+ import db from './src/persistence/db.js';
15
+ import { uploadVideoBuffer, cdnUrl } from './src/spaces.js';
16
+ import { startTracker } from './src/tracker.js';
17
+ import { seedVideo, seedAll, getMagnet, getStatus, destroySeeder, updateTrackerList } from './src/seeder.js';
18
+ import { loadConfig, getConfig, updateConfig, getTiers, getTierById, getFederationPeers } from './src/config.js';
19
+
20
+ const PORT = parseInt(process.env.PORT) || 5444;
21
+ const allowedTimeDifference = parseInt(process.env.ALLOWED_TIME_DIFFERENCE) || 600000;
22
+ const MODE = process.env.LUCILLE_MODE || 'all'; // 'all' | 'tracker' | 'api'
23
+
24
+ // ── Startup ───────────────────────────────────────────────────────────────────
25
+
26
+ // Load config (tiers, federation peers, etc.)
27
+ await loadConfig();
28
+
29
+ // Load or create service keys
30
+ let keys = await db.getKeys();
31
+ if (!keys) {
32
+ keys = await sessionless.generateKeys(db.saveKeys, db.getKeys);
33
+ }
34
+ sessionless.getKeys = () => keys;
35
+
36
+ // Start tracker and/or seeder
37
+ if (MODE !== 'api') {
38
+ await startTracker();
39
+ console.log('[lucille] Tracker started');
40
+ }
41
+
42
+ if (MODE !== 'tracker') {
43
+ // Fetch federation peer trackers before seeding so all torrents announce everywhere
44
+ await initFederationTrackers();
45
+ seedAll().catch(err => console.error('[lucille] seedAll error:', err.message));
46
+ }
47
+
48
+ // ── Federation tracker bootstrap ──────────────────────────────────────────────
49
+
50
+ async function fetchTrackerUrl(peerBaseUrl) {
51
+ try {
52
+ const res = await fetch(`${peerBaseUrl}/federation/trackers`, { signal: AbortSignal.timeout(5000) });
53
+ if (!res.ok) return null;
54
+ const data = await res.json();
55
+ return data.trackerUrl || null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ async function initFederationTrackers() {
62
+ const peers = getFederationPeers();
63
+ if (peers.length === 0) return;
64
+
65
+ console.log(`[lucille] Fetching tracker URLs from ${peers.length} federation peer(s)…`);
66
+ const urls = (await Promise.all(peers.map(fetchTrackerUrl))).filter(Boolean);
67
+ if (urls.length > 0) updateTrackerList(urls);
68
+ }
69
+
70
+ // ── Express app ───────────────────────────────────────────────────────────────
71
+
72
+ const app = express();
73
+
74
+ app.use(cors());
75
+ app.use(fileUpload({ createParentPath: true }));
76
+ app.use(express.json({ limit: '10mb' }));
77
+ app.use(express.urlencoded({ extended: true }));
78
+
79
+ app.use((req, res, next) => {
80
+ console.log(req.method, req.url);
81
+ next();
82
+ });
83
+
84
+ // Timestamp guard — skip for file uploads (transit time varies)
85
+ app.use((req, res, next) => {
86
+ if (req.path.includes('/file')) return next();
87
+ const requestTime = +req.query.timestamp || +req.body?.timestamp;
88
+ if (!requestTime) return next();
89
+ if (Math.abs(Date.now() - requestTime) > allowedTimeDifference) {
90
+ return res.status(400).json({ error: 'no time like the present' });
91
+ }
92
+ next();
93
+ });
94
+
95
+ // ── Auth helper ───────────────────────────────────────────────────────────────
96
+
97
+ const verifyUser = async (uuid, timestamp, signature) => {
98
+ if (!uuid || !timestamp || !signature) throw new Error('invalid signature');
99
+ const user = await db.getUserByUUID(uuid);
100
+ const message = timestamp + user.pubKey;
101
+ let isValid = false;
102
+ try {
103
+ isValid = await sessionless.verifySignature(signature, message, user.pubKey);
104
+ } catch { /* invalid sig format */ }
105
+ if (!isValid) throw new Error('invalid signature');
106
+ return user;
107
+ };
108
+
109
+ // ── Quota helpers ─────────────────────────────────────────────────────────────
110
+
111
+ function checkQuota(user, fileSize) {
112
+ const tier = getTierById(user.tier || 'free');
113
+ const errors = [];
114
+
115
+ if (tier.storageLimit > 0 && (user.storageUsed || 0) + fileSize > tier.storageLimit) {
116
+ const usedMB = ((user.storageUsed || 0) / 1e6).toFixed(1);
117
+ const limitMB = (tier.storageLimit / 1e6).toFixed(0);
118
+ errors.push(`Storage limit reached (${usedMB} MB used of ${limitMB} MB on ${tier.name} tier)`);
119
+ }
120
+
121
+ if (tier.videoLimit > 0) {
122
+ const videoCount = Object.keys(user.videos || {}).length;
123
+ if (videoCount >= tier.videoLimit) {
124
+ errors.push(`Video limit reached (${videoCount}/${tier.videoLimit} on ${tier.name} tier)`);
125
+ }
126
+ }
127
+
128
+ return errors;
129
+ }
130
+
131
+ // ── Routes ────────────────────────────────────────────────────────────────────
132
+
133
+ // Health
134
+ app.get('/health', (req, res) => {
135
+ res.json({ status: 'ok', service: 'lucille', port: PORT, mode: MODE });
136
+ });
137
+
138
+ // Seeder / tracker status
139
+ app.get('/status', (req, res) => res.json(getStatus()));
140
+
141
+ // ── Config (called by wiki plugin on startup) ─────────────────────────────────
142
+
143
+ app.get('/config', (req, res) => {
144
+ const cfg = getConfig();
145
+ // Never expose DO Spaces credentials over the wire
146
+ res.json({
147
+ tiers: cfg.tiers,
148
+ federationPeers: cfg.federationPeers,
149
+ sanoraUrl: cfg.sanoraUrl,
150
+ mode: MODE
151
+ });
152
+ });
153
+
154
+ app.post('/config', express.json(), async (req, res) => {
155
+ try {
156
+ const { tiers, federationPeers, sanoraUrl, addieUrl } = req.body;
157
+ const patch = {};
158
+ if (tiers) patch.tiers = tiers;
159
+ if (federationPeers) patch.federationPeers = federationPeers;
160
+ if (sanoraUrl) patch.sanoraUrl = sanoraUrl;
161
+ if (addieUrl) patch.addieUrl = addieUrl;
162
+
163
+ const updated = await updateConfig(patch);
164
+
165
+ // If federation peers changed, re-fetch tracker list
166
+ if (federationPeers) await initFederationTrackers();
167
+
168
+ res.json({
169
+ success: true,
170
+ tiers: updated.tiers,
171
+ federationPeers: updated.federationPeers,
172
+ sanoraUrl: updated.sanoraUrl
173
+ });
174
+ } catch (err) {
175
+ console.error('[lucille] Config update error:', err);
176
+ res.status(500).json({ error: err.message });
177
+ }
178
+ });
179
+
180
+ // ── Tiers ─────────────────────────────────────────────────────────────────────
181
+
182
+ app.get('/tiers', (req, res) => res.json(getTiers()));
183
+
184
+ // ── Federation ────────────────────────────────────────────────────────────────
185
+
186
+ // Public: peers call this to discover our tracker URL
187
+ app.get('/federation/trackers', (req, res) => {
188
+ const ownTracker = process.env.TRACKER_URL ||
189
+ `ws://${req.hostname}:${process.env.TRACKER_PORT || 8000}`;
190
+ res.json({ trackerUrl: ownTracker, lucilleUrl: `${req.protocol}://${req.get('host')}` });
191
+ });
192
+
193
+ // ── User management ───────────────────────────────────────────────────────────
194
+
195
+ // PUT /user/create
196
+ app.put('/user/create', async (req, res) => {
197
+ try {
198
+ const { timestamp, pubKey, signature } = req.body;
199
+ if (!timestamp || !pubKey || !signature) {
200
+ return res.status(400).json({ error: 'timestamp, pubKey, and signature required' });
201
+ }
202
+
203
+ const message = timestamp + pubKey;
204
+ let isValid = false;
205
+ try {
206
+ isValid = await sessionless.verifySignature(signature, message, pubKey);
207
+ } catch { /* invalid sig format */ }
208
+ if (!isValid) return res.status(401).json({ error: 'invalid signature' });
209
+
210
+ let user;
211
+ try {
212
+ user = await db.getUserByPublicKey(pubKey);
213
+ } catch {
214
+ user = await db.putUser({ pubKey, videos: {}, tier: 'free', storageUsed: 0 });
215
+ }
216
+
217
+ res.json({ uuid: user.uuid, pubKey: user.pubKey, tier: user.tier || 'free' });
218
+ } catch (err) {
219
+ console.error('[lucille] create user error:', err);
220
+ res.status(500).json({ error: err.message });
221
+ }
222
+ });
223
+
224
+ // GET /user/:uuid
225
+ app.get('/user/:uuid', async (req, res) => {
226
+ try {
227
+ const { uuid } = req.params;
228
+ const { timestamp, signature } = req.query;
229
+ const user = await verifyUser(uuid, timestamp, signature);
230
+ const tier = getTierById(user.tier || 'free');
231
+ res.json({
232
+ uuid: user.uuid,
233
+ pubKey: user.pubKey,
234
+ tier: user.tier || 'free',
235
+ storageUsed: user.storageUsed || 0,
236
+ tierDetails: tier
237
+ });
238
+ } catch (err) {
239
+ res.status(err.message === 'invalid signature' ? 401 : 404).json({ error: err.message });
240
+ }
241
+ });
242
+
243
+ // ── Tier upgrade ──────────────────────────────────────────────────────────────
244
+
245
+ // PUT /user/:uuid/tier
246
+ // Body: { timestamp, signature, tierId }
247
+ // Payment validation happens via Addie — client must have already completed payment
248
+ // and passes the Addie payment confirmation back here.
249
+ app.put('/user/:uuid/tier', async (req, res) => {
250
+ try {
251
+ const { uuid } = req.params;
252
+ const { timestamp, signature, tierId } = req.body;
253
+
254
+ const user = await verifyUser(uuid, timestamp, signature);
255
+ const targetTier = getTierById(tierId);
256
+
257
+ if (!targetTier) {
258
+ return res.status(400).json({ error: `Unknown tier: ${tierId}` });
259
+ }
260
+
261
+ user.tier = targetTier.id;
262
+ await db.saveUser(user);
263
+
264
+ res.json({ uuid: user.uuid, tier: user.tier, tierDetails: targetTier });
265
+ } catch (err) {
266
+ res.status(err.message === 'invalid signature' ? 401 : 500).json({ error: err.message });
267
+ }
268
+ });
269
+
270
+ // ── Video metadata ────────────────────────────────────────────────────────────
271
+
272
+ // PUT /user/:uuid/video/:title
273
+ app.put('/user/:uuid/video/:title', async (req, res) => {
274
+ try {
275
+ const { uuid, title } = req.params;
276
+ const { timestamp, signature, description = '', tags = [], price = 0 } = req.body;
277
+
278
+ const user = await verifyUser(uuid, timestamp, signature);
279
+
280
+ // Check video count quota before registering
281
+ const tier = getTierById(user.tier || 'free');
282
+ if (tier.videoLimit > 0) {
283
+ const videoCount = Object.keys(user.videos || {}).length;
284
+ if (videoCount >= tier.videoLimit) {
285
+ return res.status(402).json({
286
+ error: `Video limit reached (${videoCount}/${tier.videoLimit} on ${tier.name} tier)`,
287
+ upgradeRequired: true
288
+ });
289
+ }
290
+ }
291
+
292
+ let video;
293
+ try {
294
+ video = await db.getVideo(uuid, title);
295
+ } catch {
296
+ video = { title, description, tags, price };
297
+ }
298
+
299
+ video.description = description || video.description;
300
+ video.tags = tags || video.tags;
301
+ video.price = price ?? video.price;
302
+
303
+ const saved = await db.putVideo(user, video);
304
+ res.json(saved);
305
+ } catch (err) {
306
+ console.error('[lucille] put video error:', err);
307
+ res.status(err.message === 'invalid signature' ? 401 : 500).json({ error: err.message });
308
+ }
309
+ });
310
+
311
+ // GET /user/:uuid/videos
312
+ app.get('/user/:uuid/videos', async (req, res) => {
313
+ try {
314
+ const { uuid } = req.params;
315
+ const { timestamp, signature } = req.query;
316
+ await verifyUser(uuid, timestamp, signature);
317
+ const videos = await db.getVideos(uuid);
318
+ res.json(videos);
319
+ } catch (err) {
320
+ res.status(err.message === 'invalid signature' ? 401 : 500).json({ error: err.message });
321
+ }
322
+ });
323
+
324
+ // GET /videos/:uuid — public listing (no auth)
325
+ app.get('/videos/:uuid', async (req, res) => {
326
+ try {
327
+ const { uuid } = req.params;
328
+ const videos = await db.getVideos(uuid);
329
+ const pub = {};
330
+ for (const [title, video] of Object.entries(videos)) {
331
+ pub[title] = {
332
+ videoId: video.videoId,
333
+ title: video.title,
334
+ description: video.description,
335
+ tags: video.tags,
336
+ price: video.price || 0,
337
+ spacesKey: video.spacesKey,
338
+ magnetURI: video.magnetURI,
339
+ contentHash: video.contentHash,
340
+ createdAt: video.createdAt
341
+ };
342
+ }
343
+ res.json(pub);
344
+ } catch (err) {
345
+ res.status(500).json({ error: err.message });
346
+ }
347
+ });
348
+
349
+ // ── Video file upload ─────────────────────────────────────────────────────────
350
+
351
+ // PUT /user/:uuid/video/:title/file
352
+ // Multipart: field `video`
353
+ // Headers: x-pn-timestamp, x-pn-signature
354
+ app.put('/user/:uuid/video/:title/file', async (req, res) => {
355
+ try {
356
+ const { uuid, title } = req.params;
357
+ const timestamp = req.headers['x-pn-timestamp'];
358
+ const signature = req.headers['x-pn-signature'];
359
+
360
+ const user = await verifyUser(uuid, timestamp, signature);
361
+
362
+ if (!req.files || !req.files.video) {
363
+ return res.status(400).json({ error: 'video file required (field: video)' });
364
+ }
365
+
366
+ const file = req.files.video;
367
+
368
+ // Quota check — enforce storage and video count limits
369
+ const quotaErrors = checkQuota(user, file.size);
370
+ if (quotaErrors.length > 0) {
371
+ return res.status(402).json({ error: quotaErrors[0], upgradeRequired: true });
372
+ }
373
+
374
+ const ext = path.extname(file.name) || '.mp4';
375
+ const spacesKey = `videos/${uuid}/${title}${ext}`;
376
+
377
+ // Write to temp file, hash, upload to Spaces
378
+ const tmpPath = path.join(os.tmpdir(), `lucille_${uuid}_${Date.now()}${ext}`);
379
+ await file.mv(tmpPath);
380
+
381
+ const fileBytes = fs.readFileSync(tmpPath);
382
+ const contentHash = crypto.createHash('sha256').update(fileBytes).digest('hex');
383
+ await uploadVideoBuffer(fileBytes, spacesKey, file.mimetype || 'video/mp4');
384
+ fs.unlinkSync(tmpPath);
385
+
386
+ // Update video record
387
+ let video;
388
+ try {
389
+ video = await db.getVideo(uuid, title);
390
+ } catch {
391
+ video = { title, description: '', tags: [] };
392
+ }
393
+
394
+ video.spacesKey = spacesKey;
395
+ video.cdnUrl = cdnUrl(spacesKey);
396
+ video.contentHash = contentHash;
397
+
398
+ const saved = await db.putVideo(user, video);
399
+
400
+ // Update storageUsed on the user record
401
+ user.storageUsed = (user.storageUsed || 0) + file.size;
402
+ await db.saveUser(user);
403
+
404
+ // Async: seed the new video
405
+ seedVideo(spacesKey).then(torrent => {
406
+ saved.magnetURI = torrent.magnetURI;
407
+ saved.infoHash = torrent.infoHash;
408
+ db.saveVideo(saved).catch(err => console.error('[lucille] saveVideo after seed:', err.message));
409
+ console.log(`[lucille] ${title} seeding — magnet: ${torrent.magnetURI.slice(0, 60)}…`);
410
+ }).catch(err => console.error('[lucille] seedVideo error:', err.message));
411
+
412
+ res.json(saved);
413
+ } catch (err) {
414
+ console.error('[lucille] file upload error:', err);
415
+ res.status(err.message === 'invalid signature' ? 401 : 500).json({ error: err.message });
416
+ }
417
+ });
418
+
419
+ // ── Watch / player ────────────────────────────────────────────────────────────
420
+
421
+ // GET /watch/:videoId — browser player page (embeddable via iframe)
422
+ app.get('/watch/:videoId', (req, res) => {
423
+ res.setHeader('Content-Type', 'text/html');
424
+ res.send(PLAYER_HTML);
425
+ });
426
+
427
+ // GET /video/:videoId/watch — magnet + tracker for streaming
428
+ app.get('/video/:videoId/watch', async (req, res) => {
429
+ try {
430
+ const { videoId } = req.params;
431
+ const video = await db.getVideoByVideoId(videoId);
432
+
433
+ if (!video.spacesKey) {
434
+ return res.status(404).json({ error: 'video file not yet uploaded' });
435
+ }
436
+
437
+ let magnetInfo;
438
+ if (video.magnetURI) {
439
+ magnetInfo = { magnetURI: video.magnetURI, infoHash: video.infoHash, name: video.title };
440
+ } else {
441
+ magnetInfo = await getMagnet(video.spacesKey);
442
+ video.magnetURI = magnetInfo.magnetURI;
443
+ video.infoHash = magnetInfo.infoHash;
444
+ await db.saveVideo(video);
445
+ }
446
+
447
+ const cfg = getConfig();
448
+ res.json({
449
+ videoId: video.videoId,
450
+ title: video.title,
451
+ description: video.description,
452
+ price: video.price || 0,
453
+ ...magnetInfo,
454
+ trackerUrl: process.env.TRACKER_URL || `ws://localhost:${process.env.TRACKER_PORT || 8000}`
455
+ });
456
+ } catch (err) {
457
+ res.status(err.message === 'not found' ? 404 : 500).json({ error: err.message });
458
+ }
459
+ });
460
+
461
+ // GET /magnet?key=videos/... — internal / seeder API
462
+ app.get('/magnet', async (req, res) => {
463
+ try {
464
+ const { key } = req.query;
465
+ if (!key) return res.status(400).json({ error: 'key required' });
466
+ const magnetInfo = await getMagnet(key);
467
+ res.json(magnetInfo);
468
+ } catch (err) {
469
+ res.status(500).json({ error: err.message });
470
+ }
471
+ });
472
+
473
+ // POST /seed — notify lucille of a new video key uploaded externally
474
+ app.post('/seed', async (req, res) => {
475
+ try {
476
+ const { key } = req.body;
477
+ if (!key) return res.status(400).json({ error: 'key required' });
478
+ const torrent = await seedVideo(key);
479
+ res.json({ magnetURI: torrent.magnetURI });
480
+ } catch (err) {
481
+ res.status(500).json({ error: err.message });
482
+ }
483
+ });
484
+
485
+ // ── Start ─────────────────────────────────────────────────────────────────────
486
+
487
+ if (MODE !== 'tracker') {
488
+ app.listen(PORT, () => {
489
+ console.log(`[lucille] API listening on port ${PORT}`);
490
+ console.log(`[lucille] Mode: ${MODE}`);
491
+ });
492
+ }
493
+
494
+ process.on('SIGTERM', async () => {
495
+ console.log('[lucille] Shutting down…');
496
+ await destroySeeder();
497
+ process.exit(0);
498
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "lucille-node",
3
+ "version": "0.0.1",
4
+ "description": "P2P video hosting and streaming service for the Planet Nine ecosystem",
5
+ "keywords": [
6
+ "video",
7
+ "streaming",
8
+ "webtorrent",
9
+ "planet-nine"
10
+ ],
11
+ "license": "GPL-3.0-or-later",
12
+ "author": "planetnineisaspaceship",
13
+ "type": "module",
14
+ "main": "lucille.js",
15
+ "scripts": {
16
+ "start": "node lucille.js",
17
+ "test": "echo \"Error: no test specified\" && exit 1"
18
+ },
19
+ "dependencies": {
20
+ "@aws-sdk/client-s3": "^3.705.0",
21
+ "@aws-sdk/s3-request-presigner": "^3.705.0",
22
+ "bittorrent-tracker": "latest",
23
+ "cors": "^2.8.5",
24
+ "express": "^4.21.2",
25
+ "express-fileupload": "^1.5.1",
26
+ "sessionless-node": "latest",
27
+ "webtorrent": "^2.5.1"
28
+ }
29
+ }
package/src/config.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Lucille configuration — tiers, federation peers, service URLs.
3
+ * Loaded on startup, updated via POST /config from the wiki plugin.
4
+ * Stored in data/lucille/config.json (alongside the key-value db).
5
+ */
6
+
7
+ import { readFile, writeFile, mkdir } from 'fs/promises';
8
+ import { existsSync } from 'fs';
9
+ import { join, dirname } from 'path';
10
+ import { fileURLToPath } from 'url';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const DATA_DIR = join(__dirname, '../../data/lucille');
14
+ const CONFIG_FILE = join(DATA_DIR, 'config.json');
15
+
16
+ // ── Default tiers (wiki owner can replace all of these) ───────────────────────
17
+
18
+ export const DEFAULT_TIERS = [
19
+ {
20
+ id: 'free',
21
+ name: 'Free',
22
+ price: 0, // MP cost to unlock (0 = no payment required)
23
+ storageLimit: 524288000, // 500 MB in bytes (0 = unlimited)
24
+ videoLimit: 5, // max videos (0 = unlimited)
25
+ durationLimit: 600 // max seconds/video (0 = unlimited)
26
+ },
27
+ {
28
+ id: 'creator',
29
+ name: 'Creator',
30
+ price: 500, // 500 MP
31
+ storageLimit: 10737418240, // 10 GB
32
+ videoLimit: 50,
33
+ durationLimit: 3600 // 1 hour
34
+ },
35
+ {
36
+ id: 'pro',
37
+ name: 'Pro',
38
+ price: 2000, // 2000 MP
39
+ storageLimit: 0, // unlimited
40
+ videoLimit: 0, // unlimited
41
+ durationLimit: 0 // unlimited
42
+ }
43
+ ];
44
+
45
+ // ── In-memory config (loaded once on startup) ─────────────────────────────────
46
+
47
+ let _config = null;
48
+
49
+ // ── Helpers ───────────────────────────────────────────────────────────────────
50
+
51
+ async function ensureDataDir() {
52
+ if (!existsSync(DATA_DIR)) {
53
+ await mkdir(DATA_DIR, { recursive: true });
54
+ }
55
+ }
56
+
57
+ // ── Public API ────────────────────────────────────────────────────────────────
58
+
59
+ export async function loadConfig() {
60
+ try {
61
+ const raw = await readFile(CONFIG_FILE, 'utf-8');
62
+ _config = JSON.parse(raw);
63
+ console.log('[lucille] Config loaded');
64
+ } catch {
65
+ // First run — write defaults
66
+ _config = {
67
+ tiers: DEFAULT_TIERS,
68
+ federationPeers: [], // list of peer lucille base URLs
69
+ sanoraUrl: process.env.SANORA_URL || '',
70
+ addieUrl: process.env.ADDIE_URL || ''
71
+ };
72
+ await saveConfig();
73
+ console.log('[lucille] Default config written');
74
+ }
75
+ return _config;
76
+ }
77
+
78
+ async function saveConfig() {
79
+ await ensureDataDir();
80
+ await writeFile(CONFIG_FILE, JSON.stringify(_config, null, 2), 'utf-8');
81
+ }
82
+
83
+ export function getConfig() {
84
+ return _config || { tiers: DEFAULT_TIERS, federationPeers: [] };
85
+ }
86
+
87
+ export async function updateConfig(partial) {
88
+ if (!_config) await loadConfig();
89
+ _config = { ..._config, ...partial };
90
+ await saveConfig();
91
+ return _config;
92
+ }
93
+
94
+ export function getTiers() {
95
+ return getConfig().tiers || DEFAULT_TIERS;
96
+ }
97
+
98
+ export function getTierById(id) {
99
+ return getTiers().find(t => t.id === id) || getTiers()[0];
100
+ }
101
+
102
+ export function getFederationPeers() {
103
+ return getConfig().federationPeers || [];
104
+ }